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." +--- 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") + } + } + }) + } +}