Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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."
---
8 changes: 8 additions & 0 deletions internal/manual/chat/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/tui3/replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions internal/tui3/stopped_replay_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
})
}
}
Loading