diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..cd89ec3 --- /dev/null +++ b/TASK.md @@ -0,0 +1,46 @@ +# Task + +Resolve issue #33: [bug] text typed during a turn is lost when the agent completes + +## Issue body + +### What happened +While the agent is working, the user types into the message bar (to steer the +next turn or queue a follow-up). When the agent transitions from working to +completed, that in-progress text is deleted from the message footer and the bar +resets to empty. Anything the user was mid-typing is gone. + +### Steps to reproduce +1. Start an interactive sesh session. +2. Submit a request so the agent is actively working. +3. While it works, type some text into the message bar. +4. Wait for the agent to finish (transition to completed). + +### Expected +The text the user is typing should be preserved in the message bar at all +times, including across the working-to-completed transition. The buffer is the +user's draft and should survive state changes. + +### Actual +When the turn completes, the input editor is reset and the message bar is +cleared, discarding the partial message the user was composing. + +### Likely location +The editor buffer is reset in `harness/tui.go`. `beginInput` zeroes `t.buf` +when the editor re-opens (which fires as the prompt re-shows at completion): +`t.prompt, t.buf, t.pos, t.mask = prompt, nil, 0, mask`. That clear is +correct after a submit (`endInput`), but it also destroys a draft that was +being typed during the turn. The fix likely preserves the current `buf`/`pos` +when re-opening the editor at completion rather than unconditionally zeroing +it. + +### sesh context +- build: 33cb3ae +- provider protocol / model / context window: openai / glm-5.2 / 200000 +- active mods: none +- tuning overrides: handoff_pct:80 hard_pct:90 max_useful_context:250000 assumed_context:200000 (and a second set: handoff_pct:70 hard_pct:85 max_useful_context:400000) task_depth:3 stuck_after:3 seed_ledger_entries:10 recall_links:50 diff_lines:40 skill_note_off:false update_check:true +- skills installed: 6 +- tool mods: 1 +- mcp configured: yes +- invocation: interactive; flags: none + diff --git a/harness/tui.go b/harness/tui.go index d8e9a7a..9fb9ebd 100644 --- a/harness/tui.go +++ b/harness/tui.go @@ -770,9 +770,20 @@ func (t *tuiConsole) SetTitle(s string) { // footer itself persists between inputs. func (t *tuiConsole) beginInput(prompt string, mask bool) { t.mu.Lock() - t.prompt, t.buf, t.pos, t.mask = prompt, nil, 0, mask - t.snippets = nil - t.images = nil + // A turn can end while the user is mid-typing a steering message. Carry + // that draft into the next prompt instead of discarding it: endInput and + // the steer/stop paths already clear the buffer on submit, so a non-empty + // one here uniquely identifies the carry-over. The buffer's snippet and + // image tokens index into the slices, so they carry too. A buffer left + // masked (a secret prompt) never carries: gating on the prior mask, not + // the new one, stops a secret leaking into an ordinary prompt either way. + if !t.mask && len(t.buf) > 0 { + t.prompt, t.mask = prompt, mask + } else { + t.prompt, t.buf, t.pos, t.mask = prompt, nil, 0, mask + t.snippets = nil + t.images = nil + } t.histIdx = len(t.hist) t.draft = nil t.winTop = 0 diff --git a/harness/tui_test.go b/harness/tui_test.go index bc03d36..88478a5 100644 --- a/harness/tui_test.go +++ b/harness/tui_test.go @@ -301,6 +301,67 @@ func TestAttendTurnStopCommandCancelsWithoutQueuing(t *testing.T) { } } +// TestBeginInputPreservesDraftAcrossTurnEnd: a draft typed during a turn (but +// never submitted) must survive the working-to-completed transition and remain +// in the editor when the next prompt opens, so in-progress text is not lost. +// attendTurn leaves the typed buffer in place when the turn ends; the next +// ReadLine re-opens the editor through beginInput, which must carry that buffer +// forward instead of zeroing it. Breaker: revert beginInput to an unconditional +// t.buf = nil and the submitted line comes back empty instead of "fix it". +func TestBeginInputPreservesDraftAcrossTurnEnd(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "tui-out") + if err != nil { + t.Fatal(err) + } + defer f.Close() + tc := &tuiConsole{out: f, in: bufio.NewReader(strings.NewReader("fix it")), cols: 80} + // Phase 1: the user types a steering draft while a turn runs; the stream's + // EOF ends the attend (errTurnOver) the way a turn finishing on its own does. + if err := tc.attendTurn(turnAttend{ + done: make(chan struct{}), // never closes; EOF ends attend + cancel: func() {}, + queue: func(string) {}, + }); err != errTurnOver { + t.Fatalf("attendTurn err = %v, want errTurnOver", err) + } + if got := string(tc.buf); got != "fix it" { + t.Fatalf("draft must survive attendTurn: buf = %q, want %q", got, "fix it") + } + // Phase 2: the next prompt re-opens the editor. EOF closed the pump's + // channel, so re-arm a fresh input source the way a live console keeps + // reading, then submit with Enter. + tc.runes = nil + tc.in = bufio.NewReader(strings.NewReader("\r")) + line, err := tc.ReadLine("-> ") + if err != nil { + t.Fatalf("ReadLine: %v", err) + } + if line != "fix it" { + t.Fatalf("draft must carry into the next prompt: line = %q, want %q", line, "fix it") + } +} + +// TestBeginInputDropsMaskedDraftForOrdinaryPrompt: a non-empty buffer left from +// a masked prompt must never carry into an ordinary one, so a secret cannot +// leak across prompt types. The carry-over branch is gated on !mask, so a +// non-empty masked buffer is cleared when the next (non-masked) prompt opens. +// Breaker: drop the !mask guard on the carry-over branch and the secret text +// survives into the ordinary prompt. +func TestBeginInputDropsMaskedDraftForOrdinaryPrompt(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "tui-out") + if err != nil { + t.Fatal(err) + } + defer f.Close() + // Seed the exact state a masked prompt leaves mid-edit (non-empty buffer, + // masked), then open an ordinary prompt: the secret must not carry. + tc := &tuiConsole{out: f, cols: 80, buf: []rune("hush"), pos: 4, mask: true} + tc.beginInput("-> ", false) + if len(tc.buf) != 0 { + t.Fatalf("a masked draft must not carry into an ordinary prompt: buf = %q", string(tc.buf)) + } +} + // TestEscIsBareKeysOffIntroducer: bare-Escape detection decides by the byte // following Esc, not by timing, so it holds up under tmux/SSH latency. A lone // Esc with the stream then closed is bare; an Esc followed by a CSI introducer