Session titles settle from the whole record, server-side
A session's title now says where it came from (title_source: prompt, agent, person, item, pull, summary) and when it settled. The first prompt names it provisionally; the work service settles it in its cron once there is signal (a work item or pull request it works on, three prompts, or half an hour), again only when its scope changes (at most every 20 minutes, and only when the new title says something else), and a last time as it ends. A person's title is pinned (title_session); an agent's is kept unless the session went on to other work after naming it. Titles come from the workspace's Ask provider through integrations' new ai_brief, bounded (five calls a session at most), with a fallback: the work item's title, the pull request's, else the most telling prompt's intent without its preamble. Existing sessions are settled once by the same job. The Claude Code hook no longer titles sessions or nudges Claude to retitle them; SessionStart says once to set a title only for the whole job. The site shows a provisional title muted, and a session's page renames (pins) it or lets g1t name it again.
- G1T-Session:
- ses_01m56fqty1ej98f36jtcarc5kz
| 7 | 7 | machine to the work on g1t. With it installed, working in a g1t repository: | |
| 8 | 8 | ||
| 9 | 9 | - each Claude Code session is recorded on g1t as a | |
| 10 | − | [session](/guides/why-blame/#sessions), titled with what you asked for | |
| 11 | − | and attached to its work item and pull request, so the people and agents | |
| 10 | + | [session](/guides/why-blame/#sessions), named by g1t from the work it | |
| 11 | + | does and attached to its work item and pull request, so the people and agents | |
| 12 | 12 | also working there can see it, work item or not; | |
| 13 | 13 | - Claude starts each session knowing the work item it is on: its definition | |
| 14 | 14 | of done, the repositories it is meant to land in, who has claimed it, its | |
| ⋯ | |||
| 63 | 63 | the host in `G1T_HOST` for a self-hosted g1t. Everywhere else its hooks do | |
| 64 | 64 | nothing. A session that starts in a folder with no g1t remote is recorded | |
| 65 | 65 | once it has one: from the next prompt, or the `git remote`, `git push` or | |
| 66 | − | `g1t repo create` command that added it, titled by its first prompt. | |
| 66 | + | `g1t repo create` command that added it. | |
| 67 | 67 | ||
| 68 | 68 | ### Update | |
| 69 | 69 | ||
| ⋯ | |||
| 99 | 99 | | --- | --- | --- | | |
| 100 | 100 | | A session starts or resumes | `g1t session start --harness claude_code --harness-session-id ID` | A session for this Claude Code session. Starting the same session again, as a resume does, finds it rather than making another. | | |
| 101 | 101 | | You send a prompt | `g1t session log --kind prompt TEXT` | Your prompt's first 2,000 characters. | | |
| 102 | − | | You send a prompt that says what it is for, while the session has no title | `g1t session heartbeat --json`, then, if the session has no title, `g1t session attach --title INTENT` | Its title: what the prompt is for, as its first line or sentence without pasted text, image placeholders (`[Image #1]`), code blocks or markdown, up to 80 characters, cut on a word with `…`. A slash command, or a prompt that is only pasted text or images, gives no title, and the next prompt titles the session. A title the session already has (one Claude, you or an earlier prompt set) is never replaced. Not sent with prompts kept back. | | |
| 103 | 102 | | Claude uses a tool | `g1t session log --kind tool_call TEXT` | The tool's name and one line on what it was called with: the command, file, pattern or address, up to 200 characters, with anything that looks like a token or password replaced by `…`. | | |
| 104 | 103 | | Claude uses a skill, or you run one (`/g1t:work-on-item 42`) | `g1t session log --kind skill NAME` | The skill's name, nothing more, once in a turn. A plugin's skill or command you type as `/plugin:name` is recorded even with prompts kept back. | | |
| 105 | 104 | | Claude writes a file, makes a branch or a commit, or claims a work item | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--item`) | What it touched: the path from the repository's root, the branch, the commit, the work item. A file is one Claude edited or wrote, or one a shell command changed while it ran (the uncommitted files before and after it), never what was uncommitted already, so sessions sharing a working copy do not claim each other's work. g1t ties the session to the pull requests and work items these lead to, and tells two sessions that touch the same file. | | |
| ⋯ | |||
| 150 | 149 | than a second, Claude is told to load it itself. | |
| 151 | 150 | ||
| 152 | 151 | With no work item in sight, Claude is told that the session is already | |
| 153 | − | recorded on g1t and shows to the team with what it is for, so it can just | |
| 154 | − | work; see [Work with no work item yet](#work-with-no-work-item-yet). | |
| 152 | + | recorded on g1t and shows to the team, so it can just work, and that g1t | |
| 153 | + | names the session itself: it sets a title only if it can name the whole job; see [Work with no work item yet](#work-with-no-work-item-yet). | |
| 155 | 154 | ||
| 156 | 155 | Claude is also told to put the trailer `G1t-Session: $G1T_SESSION` on the | |
| 157 | 156 | commits it makes, so g1t ties them, and the pull request they land in, to | |
| ⋯ | |||
| 169 | 168 | Most sessions start from a question or a request, not a work item, and | |
| 170 | 169 | most never need one. The session is the record of that work: | |
| 171 | 170 | ||
| 172 | − | 1. **It is titled with what it is for.** At the session's first prompt | |
| 173 | − | that says what it is for, the plugin gives the session a title from | |
| 174 | − | it, unless it has one. The title is what you typed: text you pasted | |
| 175 | − | and image placeholders are left out, so a prompt that starts with a | |
| 176 | − | paste is titled by the words around it. Slash commands (`/clear`, | |
| 177 | − | `/g1t:status`) and a prompt that is only a paste give no title; the | |
| 178 | − | next prompt titles the session. A session whose title is only a tag, | |
| 179 | − | such as `<pasted_content id="5c50">` from a plugin older than 0.2.4, | |
| 180 | − | shows on the site by its first prompt's intent instead, else | |
| 181 | − | **Session on** its branch. To name | |
| 182 | − | it yourself, or have Claude name it, run | |
| 183 | − | `g1t session attach --title "Fix the preview builds"`; that replaces | |
| 184 | − | the title the prompt gave. Claude keeps it true as the work turns | |
| 185 | − | out to be something else: the plugin tells it at the start that it | |
| 186 | − | can retitle the session, and reminds it at the second prompt and every | |
| 187 | − | fifth after (not while the CLI is signed out or too old). | |
| 171 | + | 1. **g1t names it from the work it does.** Until there is enough to go | |
| 172 | + | on, the session is called what its first prompt asked for (its first | |
| 173 | + | line or sentence, without pasted text, image placeholders, code or | |
| 174 | + | markdown), shown muted as provisional. g1t then settles a title from | |
| 175 | + | the session's whole record: once it claims or attaches a work item or | |
| 176 | + | opens a pull request, after a few prompts, or after half an hour. With | |
| 177 | + | an [AI provider](/guides/ai/) chosen for Ask, the provider names it in | |
| 178 | + | a few words, in the work item's terms; without one, it is the work | |
| 179 | + | item's title, else the pull request's, else what its most telling | |
| 180 | + | prompt asks for, without greetings and preambles. It is named again | |
| 181 | + | only when what the session works on changes, at most every 20 minutes, | |
| 182 | + | and a last time when it ends. Claude is told this once at the start, | |
| 183 | + | and to name the session itself with | |
| 184 | + | `g1t session attach --title "Fix the preview builds"` only if it can | |
| 185 | + | name the whole job: g1t keeps that title, unless the session goes on to | |
| 186 | + | other work items or pull requests, when it is named again as it ends. | |
| 187 | + | Rename a session on its page to pin your own title: g1t and Claude never | |
| 188 | + | replace it. See [Session titles](/guides/sessions/#session-titles). | |
| 188 | 189 | 2. **It shows to the team.** The project's overview and its **Sessions** page | |
| 189 | 190 | list it in **Live now** under its pull request, else its branch, else | |
| 190 | 191 | under **No branch yet**, with who it is for, Claude Code, its state, | |
| ⋯ | |||
| 330 | 331 | the same time; the `g1t` CLI is how you see them and how they see you. | |
| 331 | 332 | ||
| 332 | 333 | - When you start, record a session and keep its returned ID: | |
| 333 | − | `g1t session start --harness codex --title "Fix the preview builds" --no-remember` | |
| 334 | + | `g1t session start --harness codex --no-remember` | |
| 334 | 335 | (or `cursor`, `copilot`, `other`: the harness you are). Set `G1T_SESSION` | |
| 335 | 336 | for every command in this run: `G1T_SESSION=ses_ID g1t ...` in Bash, | |
| 336 | 337 | or `$env:G1T_SESSION='ses_ID'; g1t ...` in PowerShell. Shell state may | |
| ⋯ | |||
| 340 | 341 | few minutes and `g1t session end` when you stop. Only create subagents | |
| 341 | 342 | when the user's task permits delegation; give each its own session with | |
| 342 | 343 | `--parent PARENT_ID --no-remember` and its own G1T_SESSION. | |
| 344 | + | - Log what you are asked as the session's prompt: | |
| 345 | + | `g1t session log --kind prompt "TEXT"` (it shows only to whoever typed | |
| 346 | + | it). g1t names the session from it for now, and settles a title from all | |
| 347 | + | the session does: its work items, pull requests and prompts. Set one | |
| 348 | + | with `g1t session attach --title "…"` only if you can name the whole job. | |
| 343 | 349 | - With no work item, just work: the session shows to the team. When the | |
| 344 | 350 | work becomes a change others should know about (a branch to push, a pull | |
| 345 | 351 | request), look for its work item (`g1t work list`) and claim it; if | |
| 17 | 17 | - **Dependencies**: the work items that have to land before this one. | |
| 18 | 18 | ||
| 19 | 19 | Until there is a work item, the session is the unit: work that starts from | |
| 20 | − | a question or a quick request is recorded as a session titled with what it | |
| 21 | − | is for, and shows to the team on its branch or pull request. It opens or | |
| 20 | + | a question or a quick request is recorded as a session, named by g1t from | |
| 21 | + | what it does ([Session titles](#session-titles)), and shows to the team on its branch or pull request. It opens or | |
| 22 | 22 | claims a work item only when it becomes a change others should know about; | |
| 23 | 23 | see [Work with no work item yet](#work-with-no-work-item-yet). | |
| 24 | 24 | ||
| ⋯ | |||
| 43 | 43 | is not the default branch), the most recently heard from first; those | |
| 44 | 44 | on neither are last, under **No branch yet**. Each session shows its | |
| 45 | 45 | title, who it acts for, its harness, its state, when it was last heard | |
| 46 | − | from and its branch. A session with no title is named by what its | |
| 47 | − | first prompt was for, else **Session on** its branch, else **Untitled | |
| 48 | − | session**. A title that is only a tag, such as | |
| 46 | + | from and its branch. A provisional title (what its first prompt asked | |
| 47 | + | for, until g1t settles one) shows muted. A session with no title is | |
| 48 | + | named by what its first prompt was for, else **Session on** its branch, | |
| 49 | + | else **Untitled session**. A title that is only a tag, such as | |
| 49 | 50 | `<pasted_content id="5c50">`, counts as no title. | |
| 50 | 51 | - **Merge queue**: what is waiting to land and what is being tested, | |
| 51 | 52 | as on the [Merge queue](/guides/merge-queue/#the-merge-queue-page) page. | |
| ⋯ | |||
| 90 | 91 | Anthropic AI provider: see | |
| 91 | 92 | [Start a session on your Anthropic account](#start-a-session-on-your-anthropic-account). | |
| 92 | 93 | ||
| 94 | + | ## Session titles | |
| 95 | + | ||
| 96 | + | A session's title names the whole of its work, and settles instead of | |
| 97 | + | following each turn. Each title says where it came from (`title_source`) | |
| 98 | + | and when it settled (`title_settled_at`): | |
| 99 | + | ||
| 100 | + | | Source | What it is | | |
| 101 | + | | --- | --- | | |
| 102 | + | | `prompt` | Provisional: what its first prompt asked for (its first line or sentence, without pasted text, image placeholders, code or markdown). Shown muted. | | |
| 103 | + | | `item`, `pull`, `summary` | Settled by g1t from the session's record. | | |
| 104 | + | | `agent` | Its harness or agent named it (`title` on start or update, `g1t session attach --title`). | | |
| 105 | + | | `person` | Someone renamed it on its page: pinned. Neither g1t nor the agent replaces it. **Let g1t name it** unpins it. | | |
| 106 | + | ||
| 107 | + | g1t settles a provisional title once there is enough to go on: the session | |
| 108 | + | claims or is attached to a work item, opens or pushes a pull request, | |
| 109 | + | records three prompts, or has run for 30 minutes. It names it again only | |
| 110 | + | when what the session works on changes (another work item or pull | |
| 111 | + | request, or many more prompts), at most every 20 minutes and only when the | |
| 112 | + | new title says something else, and a last time when the session ends, | |
| 113 | + | from everything it did. An agent's own title is kept, unless the session | |
| 114 | + | went on to other work items or pull requests after it was named, when it | |
| 115 | + | is named again as it ends. | |
| 116 | + | ||
| 117 | + | With an [AI provider](/guides/ai/) chosen for Ask, the provider names the | |
| 118 | + | session in a few words, on its fast model, from a short digest of the | |
| 119 | + | record: its work items and pull requests, its prompts (as recorded, with | |
| 120 | + | credentials taken out), its subagents' tasks, where it changed files, its | |
| 121 | + | commits and what it reported. A session is asked about at most five times, | |
| 122 | + | and one whose work is a single work item or pull request takes that title | |
| 123 | + | without asking. Without a provider, or when it fails, the title is the | |
| 124 | + | work item's, else the pull request's, else what the session's most telling | |
| 125 | + | prompt asks for, without greetings and preambles ("This project used | |
| 126 | + | to…", "I think", "So"). A subagent goes by its task and is not titled. | |
| 127 | + | ||
| 93 | 128 | ## Picking up a work item | |
| 94 | 129 | ||
| 95 | 130 | Before working on a work item, load its whole context in one call: | |
| ⋯ | |||
| 132 | 167 | session already started, so a hook can call it every time without | |
| 133 | 168 | checking first. Give `item` (the work item's number in the | |
| 134 | 169 | repository's workspace), `pull` and `branch` when you know them, and a | |
| 135 | − | `title`: what the session is for, in a few words (up to 200 | |
| 136 | − | characters). | |
| 170 | + | `title` only if you can name the whole job in a few words (up to 200 | |
| 171 | + | characters): otherwise g1t names it (see [Session titles](#session-titles)). | |
| 137 | 172 | 2. **Heartbeat** with `session` `update`, or | |
| 138 | 173 | [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/) | |
| 139 | 174 | with an empty body. The same call attaches the session to a work item | |
| 140 | − | or pull request, records the `head_commit` it pushed, or changes its | |
| 141 | − | `title`. | |
| 175 | + | or pull request, records the `head_commit` it pushed, or names it | |
| 176 | + | (`title`; a title a person pinned stays). | |
| 142 | 177 | 3. **Record** what happens with `session` `record`, or | |
| 143 | 178 | [`POST /repos/{owner}/{name}/sessions/{id}/entries`](/reference/api/sessions/record-session-entries/): | |
| 144 | 179 | prompts, reasoning, tool calls, notes and the skills it used (`skill`, | |
| 67 | 67 | type SessionEnder, | |
| 68 | 68 | sessionTitle, | |
| 69 | 69 | sessionTree, | |
| 70 | + | titleHint, | |
| 71 | + | titleIsProvisional, | |
| 70 | 72 | } from "../lib/coordination"; | |
| 71 | 73 | import { AgentSetup } from "./agent-setup"; | |
| 72 | 74 | import { Input, SubmitButton, TimeAgo } from "./ui"; | |
| ⋯ | |||
| 309 | 311 | <Actor username={session.actor.username} harness={session.harness} /> | |
| 310 | 312 | <div className="min-w-0 grow"> | |
| 311 | 313 | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"> | |
| 312 | − | <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent"> | |
| 313 | − | {sessionTitle(session)} | |
| 314 | − | </Link> | |
| 314 | + | {titleIsProvisional(session) ? ( | |
| 315 | + | // Muted while it is what the first prompt asked: g1t names it once the work is clear. | |
| 316 | + | <Hint label={titleHint(session)}> | |
| 317 | + | <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium text-muted hover:text-accent"> | |
| 318 | + | {sessionTitle(session)} | |
| 319 | + | </Link> | |
| 320 | + | </Hint> | |
| 321 | + | ) : ( | |
| 322 | + | <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent"> | |
| 323 | + | {sessionTitle(session)} | |
| 324 | + | </Link> | |
| 325 | + | )} | |
| 315 | 326 | <HarnessBadge harness={session.harness} /> | |
| 316 | 327 | <SessionStateBadge state={session.state} /> | |
| 317 | 328 | </p> | |
| 24 | 24 | resolutionOf, | |
| 25 | 25 | sessionTree, | |
| 26 | 26 | span, | |
| 27 | + | titleHint, | |
| 28 | + | titleIsProvisional, | |
| 27 | 29 | standingFailure, | |
| 28 | 30 | touchedByKind, | |
| 29 | 31 | treeSize, | |
| ⋯ | |||
| 180 | 182 | assert.equal(sessionTitle({ title: null, branch: null }, "look at the CI"), "Look at the CI", "a first prompt given stands in"); | |
| 181 | 183 | }); | |
| 182 | 184 | ||
| 185 | + | test("a title from the first prompt is provisional until g1t, the agent or a person names the session", () => { | |
| 186 | + | assert.equal(titleIsProvisional({ title: "This project used to be in syntaqx/g1t", titleSource: "prompt", parentId: null }), true); | |
| 187 | + | assert.equal(titleIsProvisional({ title: null, titleSource: null, parentId: null }), true); | |
| 188 | + | for (const titleSource of ["agent", "person", "item", "pull", "summary"] as const) { | |
| 189 | + | assert.equal(titleIsProvisional({ title: "Move issues to Work items", titleSource, parentId: null }), false, titleSource); | |
| 190 | + | } | |
| 191 | + | // From before titles said where they came from: as it is. | |
| 192 | + | assert.equal(titleIsProvisional({ title: "Rewrite the auth flow", parentId: null }), false); | |
| 193 | + | // A subagent goes by its task. | |
| 194 | + | assert.equal(titleIsProvisional({ title: null, titleSource: null, parentId: "ses_1" }), false); | |
| 195 | + | assert.match(titleHint({ title: "Mine", titleSource: "person", parentId: null }) ?? "", /keeps this title/); | |
| 196 | + | assert.match(titleHint({ title: "x", titleSource: "prompt", parentId: null }) ?? "", /first prompt/); | |
| 197 | + | assert.equal(titleHint({ title: "x", titleSource: "agent", parentId: "ses_1" }), undefined); | |
| 198 | + | }); | |
| 199 | + | ||
| 183 | 200 | test("a prompt's intent is its first line or sentence, plain, clipped on a word", () => { | |
| 184 | 201 | assert.equal(intentTitle("Make preview builds faster. They time out on big repositories."), "Make preview builds faster"); | |
| 185 | 202 | // What an editor puts ahead of the prompt is not the prompt. | |
| 128 | 128 | * What a prompt is for, as a session's title: its first line or sentence, | |
| 129 | 129 | * without code blocks or markdown, whitespace collapsed, clipped at | |
| 130 | 130 | * TITLE_LIMIT on a word with "…". Null for a slash command, a turn the | |
| 131 | − | * harness starts itself, or nothing to say. The Claude Code plugin | |
| 132 | − | * (plugins/claude-code/hooks/g1t.mjs) titles its sessions the same way. | |
| 131 | + | * harness starts itself, or nothing to say. The work service gives a | |
| 132 | + | * session its provisional title the same way, from its first prompt | |
| 133 | + | * (services/work/src/coordination/titles.rs `intent_title`). | |
| 133 | 134 | */ | |
| 134 | 135 | export function intentTitle(prompt: string | null | undefined): string | null { | |
| 135 | 136 | let text = String(prompt ?? "").replace(/^\uFEFF/, ""); | |
| ⋯ | |||
| 183 | 184 | return session.branch ? `Session on ${session.branch}` : "Untitled session"; | |
| 184 | 185 | } | |
| 185 | 186 | ||
| 187 | + | /** | |
| 188 | + | * Whether what a session is called is still provisional: what its first | |
| 189 | + | * prompt asked, until g1t settles a title from all it did. Shown muted. A | |
| 190 | + | * subagent goes by its task, which is never provisional. | |
| 191 | + | */ | |
| 192 | + | export function titleIsProvisional(session: Pick<WorkSession, "title" | "titleSource" | "parentId">): boolean { | |
| 193 | + | if (session.parentId) return false; | |
| 194 | + | return session.titleSource === "prompt" || (!session.title?.trim() && session.titleSource == null); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /** Why a title reads as it does, for a hint on it. */ | |
| 198 | + | export function titleHint(session: Pick<WorkSession, "title" | "titleSource" | "parentId">): string | undefined { | |
| 199 | + | if (session.parentId) return undefined; | |
| 200 | + | switch (session.titleSource) { | |
| 201 | + | case "person": | |
| 202 | + | return "Named by a person: g1t keeps this title."; | |
| 203 | + | case "agent": | |
| 204 | + | return "Named by its agent."; | |
| 205 | + | case "item": | |
| 206 | + | case "pull": | |
| 207 | + | case "summary": | |
| 208 | + | return "Named by g1t from what the session did."; | |
| 209 | + | default: | |
| 210 | + | return titleIsProvisional(session) ? "From its first prompt, for now: g1t names it once what it is doing is clear." : undefined; | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 186 | 214 | // ── Live now ──────────────────────────────────────────────────────────── | |
| 187 | 215 | ||
| 188 | 216 | /** One work item being worked on: its claims and the sessions on it. */ | |
| 1 | − | import { ArrowLeft, Bot, ChevronRight, ExternalLink, GitBranch, GitCommitHorizontal, Sparkles, Square } from "lucide-react"; | |
| 1 | + | import { ArrowLeft, Bot, ChevronRight, ExternalLink, GitBranch, GitCommitHorizontal, Pencil, Sparkles, Square } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useState } from "react"; | |
| 3 | 3 | import { Form, Link, redirect } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { Actor, CoordinationFeed, EndSessionDialog, HarnessBadge, SessionStateBadge } from "../../components/coordination"; | |
| 10 | 10 | import { SessionEntries } from "../../components/session-entries"; | |
| 11 | − | import { EmptyState, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | + | import { EmptyState, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 12 | 12 | import { Alert } from "../../components/ui/alert"; | |
| 13 | 13 | import { Badge } from "../../components/ui/badge"; | |
| 14 | 14 | import { Button } from "../../components/ui/button"; | |
| ⋯ | |||
| 29 | 29 | pendingLines, | |
| 30 | 30 | sessionTitle, | |
| 31 | 31 | sessionTree, | |
| 32 | + | titleHint, | |
| 33 | + | titleIsProvisional, | |
| 32 | 34 | touchedByKind, | |
| 33 | 35 | viaLabel, | |
| 34 | 36 | } from "../../lib/coordination"; | |
| ⋯ | |||
| 115 | 117 | if (!ended) return { error: "The session could not be ended. Try again in a moment." }; | |
| 116 | 118 | return ended.ok ? { ended: true } : { error: ended.error.message }; | |
| 117 | 119 | } | |
| 118 | − | if (form.get("action") !== "stop") return { error: "There is nothing to do here but stop or end the session." }; | |
| 120 | + | // Naming it pins its title; an empty one lets g1t name it again. The work service checks who may. | |
| 121 | + | if (form.get("action") === "title") { | |
| 122 | + | const title = String(form.get("title") ?? "").trim(); | |
| 123 | + | if (title.length > 200) return { error: "A title is at most 200 characters." }; | |
| 124 | + | const titled = await work.titleWorkSession(user, path, params.id, title || null).catch(() => null); | |
| 125 | + | if (!titled) return { error: "The title could not be saved. Try again in a moment." }; | |
| 126 | + | return titled.ok ? { titled: true } : { error: titled.error.message }; | |
| 127 | + | } | |
| 128 | + | if (form.get("action") !== "stop") return { error: "There is nothing to do here but stop, end or name the session." }; | |
| 119 | 129 | const result = await agentSessions.stop(user, path, params.id).catch(() => null); | |
| 120 | 130 | if (!result) return { error: "The session could not be stopped. Try again in a moment." }; | |
| 121 | 131 | return result.ok ? null : { error: result.error.message }; | |
| 122 | 132 | } | |
| 123 | 133 | ||
| 134 | + | /** | |
| 135 | + | * The session's name, muted while it is provisional, and for whoever may, | |
| 136 | + | * a way to name it (pinning it: g1t keeps a person's title) or to let g1t | |
| 137 | + | * name it again. | |
| 138 | + | */ | |
| 139 | + | function SessionHeading({ session, name, canTitle }: { session: WorkSession; name: string; canTitle: boolean }) { | |
| 140 | + | const [editing, setEditing] = useState(false); | |
| 141 | + | const provisional = titleIsProvisional(session); | |
| 142 | + | if (editing) { | |
| 143 | + | return ( | |
| 144 | + | <div className="flex min-w-0 grow flex-wrap items-center gap-2"> | |
| 145 | + | <Form method="post" className="flex min-w-0 grow items-center gap-2" onSubmit={() => setEditing(false)}> | |
| 146 | + | <input type="hidden" name="action" value="title" /> | |
| 147 | + | <Input | |
| 148 | + | name="title" | |
| 149 | + | defaultValue={provisional ? "" : (session.title ?? "")} | |
| 150 | + | placeholder={name} | |
| 151 | + | maxLength={200} | |
| 152 | + | required | |
| 153 | + | autoFocus | |
| 154 | + | aria-label="Session title" | |
| 155 | + | /> | |
| 156 | + | <SubmitButton size="xs" match={{ action: "title" }} pending="Saving…"> | |
| 157 | + | Save | |
| 158 | + | </SubmitButton> | |
| 159 | + | <Button type="button" variant="ghost" size="xs" onClick={() => setEditing(false)}> | |
| 160 | + | Cancel | |
| 161 | + | </Button> | |
| 162 | + | </Form> | |
| 163 | + | {session.titleSource === "person" && ( | |
| 164 | + | <Form method="post" onSubmit={() => setEditing(false)}> | |
| 165 | + | <input type="hidden" name="action" value="title" /> | |
| 166 | + | <input type="hidden" name="title" value="" /> | |
| 167 | + | <Button type="submit" variant="ghost" size="xs"> | |
| 168 | + | Let g1t name it | |
| 169 | + | </Button> | |
| 170 | + | </Form> | |
| 171 | + | )} | |
| 172 | + | </div> | |
| 173 | + | ); | |
| 174 | + | } | |
| 175 | + | const hint = titleHint(session); | |
| 176 | + | const text = <span className="min-w-0">{name}</span>; | |
| 177 | + | return ( | |
| 178 | + | <h2 className={`flex min-w-0 grow items-center gap-1.5 text-lg font-semibold tracking-tight wrap-break-word ${provisional ? "text-muted" : ""}`}> | |
| 179 | + | {hint ? <Hint label={hint}>{text}</Hint> : text} | |
| 180 | + | {canTitle && ( | |
| 181 | + | <Hint label="Rename"> | |
| 182 | + | <Button type="button" variant="ghost" size="icon-xs" aria-label="Rename this session" onClick={() => setEditing(true)}> | |
| 183 | + | <Pencil size={13} /> | |
| 184 | + | </Button> | |
| 185 | + | </Hint> | |
| 186 | + | )} | |
| 187 | + | </h2> | |
| 188 | + | ); | |
| 189 | + | } | |
| 190 | + | ||
| 124 | 191 | /** A part of the page under a heading. */ | |
| 125 | 192 | function Section({ title, aside, children }: { title: ReactNode; aside?: ReactNode; children: ReactNode }) { | |
| 126 | 193 | return ( | |
| ⋯ | |||
| 346 | 413 | ||
| 347 | 414 | <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 348 | 415 | <Actor username={session.actor.username} harness={session.harness} size={24} /> | |
| 349 | − | <h2 className="min-w-0 grow text-lg font-semibold tracking-tight wrap-break-word">{sessionName(session, firstPrompt)}</h2> | |
| 416 | + | <SessionHeading session={session} name={sessionName(session, firstPrompt)} canTitle={canEnd && !isSubagent} /> | |
| 350 | 417 | {isSubagent && session.agentType && ( | |
| 351 | 418 | <Badge tone="accent"> | |
| 352 | 419 | <Bot size={11} /> | |
| 283 | 283 | pub problem: Option<String>, | |
| 284 | 284 | } | |
| 285 | 285 | ||
| 286 | + | /// `ai_brief`: a short answer from the AI provider a workspace chose for | |
| 287 | + | /// Ask, on its fast model, for g1t's own use (a session's title). Only | |
| 288 | + | /// services ask it, over their bindings. Returns `Outcome<Option<String>>`: | |
| 289 | + | /// none when the workspace has no provider chosen, a failure when the | |
| 290 | + | /// provider was asked and could not answer. | |
| 291 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 292 | + | pub struct AiBriefArgs { | |
| 293 | + | pub workspace: String, | |
| 294 | + | pub system: String, | |
| 295 | + | /// What to answer about, already bounded by the caller (at most | |
| 296 | + | /// [`MAX_BRIEF_INPUT_CHARS`] are sent). | |
| 297 | + | pub input: String, | |
| 298 | + | /// At most [`MAX_BRIEF_TOKENS`]. | |
| 299 | + | pub max_tokens: u32, | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | /// The most of an [`AiBriefArgs`] input sent to the provider. | |
| 303 | + | pub const MAX_BRIEF_INPUT_CHARS: usize = 6_000; | |
| 304 | + | /// The longest answer an [`AiBriefArgs`] may ask for. | |
| 305 | + | pub const MAX_BRIEF_TOKENS: u32 = 200; | |
| 306 | + | ||
| 286 | 307 | /// `ask_settings`: what Ask answers with. Returns `Outcome<AskSetup>`. | |
| 287 | 308 | /// Members only. | |
| 288 | 309 | #[derive(Debug, Serialize, Deserialize)] |
| 206 | 206 | /// what it is for until it has a title. | |
| 207 | 207 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 208 | 208 | pub first_prompt: Option<String>, | |
| 209 | + | /// Where its title came from; none for one with no title, or one from | |
| 210 | + | /// before titles said. | |
| 211 | + | #[serde(default)] | |
| 212 | + | pub title_source: Option<TitleSource>, | |
| 213 | + | /// RFC 3339: when its title was last settled (by g1t, its agent or a | |
| 214 | + | /// person). | |
| 215 | + | #[serde(default)] | |
| 216 | + | pub title_settled_at: Option<String>, | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// Where a session's title came from. g1t settles a title from the | |
| 220 | + | /// session's whole record (coordination/titles.rs in services/work): a | |
| 221 | + | /// [`TitleSource::Prompt`] title is provisional until it does, a | |
| 222 | + | /// [`TitleSource::Person`] one is pinned and never replaced. | |
| 223 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 224 | + | #[serde(rename_all = "snake_case")] | |
| 225 | + | pub enum TitleSource { | |
| 226 | + | /// What its first prompt was for: provisional. | |
| 227 | + | Prompt, | |
| 228 | + | /// Its harness or agent named it (`g1t session attach --title`). Kept, | |
| 229 | + | /// unless the session went on to other work items or pull requests | |
| 230 | + | /// after it was named, when it is settled again as it ends. | |
| 231 | + | Agent, | |
| 232 | + | /// A person named it from the site: pinned. | |
| 233 | + | Person, | |
| 234 | + | /// The title of the work item it works on. | |
| 235 | + | Item, | |
| 236 | + | /// The title of the pull request it opened or works on. | |
| 237 | + | Pull, | |
| 238 | + | /// g1t named the whole of its work: with the workspace's AI provider, | |
| 239 | + | /// or from its most telling prompt. | |
| 240 | + | Summary, | |
| 209 | 241 | } | |
| 210 | 242 | ||
| 243 | + | impl TitleSource { | |
| 244 | + | pub fn as_str(self) -> &'static str { | |
| 245 | + | match self { | |
| 246 | + | TitleSource::Prompt => "prompt", | |
| 247 | + | TitleSource::Agent => "agent", | |
| 248 | + | TitleSource::Person => "person", | |
| 249 | + | TitleSource::Item => "item", | |
| 250 | + | TitleSource::Pull => "pull", | |
| 251 | + | TitleSource::Summary => "summary", | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | pub fn parse(name: &str) -> Option<TitleSource> { | |
| 256 | + | match name.trim() { | |
| 257 | + | "prompt" => Some(TitleSource::Prompt), | |
| 258 | + | "agent" => Some(TitleSource::Agent), | |
| 259 | + | "person" => Some(TitleSource::Person), | |
| 260 | + | "item" => Some(TitleSource::Item), | |
| 261 | + | "pull" => Some(TitleSource::Pull), | |
| 262 | + | "summary" => Some(TitleSource::Summary), | |
| 263 | + | _ => None, | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /// Whether g1t settled it from the session's record. | |
| 268 | + | pub fn settled(self) -> bool { | |
| 269 | + | matches!(self, TitleSource::Item | TitleSource::Pull | TitleSource::Summary) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// `title_session`: a person names a session from the site, pinning its | |
| 274 | + | /// title: g1t never replaces it, nor does its agent. No `title` unpins | |
| 275 | + | /// it: g1t names it again from its record. Whoever runs the session may, | |
| 276 | + | /// and anyone with write access to the repository. Returns | |
| 277 | + | /// `Outcome<Session>`. | |
| 278 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 279 | + | pub struct TitleSessionArgs { | |
| 280 | + | pub actor: User, | |
| 281 | + | pub repo: RepoPath, | |
| 282 | + | pub id: String, | |
| 283 | + | #[serde(default)] | |
| 284 | + | pub title: Option<String>, | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// The most characters a session's title keeps. | |
| 288 | + | pub const MAX_SESSION_TITLE_CHARS: usize = 200; | |
| 289 | + | ||
| 211 | 290 | /// The most characters of an untitled session's first prompt a list gives. | |
| 212 | 291 | pub const MAX_FIRST_PROMPT_CHARS: usize = 300; | |
| 213 | 292 |
| 623 | 623 | getWorkSession: (repo, id, viewer, afterSeq = 0) => | |
| 624 | 624 | call("get_work_session", { repo, id, viewer, after_seq: afterSeq }), | |
| 625 | 625 | endWorkSession: (actor, repo, id) => call("end_session", { actor, repo, id, by_hand: true }), | |
| 626 | + | titleWorkSession: (actor, repo, id, title) => call("title_session", { actor, repo, id, title }), | |
| 626 | 627 | pullSessions: (repo, viewer, numbers) => call("pull_sessions", { repo, viewer, numbers }), | |
| 627 | 628 | listDecisions: (thread, viewer) => call("list_decisions", { ...threadArgs(thread), viewer }), | |
| 628 | 629 | }; |
| 1438 | 1438 | subagents?: number; | |
| 1439 | 1439 | /** In a list, for one with no title: the start of the first prompt it recorded. */ | |
| 1440 | 1440 | firstPrompt?: string | null; | |
| 1441 | + | /** Where its title came from; none for one with no title, or from before titles said. */ | |
| 1442 | + | titleSource?: TitleSource | null; | |
| 1443 | + | /** When its title was last settled. */ | |
| 1444 | + | titleSettledAt?: string | null; | |
| 1441 | 1445 | }; | |
| 1442 | 1446 | ||
| 1447 | + | /** | |
| 1448 | + | * Where a session's title came from. g1t settles a title from the whole of | |
| 1449 | + | * the session's record: `prompt` is provisional (its first prompt's | |
| 1450 | + | * intent) until it does; `person` is pinned from the site and never | |
| 1451 | + | * replaced; `agent` is its agent's own (`g1t session attach --title`); | |
| 1452 | + | * `item`, `pull` and `summary` are g1t's. | |
| 1453 | + | */ | |
| 1454 | + | export type TitleSource = "prompt" | "agent" | "person" | "item" | "pull" | "summary"; | |
| 1455 | + | ||
| 1443 | 1456 | /** What kind of thing a session touched. */ | |
| 1444 | 1457 | export type TouchKind = "file" | "commit" | "branch" | "pull" | "item"; | |
| 1445 | 1458 | ||
| ⋯ | |||
| 1714 | 1727 | */ | |
| 1715 | 1728 | endWorkSession(actor: User, repo: RepoPath, id: string): Promise<Result<WorkSession>>; | |
| 1716 | 1729 | /** | |
| 1730 | + | * A person names a session from the site, pinning its title: neither | |
| 1731 | + | * g1t nor its agent replaces it. No title unpins it, and g1t names it | |
| 1732 | + | * again from its record. The actor's own, or anyone's with write access | |
| 1733 | + | * to the repository (`title_session`). | |
| 1734 | + | */ | |
| 1735 | + | titleWorkSession(actor: User, repo: RepoPath, id: string, title: string | null): Promise<Result<WorkSession>>; | |
| 1736 | + | /** | |
| 1717 | 1737 | * The harness sessions tied to some pull requests (at most 100), any way | |
| 1718 | 1738 | * a session is tied to one; a subagent's as the session that started it. | |
| 1719 | 1739 | * Only pull requests with any are listed. | |
| 9 | 9 | // work item, g1t work view N, given to Claude as context | |
| 10 | 10 | // UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters) | |
| 11 | 11 | // and, for a plugin's skill typed as /plugin:name, | |
| 12 | − | // g1t session log --kind skill NAME; at the session's | |
| 13 | − | // first prompt that is not a slash command, its intent | |
| 14 | − | // as the title, unless it has one: g1t session heartbeat | |
| 15 | − | // --json, then g1t session attach --title INTENT | |
| 12 | + | // g1t session log --kind skill NAME. g1t names the | |
| 13 | + | // session itself: for now from its first prompt, then | |
| 14 | + | // from all it did (services/work/src/coordination/titles.rs) | |
| 16 | 15 | // PreToolUse Agent: the task it is given, kept until its subagent starts, | |
| 17 | 16 | // which is paired with it by the call's tool_use_id or its | |
| 18 | 17 | // prompt (see pairSubagent). | |
| ⋯ | |||
| 66 | 65 | export const SUMMARY_LIMIT = 200; | |
| 67 | 66 | /** The most of a work item's work context given to Claude. */ | |
| 68 | 67 | export const CONTEXT_LIMIT = 8000; | |
| 69 | − | /** The most of a prompt's intent that titles a session. */ | |
| 70 | − | export const TITLE_LIMIT = 80; | |
| 71 | 68 | /** The most of a subagent's task (its description) that is sent. */ | |
| 72 | 69 | export const TASK_LIMIT = 200; | |
| 73 | 70 | /** The most of a subagent's prompt that is sent. */ | |
| ⋯ | |||
| 269 | 266 | .join("\n") | |
| 270 | 267 | .trim(); | |
| 271 | 268 | } | |
| 272 | − | ||
| 273 | − | /** A turn the harness starts itself, as a tag: `<command-name>`, `<task-notification>`, one with attributes. */ | |
| 274 | − | const HARNESS_TAG = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i; | |
| 275 | 269 | ||
| 276 | − | /** What an editor adds ahead of a prompt, a whole block at a time: `<ide_opened_file>…</ide_opened_file>`. */ | |
| 277 | − | const IDE_CONTEXT = /^\s*<(ide_[a-z_]+|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/i; | |
| 278 | − | ||
| 279 | − | /** | |
| 280 | − | * What a prompt is for, as a session's title: its first line or sentence, | |
| 281 | − | * without code blocks or markdown, whitespace collapsed, clipped at | |
| 282 | − | * TITLE_LIMIT on a word with "…". Null for a slash command (/clear, | |
| 283 | − | * /g1t:status 4), a turn Claude Code starts itself, or nothing to say. | |
| 284 | − | * apps/web/app/lib/coordination.ts reads an untitled session's first | |
| 285 | − | * prompt the same way. | |
| 286 | − | */ | |
| 287 | − | export function intentTitle(prompt) { | |
| 288 | − | let text = String(prompt ?? "").replace(/^\uFEFF/, ""); | |
| 289 | − | // What the harness pasted in or attached is not what the prompt is for: | |
| 290 | − | // pasted text (Claude Code's <pasted_content id="…">…</pasted_content>) | |
| 291 | − | // and image placeholders ([Image #1], [Image: source: …]) go first. | |
| 292 | − | text = text | |
| 293 | − | .replace(/<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content[^>]*>|$)/gi, "\n") | |
| 294 | − | .replace(/\[Image(?: #\d+)?(?::[^\]\n]*)?\]/g, " "); | |
| 295 | − | // Context an editor puts before what was typed (VS Code's | |
| 296 | − | // <ide_opened_file>, <ide_selection>) is not a turn the harness started. | |
| 297 | − | while (IDE_CONTEXT.test(text)) text = text.replace(IDE_CONTEXT, ""); | |
| 298 | − | if (/^\s*\//.test(text) || HARNESS_TAG.test(text)) return null; | |
| 299 | − | text = text | |
| 300 | − | .replace(/```[\s\S]*?(```|$)/g, "\n") | |
| 301 | − | .replace(/~~~[\s\S]*?(~~~|$)/g, "\n") | |
| 302 | − | .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") | |
| 303 | − | .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") | |
| 304 | − | .replace(/`([^`]*)`/g, "$1"); | |
| 305 | − | const line = text | |
| 306 | − | .split(/\r?\n/) | |
| 307 | − | .map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "").replace(/(\*\*|__|~~)(.+?)\1/g, "$2").trim()) | |
| 308 | − | .find((l) => /[\p{L}\p{N}]/u.test(l)); | |
| 309 | − | if (!line) return null; | |
| 310 | − | // Its first sentence: up to a stop followed by a space. | |
| 311 | − | let intent = line.replace(/\s+/g, " ").split(/(?<=[.!?])\s+(?=\S)/)[0].trim(); | |
| 312 | − | if (intent.length > TITLE_LIMIT) { | |
| 313 | − | const cut = intent.slice(0, TITLE_LIMIT - 1); | |
| 314 | − | const space = cut.lastIndexOf(" "); | |
| 315 | − | intent = `${(space > TITLE_LIMIT / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, "")}…`; | |
| 316 | − | } else { | |
| 317 | − | intent = intent.replace(/[\s,;:.]+$/, ""); | |
| 318 | − | } | |
| 319 | − | if (intent.length < 2) return null; | |
| 320 | − | return intent[0].toLocaleUpperCase() + intent.slice(1); | |
| 321 | − | } | |
| 322 | − | ||
| 323 | 270 | /** The plugin skill a prompt runs when it is typed as one: `g1t:work-on-item` for `/g1t:work-on-item 42`. */ | |
| 324 | 271 | export function typedSkill(prompt) { | |
| 325 | 272 | const text = String(prompt ?? "").replace(/^/, ""); | |
| ⋯ | |||
| 372 | 319 | // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent. | |
| 373 | 320 | const skill = typedSkill(prompt); | |
| 374 | 321 | if (skill) job.steps.push(skillStep(skill)); | |
| 375 | − | // What it is for titles the session, until it has a title (see hook and work). | |
| 376 | − | const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(redact(prompt)); | |
| 377 | − | if (intent) job.steps.push({ title: intent }); | |
| 322 | + | // g1t names the session from its prompts and all it does: nothing to send for that. | |
| 378 | 323 | return job.steps.length ? job : null; | |
| 379 | 324 | } | |
| 380 | 325 | case "SubagentStart": | |
| ⋯ | |||
| 606 | 551 | shown: (session) => join(dir, `${safe}.shown.${safeName(session)}.json`), | |
| 607 | 552 | item: join(dir, `${safe}.item`), | |
| 608 | 553 | turn: join(dir, `${safe}.turn`), | |
| 609 | − | titled: join(dir, `${safe}.titled`), | |
| 610 | 554 | task: (key) => join(dir, `${safe}.task.${safeName(key)}.json`), | |
| 611 | 555 | shell: (toolUseId) => join(dir, `${safe}.shell.${safeName(toolUseId)}.json`), | |
| 612 | 556 | unrecorded: join(dir, `${safe}.unrecorded.json`), | |
| ⋯ | |||
| 856 | 800 | child.on("error", () => {}); | |
| 857 | 801 | child.unref(); | |
| 858 | 802 | } catch {} | |
| 859 | − | } | |
| 860 | − | ||
| 861 | − | /** Whether a session, as `--json` prints it, has a title; unreadable reads as having one, so nothing is replaced. */ | |
| 862 | − | export function hasTitle(stdout) { | |
| 863 | − | try { | |
| 864 | − | const title = JSON.parse(stdout).title; | |
| 865 | − | return typeof title === "string" && title.trim() !== ""; | |
| 866 | − | } catch { | |
| 867 | − | return true; | |
| 868 | − | } | |
| 869 | 803 | } | |
| 870 | 804 | ||
| 871 | 805 | /** The session id in a `g1t session start --json` answer. */ | |
| ⋯ | |||
| 975 | 909 | if (branch && branch !== "HEAD") touch.push("--branch", branch); | |
| 976 | 910 | cli(bin, touch, opts); | |
| 977 | 911 | } | |
| 978 | − | } else if (step.title) { | |
| 979 | − | // Only a session with no title: one set before (by the agent, a | |
| 980 | − | // person, or this at an earlier prompt) is never replaced. | |
| 981 | − | const seen = cli(bin, ["session", "heartbeat", "--json"], opts); | |
| 982 | − | if (seen.status === EXIT_AUTH) return setProblem(env, "signed_out"); | |
| 983 | − | if (seen.status === EXIT_USAGE) return setProblem(env, "refused", "session heartbeat"); | |
| 984 | − | if (seen.status !== 0 || hasTitle(seen.stdout)) continue; | |
| 985 | − | const out = cli(bin, ["session", "attach", `--title=${step.title}`], opts); | |
| 986 | − | if (out.status === EXIT_USAGE) return setProblem(env, "refused", "session attach"); | |
| 987 | 912 | } else if (step.touchHead) { | |
| 988 | 913 | const head = git(job.cwd, ["rev-parse", "HEAD"]); | |
| 989 | 914 | if (head) cli(bin, ["session", "touch", "--commit", head], opts); | |
| ⋯ | |||
| 1023 | 948 | const TRAILER = "Add the trailer `G1t-Session: $G1T_SESSION` to the commits you make (git commit --trailer), so g1t ties them to this session. A commit pushed straight to the default branch that finishes a work item says `Closes #N` in its message (`Part of #N` when it does only part of it): a bare `(#N)` only links it, and the item stays open."; | |
| 1024 | 949 | ||
| 1025 | 950 | /** | |
| 1026 | − | * How the agent keeps its session's title true: it starts as the first | |
| 1027 | − | * prompt's intent, which often stops being what the session is about. | |
| 951 | + | * What the agent is told of its session's title, once: g1t names the | |
| 952 | + | * session from all it does, so it sets one only for the whole job. | |
| 1028 | 953 | */ | |
| 1029 | − | export const RETITLE = 'Its title starts as your first prompt; once what you are actually doing is clear, and whenever it changes, retitle it in a few words with `g1t session attach --title "…"`.'; | |
| 1030 | − | ||
| 1031 | − | /** | |
| 1032 | − | * At which prompts the agent is reminded to retitle: the second (what the | |
| 1033 | − | * work is has usually shown by then) and every fifth after. | |
| 1034 | − | */ | |
| 1035 | − | export function retitleNudge(turn) { | |
| 1036 | − | return turn === 2 || (turn > 2 && turn % 5 === 0) ? `g1t: if this session's title no longer says what it is doing, retitle it: \`g1t session attach --title "…"\`.` : null; | |
| 1037 | − | } | |
| 954 | + | export const TITLE_NOTE = 'g1t names the session from what it does; set a title with `g1t session attach --title "…"` only if you can name the whole job.'; | |
| 1038 | 955 | ||
| 1039 | 956 | /** | |
| 1040 | 957 | * SessionStart: marks the session as one in a g1t working copy, and | |
| ⋯ | |||
| 1077 | 994 | context = `g1t: ${where} points at work item #${item}. Load what is known about it with \`g1t work view ${item}\`, and claim it with \`g1t work claim ${item}\` before you start. ${TRAILER}`; | |
| 1078 | 995 | } | |
| 1079 | 996 | } else { | |
| 1080 | − | context = `g1t: this session is recorded on g1t and shows to the team, titled with what you are asked to do. Just work. If it turns into a change others should know about (a branch to push, a pull request), first look for its work item (\`g1t work list\`) and claim it with \`g1t work claim N\`; if there is none, open a work item and claim it with \`g1t work claim --title "…"\`. Never open work items for questions, exploration or trivial fixes. ${RETITLE} ${TRAILER}`; | |
| 997 | + | context = `g1t: this session is recorded on g1t and shows to the team. Just work. If it turns into a change others should know about (a branch to push, a pull request), first look for its work item (\`g1t work list\`) and claim it with \`g1t work claim N\`; if there is none, open a work item and claim it with \`g1t work claim --title "…"\`. Never open work items for questions, exploration or trivial fixes. ${TITLE_NOTE} ${TRAILER}`; | |
| 1081 | 998 | } | |
| 1082 | 999 | if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context }; | |
| 1083 | 1000 | return output; | |
| ⋯ | |||
| 1172 | 1089 | * A session that started where there was no g1t remote (SessionStart left | |
| 1173 | 1090 | * it unrecorded) is recorded once there is one: looked for at each prompt, | |
| 1174 | 1091 | * and after a shell command that may have added one (mayAddRemote), never | |
| 1175 | − | * after other tools. Returns what SessionStart would have printed, and the | |
| 1176 | − | * title its first prompt with an intent gave it, or null while there is no | |
| 1177 | − | * g1t remote (keeping that title for later). | |
| 1092 | + | * after other tools. Returns what SessionStart would have printed, or null | |
| 1093 | + | * while there is no g1t remote. | |
| 1178 | 1094 | */ | |
| 1179 | 1095 | export function lateStart(event, env, files) { | |
| 1180 | 1096 | const name = event?.hook_event_name; | |
| 1181 | 1097 | if (name !== "UserPromptSubmit" && name !== "PostToolUse") return null; | |
| 1182 | 1098 | if (name === "PostToolUse" && !(SHELL_TOOLS.has(event.tool_name) && mayAddRemote(event.tool_input?.command))) return null; | |
| 1183 | 1099 | if (!existsSync(files.unrecorded)) return null; | |
| 1184 | − | const kept = readJson(files.unrecorded, {}) ?? {}; | |
| 1185 | 1100 | const output = sessionStart(event, env, findCli(env)); | |
| 1186 | − | if (!output) { | |
| 1187 | − | const intent = name === "UserPromptSubmit" && !kept.title && env.G1T_RECORD_PROMPTS !== "0" ? intentTitle(redact(event.prompt)) : null; | |
| 1188 | − | if (intent) write(files.unrecorded, JSON.stringify({ ...kept, title: intent })); | |
| 1189 | − | return null; | |
| 1190 | − | } | |
| 1101 | + | if (!output) return null; | |
| 1191 | 1102 | rmSync(files.unrecorded, { force: true }); | |
| 1192 | − | return { output, title: kept.title ?? null }; | |
| 1103 | + | return { output }; | |
| 1193 | 1104 | } | |
| 1194 | 1105 | ||
| 1195 | 1106 | /** Runs one hook: what it prints, and the job it hands to the background. */ | |
| ⋯ | |||
| 1231 | 1142 | if (name === "SessionEnd" && !existsSync(files.marker)) rmSync(files.unrecorded, { force: true }); | |
| 1232 | 1143 | // Only sessions found in a g1t working copy are recorded. | |
| 1233 | 1144 | if (!existsSync(files.marker) || !findCli(env)) return { output: null, job: null }; | |
| 1234 | − | if (late) { | |
| 1235 | − | job.start = true; | |
| 1236 | − | // What its first prompt was for titles it, over what this one is for. | |
| 1237 | − | if (late.title) job.steps = [...job.steps.filter((step) => !step.title), { title: late.title }]; | |
| 1238 | − | } | |
| 1145 | + | if (late) job.start = true; | |
| 1239 | 1146 | for (const step of job.steps) if (step.skill) step.key = `skill:${safeName(step.skill)}:${turn}`; | |
| 1240 | − | // The first prompt with an intent titles the session; later ones do not try. | |
| 1241 | − | if (job.steps.some((step) => step.title)) { | |
| 1242 | − | if (existsSync(files.titled)) job.steps = job.steps.filter((step) => !step.title); | |
| 1243 | − | else write(files.titled, "1"); | |
| 1244 | − | } | |
| 1245 | 1147 | if (name === "SubagentStart") { | |
| 1246 | 1148 | // Paired with its Agent call in the background half (pairSubagent), which may wait for Claude Code's files. | |
| 1247 | 1149 | job.pair = true; | |
| ⋯ | |||
| 1253 | 1155 | const context = [ | |
| 1254 | 1156 | late?.output.hookSpecificOutput?.additionalContext, | |
| 1255 | 1157 | name === "UserPromptSubmit" ? updateContext(event, env) : null, | |
| 1256 | − | // Only while it is being recorded: not with the CLI signed out or too old. | |
| 1257 | − | name === "UserPromptSubmit" && !readJson(join(dataDir(env), "notice.json"), {}).problem ? retitleNudge(turn) : null, | |
| 1258 | 1158 | ] | |
| 1259 | 1159 | .filter(Boolean) | |
| 1260 | 1160 | .join("\n\n"); | |
| 8 | 8 | import { fileURLToPath } from "node:url"; | |
| 9 | 9 | ||
| 10 | 10 | import { | |
| 11 | − | retitleNudge, | |
| 11 | + | TITLE_NOTE, | |
| 12 | 12 | updateContext, | |
| 13 | 13 | UPDATES_REPEAT_MS, | |
| 14 | 14 | howToUpdate, | |
| ⋯ | |||
| 17 | 17 | agentResult, | |
| 18 | 18 | branchFromCommand, | |
| 19 | 19 | g1tTool, | |
| 20 | − | hasTitle, | |
| 21 | 20 | hook, | |
| 22 | − | intentTitle, | |
| 23 | 21 | repoPath, | |
| 24 | 22 | MIN_CLI, | |
| 25 | − | TITLE_LIMIT, | |
| 26 | 23 | oldNode, | |
| 27 | 24 | older, | |
| 28 | 25 | typedSkill, | |
| ⋯ | |||
| 73 | 70 | assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, {}).steps[0].args.at(-1), "hi"); | |
| 74 | 71 | }); | |
| 75 | 72 | ||
| 76 | − | test("a prompt's intent is its first line or sentence, plain, clipped on a word", () => { | |
| 77 | − | assert.equal(intentTitle("fix the login redirect"), "Fix the login redirect"); | |
| 78 | − | assert.equal(intentTitle("Make preview builds faster. They time out on big repositories."), "Make preview builds faster"); | |
| 79 | − | assert.equal(intentTitle("Why does `src/app.ts` fail? It worked yesterday."), "Why does src/app.ts fail?"); | |
| 80 | − | assert.equal(intentTitle("```js\nconst a = 1;\n```\n## Clean **this** up\nmore"), "Clean this up"); | |
| 81 | − | assert.equal(intentTitle("\n\n- look at [the docs](https://docs.g1t.sh) for g1t.mjs"), "Look at the docs for g1t.mjs"); | |
| 82 | − | assert.equal(intentTitle(" spread over\tspaces "), "Spread over spaces"); | |
| 83 | − | const long = intentTitle("[TEST] Tidy up the README and then look at the CI pipeline, which keeps failing on the Windows runners"); | |
| 84 | − | assert.ok(long.length <= TITLE_LIMIT, long); | |
| 85 | − | assert.ok(long.endsWith("…") && !long.endsWith(" …"), long); | |
| 86 | − | assert.ok(long.startsWith("[TEST] Tidy up the README"), long); | |
| 87 | − | assert.ok("[TEST] Tidy up the README and then look at the CI pipeline, which keeps failing on the Windows runners".startsWith(long.slice(0, -1))); | |
| 88 | − | // What was pasted or attached is left out: the words typed around it title the session. | |
| 89 | − | assert.equal(intentTitle('<pasted_content id="5c50">\nUncaught TypeError: x is undefined\n</pasted_content>\nAlso you broke the buttons'), "Also you broke the buttons"); | |
| 90 | − | assert.equal(intentTitle("[Image #1] the PR link is missing on Home"), "The PR link is missing on Home"); | |
| 91 | − | assert.equal(intentTitle("[Image: source: C:\\tmp\\1.png]\nmake it render"), "Make it render"); | |
| 92 | − | assert.equal(intentTitle('<pasted_content id="1">only a paste'), null); | |
| 93 | − | // What VS Code puts ahead of what was typed is not a turn Claude Code started. | |
| 94 | − | assert.equal( | |
| 95 | − | intentTitle("<ide_opened_file>The user opened the file c:\\a\\Dockerfile in the IDE.</ide_opened_file>\nWhether it's the desktop app or the page, it crashes"), | |
| 96 | − | "Whether it's the desktop app or the page, it crashes", | |
| 97 | − | ); | |
| 98 | − | assert.equal(intentTitle("<ide_selection>const a = 1;</ide_selection>\n<ide_opened_file>a.ts</ide_opened_file>\nrename this"), "Rename this"); | |
| 99 | − | assert.equal(intentTitle("<ide_opened_file>a.ts</ide_opened_file>\n/clear"), null); | |
| 100 | − | // Not intents: slash commands, turns Claude Code starts, nothing to say. | |
| 101 | − | for (const prompt of ["/clear", "/compact keep the plan", "/g1t:status 4", "<command-name>/clear</command-name>", '<local-command-stdout lang="en">ok</local-command-stdout>', "```\ncode only\n```", "?", "", null]) { | |
| 102 | − | assert.equal(intentTitle(prompt), null, String(prompt)); | |
| 73 | + | test("UserPromptSubmit sends no title: g1t names the session from what it records", () => { | |
| 74 | + | for (const prompt of ["fix the login redirect", "/clear", "Build me a todo app. Use React."]) { | |
| 75 | + | const job = plan({ ...base, hook_event_name: "UserPromptSubmit", prompt }, {}); | |
| 76 | + | assert.ok(job.steps.every((s) => s.args && !s.title && !s.args.includes("attach")), prompt); | |
| 103 | 77 | } | |
| 104 | − | }); | |
| 105 | − | ||
| 106 | − | test("UserPromptSubmit titles the session with its intent, unless prompts are kept back", () => { | |
| 107 | − | const job = plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "fix the login redirect" }, {}); | |
| 108 | − | assert.deepEqual(job.steps.at(-1), { title: "Fix the login redirect" }); | |
| 109 | − | assert.ok(!plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "/clear" }, {}).steps.some((s) => s.title)); | |
| 110 | 78 | assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "fix it" }, { G1T_RECORD_PROMPTS: "0" }), null); | |
| 111 | − | assert.equal(hasTitle(JSON.stringify({ id: "ses_1", title: null })), false); | |
| 112 | − | assert.equal(hasTitle(JSON.stringify({ id: "ses_1", title: " " })), false); | |
| 113 | − | assert.equal(hasTitle(JSON.stringify({ id: "ses_1", title: "Fix it" })), true); | |
| 114 | − | assert.equal(hasTitle("not json"), true, "unreadable: nothing is replaced"); | |
| 115 | 79 | }); | |
| 116 | 80 | ||
| 117 | 81 | test("G1T_RECORD_PROMPTS=0 keeps prompts back, and G1T_RECORD=0 everything", () => { | |
| ⋯ | |||
| 600 | 564 | assert.equal(pairSubagent({ id: "a7", type: "Explore" }, files, join(dir, "t.jsonl"), { G1T_RECORD_PROMPTS: "0" }).prompt, undefined); | |
| 601 | 565 | }); | |
| 602 | 566 | ||
| 603 | − | test("a session started with no g1t remote is recorded once a command adds one, titled by its first prompt", () => { | |
| 567 | + | test("a session started with no g1t remote is recorded once a command adds one", () => { | |
| 604 | 568 | git("remote", "remove", "origin"); | |
| 605 | 569 | const at = (event) => ({ ...base, cwd: dir, ...event }); | |
| 606 | 570 | assert.deepEqual(hook(at({ hook_event_name: "SessionStart", source: "startup" }), env), { output: null, job: null }); | |
| ⋯ | |||
| 617 | 581 | assert.equal(job.start, true); | |
| 618 | 582 | assert.equal(output.hookSpecificOutput.hookEventName, "PostToolUse"); | |
| 619 | 583 | assert.match(output.hookSpecificOutput.additionalContext, /work item #42/, "told what SessionStart would have told it"); | |
| 620 | − | assert.deepEqual(job.steps.at(-1), { title: "Build me a todo app" }); | |
| 621 | 584 | work(job, env); | |
| 622 | 585 | const recorded = calls().filter((c) => c.args[0] === "session"); | |
| 623 | 586 | assert.deepEqual(recorded[0].args, ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123", "--json"]); | |
| 624 | 587 | assert.ok(recorded.some((c) => c.args[1] === "log" && c.args.at(-1).startsWith("Bash: g1t repo create acme/app"))); | |
| 625 | − | assert.ok(recorded.some((c) => c.args.join(" ") === "session attach --title=Build me a todo app")); | |
| 626 | − | // From here on it is recorded like any other, and titled once. | |
| 588 | + | // g1t names it, from what it records from here on: no title is sent. | |
| 589 | + | assert.ok(!recorded.some((c) => c.args[1] === "attach")); | |
| 627 | 590 | const next = hook(at({ hook_event_name: "UserPromptSubmit", prompt: "Now add tests" }), env).job; | |
| 628 | − | assert.ok(next && !next.steps.some((s) => s.title) && !next.start); | |
| 591 | + | assert.ok(next && !next.start); | |
| 592 | + | assert.deepEqual(next.steps.map((s) => s.args.slice(0, 4)), [["session", "log", "--kind", "prompt"]]); | |
| 629 | 593 | }); | |
| 630 | 594 | ||
| 631 | 595 | test("a session given a g1t remote between prompts is recorded at the next one", () => { | |
| ⋯ | |||
| 636 | 600 | git("remote", "set-url", "origin", "https://g1t.sh/acme/app.git"); | |
| 637 | 601 | const { job } = hook(at({ hook_event_name: "UserPromptSubmit", prompt: "fix the login" }), env); | |
| 638 | 602 | assert.equal(job.start, true); | |
| 639 | − | assert.deepEqual(job.steps.at(-1), { title: "Fix the login" }, "no earlier prompt said what it is for: this one does"); | |
| 603 | + | assert.equal(job.steps.at(-1).args.at(-1), "fix the login", "its prompt is recorded, and g1t names the session from it"); | |
| 640 | 604 | assert.ok(existsSync(sessionFiles(base.session_id, env).marker)); | |
| 641 | 605 | assert.ok(!existsSync(sessionFiles(base.session_id, env).unrecorded)); | |
| 642 | 606 | }); | |
| ⋯ | |||
| 836 | 800 | assert.match(context, /Just work/); | |
| 837 | 801 | assert.match(context, /`g1t work claim --title "…"`/); | |
| 838 | 802 | assert.match(context, /Never open work items for questions, exploration or trivial fixes/); | |
| 839 | − | assert.match(context, /retitle it in a few words with `g1t session attach --title "…"`/); | |
| 803 | + | // Told once that g1t names the session, and when to name it itself. | |
| 804 | + | assert.ok(context.includes(TITLE_NOTE)); | |
| 805 | + | assert.match(context, /only if you can name the whole job/); | |
| 806 | + | assert.doesNotMatch(context, /retitle/i); | |
| 840 | 807 | assert.ok(context.length < 1100, `injected every session, so short: ${context.length}`); | |
| 841 | 808 | assert.ok(!calls().some((c) => c.args[0] === "work"), "no work item's context is asked for"); | |
| 842 | 809 | }); | |
| 843 | 810 | ||
| 844 | − | test("Claude is reminded to retitle the session at its second prompt and every fifth after, no more", () => { | |
| 845 | − | assert.deepEqual( | |
| 846 | − | Array.from({ length: 16 }, (_, i) => i + 1).filter((turn) => retitleNudge(turn)), | |
| 847 | − | [2, 5, 10, 15], | |
| 848 | − | ); | |
| 849 | − | assert.match(retitleNudge(2), /g1t session attach --title/); | |
| 850 | − | }); | |
| 851 | − | ||
| 852 | − | test("the session's first prompt with an intent titles it, once, and never over a title it has", () => { | |
| 811 | + | test("Claude is never nudged to retitle, and no prompt sets a title", () => { | |
| 853 | 812 | hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env); | |
| 854 | 813 | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env); | |
| 855 | − | const prompt = (text, more = {}) => hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: text }, { ...env, ...more }).job; | |
| 856 | − | // A slash command says nothing of what the session is for. | |
| 857 | − | work(prompt("/clear"), env); | |
| 858 | − | assert.ok(!calls().some((c) => c.args[1] === "attach" || c.args[1] === "heartbeat")); | |
| 859 | − | work(prompt("[TEST] fix the preview builds. They time out."), env); | |
| 860 | − | const titled = calls().filter((c) => c.args[1] === "heartbeat" || c.args[1] === "attach"); | |
| 861 | − | assert.deepEqual(titled, [ | |
| 862 | − | { args: ["session", "heartbeat", "--json"], session: "ses_fake01" }, | |
| 863 | − | { args: ["session", "attach", "--title=[TEST] fix the preview builds"], session: "ses_fake01" }, | |
| 864 | − | ]); | |
| 865 | − | // Later prompts do not try again. | |
| 866 | − | const next = prompt("now the tests"); | |
| 867 | − | assert.ok(!next.steps.some((s) => s.title)); | |
| 868 | − | }); | |
| 869 | − | ||
| 870 | − | test("a session titled already keeps its title", () => { | |
| 871 | − | hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env); | |
| 872 | − | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env); | |
| 873 | − | const job = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env).job; | |
| 874 | − | work(job, { ...env, FAKE_G1T_TITLE: "Rewrite the auth flow" }); | |
| 875 | − | assert.ok(calls().some((c) => c.args[1] === "heartbeat")); | |
| 876 | − | assert.ok(!calls().some((c) => c.args[1] === "attach"), "a title set before is never replaced"); | |
| 814 | + | for (const text of ["/clear", "[TEST] fix the preview builds. They time out.", "now the tests", "and the docs", "commit it", "push"]) { | |
| 815 | + | const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: text }, env); | |
| 816 | + | assert.doesNotMatch(JSON.stringify(output ?? {}), /retitle|attach --title/, text); | |
| 817 | + | if (job) work(job, env); | |
| 818 | + | } | |
| 819 | + | assert.ok(!calls().some((c) => c.args[1] === "attach" || (c.args[1] === "heartbeat" && c.args.includes("--json"))), "no title is set or looked for"); | |
| 877 | 820 | }); | |
| 878 | 821 | ||
| 879 | 822 | test("Claude's own commands get the session's id once it has started", () => { | |
| 4 | 4 | the same time; the `g1t` CLI is how you see them and how they see you. | |
| 5 | 5 | ||
| 6 | 6 | - When you start, record a session and keep its returned ID: | |
| 7 | − | `g1t session start --harness codex --title "Fix the preview builds" --no-remember` | |
| 7 | + | `g1t session start --harness codex --no-remember` | |
| 8 | 8 | (or `cursor`, `copilot`, `other`: the harness you are). Set `G1T_SESSION` | |
| 9 | 9 | for every command in this run: `G1T_SESSION=ses_ID g1t ...` in Bash, | |
| 10 | 10 | or `$env:G1T_SESSION='ses_ID'; g1t ...` in PowerShell. Shell state may | |
| ⋯ | |||
| 14 | 14 | few minutes and `g1t session end` when you stop. Only create subagents | |
| 15 | 15 | when the user's task permits delegation; give each its own session with | |
| 16 | 16 | `--parent PARENT_ID --no-remember` and its own G1T_SESSION. | |
| 17 | + | - Log what you are asked as the session's prompt: | |
| 18 | + | `g1t session log --kind prompt "TEXT"` (it shows only to whoever typed | |
| 19 | + | it). g1t names the session from it for now, and settles a title from all | |
| 20 | + | the session does: its work items, pull requests and prompts. Set one | |
| 21 | + | with `g1t session attach --title "…"` only if you can name the whole job. | |
| 17 | 22 | - With no work item, just work: the session shows to the team. When the | |
| 18 | 23 | work becomes a change others should know about (a branch to push, a pull | |
| 19 | 24 | request), look for its work item (`g1t work list`) and claim it; if | |
| 22 | 22 | use g1t_secrets as crypto; | |
| 23 | 23 | ||
| 24 | 24 | /// The methods this module answers. | |
| 25 | − | pub const METHODS: &[&str] = &["ai_list", "ai_add", "ai_update", "ai_remove", "ai_test", "ask_settings", "set_ask_settings", "ask"]; | |
| 25 | + | pub const METHODS: &[&str] = &["ai_list", "ai_add", "ai_update", "ai_remove", "ai_test", "ask_settings", "set_ask_settings", "ask", "ai_brief"]; | |
| 26 | 26 | ||
| 27 | 27 | /// Answers one of [`METHODS`]. | |
| 28 | 28 | pub async fn route(service: &Integrations, method: &str, body: Value) -> Result<Response> { | |
| ⋯ | |||
| 35 | 35 | "ask_settings" => reply(&service.ask_settings(args(body)?).await?), | |
| 36 | 36 | "set_ask_settings" => reply(&service.set_ask_settings(args(body)?).await?), | |
| 37 | 37 | "ask" => service.ask(args(body)?).await, | |
| 38 | + | "ai_brief" => reply(&service.ai_brief(args(body)?).await?), | |
| 38 | 39 | _ => Response::error("Unknown method", 404), | |
| 39 | 40 | } | |
| 40 | 41 | } | |
| ⋯ | |||
| 643 | 644 | Ok(Response::from_stream(lines)?.with_headers(headers)) | |
| 644 | 645 | } | |
| 645 | 646 | ||
| 647 | + | /// A short answer for g1t's own use (`AiBriefArgs`): the workspace's | |
| 648 | + | /// Ask provider, on its fast model when it lists one, without tools. | |
| 649 | + | /// None when the workspace has chosen none, or it cannot be used. | |
| 650 | + | pub(crate) async fn ai_brief(&self, a: AiBriefArgs) -> Result<Outcome<Option<String>>> { | |
| 651 | + | let workspace = a.workspace.to_lowercase(); | |
| 652 | + | let (setup, row) = self.ask_setup(&workspace).await?; | |
| 653 | + | let (Some(row), None) = (row, setup.problem) else { | |
| 654 | + | return Ok(Outcome::Ok(None)); | |
| 655 | + | }; | |
| 656 | + | let model = if setup.auto { auto::choose(&auto_models(&row), row.config().model.as_deref(), auto::Need::Quick) } else { setup.model }; | |
| 657 | + | let Some(model) = model else { | |
| 658 | + | return Ok(Outcome::Ok(None)); | |
| 659 | + | }; | |
| 660 | + | let provider = row.provider(); | |
| 661 | + | let config = row.config(); | |
| 662 | + | let key = self.ai_secret(&row); | |
| 663 | + | let input: String = a.input.chars().take(MAX_BRIEF_INPUT_CHARS).collect(); | |
| 664 | + | let messages = [AskMessage { role: AskRole::User, content: input }]; | |
| 665 | + | let built = match ask::build(&ask::Request { | |
| 666 | + | provider, | |
| 667 | + | config: &config, | |
| 668 | + | key: key.as_deref(), | |
| 669 | + | model: &model, | |
| 670 | + | system: &a.system, | |
| 671 | + | messages: &messages, | |
| 672 | + | rounds: &[], | |
| 673 | + | tools: &[], | |
| 674 | + | answer_now: false, | |
| 675 | + | max_tokens: a.max_tokens.clamp(1, MAX_BRIEF_TOKENS), | |
| 676 | + | }) { | |
| 677 | + | Ok(built) => built, | |
| 678 | + | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 679 | + | }; | |
| 680 | + | let headers: Vec<(&str, &str)> = built.headers.iter().map(|(name, value)| (name.as_str(), value.as_str())).collect(); | |
| 681 | + | let mut response = match http::open(Method::Post, &built.url, &headers, Some(built.body)).await { | |
| 682 | + | Ok(response) => response, | |
| 683 | + | Err(error) => return Ok(fail(FailureCode::Unavailable, format!("{} could not be reached: {error}", provider.label()))), | |
| 684 | + | }; | |
| 685 | + | let status = response.status_code(); | |
| 686 | + | if !(200..300).contains(&status) { | |
| 687 | + | let problem = ask_loop::refused_by(provider, status, response.text().await.unwrap_or_default()); | |
| 688 | + | self.ai_note(&row.id, Some(&problem)).await?; | |
| 689 | + | return Ok(fail(FailureCode::Unavailable, problem)); | |
| 690 | + | } | |
| 691 | + | let bytes = response.bytes().await?; | |
| 692 | + | let mut round = ask::Round::new(provider, false); | |
| 693 | + | round.feed(&bytes); | |
| 694 | + | round.end(); | |
| 695 | + | if let Some(problem) = round.error() { | |
| 696 | + | return Ok(fail(FailureCode::Unavailable, problem.to_owned())); | |
| 697 | + | } | |
| 698 | + | let text = round.text().trim().to_owned(); | |
| 699 | + | Ok(Outcome::Ok((!text.is_empty()).then_some(text))) | |
| 700 | + | } | |
| 701 | + | ||
| 646 | 702 | /// An answer that never started: its credential ends now. | |
| 647 | 703 | pub(crate) async fn ask_refused(&self, mut answering: ask_loop::Answering, problem: &str) -> Result<Response> { | |
| 648 | 704 | if let Some(id) = answering.credential.take() { | |
| 1 | + | -- Session titles that settle (src/coordination/titles.rs). A session's | |
| 2 | + | -- title says where it came from (`title_source`): `prompt` (provisional, | |
| 3 | + | -- the first prompt's intent), `agent` (its harness or agent said so), | |
| 4 | + | -- `person` (pinned from the site, never replaced), `item` or `pull` (the | |
| 5 | + | -- work item's or pull request's title) or `summary` (g1t named the whole | |
| 6 | + | -- of the work, with the workspace's AI provider or without one). | |
| 7 | + | -- | |
| 8 | + | -- g1t settles a title when one is due (`title_due_at`, read by the cron): | |
| 9 | + | -- once the session has enough to go on, again only when what it works on | |
| 10 | + | -- changes (at most every 20 minutes), and a last time when it ends | |
| 11 | + | -- (`title_final`). `title_basis` is what it was settled from (its work | |
| 12 | + | -- items and pull requests), `title_prompts` how many prompts it had then, | |
| 13 | + | -- `title_calls` how many times the AI provider was asked (a handful at | |
| 14 | + | -- most), and `prompts` how many prompts it recorded. | |
| 15 | + | ALTER TABLE sessions ADD COLUMN title_source TEXT; | |
| 16 | + | ALTER TABLE sessions ADD COLUMN title_settled_at TEXT; | |
| 17 | + | ALTER TABLE sessions ADD COLUMN title_due_at TEXT; | |
| 18 | + | ALTER TABLE sessions ADD COLUMN title_final INTEGER NOT NULL DEFAULT 0; | |
| 19 | + | ALTER TABLE sessions ADD COLUMN title_basis TEXT; | |
| 20 | + | ALTER TABLE sessions ADD COLUMN title_prompts INTEGER NOT NULL DEFAULT 0; | |
| 21 | + | ALTER TABLE sessions ADD COLUMN title_calls INTEGER NOT NULL DEFAULT 0; | |
| 22 | + | ALTER TABLE sessions ADD COLUMN prompts INTEGER NOT NULL DEFAULT 0; | |
| 23 | + | ||
| 24 | + | UPDATE sessions SET prompts = ( | |
| 25 | + | SELECT COUNT(*) FROM session_entries e WHERE e.session_id = sessions.id AND e.kind = 'prompt' | |
| 26 | + | ); | |
| 27 | + | ||
| 28 | + | -- Every session started before this is settled once (the backfill): a | |
| 29 | + | -- subagent keeps its task, so only sessions at the top of a tree. | |
| 30 | + | UPDATE sessions SET title_due_at = COALESCE(ended_at, last_seen_at) WHERE parent_id IS NULL; | |
| 31 | + | ||
| 32 | + | CREATE INDEX sessions_title_due ON sessions (title_due_at) WHERE title_due_at IS NOT NULL; |
| 211 | 211 | async fn link(&self, session_id: &str, repo_id: &str, kind: LinkKind, number: u32, item_id: &str, via: LinkVia) { | |
| 212 | 212 | let at = rfc3339(now_ms()); | |
| 213 | 213 | let linked = async { | |
| 214 | − | self.db | |
| 215 | − | .prepare(INSERT_LINK) | |
| 214 | + | let new = self | |
| 215 | + | .db | |
| 216 | + | .prepare(format!("{INSERT_LINK} RETURNING via")) | |
| 216 | 217 | .bind(&[ | |
| 217 | 218 | session_id.into(), | |
| 218 | 219 | repo_id.into(), | |
| 219 | 220 | kind.as_str().into(), | |
| 220 | 221 | number.into(), | |
| 221 | 222 | via.as_str().into(), | |
| 222 | − | at.into(), | |
| 223 | + | at.as_str().into(), | |
| 223 | 224 | item_id.into(), | |
| 224 | 225 | ])? | |
| 225 | − | .run() | |
| 226 | − | .await | |
| 226 | + | .all() | |
| 227 | + | .await? | |
| 228 | + | .results::<serde_json::Value>()?; | |
| 229 | + | // Tied to what it works on for the first time: its title is due (titles.rs). | |
| 230 | + | if !new.is_empty() && via != LinkVia::Ref { | |
| 231 | + | self.db.prepare(super::titles::linked_sql()).bind(&[session_id.into(), at.as_str().into()])?.run().await?; | |
| 232 | + | } | |
| 233 | + | Ok::<(), worker::Error>(()) | |
| 227 | 234 | }; | |
| 228 | 235 | if let Err(error) = linked.await { | |
| 229 | 236 | worker::console_error!("session {session_id}: not tied to {} #{number}{item_id}: {error}", kind.as_str()); |
| 17 | 17 | mod overlaps; | |
| 18 | 18 | mod sessions; | |
| 19 | 19 | pub(crate) mod shown; | |
| 20 | + | pub(crate) mod titles; | |
| 20 | 21 | #[cfg(test)] | |
| 21 | 22 | mod tests; | |
| 22 | 23 | ||
| ⋯ | |||
| 50 | 51 | " AS item, | |
| 51 | 52 | pull, branch, actor_id, actor_name, harness, harness_session_id, | |
| 52 | 53 | title, state, head_commit, started_at, last_seen_at, ended_at, end_reason, | |
| 53 | − | parent_id, agent_type, task, prompt, result, model" | |
| 54 | + | parent_id, agent_type, task, prompt, result, model, title_source, title_settled_at" | |
| 54 | 55 | ); | |
| 55 | 56 | ||
| 56 | 57 | /// A claim's columns, with its work item's workspace and number. | |
| ⋯ | |||
| 99 | 100 | result: Option<String>, | |
| 100 | 101 | #[serde(default)] | |
| 101 | 102 | model: Option<String>, | |
| 103 | + | #[serde(default)] | |
| 104 | + | title_source: Option<String>, | |
| 105 | + | #[serde(default)] | |
| 106 | + | title_settled_at: Option<String>, | |
| 102 | 107 | } | |
| 103 | 108 | ||
| 104 | 109 | impl SessionRow { | |
| ⋯ | |||
| 129 | 134 | model: self.model, | |
| 130 | 135 | subagents: 0, | |
| 131 | 136 | first_prompt: None, | |
| 137 | + | title_source: self.title_source.as_deref().and_then(TitleSource::parse), | |
| 138 | + | title_settled_at: self.title_settled_at, | |
| 132 | 139 | } | |
| 133 | 140 | } | |
| 134 | 141 | } | |
| ⋯ | |||
| 189 | 196 | const SWEEP_LIMIT: u32 = 50; | |
| 190 | 197 | ||
| 191 | 198 | /// Ends a repository's (`?1`) session `?3` if last seen at or before `?2`, | |
| 192 | − | /// as `timed_out`, as of when it was last seen. | |
| 199 | + | /// as `timed_out`, as of when it was last seen; its title is settled a | |
| 200 | + | /// last time (titles.rs). | |
| 193 | 201 | pub(crate) fn time_out_sessions_sql() -> String { | |
| 194 | 202 | format!( | |
| 195 | − | "UPDATE sessions SET state = 'ended', ended_at = last_seen_at, end_reason = 'timed_out' | |
| 203 | + | "UPDATE sessions SET state = 'ended', ended_at = last_seen_at, end_reason = 'timed_out', {final_due} | |
| 196 | 204 | WHERE repo_id = ?1 AND state = 'active' AND last_seen_at <= ?2 AND id = ?3 | |
| 197 | − | RETURNING {SESSION_COLUMNS}" | |
| 205 | + | RETURNING {SESSION_COLUMNS}", | |
| 206 | + | final_due = titles::final_due("last_seen_at") | |
| 198 | 207 | ) | |
| 199 | 208 | } | |
| 200 | 209 | ||
| 31 | 31 | /// The most pull requests one `pull_sessions` asks about. | |
| 32 | 32 | const MAX_PULL_SESSIONS: usize = 100; | |
| 33 | 33 | use super::updates::push_delivery; | |
| 34 | − | use super::{SESSION_COLUMNS, SessionRow, after_minutes, shown, tidy, unwrap_or_fail}; | |
| 34 | + | use super::{SESSION_COLUMNS, SessionRow, after_minutes, shown, tidy, titles, unwrap_or_fail}; | |
| 35 | 35 | use crate::Work; | |
| 36 | 36 | use crate::items; | |
| 37 | 37 | use crate::rows::NumberRow; | |
| ⋯ | |||
| 40 | 40 | const ENTRY_PAGE: u32 = 500; | |
| 41 | 41 | /// The most sessions one list returns. | |
| 42 | 42 | const LIST_LIMIT: u32 = 100; | |
| 43 | − | const MAX_TITLE_CHARS: usize = 200; | |
| 43 | + | const MAX_TITLE_CHARS: usize = MAX_SESSION_TITLE_CHARS; | |
| 44 | 44 | const MAX_BRANCH_CHARS: usize = 255; | |
| 45 | 45 | const MAX_ID_CHARS: usize = 200; | |
| 46 | 46 | const MAX_COMMIT_CHARS: usize = 64; | |
| ⋯ | |||
| 125 | 125 | format!( | |
| 126 | 126 | "INSERT INTO sessions | |
| 127 | 127 | (id, repo_id, item_id, pull, branch, actor_id, actor_name, harness, harness_session_id, | |
| 128 | − | title, state, head_commit, started_at, last_seen_at, parent_id, agent_type, task, prompt, model) | |
| 129 | − | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) | |
| 128 | + | title, state, head_commit, started_at, last_seen_at, parent_id, agent_type, task, prompt, model, | |
| 129 | + | title_source, title_settled_at) | |
| 130 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, | |
| 131 | + | CASE WHEN ?10 IS NULL THEN NULL ELSE 'agent' END, CASE WHEN ?10 IS NULL THEN NULL ELSE ?12 END) | |
| 130 | 132 | ON CONFLICT (repo_id, actor_id, harness, harness_session_id) | |
| 131 | 133 | WHERE harness_session_id IS NOT NULL DO NOTHING | |
| 132 | 134 | RETURNING {SESSION_COLUMNS}" | |
| ⋯ | |||
| 141 | 143 | ) | |
| 142 | 144 | } | |
| 143 | 145 | ||
| 144 | − | /// The fields `?1`–`?10` of [`Fields::binds`], each kept when not given. | |
| 146 | + | /// The fields `?1`–`?10` of [`Fields::binds`], each kept when not given; | |
| 147 | + | /// a title as [`titles::SET_TITLE`] takes it, from `?13` (`?11` now). | |
| 145 | 148 | const SET_FIELDS: &str = "item_id = COALESCE(?1, item_id), pull = COALESCE(?2, pull), branch = COALESCE(?3, branch), | |
| 146 | − | head_commit = COALESCE(?4, head_commit), title = COALESCE(?5, title), | |
| 149 | + | head_commit = COALESCE(?4, head_commit), | |
| 147 | 150 | agent_type = COALESCE(?6, agent_type), task = COALESCE(?7, task), prompt = COALESCE(?8, prompt), | |
| 148 | 151 | model = COALESCE(?9, model), result = COALESCE(?10, result)"; | |
| 149 | 152 | ||
| 150 | 153 | /// A heartbeat: seen now (`?11`), active again, with whichever fields are | |
| 151 | − | /// given, of session `?12`. | |
| 154 | + | /// given, of session `?12`; a title given came from `?13`. One that ended | |
| 155 | + | /// is named again as it ends again. | |
| 152 | 156 | pub(crate) fn resume_sql() -> String { | |
| 153 | 157 | format!( | |
| 154 | 158 | "UPDATE sessions | |
| 155 | − | SET {SET_FIELDS}, | |
| 159 | + | SET {SET_FIELDS}, {set_title}, | |
| 160 | + | title_final = CASE WHEN state = 'ended' THEN 0 ELSE title_final END, | |
| 156 | 161 | last_seen_at = ?11, state = 'active', ended_at = NULL, end_reason = NULL | |
| 157 | 162 | WHERE id = ?12 | |
| 158 | − | RETURNING {SESSION_COLUMNS}" | |
| 163 | + | RETURNING {SESSION_COLUMNS}", | |
| 164 | + | set_title = titles::SET_TITLE, | |
| 159 | 165 | ) | |
| 160 | 166 | } | |
| 161 | 167 | ||
| 162 | 168 | /// The fields of session `?12`, changed without bringing it back: for a | |
| 163 | − | /// subagent's late hooks (`?11` unused, so the binds are the same). | |
| 169 | + | /// subagent's late hooks (the binds are the same). | |
| 164 | 170 | pub(crate) fn refresh_sql() -> String { | |
| 165 | 171 | format!( | |
| 166 | − | "UPDATE sessions SET {SET_FIELDS} | |
| 172 | + | "UPDATE sessions SET {SET_FIELDS}, {set_title} | |
| 167 | 173 | WHERE id = ?12 AND ?11 IS NOT NULL | |
| 168 | − | RETURNING {SESSION_COLUMNS}" | |
| 174 | + | RETURNING {SESSION_COLUMNS}", | |
| 175 | + | set_title = titles::SET_TITLE, | |
| 169 | 176 | ) | |
| 170 | 177 | } | |
| 171 | 178 | ||
| ⋯ | |||
| 330 | 337 | prompt: fields.prompt.clone(), | |
| 331 | 338 | result: None, | |
| 332 | 339 | model: fields.model.clone(), | |
| 340 | + | title_source: fields.title.as_ref().map(|_| TitleSource::Agent.as_str().to_owned()), | |
| 341 | + | title_settled_at: fields.title.as_ref().map(|_| at.clone()), | |
| 333 | 342 | } | |
| 334 | 343 | .at(now); | |
| 335 | 344 | let started = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor)); | |
| ⋯ | |||
| 412 | 421 | let parent_id = row.parent_id.clone(); | |
| 413 | 422 | let id = row.id.clone(); | |
| 414 | 423 | let at = rfc3339(now); | |
| 424 | + | let current_source = row.title_source.as_deref().and_then(TitleSource::parse); | |
| 425 | + | // A title given: its agent's, unless it is what g1t calls it already. | |
| 426 | + | let given = fields.title.as_deref().map(|title| titles::given_source(title, row.title.as_deref(), current_source)); | |
| 427 | + | let pinned = current_source == Some(TitleSource::Person); | |
| 415 | 428 | let mut binds = fields.binds(); | |
| 416 | 429 | binds.push(at.as_str().into()); | |
| 417 | 430 | binds.push(id.as_str().into()); | |
| 431 | + | binds.push(given.map_or(JsValue::NULL, |source| source.as_str().into())); | |
| 418 | 432 | let sql = if finished { refresh_sql() } else { resume_sql() }; | |
| 419 | 433 | let mut statements = vec![self.db.prepare(sql).bind(&binds)?]; | |
| 420 | 434 | // Brought back from having ended: announced with the change. | |
| ⋯ | |||
| 425 | 439 | pull: fields.pull.or(row.pull), | |
| 426 | 440 | branch: fields.branch.clone().or(row.branch.clone()), | |
| 427 | 441 | head_commit: fields.head_commit.clone().or(row.head_commit.clone()), | |
| 428 | − | title: fields.title.clone().or(row.title.clone()), | |
| 442 | + | title: if pinned { row.title.clone() } else { fields.title.clone().or(row.title.clone()) }, | |
| 443 | + | title_source: if pinned { row.title_source.clone() } else { given.map(|source| source.as_str().to_owned()).or(row.title_source.clone()) }, | |
| 429 | 444 | agent_type: fields.agent_type.clone().or(row.agent_type.clone()), | |
| 430 | 445 | task: fields.task.clone().or(row.task.clone()), | |
| 431 | 446 | prompt: fields.prompt.clone().or(row.prompt.clone()), | |
| ⋯ | |||
| 628 | 643 | let mut statements = vec![self | |
| 629 | 644 | .db | |
| 630 | 645 | .prepare(format!( | |
| 631 | − | "UPDATE sessions SET state = 'ended', ended_at = ?, end_reason = ?, last_seen_at = ?, result = COALESCE(?, result) | |
| 632 | − | WHERE id = ? AND state = 'active' | |
| 633 | − | RETURNING {SESSION_COLUMNS}" | |
| 646 | + | "UPDATE sessions SET state = 'ended', ended_at = ?1, end_reason = ?2, last_seen_at = ?1, result = COALESCE(?3, result), | |
| 647 | + | {final_due} | |
| 648 | + | WHERE id = ?4 AND state = 'active' | |
| 649 | + | RETURNING {SESSION_COLUMNS}", | |
| 650 | + | final_due = titles::final_due("?1"), | |
| 634 | 651 | )) | |
| 635 | − | .bind(&[at.as_str().into(), reason.as_str().into(), at.as_str().into(), text_or_null(&result), row.id.as_str().into()])?]; | |
| 652 | + | .bind(&[at.as_str().into(), reason.as_str().into(), text_or_null(&result), row.id.as_str().into()])?]; | |
| 636 | 653 | let expected = end_one(row.clone(), &reason, result.clone()); | |
| 637 | 654 | let sent = push_delivery( | |
| 638 | 655 | &mut statements, | |
| ⋯ | |||
| 696 | 713 | Ok(Outcome::Ok(session)) | |
| 697 | 714 | } | |
| 698 | 715 | ||
| 716 | + | /// A person names a session from the site (`TitleSessionArgs`): pinned, | |
| 717 | + | /// or with no title, unpinned for g1t to name again. | |
| 718 | + | pub(crate) async fn title_session(&self, a: TitleSessionArgs) -> Result<Outcome<Session>> { | |
| 719 | + | let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?); | |
| 720 | + | let Some(row) = self.session_row(&repo.id, a.id.trim()).await? else { | |
| 721 | + | return Ok(not_found()); | |
| 722 | + | }; | |
| 723 | + | let can_push = matches!(crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::Push), Outcome::Ok(())); | |
| 724 | + | if !may_end_session(&row.actor_id, &a.actor.id, can_push) { | |
| 725 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Only whoever runs this session, or someone with write access to the repository, can name it.")); | |
| 726 | + | } | |
| 727 | + | let at = rfc3339(now_ms()); | |
| 728 | + | let title = tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS); | |
| 729 | + | let statement = match &title { | |
| 730 | + | Some(title) => self.db.prepare(titles::PIN_TITLE).bind(&[title.as_str().into(), at.as_str().into(), row.id.as_str().into()])?, | |
| 731 | + | None => self.db.prepare(titles::UNPIN_TITLE).bind(&[at.as_str().into(), row.id.as_str().into()])?, | |
| 732 | + | }; | |
| 733 | + | statement.run().await?; | |
| 734 | + | let Some(row) = self.session_row(&repo.id, &row.id).await? else { | |
| 735 | + | return Ok(not_found()); | |
| 736 | + | }; | |
| 737 | + | let session = row.at(now_ms()); | |
| 738 | + | self.publish_as( | |
| 739 | + | "session.progressed", | |
| 740 | + | &repo.id, | |
| 741 | + | Some(a.actor.id.clone()), | |
| 742 | + | SessionProgressed { repo_id: repo.id.clone(), number: session.pull.or(session.item), session_id: session.id.clone(), root_id: None }, | |
| 743 | + | ) | |
| 744 | + | .await?; | |
| 745 | + | let mut sessions = [session]; | |
| 746 | + | shown::sessions(&mut sessions, &Some(a.actor.clone()), &repo.namespace); | |
| 747 | + | let [session] = sessions; | |
| 748 | + | Ok(Outcome::Ok(session)) | |
| 749 | + | } | |
| 750 | + | ||
| 699 | 751 | pub(crate) async fn get_work_session(&self, a: WorkSessionArgs) -> Result<Outcome<SessionDetail>> { | |
| 700 | 752 | let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?); | |
| 701 | 753 | let Some(row) = self.session_row(&repo.id, a.id.trim()).await? else { | |
| ⋯ | |||
| 932 | 984 | let total = a.entries.len() as u32; | |
| 933 | 985 | let mut statements = Vec::with_capacity(a.entries.len()); | |
| 934 | 986 | let mut prompts = String::new(); | |
| 987 | + | // Each prompt's place among the statements, and what the first one with one is for. | |
| 988 | + | let mut prompt_at: Vec<usize> = Vec::new(); | |
| 989 | + | let mut intent: Option<String> = None; | |
| 935 | 990 | for keyed in a.entries { | |
| 936 | 991 | let entry = keyed.entry; | |
| 937 | 992 | let kind = serde_json::to_value(entry.kind)?; | |
| ⋯ | |||
| 942 | 997 | // A skill is its name, nothing more. | |
| 943 | 998 | text = text.trim().chars().take(MAX_AGENT_CHARS).collect(); | |
| 944 | 999 | } | |
| 945 | − | if entry.kind == SessionEntryKind::Prompt && prompts.len() < MAX_REF_TEXT { | |
| 946 | − | prompts.push_str(&text); | |
| 947 | − | prompts.push('\n'); | |
| 1000 | + | if entry.kind == SessionEntryKind::Prompt { | |
| 1001 | + | if prompts.len() < MAX_REF_TEXT { | |
| 1002 | + | prompts.push_str(&text); | |
| 1003 | + | prompts.push('\n'); | |
| 1004 | + | } | |
| 1005 | + | prompt_at.push(statements.len()); | |
| 1006 | + | if intent.is_none() { | |
| 1007 | + | intent = titles::intent_title(&text); | |
| 1008 | + | } | |
| 948 | 1009 | } | |
| 949 | 1010 | let key = tidy(keyed.key.as_deref(), MAX_KEY_CHARS); | |
| 950 | 1011 | // Each takes the next number of the session, and of its pull | |
| ⋯ | |||
| 966 | 1027 | ); | |
| 967 | 1028 | } | |
| 968 | 1029 | let mut recorded = 0; | |
| 969 | − | for result in self.db.batch(statements).await? { | |
| 970 | − | recorded += result.results::<NumberRow>()?.len() as u32; | |
| 1030 | + | let mut prompted = 0; | |
| 1031 | + | for (index, result) in self.db.batch(statements).await?.into_iter().enumerate() { | |
| 1032 | + | let new = result.results::<NumberRow>()?.len() as u32; | |
| 1033 | + | recorded += new; | |
| 1034 | + | if prompt_at.contains(&index) { | |
| 1035 | + | prompted += new; | |
| 1036 | + | } | |
| 1037 | + | } | |
| 1038 | + | if prompted > 0 { | |
| 1039 | + | // The first prompt names the session for now; enough of them settle its title (titles.rs). | |
| 1040 | + | let now = now_ms(); | |
| 1041 | + | self.db | |
| 1042 | + | .prepare(titles::prompted_sql()) | |
| 1043 | + | .bind(&[ | |
| 1044 | + | session.id.as_str().into(), | |
| 1045 | + | prompted.into(), | |
| 1046 | + | text_or_null(&intent), | |
| 1047 | + | rfc3339(now).into(), | |
| 1048 | + | after_minutes(now, titles::SETTLE_AFTER_MINUTES).into(), | |
| 1049 | + | ])? | |
| 1050 | + | .run() | |
| 1051 | + | .await?; | |
| 971 | 1052 | } | |
| 972 | 1053 | if !prompts.is_empty() && recorded > 0 { | |
| 973 | 1054 | self.link_refs(&session.id, &repo.id, &prompts).await?; | |
| 211 | 211 | model: Some("claude-opus-5-5".into()), | |
| 212 | 212 | subagents: 0, | |
| 213 | 213 | first_prompt: Some("ugh fix it".into()), | |
| 214 | + | title_source: None, | |
| 215 | + | title_settled_at: None, | |
| 214 | 216 | } | |
| 215 | 217 | } | |
| 216 | 218 |
| 180 | 180 | &resume_sql(), | |
| 181 | 181 | params![ | |
| 182 | 182 | None::<String>, 9, None::<String>, "abc123", None::<String>, None::<String>, None::<String>, None::<String>, | |
| 183 | − | None::<String>, None::<String>, late, "ses_1" | |
| 183 | + | None::<String>, None::<String>, late, "ses_1", None::<String> | |
| 184 | 184 | ], | |
| 185 | 185 | |row| row.get(11), | |
| 186 | 186 | ) | |
| ⋯ | |||
| 207 | 207 | &refresh_sql(), | |
| 208 | 208 | params![ | |
| 209 | 209 | None::<String>, None::<u32>, None::<String>, None::<String>, None::<String>, None::<String>, "List endpoints", | |
| 210 | − | None::<String>, None::<String>, None::<String>, rfc3339(NOW), "ses_c1" | |
| 210 | + | None::<String>, None::<String>, None::<String>, rfc3339(NOW), "ses_c1", None::<String> | |
| 211 | 211 | ], | |
| 212 | 212 | |row| Ok((row.get(11)?, row.get(19)?, row.get(21)?)), | |
| 213 | 213 | ) | |
| ⋯ | |||
| 669 | 669 | let next = update_id("integration.failed", "qe_1:att_3:abc123"); | |
| 670 | 670 | assert_ne!(next, id); | |
| 671 | 671 | } | |
| 672 | + | ||
| 673 | + | // --- Titles that settle (titles.rs) ---------------------------------------- | |
| 674 | + | ||
| 675 | + | /// A session's title as stored: its text, source and when it is due. | |
| 676 | + | fn titled(db: &Connection, id: &str) -> (Option<String>, Option<String>, Option<String>) { | |
| 677 | + | db.query_row("SELECT title, title_source, title_due_at FROM sessions WHERE id = ?", [id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) | |
| 678 | + | .unwrap() | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | /// Records `n` prompts the first of which is for `intent`, at `at`. | |
| 682 | + | fn prompted(db: &Connection, id: &str, n: u32, intent: Option<&str>, at: u64) { | |
| 683 | + | db.execute(&super::titles::prompted_sql(), params![id, n, intent, rfc3339(at), after_minutes(at, super::titles::SETTLE_AFTER_MINUTES)]).unwrap(); | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | /// A heartbeat giving the title `title` from `source`, at `at`. | |
| 687 | + | fn heartbeat_titled(db: &Connection, id: &str, title: &str, source: &str, at: u64) { | |
| 688 | + | db.query_row( | |
| 689 | + | &resume_sql(), | |
| 690 | + | params![ | |
| 691 | + | None::<String>, None::<u32>, None::<String>, None::<String>, title, None::<String>, None::<String>, None::<String>, | |
| 692 | + | None::<String>, None::<String>, rfc3339(at), id, source | |
| 693 | + | ], | |
| 694 | + | |_| Ok(()), | |
| 695 | + | ) | |
| 696 | + | .unwrap(); | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | const MINUTE: u64 = 60_000; | |
| 700 | + | ||
| 701 | + | #[test] | |
| 702 | + | fn the_first_prompt_names_a_session_for_now_and_enough_of_them_settle_it() { | |
| 703 | + | let db = database(); | |
| 704 | + | start(&db, "ses_1", Some("a")); | |
| 705 | + | prompted(&db, "ses_1", 1, Some("This project used to be in syntaqx/g1t"), NOW); | |
| 706 | + | let due_later = after_minutes(NOW, super::titles::SETTLE_AFTER_MINUTES); | |
| 707 | + | assert_eq!(titled(&db, "ses_1"), (Some("This project used to be in syntaqx/g1t".into()), Some("prompt".into()), Some(due_later.clone()))); | |
| 708 | + | // A later prompt does not rename it, nor bring it forward yet. | |
| 709 | + | prompted(&db, "ses_1", 1, Some("Fix the remotes"), NOW + MINUTE); | |
| 710 | + | assert_eq!(titled(&db, "ses_1").0.as_deref(), Some("This project used to be in syntaqx/g1t")); | |
| 711 | + | assert_eq!(titled(&db, "ses_1").2, Some(due_later)); | |
| 712 | + | // The third: due now. | |
| 713 | + | prompted(&db, "ses_1", 1, None, NOW + 2 * MINUTE); | |
| 714 | + | assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW + 2 * MINUTE))); | |
| 715 | + | // A subagent is never titled from prompts. | |
| 716 | + | start_child(&db, "ses_c", "ses_1", "a/agent/1", "Explore"); | |
| 717 | + | prompted(&db, "ses_c", 3, Some("Look around"), NOW); | |
| 718 | + | assert_eq!(titled(&db, "ses_c"), (None, None, None)); | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | #[test] | |
| 722 | + | fn an_agents_title_is_kept_and_a_pinned_one_never_replaced() { | |
| 723 | + | let db = database(); | |
| 724 | + | start(&db, "ses_1", Some("a")); | |
| 725 | + | prompted(&db, "ses_1", 1, Some("Fix login"), NOW); | |
| 726 | + | heartbeat_titled(&db, "ses_1", "Rewrite the auth flow", "agent", NOW + MINUTE); | |
| 727 | + | assert_eq!(titled(&db, "ses_1"), (Some("Rewrite the auth flow".into()), Some("agent".into()), None)); | |
| 728 | + | // Its prompts and links leave it be until it ends. | |
| 729 | + | prompted(&db, "ses_1", 5, None, NOW + 2 * MINUTE); | |
| 730 | + | db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 2 * MINUTE)]).unwrap(); | |
| 731 | + | assert_eq!(titled(&db, "ses_1").2, None); | |
| 732 | + | // A person pins it: neither the agent nor g1t replaces it. | |
| 733 | + | db.execute(super::titles::PIN_TITLE, params!["Auth, rewritten", rfc3339(NOW + 3 * MINUTE), "ses_1"]).unwrap(); | |
| 734 | + | heartbeat_titled(&db, "ses_1", "Something else", "agent", NOW + 4 * MINUTE); | |
| 735 | + | prompted(&db, "ses_1", 5, None, NOW + 4 * MINUTE); | |
| 736 | + | db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 4 * MINUTE)]).unwrap(); | |
| 737 | + | assert_eq!(titled(&db, "ses_1"), (Some("Auth, rewritten".into()), Some("person".into()), None)); | |
| 738 | + | // Not even as it ends. | |
| 739 | + | let late = rfc3339(NOW + g1t_contracts::work::SESSION_TIMEOUT_MS * 2); | |
| 740 | + | db.prepare(&time_out_sessions_sql()).unwrap().query_map(params!["rep_1", late, "ses_1"], |_| Ok(())).unwrap().count(); | |
| 741 | + | assert_eq!(titled(&db, "ses_1").2, None); | |
| 742 | + | // Unpinned: g1t names it again. | |
| 743 | + | db.execute(super::titles::UNPIN_TITLE, params![rfc3339(NOW + 5 * MINUTE), "ses_1"]).unwrap(); | |
| 744 | + | assert_eq!(titled(&db, "ses_1"), (Some("Auth, rewritten".into()), Some("prompt".into()), Some(rfc3339(NOW + 5 * MINUTE)))); | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | #[test] | |
| 748 | + | fn an_agent_echoing_the_provisional_title_keeps_it_provisional() { | |
| 749 | + | let db = database(); | |
| 750 | + | start(&db, "ses_1", Some("a")); | |
| 751 | + | prompted(&db, "ses_1", 1, Some("Fix login"), NOW); | |
| 752 | + | heartbeat_titled(&db, "ses_1", "Fix login", "prompt", NOW + MINUTE); | |
| 753 | + | let (_, source, due) = titled(&db, "ses_1"); | |
| 754 | + | assert_eq!((source.as_deref(), due), (Some("prompt"), Some(after_minutes(NOW, super::titles::SETTLE_AFTER_MINUTES)))); | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | #[test] | |
| 758 | + | fn a_settled_title_is_due_again_only_after_a_while_and_a_change() { | |
| 759 | + | let db = database(); | |
| 760 | + | start(&db, "ses_1", Some("a")); | |
| 761 | + | let settled = rfc3339(NOW); | |
| 762 | + | db.execute( | |
| 763 | + | "UPDATE sessions SET title = 'Move issues to Work items', title_source = 'item', title_settled_at = ?1, title_due_at = NULL, | |
| 764 | + | title_prompts = 3, prompts = 3 WHERE id = 'ses_1'", | |
| 765 | + | [settled.as_str()], | |
| 766 | + | ) | |
| 767 | + | .unwrap(); | |
| 768 | + | // Tied to more work five minutes on: due, but not before twenty minutes after it settled. | |
| 769 | + | db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 5 * MINUTE)]).unwrap(); | |
| 770 | + | assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW + 20 * MINUTE))); | |
| 771 | + | // Later still: as soon as it is tied. | |
| 772 | + | db.execute("UPDATE sessions SET title_due_at = NULL WHERE id = 'ses_1'", []).unwrap(); | |
| 773 | + | db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 45 * MINUTE)]).unwrap(); | |
| 774 | + | assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW + 45 * MINUTE))); | |
| 775 | + | // Prompts: due again only once they grew past more_prompts(3) = 8. | |
| 776 | + | db.execute("UPDATE sessions SET title_due_at = NULL WHERE id = 'ses_1'", []).unwrap(); | |
| 777 | + | prompted(&db, "ses_1", 4, None, NOW + 50 * MINUTE); | |
| 778 | + | assert_eq!(titled(&db, "ses_1").2, None); | |
| 779 | + | prompted(&db, "ses_1", 1, None, NOW + 51 * MINUTE); | |
| 780 | + | assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW + 51 * MINUTE))); | |
| 781 | + | // Settled for good as it ended: nothing makes it due but ending again. | |
| 782 | + | db.execute("UPDATE sessions SET title_due_at = NULL, title_final = 1, state = 'ended' WHERE id = 'ses_1'", []).unwrap(); | |
| 783 | + | db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 90 * MINUTE)]).unwrap(); | |
| 784 | + | assert_eq!(titled(&db, "ses_1").2, None); | |
| 785 | + | // Brought back, it is named again as it ends again. | |
| 786 | + | heartbeat_titled(&db, "ses_1", "x", "agent", NOW + 91 * MINUTE); | |
| 787 | + | let kept: u32 = db.query_row("SELECT title_final FROM sessions WHERE id = 'ses_1'", [], |row| row.get(0)).unwrap(); | |
| 788 | + | assert_eq!(kept, 0); | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | #[test] | |
| 792 | + | fn a_session_timing_out_is_named_a_last_time_its_subagents_not() { | |
| 793 | + | let db = database(); | |
| 794 | + | start(&db, "ses_1", Some("a")); | |
| 795 | + | start_child(&db, "ses_c", "ses_1", "a/agent/1", "Explore"); | |
| 796 | + | let late = rfc3339(NOW + g1t_contracts::work::SESSION_TIMEOUT_MS); | |
| 797 | + | for id in ["ses_1", "ses_c"] { | |
| 798 | + | db.prepare(&time_out_sessions_sql()).unwrap().query_map(params!["rep_1", late, id], |_| Ok(())).unwrap().count(); | |
| 799 | + | } | |
| 800 | + | assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW))); | |
| 801 | + | assert_eq!(titled(&db, "ses_c").2, None); | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | #[test] | |
| 805 | + | fn sessions_from_before_titles_settled_are_settled_once() { | |
| 806 | + | let db = crate::migrated::database_before("0051_session_titles.sql"); | |
| 807 | + | let at = rfc3339(NOW); | |
| 808 | + | db.execute( | |
| 809 | + | "INSERT INTO sessions (id, repo_id, actor_id, actor_name, harness, title, state, started_at, last_seen_at, ended_at) | |
| 810 | + | VALUES ('ses_old', 'rep_1', 'usr_1', 'ana', 'claude_code', 'This project used to be in syntaqx/g1t', 'ended', ?1, ?1, ?1)", | |
| 811 | + | [at.as_str()], | |
| 812 | + | ) | |
| 813 | + | .unwrap(); | |
| 814 | + | db.execute( | |
| 815 | + | "INSERT INTO sessions (id, repo_id, actor_id, actor_name, harness, state, started_at, last_seen_at, parent_id) | |
| 816 | + | VALUES ('ses_sub', 'rep_1', 'usr_1', 'ana', 'claude_code', 'ended', ?1, ?1, 'ses_old')", | |
| 817 | + | [at.as_str()], | |
| 818 | + | ) | |
| 819 | + | .unwrap(); | |
| 820 | + | for n in 1..=2u32 { | |
| 821 | + | db.execute( | |
| 822 | + | "INSERT INTO session_entries (seq, kind, text, at, session_id, session_seq) VALUES (?1, 'prompt', 'hi', ?2, 'ses_old', ?1)", | |
| 823 | + | params![n, at], | |
| 824 | + | ) | |
| 825 | + | .unwrap(); | |
| 826 | + | } | |
| 827 | + | crate::migrated::migrate_from(&db, "0051_session_titles.sql"); | |
| 828 | + | let (prompts, due): (u32, Option<String>) = | |
| 829 | + | db.query_row("SELECT prompts, title_due_at FROM sessions WHERE id = 'ses_old'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); | |
| 830 | + | assert_eq!((prompts, due), (2, Some(at))); | |
| 831 | + | assert_eq!(titled(&db, "ses_sub").2, None); | |
| 832 | + | } | |
| 283 | 283 | events: Fetcher, | |
| 284 | 284 | /// GitHub Actions: runs a merge queue's `merge_group` workflows. | |
| 285 | 285 | actions: Fetcher, | |
| 286 | + | /// Integrations: the workspace's AI provider names sessions | |
| 287 | + | /// (coordination/titles.rs). None where it is not bound. | |
| 288 | + | integrations: Option<Fetcher>, | |
| 286 | 289 | /// Where this request's time went, for its `Server-Timing`. | |
| 287 | 290 | timing: g1t_kit::d1::Timing, | |
| 288 | 291 | /// A pull request's rows read in one batch for this request | |
| ⋯ | |||
| 2559 | 2562 | repos: env.service("REPOS")?, | |
| 2560 | 2563 | events: env.service("EVENTS")?, | |
| 2561 | 2564 | actions: env.service("ACTIONS")?, | |
| 2565 | + | integrations: env.service("INTEGRATIONS").ok(), | |
| 2562 | 2566 | timing: g1t_kit::d1::Timing::default(), | |
| 2563 | 2567 | prefetched: std::cell::RefCell::new(None), | |
| 2564 | 2568 | known_repos: std::cell::RefCell::new(std::collections::HashMap::new()), | |
| ⋯ | |||
| 2670 | 2674 | "start_session" => reply(&work.start_session(args(body)?).await?), | |
| 2671 | 2675 | "update_session" => reply(&work.update_session(args(body)?).await?), | |
| 2672 | 2676 | "end_session" => reply(&work.end_session(args(body)?).await?), | |
| 2677 | + | "title_session" => reply(&work.title_session(args(body)?).await?), | |
| 2673 | 2678 | "get_work_session" => reply(&work.get_work_session(args(body)?).await?), | |
| 2674 | 2679 | "list_work_sessions" => reply(&work.list_work_sessions(args(body)?).await?), | |
| 2675 | 2680 | "pull_sessions" => reply(&work.pull_sessions(args(body)?).await?), | |
| ⋯ | |||
| 2711 | 2716 | } | |
| 2712 | 2717 | ||
| 2713 | 2718 | /// The cron trigger: moves on the job that moves issues into work items | |
| 2714 | − | /// (items/migration.rs), until it is done. | |
| 2719 | + | /// (items/migration.rs), until it is done, and settles the sessions' | |
| 2720 | + | /// titles that are due (coordination/titles.rs). | |
| 2715 | 2721 | #[event(scheduled)] | |
| 2716 | 2722 | async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { | |
| 2717 | 2723 | let moved = async { | |
| ⋯ | |||
| 2721 | 2727 | if let Err(error) = moved.await { | |
| 2722 | 2728 | worker::console_error!("moving issues into work items: {error}"); | |
| 2723 | 2729 | } | |
| 2730 | + | let settled = async { | |
| 2731 | + | let work = service(&env)?; | |
| 2732 | + | work.settle_titles().await | |
| 2733 | + | }; | |
| 2734 | + | if let Err(error) = settled.await { | |
| 2735 | + | worker::console_error!("settling session titles: {error}"); | |
| 2736 | + | } | |
| 2724 | 2737 | } | |
| 2725 | 2738 | ||
| 2726 | 2739 | /// Events from the bus, delivered on this service's own queue. | |
| 29 | 29 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 30 | 30 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 31 | 31 | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 32 | − | { "binding": "ACTIONS", "service": "g1t-actions" } | |
| 32 | + | { "binding": "ACTIONS", "service": "g1t-actions" }, | |
| 33 | + | // The workspace's AI provider, which names sessions from what they | |
| 34 | + | // did (src/coordination/titles.rs, integrations' `ai_brief`). | |
| 35 | + | { "binding": "INTEGRATIONS", "service": "g1t-integrations" } | |
| 33 | 36 | ], | |
| 34 | 37 | "queues": { | |
| 35 | 38 | "consumers": [{ "queue": "g1t-events-work", "max_batch_size": 100, "max_batch_timeout": 1, "max_retries": 3, "dead_letter_queue": "g1t-events-dlq" }] |
Comments
Sign in to comment.