From 2277b0e8d74fddae746473053dfb9a6773738141 Mon Sep 17 00:00:00 2001 From: "sesh-dispatch[bot]" <296316602+sesh-dispatch[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:41:39 +0000 Subject: [PATCH 1/3] fix(tui): preserve typed draft across turn completion A steering message typed while the agent worked was discarded when the turn completed. beginInput unconditionally zeroed the editor buffer when the between-turns prompt re-opened the editor, destroying a draft that survived the turn in the live editor's buffer. Carry an existing non-empty buffer (with its cursor, snippets, and images) into the next prompt. Submit, steer, and /stop all clear the buffer before returning, so a non-empty buffer here uniquely identifies the carry-over. A masked (secret) buffer never carries, keyed on the prior mask so it holds in both directions. --- harness/tui.go | 17 ++++++++++--- harness/tui_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) 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 From d4c8040850d3ad1a1fb5153f858f428b8ee3f4c0 Mon Sep 17 00:00:00 2001 From: "sesh-dispatch[bot]" <296316602+sesh-dispatch[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:42:20 +0000 Subject: [PATCH 2/3] dispatch: resolve #33 --- TASK.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 TASK.md 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 + From 52c025eea3ce76ae9172d707e1d33aebb8c54e34 Mon Sep 17 00:00:00 2001 From: mike-diff Date: Thu, 20 Aug 2026 16:50:33 -0700 Subject: [PATCH 3/3] fix(tui): keep drafts on one side of the secret boundary --- TASK.md | 46 --------------------------------------------- harness/tui.go | 11 +++++++---- harness/tui_test.go | 29 ++++++++++++++++++++++++---- 3 files changed, 32 insertions(+), 54 deletions(-) delete mode 100644 TASK.md diff --git a/TASK.md b/TASK.md deleted file mode 100644 index cd89ec3..0000000 --- a/TASK.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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 9fb9ebd..912acdb 100644 --- a/harness/tui.go +++ b/harness/tui.go @@ -774,10 +774,13 @@ func (t *tuiConsole) beginInput(prompt string, mask bool) { // 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 { + // image tokens index into the slices, so they carry too. + // + // A draft never crosses a secret boundary in either direction: not out of + // a masked prompt, where it would leak the secret into plain view, and not + // into one, where it would sit in the field rendered as dots and be + // submitted as the key on the next Enter. + if !t.mask && !mask && len(t.buf) > 0 { t.prompt, t.mask = prompt, mask } else { t.prompt, t.buf, t.pos, t.mask = prompt, nil, 0, mask diff --git a/harness/tui_test.go b/harness/tui_test.go index 88478a5..ab28e78 100644 --- a/harness/tui_test.go +++ b/harness/tui_test.go @@ -343,10 +343,10 @@ func TestBeginInputPreservesDraftAcrossTurnEnd(t *testing.T) { // 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. +// leak across prompt types. The carry-over branch requires the PRIOR prompt to +// be unmasked, so a non-empty masked buffer is cleared when the next prompt +// opens. Breaker: drop the !t.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 { @@ -362,6 +362,27 @@ func TestBeginInputDropsMaskedDraftForOrdinaryPrompt(t *testing.T) { } } +// TestBeginInputDropsDraftForSecretPrompt: the other direction of the same +// boundary. An ordinary draft must not carry INTO a masked prompt, where it +// would render as dots and be submitted as the secret on the next Enter. No +// current path reaches a secret prompt with a draft pending (every route to one +// goes through a submitted line, which clears the buffer), so this guards a +// boundary rather than fixing a live bug: the carry rule is about crossing the +// secret line at all, not about which side it started on. Breaker: relax the +// guard to `!t.mask && len(t.buf) > 0` and the draft lands in the key field. +func TestBeginInputDropsDraftForSecretPrompt(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "tui-out") + if err != nil { + t.Fatal(err) + } + defer f.Close() + tc := &tuiConsole{out: f, cols: 80, buf: []rune("my draft text"), pos: 13} + tc.beginInput("api key: ", true) + if len(tc.buf) != 0 { + t.Fatalf("a draft must not carry into a masked 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