From 981666fbd84e73380177a6d05af1aefacb5d0188 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 29 Sep 2026 18:29:47 -0400 Subject: [PATCH 1/2] tui3: a stopped answer can still be opened after reopening its conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live, a turn the person stops folds its unfinished words behind `▸ stopped by you · ctrl+e` (the design since 2026-08-24). After /quit and reopening, the same turn showed only the person's message: the partial was in the journal, recorded as an interrupted aside since #1627, but replay sent every aside down the generic note path, and a trailing note at the end of a stopped turn is left outside the fold as surface news. With no work to count, no chip was drawn and ctrl+e had nothing to open. Replay now rebuilds an interrupted aside as cut, settled assistant work, so the existing fold draws `▸ stopped by you` and discloses the exact words. Ordinary asides, `[update]` partials and completed answers keep their paths; no session code, journal bytes or provider context change. The new test writes a real stopped session.Agent journal through a streaming provider, reopens it, and checks the chip and its disclosure for a plain answer, an `[update]` and a response stopped mid tool call. Found by the 2026-09-29 happy-path pass over dev 461a43fb6 (lane 6). Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/keys.md | 8 ++ internal/tui3/replay.go | 11 +++ internal/tui3/stopped_replay_test.go | 116 +++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 internal/tui3/stopped_replay_test.go diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index c69c758bae..48b3b08812 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -410,6 +410,14 @@ land, stops the answer and then quits. Nothing you typed is lost when it does: t and anything waiting for an answer are written to disk on the way out. See "Quitting codeaf — how do I exit, close it, or why did ctrl+c not quit" below. +## Reopen a conversation after stopping an answer — where did the answer I stopped go + +When you stop an answer with `esc` or `ctrl+c`, its unfinished words fold behind +`▸ stopped by you`. Reopening the conversation keeps that chip. Press `ctrl+e` +to open it and read the words that arrived before you stopped. They stay +unfinished work, not a completed answer. After reopening, the chip may leave out +how long the answer ran, because that time is not saved. + ## Esc is not stopping it — how long does a stop take, why the turn is still finishing, how long stopping takes, and what happens if it will not stop or will not let go **I pressed escape and it is still running.** That is this section: escape is not being diff --git a/internal/tui3/replay.go b/internal/tui3/replay.go index 691a685e56..82f9c47013 100644 --- a/internal/tui3/replay.go +++ b/internal/tui3/replay.go @@ -781,6 +781,17 @@ func (a *app) replayBlocks(entries []session.DisplayEntry, shape replayShape) ([ if text == "" { continue } + // AN INTERRUPTED OPERATIONAL PARTIAL IS STILL THE MODEL'S WORK. + // The journal gives it an aside audience so it cannot stand as an + // answer, but a generic note at the end of a stopped turn is left + // outside the fold as news from the surface. Keep the partial as cut + // assistant work so the stopped chip can disclose its exact words. + if e.Interrupted { + blocks = append(blocks, entry{ + kind: entryAssistant, text: text, turn: turn, settled: true, cut: true, + }) + continue + } // A LINE THE TEAM SENT IS A CARD, headed by who said it to whom // (teamcard.go), and never the person's `›`. A TEAM WAKE WITH // NOTHING DELIVERED IN IT IS NOT DRAWN: it is the sentence that told diff --git a/internal/tui3/stopped_replay_test.go b/internal/tui3/stopped_replay_test.go new file mode 100644 index 0000000000..d400604cac --- /dev/null +++ b/internal/tui3/stopped_replay_test.go @@ -0,0 +1,116 @@ +package tui3 + +import ( + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A real stopped session writes the partial answer before the surface reopens +// it, so this test crosses the same journal boundary as leaving for Home. +func TestAStoppedPlainAnswerReopensBehindAReadableDisclosure(t *testing.T) { + for _, tc := range []struct { + name, streamed, visible string + tool bool + }{ + {"plain answer", "The river bends past the old bridge.", "The river bends past the old bridge.", false}, + {"explicit update", "[update] The first file is ready.", "The first file is ready.", false}, + {"forming tool call", "I am checking the river map.", "I am checking the river map.", true}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, `data: {"choices":[{"index":0,"delta":{"role":"assistant","content":`+strconv.Quote(tc.streamed)+`}}]}`+"\n\n") + w.(http.Flusher).Flush() + if tc.tool { + _, _ = io.WriteString(w, `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"path\":"}}]}}]}`+"\n\n") + w.(http.Flusher).Flush() + } + <-r.Context().Done() + })) + defer server.Close() + + dir := t.TempDir() + path := filepath.Join(dir, "session.jsonl") + agent, err := session.New(session.Config{Workspace: dir, SessionFile: path, Model: "vendor/m", APIKey: "test", BaseURL: server.URL, System: "SYSTEM"}) + if err != nil { + t.Fatal(err) + } + events, err := agent.Submit(t.Context(), "Tell me about the river") + if err != nil { + t.Fatal(err) + } + gotText, gotTool := false, !tc.tool + for !gotText || !gotTool { + select { + case ev, ok := <-events: + if !ok { + t.Fatal("stream ended before the person stopped it") + } + gotText = gotText || ev.Kind == session.EventTextDelta && strings.Contains(ev.Text, tc.streamed) + gotTool = gotTool || ev.Kind == session.EventToolForming + case <-time.After(5 * time.Second): + t.Fatal("stream did not reach the stop point") + } + } + agent.Interrupt() + for range events { + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + + record := session.ReadTranscript(path) + if len(record.Entries) < 2 || !record.Entries[1].Interrupted || record.Entries[1].Answer { + t.Fatalf("journal did not keep the interrupted response: %#v", record.Entries) + } + if tc.name == "plain answer" && (record.Entries[1].Role != "aside" || record.Entries[1].Addressed) { + t.Fatalf("the unmarked partial changed audience: %#v", record.Entries[1]) + } + if tc.tool { + for _, e := range record.Entries { + if e.Role == "tool" { + t.Fatalf("a forming call that never ran entered the record: %#v", e) + } + } + } + + reopened := resumedAgent(t, dir, path) + a := newApp(t.Context(), Options{Agent: reopened, Workspace: dir, Resumed: true}) + a.width, a.height = 90, 30 + a.workMode = config.WorkFold + a.touch() + closed := strings.Join(plainRows(a), "\n") + if tc.name == "explicit update" { + if strings.Count(closed, tc.visible) != 1 || !strings.Contains(closed, "interrupted") { + t.Fatalf("the addressed update changed on reopen:\n%s", closed) + } + return + } + if !strings.Contains(closed, "▸ stopped by you") || strings.Contains(closed, tc.visible) { + t.Fatalf("the stopped response has no closed disclosure:\n%s", closed) + } + drive(t, a, key("ctrl+e")) + opened := strings.Join(plainRows(a), "\n") + if strings.Count(opened, tc.visible) != 1 || !strings.Contains(opened, "▾ stopped by you") { + t.Fatalf("opening the stopped turn did not reveal its words once:\n%s", opened) + } + if line := rowWithText(t, a, tc.visible); !strings.HasPrefix(plain(line.text), " ") || !strings.Contains(line.text, sgrOf(a.pal.narr)) { + t.Fatalf("the stopped words were promoted to an answer: %q", line.text) + } + for _, e := range a.entries { + if e.kind == entryAssistant && confirmedAnswer(&e) { + t.Fatal("the stopped turn reopened as a completed answer") + } + } + }) + } +} From ca868da186b67b0f0ddcd4ee1fb964062a36c33c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 29 Sep 2026 18:44:51 -0400 Subject: [PATCH 2/2] changes: entry for #1681 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../1681-stopped-answer-reopens-behind-its-chip.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/changes/unreleased/1681-stopped-answer-reopens-behind-its-chip.md diff --git a/docs/changes/unreleased/1681-stopped-answer-reopens-behind-its-chip.md b/docs/changes/unreleased/1681-stopped-answer-reopens-behind-its-chip.md new file mode 100644 index 0000000000..40db1eb274 --- /dev/null +++ b/docs/changes/unreleased/1681-stopped-answer-reopens-behind-its-chip.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: a stopped answer can still be opened after reopening its conversation +pr: 1681 +surface: [chat, docs] +invalidates: + - "Reopening a conversation whose last answer you stopped showed only your message: no `▸ stopped by you` chip, and ctrl+e opened nothing, though the words were in the journal (dev only, since #1627). The reopened turn now draws the same stopped chip, and ctrl+e shows the words that arrived before the stop, as unfinished work." +---