Skip to content
Open
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
46 changes: 46 additions & 0 deletions TASK.md
Original file line number Diff line number Diff line change
@@ -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

17 changes: 14 additions & 3 deletions harness/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions harness/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading