From c18fcdb6c8c3a45d8ba52fb24cff15313450dab8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:48:58 -0400 Subject: [PATCH 1/9] Keep shell commands literal and skip them when naming conversations --- internal/manual/chat/commands.md | 8 +++ internal/session/placemeta.go | 3 + internal/session/title.go | 30 +++++--- internal/session/title_shell_test.go | 103 +++++++++++++++++++++++++++ internal/tui3/names.go | 11 ++- internal/tui3/resume.go | 6 +- internal/tui3/title_shell_test.go | 30 ++++++++ 7 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 internal/session/title_shell_test.go create mode 100644 internal/tui3/title_shell_test.go diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 352ee288db..6a3e6dbd61 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -30,6 +30,14 @@ An empty `!`, attached files, or a busy conversation leaves your draft in place and explains what to change. Wait for the turn to finish or stop it first. Task pages accept task messages; run `!` commands from the parent conversation. +## Conversation titles after shell commands + +A conversation containing only `!` commands keeps a literal command preview as +its working name. Home and conversation lists preserve its casing and punctuation; +long commands are clipped to fit. Shell turns do not ask a model for a title. +Once you send an ordinary message, automatic naming uses that message, skipping +recorded human shell turns. Its reply is included when already available. + ## Typing a slash to see the command list Type `/` in the home or conversation message box to see every available command in diff --git a/internal/session/placemeta.go b/internal/session/placemeta.go index 7372390d38..c9433d11d2 100644 --- a/internal/session/placemeta.go +++ b/internal/session/placemeta.go @@ -160,6 +160,9 @@ func (s *stampWriter) settle() { // name that turned out not to be one is thrown away and the words have to come // back ([openingPlaceholder]). func placeholderTitle(text string) string { + if _, shell := BashCommand(text); shell { + return clip(strings.TrimSpace(firstLine(text)), metaTitleLimit) + } return clip(strings.Join(strings.Fields(firstLine(text)), " "), metaTitleLimit) } diff --git a/internal/session/title.go b/internal/session/title.go index 411427c1a2..2f97890ec5 100644 --- a/internal/session/title.go +++ b/internal/session/title.go @@ -498,23 +498,37 @@ func (a *Agent) setTitleIfUnnamed(title string, _ ...string) bool { // interrupted. The name should describe what the conversation is about, and // that is where it was stated. func (a *Agent) firstExchangeLocked() (string, string) { - question, answer := "", "" + question := "" for _, message := range a.messages { switch message.Role { case "user": - if question == "" { - question = messageContentText(message) + // Do not borrow an answer from a later, unrelated user turn. + if question != "" { + return question, "" } + question = strings.TrimSpace(messageContentText(message)) case "assistant": - if question != "" && answer == "" { - answer = messageContentText(message) + // Human shell turns journal as user/call/result. Their durable call + // mark, not a leading ! in ordinary model input, identifies them. + if humanShellReply(message) { + question = "" + continue + } + if answer := strings.TrimSpace(messageContentText(message)); question != "" && answer != "" { + return question, answer } } - if question != "" && answer != "" { - break + } + return question, "" +} + +func humanShellReply(message ai.Message) bool { + for _, call := range message.ToolCalls { + if IsUserBashCall(call.ID) { + return true } } - return strings.TrimSpace(question), strings.TrimSpace(answer) + return false } func messageContentText(message ai.Message) string { diff --git a/internal/session/title_shell_test.go b/internal/session/title_shell_test.go new file mode 100644 index 0000000000..d353582995 --- /dev/null +++ b/internal/session/title_shell_test.go @@ -0,0 +1,103 @@ +package session + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func TestShellTurnsAreNotTheOpeningExchange(t *testing.T) { + shell := []ai.Message{ + textMessage("user", "!printf 'COMBO-%s' hi"), + {Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "user_bash_test"}}}, + textMessage("tool", "COMBO-hi"), + } + for _, tc := range []struct { + name string + messages []ai.Message + question, answer string + }{ + {name: "shell only", messages: shell}, + {name: "several shell turns", messages: append(append([]ai.Message{}, shell...), shell...)}, + {name: "ordinary follow-up", messages: append(append([]ai.Message{}, shell...), textMessage("user", "Explain the output"), textMessage("assistant", "It prints a greeting")), question: "Explain the output", answer: "It prints a greeting"}, + {name: "ordinary exclamation", messages: []ai.Message{textMessage("user", "! means what in bash?"), textMessage("assistant", "It can negate status")}, question: "! means what in bash?", answer: "It can negate status"}, + {name: "model shell call", messages: []ai.Message{textMessage("user", "Check the project"), {Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "model_bash_test"}}}, textMessage("tool", "clean"), textMessage("assistant", "The project is clean")}, question: "Check the project", answer: "The project is clean"}, + {name: "interrupted question", messages: []ai.Message{textMessage("user", "First question"), textMessage("user", "Second question"), textMessage("assistant", "Second answer")}, question: "First question"}, + } { + t.Run(tc.name, func(t *testing.T) { + a := &Agent{messages: tc.messages} + question, answer := a.firstExchangeLocked() + if question != tc.question || answer != tc.answer { + t.Fatalf("exchange = %q / %q, want %q / %q", question, answer, tc.question, tc.answer) + } + }) + } +} + +func TestShellOpeningKeepsLiteralPlaceholderThenNamesTheOrdinaryMessage(t *testing.T) { + for _, reopen := range []bool{false, true} { + name := "live" + if reopen { + name = "reopened" + } + t.Run(name, func(t *testing.T) { + const command = `!printf 'MiXeD two_spaces;\n'` + const question = "Explain the greeting format" + const title = "explaining the greeting format" + asked := make(chan string, 1) + c := &scriptedCompleter{steps: oneTurn("It preserves the spaces and prints a newline.")} + c.aside = func(messages []ai.Message) (*ai.Response, bool) { + if !isTitleCall(messages) { + return nil, false + } + asked <- messageContentText(messages[len(messages)-1]) + return textResponse(title), true + } + dir := filepath.Join(t.TempDir(), "0123456789abcdef") + a, _ := newTestAgent(t, c, func(cfg *Config) { + cfg.Place = Place{Dir: dir, Workspace: cfg.Workspace} + cfg.SessionFile = filepath.Join(dir, "transcript.jsonl") + }) + ch, err := a.SubmitBash(t.Context(), command) + if err != nil { + t.Fatal(err) + } + collect(t, ch) + a.SettleWrites() + meta, err := LoadMeta(dir) + if err != nil || meta.Title != command || a.Title() != "" || c.asideRequests() != 0 { + t.Fatalf("shell named itself or changed its literal preview: %+v, %v, title %q", meta, err, a.Title()) + } + if reopen { + if err := a.Close(); err != nil { + t.Fatal(err) + } + a, err = newAgent(a.config, c) + if err != nil { + t.Fatal(err) + } + defer a.Close() + } + ch, err = a.Submit(t.Context(), question) + if err != nil { + t.Fatal(err) + } + collect(t, ch) + if got := awaitTitle(t, a); got != title { + t.Fatalf("title = %q", got) + } + a.titleJobs.Wait() + request := <-asked + if !strings.Contains(request, question) || strings.Contains(request, command) || strings.Contains(request, "MiXeD") { + t.Fatalf("namer did not use the ordinary opening: %q", request) + } + a.SettleWrites() + meta, err = LoadMeta(dir) + if err != nil || meta.Title != title { + t.Fatalf("Home metadata = %+v, %v", meta, err) + } + }) + } +} diff --git a/internal/tui3/names.go b/internal/tui3/names.go index eaa8811eb2..a3bfdbc761 100644 --- a/internal/tui3/names.go +++ b/internal/tui3/names.go @@ -163,7 +163,7 @@ func waitTitle(ch <-chan session.Event, gen int) tea.Cmd { // [idShaped] is the guard that tells the two apart. func listName(title, transcript string) string { if name := strings.TrimSpace(title); name != "" { - return titleCase(unpackName(name)) + return listTitle(name) } if stem := strings.TrimSuffix(sessionStem(transcript), ".jsonl"); stem != "" && !idShaped(stem) { return titleCase(unpackName(stem)) @@ -171,6 +171,15 @@ func listName(title, transcript string) string { return unnamedConversationWord } +// listTitle leaves shell placeholders literal: capitalization and slug expansion +// can change a command's meaning. Ordinary conversation names keep their style. +func listTitle(name string) string { + if _, shell := session.BashCommand(name); shell { + return strings.TrimSpace(name) + } + return titleCase(unpackName(name)) +} + // idShaped reports whether a name is a MACHINE'S name rather than a person's: // a timestamp, a hex tail, a ULID, a folder minted by a counter. // diff --git a/internal/tui3/resume.go b/internal/tui3/resume.go index d691f9ac0c..966e169a96 100644 --- a/internal/tui3/resume.go +++ b/internal/tui3/resume.go @@ -26,6 +26,7 @@ package tui3 // that has to be dismissed before it can be told it was useless. import ( + "github.com/Agent-Field/codeaf/internal/session" "sort" "strings" "time" @@ -280,7 +281,7 @@ func humanName(session Session) string { // called the same thing (sessionrows.go). name = strings.TrimSuffix(sessionStem(session.File), ".jsonl") } - return titleCase(unpackName(name)) + return listTitle(name) } // openingWords is how much of a first message becomes a name. Seven words is @@ -291,6 +292,9 @@ const openingWords = 7 // openingName is a name derived from the first thing the person said: its // opening words, with the punctuation a sentence ends on taken off. func openingName(opening string) string { + if _, shell := session.BashCommand(opening); shell { + return strings.TrimSpace(opening) + } words := strings.Fields(strings.TrimSpace(opening)) if len(words) == 0 { return "" diff --git a/internal/tui3/title_shell_test.go b/internal/tui3/title_shell_test.go new file mode 100644 index 0000000000..b0f43a2a7f --- /dev/null +++ b/internal/tui3/title_shell_test.go @@ -0,0 +1,30 @@ +package tui3 + +import ( + "strings" + "testing" + "time" +) + +func TestShellOpeningNamesStayLiteralOnHomeAndResume(t *testing.T) { + for _, command := range []string{`!for i in 1 2 3; do echo COMBO-$i; done`, `!my_script-name`, `!printf 'two spaces;'`} { + t.Run(command, func(t *testing.T) { + lab := newHomeLab(t) + now := lab.pin(time.Date(2026, 9, 28, 12, 0, 0, 0, time.UTC)) + mine := lab.session("-alpha", "aaaa000000000001", command, lab.workspace("alpha"), now) + a := lab.app(mine) + runCmd(a.openHome()) + if got := homeText(a); !strings.Contains(got, command) { + t.Fatalf("Home changed the shell preview %q:\n%s", command, got) + } + for _, row := range []Session{{Title: command}, {Opening: command}} { + if got := humanName(row); got != command { + t.Fatalf("resume = %q, want %q", got, command) + } + } + if got := chatTabName(command); got != command { + t.Fatalf("tab = %q", got) + } + }) + } +} From d4c4f232d8adc8fe3b7b70ff00c170f154bf3efc Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:17:13 -0400 Subject: [PATCH 2/9] Document shell conversation title fix --- .../changes/unreleased/1667-shell-conversation-titles.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/changes/unreleased/1667-shell-conversation-titles.md diff --git a/docs/changes/unreleased/1667-shell-conversation-titles.md b/docs/changes/unreleased/1667-shell-conversation-titles.md new file mode 100644 index 0000000000..462e233049 --- /dev/null +++ b/docs/changes/unreleased/1667-shell-conversation-titles.md @@ -0,0 +1,9 @@ +--- +kind: fixed +title: Conversation names skip opening human shell commands +pr: 1667 +surface: [chat, engine, docs] +invalidates: + - "Conversation lists title-cased opening shell commands and collapsed their spaces. Shell-command previews now preserve casing, punctuation and spacing within the existing preview limit." + - "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and names the first ordinary exchange, including after reopening." +--- From 87fa983679108fa2df38fb5db26a48d037e30217 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 29 Sep 2026 09:57:27 -0400 Subject: [PATCH 3/9] manual: shell-opened conversation names on /resume and in the naming page The /resume section still said every name is title-cased and cut to seven words, and the naming section still said naming starts with the first message. A `!` command is now shown exactly as typed and skipped by the namer, so both pages say so, and the shell section states that naming starts before any reply, so a follow-up such as "what did that print?" is named from its own words. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/commands.md | 9 ++++++--- internal/manual/chat/sessions-and-rewind.md | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 6a3e6dbd61..b56f14960a 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -36,7 +36,9 @@ A conversation containing only `!` commands keeps a literal command preview as its working name. Home and conversation lists preserve its casing and punctuation; long commands are clipped to fit. Shell turns do not ask a model for a title. Once you send an ordinary message, automatic naming uses that message, skipping -recorded human shell turns. Its reply is included when already available. +recorded human shell turns. Naming starts as soon as that message is sent, so a reply +is included only when one already exists; a message that only points at the output, +such as "what did that print?", is named from its own words. ## Typing a slash to see the command list @@ -1157,8 +1159,9 @@ esc leaves the conversation exactly as it was. Ten rows show, each a name, a description and an age. The name climbs a ladder: the title the session gave itself, else the first seven words you said in it, else the transcript's file name — then title-cased, with small words left lowercase and nothing ever -lowercased, so `OpenAI` keeps its shape. The description is the last thing that happened, -capped at 80 columns. The age (`2h ago`, or a date past a month) is reserved first and +lowercased, so `OpenAI` keeps its shape. A name that is still a `!` command is the +exception: it is shown exactly as typed, never title-cased or cut to seven words. The +description is the last thing that happened, capped at 80 columns. The age (`2h ago`, or a date past a month) is reserved first and never cut. **Ids and file names appear nowhere.** The cursor opens on the conversation you are already in. enter on another row closes this diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 38297b874c..ceb66e7ff8 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -497,9 +497,12 @@ model's (`gpt-4.1-mini:high`), and the name came off it on 2026-09-17 because a takes the room the numbers need. **It starts with your first message.** The small model on the `title` role is shown the -opening question and asked for one descriptive **5–8 word phrase**. The answer and the naming request run independently. A late name still -reaches an idle chat, a background tab, or a hosted chat after the connection is restored; -no refresh or follow-up message is needed. +opening question and asked for one descriptive **5–8 word phrase**. A `!` shell command +is not that message: a conversation that opens with `!` commands keeps the command as its +name until you send an ordinary message, and is then named from that message. The answer +and the naming request run independently. A late name still reaches an idle chat, a +background tab, or a hosted chat after the connection is restored; no refresh or +follow-up message is needed. Each ask is bounded to twenty seconds so a slow cheap endpoint yields to the existing fallback promptly. Temporary provider failures are asked again for as long as the From 0e2d85a086caeec8ef1f9c3e9c19aeb9b139b384 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 29 Sep 2026 09:57:27 -0400 Subject: [PATCH 4/9] changes: #1667 entry says what the saved and live rows keep The live Home row and the tab still fold repeated spaces in a shell command; only saved rows keep them. And the namer uses the first ordinary message, not an exchange, because naming starts before the reply exists. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/1667-shell-conversation-titles.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changes/unreleased/1667-shell-conversation-titles.md b/docs/changes/unreleased/1667-shell-conversation-titles.md index 462e233049..9fea46448d 100644 --- a/docs/changes/unreleased/1667-shell-conversation-titles.md +++ b/docs/changes/unreleased/1667-shell-conversation-titles.md @@ -4,6 +4,6 @@ title: Conversation names skip opening human shell commands pr: 1667 surface: [chat, engine, docs] invalidates: - - "Conversation lists title-cased opening shell commands and collapsed their spaces. Shell-command previews now preserve casing, punctuation and spacing within the existing preview limit." - - "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and names the first ordinary exchange, including after reopening." + - "Conversation lists title-cased opening shell commands and collapsed their spaces. Shell-command previews now keep their casing and punctuation, and saved rows keep their spacing, within the existing preview limit." + - "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and names the conversation from the first ordinary message, including after reopening." --- From 447436ed70642f14b5ac58b5f6cb8386ff784cd1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:30:43 -0400 Subject: [PATCH 5/9] Wait for an ordinary answer before naming shell-opened conversations --- .../1667-shell-conversation-titles.md | 2 +- internal/manual/chat/commands.md | 10 +++--- internal/manual/chat/sessions-and-rewind.md | 5 +-- internal/session/agent.go | 6 ++-- internal/session/title.go | 31 +++++++++++++++---- internal/session/title_shell_test.go | 28 +++++++++++++---- 6 files changed, 60 insertions(+), 22 deletions(-) diff --git a/docs/changes/unreleased/1667-shell-conversation-titles.md b/docs/changes/unreleased/1667-shell-conversation-titles.md index 9fea46448d..3670fc0e66 100644 --- a/docs/changes/unreleased/1667-shell-conversation-titles.md +++ b/docs/changes/unreleased/1667-shell-conversation-titles.md @@ -5,5 +5,5 @@ pr: 1667 surface: [chat, engine, docs] invalidates: - "Conversation lists title-cased opening shell commands and collapsed their spaces. Shell-command previews now keep their casing and punctuation, and saved rows keep their spacing, within the existing preview limit." - - "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and names the conversation from the first ordinary message, including after reopening." + - "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and waits for the first ordinary question with an answer, including after reopening. Both feed the title so a question like 'what did that print?' has context; the answer may quote shell output. Ordinary openings still name immediately." --- diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index b56f14960a..88bb448762 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -35,10 +35,12 @@ Task pages accept task messages; run `!` commands from the parent conversation. A conversation containing only `!` commands keeps a literal command preview as its working name. Home and conversation lists preserve its casing and punctuation; long commands are clipped to fit. Shell turns do not ask a model for a title. -Once you send an ordinary message, automatic naming uses that message, skipping -recorded human shell turns. Naming starts as soon as that message is sent, so a reply -is included only when one already exists; a message that only points at the output, -such as "what did that print?", is named from its own words. +After a shell opening, automatic naming waits for the first ordinary message with +an answer and uses both, skipping recorded human shell turns. The command preview +stays until the title arrives. This gives a question such as "what did that print?" +the context of its answer; the answer may quote shell output. An unanswered or +interrupted question does not prevent a later answered message from naming the chat. +Conversations that begin with an ordinary message still start naming immediately. ## Typing a slash to see the command list diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index ceb66e7ff8..dccce131b6 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -499,8 +499,9 @@ takes the room the numbers need. **It starts with your first message.** The small model on the `title` role is shown the opening question and asked for one descriptive **5–8 word phrase**. A `!` shell command is not that message: a conversation that opens with `!` commands keeps the command as its -name until you send an ordinary message, and is then named from that message. The answer -and the naming request run independently. A late name still reaches an idle chat, a +name until an ordinary message has an answer, and is then named from that question and +answer. Unanswered questions are skipped after a shell opening. For an ordinary opening, +the answer and naming still run independently from the start. A late name reaches an idle chat, a background tab, or a hosted chat after the connection is restored; no refresh or follow-up message is needed. diff --git a/internal/session/agent.go b/internal/session/agent.go index 5346319247..6cdc299c1c 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1815,9 +1815,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * a.recordUserLocked(user) // AND THE SESSION STARTS NAMING ITSELF NOW, on the person's own words, // beside the answer rather than behind it (title.go). The message is in - // the transcript on the line above, which is the only thing the namer - // needs; it is started under this lock so that two Submits racing to be - // the first cannot buy two names. + // the transcript on the line above; a shell opening additionally waits + // for an ordinary reply to explain its output. Naming is started under + // this lock so two Submits racing to be first cannot buy two names. if user.bash == "" { a.startTitleLocked() } diff --git a/internal/session/title.go b/internal/session/title.go index 2f97890ec5..574fcef0b2 100644 --- a/internal/session/title.go +++ b/internal/session/title.go @@ -24,6 +24,10 @@ package session // needed for. The first message is enough to name a session — it is what the // person came to ask — and the answer, when there is one by then, is added // to the prompt as it always was. +// A SHELL OPENING WAITS FOR AN ANSWER. Its first ordinary question can be +// only "what did that print?", which says nothing without the reply. Human +// shell turns keep their command preview until an ordinary exchange has +// an answer; naming then starts through the end-of-turn door. // // - AND IT IS NEVER SERIAL WITH THE ANSWER. The errand runs on its own // goroutine, on the SESSION'S lifetime and not the turn's ([Agent.titleCtx]), @@ -138,8 +142,9 @@ const titleWindow = 2 * time.Minute // startTitleLocked starts the session naming itself, if it has no name yet. // // It runs from [Agent.startTurnLocked] with a.mu held, immediately after the -// person's first message has been recorded — the gate and the mark are taken -// under that same hold, so two Submits racing cannot buy two names. +// person's first message has been recorded, or from [Agent.maybeTitle] once a +// shell opening has an ordinary answer. The gate and mark share the hold, so +// two Submits racing cannot buy two names. func (a *Agent) startTitleLocked() { if a.file == nil || a.titleTried || strings.TrimSpace(a.title) != "" || a.closed || a.titleCtx == nil { return @@ -180,7 +185,8 @@ func (a *Agent) startTitleLocked() { // IT IS NOW A SECOND DOOR ONTO ONE ERRAND rather than the errand itself, and it // is still here for the session whose first message was accepted before this // gate could pass — a resume of an untitled journal whose reopening turn is a -// wake, a turn started with no message of its own at all. [Agent.startTitleLocked] +// wake, a turn started with no message of its own at all, or a shell opening +// waiting for its first ordinary answer. [Agent.startTitleLocked] // refuses a session that is already naming itself, so the ordinary turn reaches // this line and buys nothing. func (a *Agent) maybeTitle(context.Context, *eventHub) { @@ -490,7 +496,8 @@ func (a *Agent) setTitleIfUnnamed(title string, _ ...string) bool { } // firstExchangeLocked returns the session's opening question and the first -// thing the assistant said back, both as plain text. +// thing the assistant said back, both as plain text. After a human shell opening, +// it returns nothing until an ordinary question has an answer without tool calls. // // It reads from the front of the transcript rather than from the turn that just // ended, which matters for the session whose first turn is not its first @@ -499,11 +506,14 @@ func (a *Agent) setTitleIfUnnamed(title string, _ ...string) bool { // that is where it was stated. func (a *Agent) firstExchangeLocked() (string, string) { question := "" + shellOpening := false for _, message := range a.messages { switch message.Role { case "user": - // Do not borrow an answer from a later, unrelated user turn. - if question != "" { + // Do not borrow an answer from a later, unrelated user turn. After + // a shell opening, skip unanswered questions so an interrupted turn + // cannot prevent a later completed exchange from naming the chat. + if question != "" && !shellOpening { return question, "" } question = strings.TrimSpace(messageContentText(message)) @@ -511,14 +521,23 @@ func (a *Agent) firstExchangeLocked() (string, string) { // Human shell turns journal as user/call/result. Their durable call // mark, not a leading ! in ordinary model input, identifies them. if humanShellReply(message) { + shellOpening = true question = "" continue } + if shellOpening && len(message.ToolCalls) > 0 { + // Tool narration is not the answer that explains the output. + continue + } if answer := strings.TrimSpace(messageContentText(message)); question != "" && answer != "" { return question, answer } } } + if shellOpening { + // Leave titleTried unset until the end-of-turn path has a reply. + return "", "" + } return question, "" } diff --git a/internal/session/title_shell_test.go b/internal/session/title_shell_test.go index d353582995..952667cf8f 100644 --- a/internal/session/title_shell_test.go +++ b/internal/session/title_shell_test.go @@ -1,6 +1,7 @@ package session import ( + "context" "path/filepath" "strings" "testing" @@ -21,7 +22,10 @@ func TestShellTurnsAreNotTheOpeningExchange(t *testing.T) { }{ {name: "shell only", messages: shell}, {name: "several shell turns", messages: append(append([]ai.Message{}, shell...), shell...)}, + {name: "waiting for ordinary answer", messages: append(append([]ai.Message{}, shell...), textMessage("user", "What did that print?"))}, + {name: "tool narration is not the answer", messages: append(append([]ai.Message{}, shell...), textMessage("user", "What did that print?"), ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "Let me check"}}, ToolCalls: []ai.ToolCall{{ID: "model_read_test"}}})}, {name: "ordinary follow-up", messages: append(append([]ai.Message{}, shell...), textMessage("user", "Explain the output"), textMessage("assistant", "It prints a greeting")), question: "Explain the output", answer: "It prints a greeting"}, + {name: "unanswered shell follow-up does not prevent later naming", messages: append(append([]ai.Message{}, shell...), textMessage("user", "First question"), textMessage("user", "What did that print?"), textMessage("assistant", "It printed a greeting")), question: "What did that print?", answer: "It printed a greeting"}, {name: "ordinary exclamation", messages: []ai.Message{textMessage("user", "! means what in bash?"), textMessage("assistant", "It can negate status")}, question: "! means what in bash?", answer: "It can negate status"}, {name: "model shell call", messages: []ai.Message{textMessage("user", "Check the project"), {Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "model_bash_test"}}}, textMessage("tool", "clean"), textMessage("assistant", "The project is clean")}, question: "Check the project", answer: "The project is clean"}, {name: "interrupted question", messages: []ai.Message{textMessage("user", "First question"), textMessage("user", "Second question"), textMessage("assistant", "Second answer")}, question: "First question"}, @@ -36,7 +40,7 @@ func TestShellTurnsAreNotTheOpeningExchange(t *testing.T) { } } -func TestShellOpeningKeepsLiteralPlaceholderThenNamesTheOrdinaryMessage(t *testing.T) { +func TestShellOpeningKeepsLiteralPlaceholderUntilTheOrdinaryAnswer(t *testing.T) { for _, reopen := range []bool{false, true} { name := "live" if reopen { @@ -44,10 +48,22 @@ func TestShellOpeningKeepsLiteralPlaceholderThenNamesTheOrdinaryMessage(t *testi } t.Run(name, func(t *testing.T) { const command = `!printf 'MiXeD two_spaces;\n'` - const question = "Explain the greeting format" + const question = "What did that print?" + const answer = "It printed MiXeD two_spaces; followed by a newline." const title = "explaining the greeting format" asked := make(chan string, 1) - c := &scriptedCompleter{steps: oneTurn("It preserves the spaces and prints a newline.")} + var a *Agent + c := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + a.mu.Lock() + started := a.titleTried + a.mu.Unlock() + if started { + t.Error("naming started before the ordinary answer existed") + } + return textResponse(answer), nil + }, + }} c.aside = func(messages []ai.Message) (*ai.Response, bool) { if !isTitleCall(messages) { return nil, false @@ -56,7 +72,7 @@ func TestShellOpeningKeepsLiteralPlaceholderThenNamesTheOrdinaryMessage(t *testi return textResponse(title), true } dir := filepath.Join(t.TempDir(), "0123456789abcdef") - a, _ := newTestAgent(t, c, func(cfg *Config) { + a, _ = newTestAgent(t, c, func(cfg *Config) { cfg.Place = Place{Dir: dir, Workspace: cfg.Workspace} cfg.SessionFile = filepath.Join(dir, "transcript.jsonl") }) @@ -90,8 +106,8 @@ func TestShellOpeningKeepsLiteralPlaceholderThenNamesTheOrdinaryMessage(t *testi } a.titleJobs.Wait() request := <-asked - if !strings.Contains(request, question) || strings.Contains(request, command) || strings.Contains(request, "MiXeD") { - t.Fatalf("namer did not use the ordinary opening: %q", request) + if !strings.Contains(request, question) || !strings.Contains(request, answer) || strings.Contains(request, command) { + t.Fatalf("namer did not use the ordinary question and answer: %q", request) } a.SettleWrites() meta, err = LoadMeta(dir) From 8d8749485c1b5e72f3ec918042bc15a81d6f821b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:52:33 -0400 Subject: [PATCH 6/9] Keep task-only conversations discoverable after abrupt exits --- cmd/codeaf/chatv3_layout.go | 2 +- cmd/codeaf/chatv3_layout_test.go | 20 +++++++ .../1667-shell-conversation-titles.md | 3 +- internal/manual/chat/sessions-and-rewind.md | 6 +++ internal/plandb/preview.go | 37 +++++++++++++ internal/plandb/preview_test.go | 35 ++++++++++++ internal/session/delegate_door_test.go | 31 +++++++++++ internal/session/peek.go | 16 ++++-- internal/session/recentplace_test.go | 52 ++++++++++++++++++ internal/session/task_conversation.go | 53 +++++++++++++++++++ internal/session/world.go | 15 ++++-- 11 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 internal/plandb/preview.go create mode 100644 internal/plandb/preview_test.go create mode 100644 internal/session/task_conversation.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index a9b25240f6..65ac09c628 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -660,7 +660,7 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { func v3EmptySession(dir string) bool { place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) - if spoken || !sure { + if spoken || !sure || session.HasSavedTasks(dir) { return false } for _, kept := range []string{place.Work(), place.Trees(), place.Artifacts()} { diff --git a/cmd/codeaf/chatv3_layout_test.go b/cmd/codeaf/chatv3_layout_test.go index 0c9103a45f..55c86772ab 100644 --- a/cmd/codeaf/chatv3_layout_test.go +++ b/cmd/codeaf/chatv3_layout_test.go @@ -373,3 +373,23 @@ func TestTheReaperKeepsEveryFolderItCannotSettle(t *testing.T) { }) } } + +func TestLaunchKeepsTaskOnlyConversationFolders(t *testing.T) { + for _, name := range []string{"plandb.db", "plandb.db.1", "tasks.json", "tasks/1/transcript.jsonl"} { + t.Run(name, func(t *testing.T) { + bucket := t.TempDir() + dir := writeV3Session(t, bucket, "aaaaaaaaaaaaaaaa", "", time.Time{}) + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + // Even a torn store is evidence of work, never permission to reap it. + if err := os.WriteFile(path, []byte("partial saved work"), 0o600); err != nil { + t.Fatal(err) + } + if v3EmptySession(dir) { + t.Fatal("launch would reuse or reap saved task work") + } + }) + } +} diff --git a/docs/changes/unreleased/1667-shell-conversation-titles.md b/docs/changes/unreleased/1667-shell-conversation-titles.md index 3670fc0e66..16d08e7925 100644 --- a/docs/changes/unreleased/1667-shell-conversation-titles.md +++ b/docs/changes/unreleased/1667-shell-conversation-titles.md @@ -1,9 +1,10 @@ --- kind: fixed -title: Conversation names skip opening human shell commands +title: Conversation names skip shell commands and task-only chats remain resumable pr: 1667 surface: [chat, engine, docs] invalidates: - "Conversation lists title-cased opening shell commands and collapsed their spaces. Shell-command previews now keep their casing and punctuation, and saved rows keep their spacing, within the existing preview limit." - "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and waits for the first ordinary question with an answer, including after reopening. Both feed the title so a question like 'what did that print?' has context; the answer may quote shell output. Ordinary openings still name immediately." + - "Conversations opened directly with /senior-dev or /task could be absent from /resume and home after an abrupt exit because no ordinary user message existed. Lists now read the saved task brief, and empty-launch cleanup preserves task stores and journals." --- diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index dccce131b6..71ce0cc642 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -573,6 +573,12 @@ appear nowhere. The name of a conversation is the title it gave itself; failing that, the first seven words you said; failing that, the file name with `.jsonl` stripped. +A conversation started with `/senior-dev` or `/task` is listed even if you never +sent an ordinary chat message. Its saved task brief supplies the preview until the +conversation has its own words or title, including after an abrupt exit. Empty-launch +cleanup preserves saved task work. Listing the conversation does not restart or finish +the interrupted task. + There is **no argument form** of `/resume`. A conversation is named by a title the model wrote and lives in a timestamped file, so the only honest way to ask for one is to be shown them. diff --git a/internal/plandb/preview.go b/internal/plandb/preview.go new file mode 100644 index 0000000000..cc1c9f30be --- /dev/null +++ b/internal/plandb/preview.go @@ -0,0 +1,37 @@ +package plandb + +import ( + "database/sql" + "net/url" + "time" +) + +// RootPreview is the original request behind a saved run. Listing a conversation +// needs these words, not a replay or a writable handle to the task graph. +type RootPreview struct { + Title string + Description string + CreatedAt time.Time +} + +// ReadRootPreview reads only the committed root of an existing store. It never +// creates a database, migrates its schema or settles a task left running by a +// process that exited, so a conversation picker can safely read live work too. +func ReadRootPreview(path string) (RootPreview, error) { + uri := url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"} + db, err := sql.Open("sqlite", uri.String()) + if err != nil { + return RootPreview{}, err + } + defer db.Close() + db.SetMaxOpenConns(1) + var root RootPreview + var created string + err = db.QueryRow(`SELECT tasks.title, tasks.description, tasks.created_at + FROM tasks JOIN meta ON tasks.id = meta.root_id WHERE meta.id = 1`).Scan(&root.Title, &root.Description, &created) + if err != nil { + return RootPreview{}, err + } + root.CreatedAt, err = parseTime(created) + return root, err +} diff --git a/internal/plandb/preview_test.go b/internal/plandb/preview_test.go new file mode 100644 index 0000000000..abb7617782 --- /dev/null +++ b/internal/plandb/preview_test.go @@ -0,0 +1,35 @@ +package plandb + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRootPreviewReadsBesideAWriterAndNeverCreatesAStore(t *testing.T) { + path := filepath.Join(t.TempDir(), "run brief.db") + if _, err := ReadRootPreview(path); err == nil { + t.Fatal("missing store was readable") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("read created a store: %v", err) + } + store, err := Open(path, "program", "1", "fix parser", "fix the parser crash") + if err != nil { + t.Fatal(err) + } + defer store.Close() + // A live worker holding the write transaction must not block the picker. + tx, err := store.beginWrite() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + got, err := ReadRootPreview(path) + if err != nil { + t.Fatal(err) + } + if got.Title != "fix parser" || got.Description != "fix the parser crash" || !got.CreatedAt.Equal(store.Task("1").CreatedAt) { + t.Fatalf("wrong saved root: %+v", got) + } +} diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index b8c6759d37..3fc977d3fe 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -589,3 +589,34 @@ func TestAProgramsRunIsReadableWithTheSwitchOff(t *testing.T) { } endBeltRun(t, agent, double) } + +func TestStartingAProgramWithoutChatIsImmediatelyResumable(t *testing.T) { + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + workspace := newTestRepo(t) + bucket := t.TempDir() + place := Place{Dir: filepath.Join(bucket, "aaaaaaaaaaaaaaaa"), Workspace: workspace} + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(cfg *Config) { + cfg.Workspace = workspace + cfg.Place = place + cfg.SessionFile = place.Transcript() + cfg.AskConsent = false + cfg.Delegates = testPrograms("fake") + }) + const brief = "repair the parser after the upgrade" + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", brief); err != nil { + t.Fatal(err) + } + <-double.entered + // Read while the program is still running, without closing the agent or + // settling metadata writes; abrupt exit cannot supply either of those. + found := RecentSessions(bucket, 10) + rows := ReadRows([]string{place.Transcript()}) + endBeltRun(t, agent, double) + if len(found) != 1 || found[0].Opening != brief { + t.Fatalf("program opening missing from resume: %+v", found) + } + if _, ok := rows[place.Transcript()]; !ok { + t.Fatal("program opening missing from home") + } +} diff --git a/internal/session/peek.go b/internal/session/peek.go index 8ea72ac7df..d039268b16 100644 --- a/internal/session/peek.go +++ b/internal/session/peek.go @@ -56,8 +56,8 @@ type Summary struct { At time.Time // Asked is how many of the person's messages the FILE holds. It counts // lines rather than turns — a compaction re-journals the messages it kept, - // so a long session counts some of them twice — because the only question - // asked of it is whether anything was ever said here at all. + // so a long session counts some of them twice. A task-only conversation can + // have a saved brief and still have no chat messages. Asked int } @@ -68,7 +68,8 @@ const summaryClip = 200 // Peek reads one transcript and reports what a picker can show of it. The // boolean is false for a file that is not a conversation — missing, unreadable, -// a header with nothing under it, or a session nobody ever spoke in. A picker +// a header with nothing under it, or a session with neither messages nor saved +// task work. Command-only conversations fall back to the saved run brief. A picker // row for one of those is a row with no words on it and nothing behind it. func Peek(path string) (Summary, bool) { file, err := os.Open(path) @@ -113,7 +114,7 @@ func Peek(path string) (Summary, bool) { // picture and nothing else is the ordinary case (the surface's // attach.go writes exactly that), and a file whose only turn was // one is a conversation — so it counts toward [Summary.Asked], - // which is the whole test for whether this file is one at all. + // which identifies an ordinary chat independently of saved task work. // What it cannot be is the OPENING or the LAST line: those are // sentences, and this message has none. The answer below is what // the last line falls back to, for precisely this case. @@ -147,7 +148,12 @@ func Peek(path string) (Summary, bool) { // and a picker row built from the first half of a file says more than a row // that is missing because the second half was unreadable. if summary.Asked == 0 { - return Summary{}, false + saved, ok := savedTaskSummary(path) + if !ok { + return Summary{}, false + } + saved.Title = summary.Title + return saved, true } if summary.Last == "" { summary.Last = answered diff --git a/internal/session/recentplace_test.go b/internal/session/recentplace_test.go index a864c931ae..888cecc1d8 100644 --- a/internal/session/recentplace_test.go +++ b/internal/session/recentplace_test.go @@ -1,10 +1,13 @@ package session import ( + "fmt" "os" "path/filepath" "testing" "time" + + "github.com/Agent-Field/codeaf/internal/plandb" ) // A directory holding both shapes at once is the state every machine passes @@ -106,3 +109,52 @@ func TestADirectoryWithNoTranscriptIsNotListed(t *testing.T) { t.Fatalf("the listing is %+v", found) } } + +// A program can persist its work before any ordinary chat turn exists. These +// are the files left by an abrupt exit, with no close or metadata flush. +func TestTaskOnlyConversationSurvivesInConversationLists(t *testing.T) { + for _, archived := range []bool{false, true} { + t.Run(fmt.Sprint(archived), func(t *testing.T) { + bucket := t.TempDir() + place := Place{Dir: filepath.Join(bucket, "aaaaaaaaaaaaaaaa")} + writeTranscript(t, place.Transcript(), `{"type":"session","version":1}`) + if err := SaveMeta(place.Dir, Meta{ID: filepath.Base(place.Dir)}); err != nil { + t.Fatal(err) + } + path := filepath.Join(place.Dir, planStoreFilename) + if archived { + path += ".1" + } + const brief = "repair the parser after the upgrade" + store, err := plandb.Open(path, "program run", "1", brief, brief, filepath.Base(place.Dir)) + if err != nil { + t.Fatal(err) + } + wantAt := store.Task("1").CreatedAt + if err := store.Close(); err != nil { + t.Fatal(err) + } + found := RecentSessions(bucket, 10) + if len(found) != 1 || found[0].File != place.Transcript() || found[0].Opening != brief || !found[0].At.Equal(wantAt) { + t.Fatalf("resume lost the task-only conversation: %+v", found) + } + row, ok := ReadRows([]string{place.Transcript()})[place.Transcript()] + if !ok || row.Title != brief || !row.At.Equal(wantAt) { + t.Fatalf("home lost the task-only conversation: %+v", row) + } + // Reading does not invent a chat message or mark an interrupted task done. + spoken, sure := SpokeIn(place.Transcript()) + if spoken || !sure { + t.Fatalf("listing changed the journal: spoken=%v sure=%v", spoken, sure) + } + reopened, err := plandb.Open(path, "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if reopened.Task("1").Status != plandb.StatusRunning { + t.Fatal("listing settled the task") + } + }) + } +} diff --git a/internal/session/task_conversation.go b/internal/session/task_conversation.go new file mode 100644 index 0000000000..a825f41177 --- /dev/null +++ b/internal/session/task_conversation.go @@ -0,0 +1,53 @@ +package session + +import ( + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// savedTaskSummary recovers a command-only conversation from the run it saved. +// /senior-dev starts work without inserting a model-facing chat message, so an +// interrupted run may be the only durable record of the person's opening words. +func savedTaskSummary(transcript string) (Summary, bool) { + if filepath.Base(transcript) != placeTranscript { + return Summary{}, false + } + path := filepath.Join(filepath.Dir(transcript), planStoreFilename) + // The current run is newest. Only consult older stores if it cannot + // supply a brief, keeping the normal picker read to one root row. + paths := append(planArchivePaths(path), path) + for i := len(paths) - 1; i >= 0; i-- { + if _, err := os.Stat(paths[i]); err != nil { + continue + } + root, err := plandb.ReadRootPreview(paths[i]) + if err != nil { + continue + } + opening := summaryLine(root.Description) + if opening == "" { + opening = summaryLine(root.Title) + } + if opening == "" { + continue + } + return Summary{File: transcript, Opening: opening, Last: opening, LastUser: opening, At: root.CreatedAt}, true + } + return Summary{}, false +} + +// HasSavedTasks protects task-only conversations from empty-launch cleanup. +// Even unreadable or partly written work must survive: a failed read is not +// evidence that a folder is disposable. +func HasSavedTasks(dir string) bool { + place := Place{Dir: dir} + path := filepath.Join(dir, planStoreFilename) + for _, saved := range []string{place.Tasks(), place.NodeJournals(), path, path + ".1"} { + if _, err := os.Lstat(saved); !os.IsNotExist(err) { + return true + } + } + return false +} diff --git a/internal/session/world.go b/internal/session/world.go index 5c51274c85..caae9ca891 100644 --- a/internal/session/world.go +++ b/internal/session/world.go @@ -648,7 +648,7 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { // transcript in it is not a session at all and is skipped. A folder whose // meta.json says nobody has ever spoken — a `lastUserAt` that is not there — is // the empty shell a launch mints and the groom reuses (cmd/codeaf's -// v3ScanBucket), and it is skipped too. But a folder whose meta.json is MISSING +// v3ScanBucket), and it is skipped too unless a saved task supplies its brief. But a folder whose meta.json is MISSING // or unreadable is kept, for the reason the sweep keeps it: a session that // cannot say what it is, stays, because hiding somebody's conversation on the // strength of a lookup file is the more expensive mistake. @@ -661,8 +661,17 @@ func readSessionRow(dir, id string, now time.Time) (SessionRow, bool) { } meta, _ := LoadMeta(dir) named := strings.TrimSpace(meta.ID) != "" - if named && meta.LastUserAt.IsZero() { - return SessionRow{}, false + if meta.LastUserAt.IsZero() { + saved, ok := savedTaskSummary(transcript) + if !ok && named { + return SessionRow{}, false + } + if ok { + meta.LastUserAt = saved.At + if strings.TrimSpace(meta.Title) == "" { + meta.Title = saved.Opening + } + } } at := meta.LastUserAt if at.IsZero() { From ae3582c0b35ff83ef640559c108b6eb27eb85de7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 29 Sep 2026 12:58:05 -0400 Subject: [PATCH 7/9] session: a note the session wrote never names a conversation Since naming after a `!` opening waits for an answered question, every later user-role message replaced the question. The memory, volatile and team notes land in that role between the person's question and the answer, and a wake's `while you worked:` delivery lands there on a shell-only conversation, so either one became what the conversation was named from. firstExchangeLocked now skips them with the predicate the compacted summary already used to find the person's own messages, shared as sessionNoteLocked. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../1667-shell-conversation-titles.md | 2 +- internal/session/compact_summary.go | 2 +- internal/session/title.go | 10 +++ internal/session/title_shell_test.go | 64 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/changes/unreleased/1667-shell-conversation-titles.md b/docs/changes/unreleased/1667-shell-conversation-titles.md index 16d08e7925..4eba5b1a2d 100644 --- a/docs/changes/unreleased/1667-shell-conversation-titles.md +++ b/docs/changes/unreleased/1667-shell-conversation-titles.md @@ -5,6 +5,6 @@ pr: 1667 surface: [chat, engine, docs] invalidates: - "Conversation lists title-cased opening shell commands and collapsed their spaces. Shell-command previews now keep their casing and punctuation, and saved rows keep their spacing, within the existing preview limit." - - "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and waits for the first ordinary question with an answer, including after reopening. Both feed the title so a question like 'what did that print?' has context; the answer may quote shell output. Ordinary openings still name immediately." + - "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and waits for the first ordinary question with an answer, including after reopening. Both feed the title so a question like 'what did that print?' has context; the answer may quote shell output. Ordinary openings still name immediately. Notes codeaf writes itself, including memory and deliveries, are never taken as the question." - "Conversations opened directly with /senior-dev or /task could be absent from /resume and home after an abrupt exit because no ordinary user message existed. Lists now read the saved task brief, and empty-launch cleanup preserves task stores and journals." --- diff --git a/internal/session/compact_summary.go b/internal/session/compact_summary.go index 299ee89b99..8fe6fc660c 100644 --- a/internal/session/compact_summary.go +++ b/internal/session/compact_summary.go @@ -407,7 +407,7 @@ func (a *Agent) personMessagesLocked() []int { if a.messages[index].Role != "user" { continue } - if isCodeafNote(messageContentText(a.messages[index])) || a.file.isNote(a.messages[index]) { + if a.sessionNoteLocked(a.messages[index]) { continue } persons = append(persons, index) diff --git a/internal/session/title.go b/internal/session/title.go index 574fcef0b2..1ebd670a44 100644 --- a/internal/session/title.go +++ b/internal/session/title.go @@ -498,6 +498,7 @@ func (a *Agent) setTitleIfUnnamed(title string, _ ...string) bool { // firstExchangeLocked returns the session's opening question and the first // thing the assistant said back, both as plain text. After a human shell opening, // it returns nothing until an ordinary question has an answer without tool calls. +// Notes the session wrote cannot serve as the person's question. // // It reads from the front of the transcript rather than from the turn that just // ended, which matters for the session whose first turn is not its first @@ -510,6 +511,9 @@ func (a *Agent) firstExchangeLocked() (string, string) { for _, message := range a.messages { switch message.Role { case "user": + if a.sessionNoteLocked(message) { + continue + } // Do not borrow an answer from a later, unrelated user turn. After // a shell opening, skip unanswered questions so an interrupted turn // cannot prevent a later completed exchange from naming the chat. @@ -541,6 +545,12 @@ func (a *Agent) firstExchangeLocked() (string, string) { return question, "" } +// sessionNoteLocked identifies user-role messages the session wrote, so only +// the person's words can anchor a conversation title or a compacted summary. +func (a *Agent) sessionNoteLocked(message ai.Message) bool { + return isCodeafNote(messageContentText(message)) || a.file.isNote(message) +} + func humanShellReply(message ai.Message) bool { for _, call := range message.ToolCalls { if IsUserBashCall(call.ID) { diff --git a/internal/session/title_shell_test.go b/internal/session/title_shell_test.go index 952667cf8f..73f1646b73 100644 --- a/internal/session/title_shell_test.go +++ b/internal/session/title_shell_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/Agent-Field/agentfield/sdk/go/ai" ) @@ -25,7 +26,10 @@ func TestShellTurnsAreNotTheOpeningExchange(t *testing.T) { {name: "waiting for ordinary answer", messages: append(append([]ai.Message{}, shell...), textMessage("user", "What did that print?"))}, {name: "tool narration is not the answer", messages: append(append([]ai.Message{}, shell...), textMessage("user", "What did that print?"), ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "Let me check"}}, ToolCalls: []ai.ToolCall{{ID: "model_read_test"}}})}, {name: "ordinary follow-up", messages: append(append([]ai.Message{}, shell...), textMessage("user", "Explain the output"), textMessage("assistant", "It prints a greeting")), question: "Explain the output", answer: "It prints a greeting"}, + {name: "memory note follows the question", messages: append(append([]ai.Message{}, shell...), textMessage("user", "Explain the output"), textMessage("user", memoryNoteOpening+"\n\nsaved preference"), textMessage("assistant", "It prints a greeting")), question: "Explain the output", answer: "It prints a greeting"}, + {name: "volatile note follows the question", messages: append(append([]ai.Message{}, shell...), textMessage("user", "Explain the output"), textMessage("user", volatileNoteOpening+"\n\nwork in progress"), textMessage("assistant", "It prints a greeting")), question: "Explain the output", answer: "It prints a greeting"}, {name: "unanswered shell follow-up does not prevent later naming", messages: append(append([]ai.Message{}, shell...), textMessage("user", "First question"), textMessage("user", "What did that print?"), textMessage("assistant", "It printed a greeting")), question: "What did that print?", answer: "It printed a greeting"}, + {name: "session note before ordinary opening", messages: []ai.Message{textMessage("user", memoryNoteOpening+"\n\nsaved preference"), textMessage("user", "Explain the output"), textMessage("assistant", "It prints a greeting")}, question: "Explain the output", answer: "It prints a greeting"}, {name: "ordinary exclamation", messages: []ai.Message{textMessage("user", "! means what in bash?"), textMessage("assistant", "It can negate status")}, question: "! means what in bash?", answer: "It can negate status"}, {name: "model shell call", messages: []ai.Message{textMessage("user", "Check the project"), {Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "model_bash_test"}}}, textMessage("tool", "clean"), textMessage("assistant", "The project is clean")}, question: "Check the project", answer: "The project is clean"}, {name: "interrupted question", messages: []ai.Message{textMessage("user", "First question"), textMessage("user", "Second question"), textMessage("assistant", "Second answer")}, question: "First question"}, @@ -40,6 +44,66 @@ func TestShellTurnsAreNotTheOpeningExchange(t *testing.T) { } } +func TestAWakeDoesNotNameAShellOnlyConversation(t *testing.T) { + const command = "!printf 'SHELL-R2'" + const question = "What did the shell print?" + const answer = "The shell printed SHELL-R2." + const delivery = "background delivery R2" + const title = "explaining the shell output" + asked := make(chan string, 1) + c := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("wake answer R2"), nil }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse(answer), nil }, + }} + c.aside = func(messages []ai.Message) (*ai.Response, bool) { + if !isTitleCall(messages) { + return nil, false + } + asked <- messageContentText(messages[len(messages)-1]) + return textResponse(title), true + } + dir := filepath.Join(t.TempDir(), "0123456789abcdef") + a, _ := newTestAgent(t, c, func(cfg *Config) { + cfg.Place = Place{Dir: dir, Workspace: cfg.Workspace} + cfg.SessionFile = filepath.Join(dir, "transcript.jsonl") + }) + ch, err := a.SubmitBash(t.Context(), command) + if err != nil { + t.Fatal(err) + } + collect(t, ch) + wakes, leave := a.WatchWakes() + defer leave() + a.enqueueSteering(delivery) + select { + case turn := <-wakes: + collect(t, turn) + case <-time.After(10 * time.Second): + t.Fatal("session-authored delivery did not wake the conversation") + } + a.mu.Lock() + tried := a.titleTried + a.mu.Unlock() + a.SettleWrites() + meta, err := LoadMeta(dir) + if err != nil || meta.Title != command || a.Title() != "" || tried || c.asideRequests() != 0 { + t.Fatalf("wake named a shell-only conversation: meta=%+v err=%v title=%q tried=%t calls=%d", meta, err, a.Title(), tried, c.asideRequests()) + } + ch, err = a.Submit(t.Context(), question) + if err != nil { + t.Fatal(err) + } + collect(t, ch) + if got := awaitTitle(t, a); got != title { + t.Fatalf("title = %q, want %q", got, title) + } + a.titleJobs.Wait() + request := <-asked + if !strings.Contains(request, question) || !strings.Contains(request, answer) || strings.Contains(request, delivery) || strings.Contains(request, "wake answer R2") { + t.Fatalf("namer did not use the person's question and answer: %q", request) + } +} + func TestShellOpeningKeepsLiteralPlaceholderUntilTheOrdinaryAnswer(t *testing.T) { for _, reopen := range []bool{false, true} { name := "live" From dae7691f183d4b0933bbb2000c990b81cbd651b5 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 29 Sep 2026 13:07:46 -0400 Subject: [PATCH 8/9] manual: an interrupted question after a shell opening, and the clipped preview A partly answered, interrupted question names the conversation from that exchange when the next message is sent; only a question with no answer at all is skipped for the next answered one. The /resume paragraph said a shell name is shown exactly as typed while it is clipped to fit, and two long lines are rewrapped. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/commands.md | 13 +++++++------ internal/manual/chat/sessions-and-rewind.md | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 88bb448762..a628c7eed0 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -38,9 +38,10 @@ long commands are clipped to fit. Shell turns do not ask a model for a title. After a shell opening, automatic naming waits for the first ordinary message with an answer and uses both, skipping recorded human shell turns. The command preview stays until the title arrives. This gives a question such as "what did that print?" -the context of its answer; the answer may quote shell output. An unanswered or -interrupted question does not prevent a later answered message from naming the chat. -Conversations that begin with an ordinary message still start naming immediately. +the context of its answer; the answer may quote shell output. An interrupted question +does not stop naming: if part of its answer had arrived, your next message names the chat +from that exchange, and if none had, the next answered question does. Conversations that +begin with an ordinary message still start naming immediately. ## Typing a slash to see the command list @@ -1162,9 +1163,9 @@ Ten rows show, each a name, a description and an age. The name climbs a ladder: the session gave itself, else the first seven words you said in it, else the transcript's file name — then title-cased, with small words left lowercase and nothing ever lowercased, so `OpenAI` keeps its shape. A name that is still a `!` command is the -exception: it is shown exactly as typed, never title-cased or cut to seven words. The -description is the last thing that happened, capped at 80 columns. The age (`2h ago`, or a date past a month) is reserved first and -never cut. **Ids and file names appear nowhere.** +exception: it is shown as typed, clipped only to fit, never title-cased or cut to seven +words. The description is the last thing that happened, capped at 80 columns. The age +(`2h ago`, or a date past a month) is reserved first and never cut. **Ids and file names appear nowhere.** The cursor opens on the conversation you are already in. enter on another row closes this agent, interrupting a running turn first, opens the chosen transcript, clears everything diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 71ce0cc642..2659cef227 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -501,8 +501,8 @@ opening question and asked for one descriptive **5–8 word phrase**. A `!` shel is not that message: a conversation that opens with `!` commands keeps the command as its name until an ordinary message has an answer, and is then named from that question and answer. Unanswered questions are skipped after a shell opening. For an ordinary opening, -the answer and naming still run independently from the start. A late name reaches an idle chat, a -background tab, or a hosted chat after the connection is restored; no refresh or +the answer and naming still run independently from the start. A late name reaches an idle +chat, a background tab, or a hosted chat after the connection is restored; no refresh or follow-up message is needed. Each ask is bounded to twenty seconds so a slow cheap endpoint yields to the existing From bab38b20d61714e917828ce635be66f5f693e09a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 29 Sep 2026 13:48:32 -0400 Subject: [PATCH 9/9] manual: listing a task-only conversation can leave SQLite's side files The /resume page said the list never writes and never creates a file. Reading a task-only conversation's saved brief opens its plan store read-only, and on a store that was closed cleanly that leaves plandb.db-wal and plandb.db-shm beside it. The paragraph on task-only conversations also said a title replaces the brief as the preview, but the brief stays the row's description until the person sends a message. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/sessions-and-rewind.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 2659cef227..87434152ec 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -574,17 +574,19 @@ The name of a conversation is the title it gave itself; failing that, the first you said; failing that, the file name with `.jsonl` stripped. A conversation started with `/senior-dev` or `/task` is listed even if you never -sent an ordinary chat message. Its saved task brief supplies the preview until the -conversation has its own words or title, including after an abrupt exit. Empty-launch -cleanup preserves saved task work. Listing the conversation does not restart or finish -the interrupted task. +sent an ordinary chat message, including after an abrupt exit. Until you send one, its +saved task brief is the row's description, and its name too unless the conversation +has earned a title. Empty-launch cleanup preserves saved task work. Listing the +conversation does not restart or finish the interrupted task. There is **no argument form** of `/resume`. A conversation is named by a title the model wrote and lives in a timestamped file, so the only honest way to ask for one is to be shown them. -The list is read without locking anything: open, scan, close. It never writes and never -creates a file, so it can show a conversation another window is holding open. One known +The list is read without locking anything: open, scan, close. It never writes to a +conversation, so it can show a conversation another window is holding open. The one +exception is a task-only conversation: reading its saved brief opens the task store +read-only, which can leave SQLite's `plandb.db-wal` and `plandb.db-shm` files beside it. One known staleness: the list reads what the file says rather than the transcript a resume would rebuild, so a rewind with nothing typed after it leaves the taken-back message as the row's description.