Skip to content

Commit c8345b7

Browse files
frenchie4111claude
andcommitted
fix(chat): place mid-turn messages where claude read them
A queued bubble was appended where the user hit enter — mid-stream of whatever claude was already saying — and stayed there, so the conversation read as if the interjection came before content it was actually sent after. Two halves: * Live: the requesting boundary now moves formerly-queued entries to the tail as it clears isQueued. Relative order among several queued entries is kept, and when they're already last the array comes out identical, so nothing visibly moves in the common case. * Reload: claude records a mid-turn interjection as a queued_command attachment rather than a user record, positioned at the drain boundary. parseTranscriptEntries skipped all attachments, so every message sent while the agent was working vanished from the scrollback on reload. Seed those as user entries; other attachment subtypes stay ignored. Verified against a live session: the seeded record lands between the tool_result it interrupted and the reply that answers it, matching the live ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 486fd23 commit c8345b7

5 files changed

Lines changed: 275 additions & 18 deletions

File tree

src/main/json-claude-manager-fork.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,109 @@ describe('JsonClaudeManager.forkAt', () => {
260260
expect(result.reason).toMatch(/assistant/)
261261
})
262262
})
263+
264+
// Claude records a mid-turn interjection as a 'queued_command'
265+
// attachment rather than a `user` record, positioned at the agent-loop
266+
// boundary where it drained the input queue. Skipping attachments (as
267+
// the parser did) meant every message the user sent while the agent was
268+
// working vanished from the scrollback on reload.
269+
describe('JsonClaudeManager.seedFromTranscript — mid-turn messages', () => {
270+
beforeEach(() => {
271+
tmpHome = mkdtempSync(join(tmpdir(), 'harness-seed-'))
272+
})
273+
274+
afterEach(() => {
275+
rmSync(tmpHome, { recursive: true, force: true })
276+
vi.clearAllMocks()
277+
})
278+
279+
function writeTranscript(sessionId: string, worktree: string, lines: object[]): void {
280+
const dir = transcriptDir(worktree)
281+
mkdirSync(dir, { recursive: true })
282+
writeFileSync(
283+
join(dir, `${sessionId}.jsonl`),
284+
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
285+
'utf8'
286+
)
287+
}
288+
289+
it('seeds a queued_command attachment as a user entry at its transcript position', () => {
290+
const store = new Store()
291+
const mgr = makeManager(store)
292+
const sessionId = '55555555-5555-5555-5555-555555555555'
293+
const worktree = '/tmp/wt-seed-queued'
294+
295+
writeTranscript(sessionId, worktree, [
296+
{ type: 'user', sessionId, message: { content: 'first turn' } },
297+
{
298+
type: 'assistant',
299+
sessionId,
300+
message: { id: 'msg_a', content: [{ type: 'tool_use', id: 'tu1', name: 'Bash' }] }
301+
},
302+
// The interjection lands here — after the tool call it interrupted,
303+
// before the reply that took it into account.
304+
{
305+
type: 'attachment',
306+
sessionId,
307+
attachment: {
308+
type: 'queued_command',
309+
prompt: 'actually, also check the logs',
310+
commandMode: 'prompt'
311+
}
312+
},
313+
{
314+
type: 'assistant',
315+
sessionId,
316+
message: { id: 'msg_b', content: [{ type: 'text', text: 'on it' }] }
317+
}
318+
])
319+
store.dispatch({
320+
type: 'jsonClaude/sessionStarted',
321+
payload: { sessionId, worktreePath: worktree }
322+
})
323+
324+
mgr.seedFromTranscript(sessionId, worktree)
325+
326+
const entries = store.getSnapshot().state.jsonClaude.sessions[sessionId].entries
327+
expect(entries.map((e) => e.kind)).toEqual([
328+
'user',
329+
'assistant',
330+
'user',
331+
'assistant'
332+
])
333+
expect(entries[2].text).toBe('actually, also check the logs')
334+
expect(entries[2].isQueued).toBeUndefined()
335+
})
336+
337+
it('ignores bookkeeping attachments and prompt-less queued_commands', () => {
338+
const store = new Store()
339+
const mgr = makeManager(store)
340+
const sessionId = '66666666-6666-6666-6666-666666666666'
341+
const worktree = '/tmp/wt-seed-attachments'
342+
343+
writeTranscript(sessionId, worktree, [
344+
{ type: 'user', sessionId, message: { content: 'hello' } },
345+
{
346+
type: 'attachment',
347+
sessionId,
348+
attachment: { type: 'total_tokens_reminder', text: '<total_tokens>1</total_tokens>' }
349+
},
350+
{
351+
type: 'attachment',
352+
sessionId,
353+
attachment: { type: 'deferred_tools_delta', addedNames: ['WebFetch'] }
354+
},
355+
{ type: 'attachment', sessionId, attachment: { type: 'queued_command', prompt: '' } }
356+
])
357+
store.dispatch({
358+
type: 'jsonClaude/sessionStarted',
359+
payload: { sessionId, worktreePath: worktree }
360+
})
361+
362+
mgr.seedFromTranscript(sessionId, worktree)
363+
364+
const entries = store.getSnapshot().state.jsonClaude.sessions[sessionId].entries
365+
expect(entries).toHaveLength(1)
366+
expect(entries[0].text).toBe('hello')
367+
})
368+
})

src/main/json-claude-manager.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,44 @@ describe('JsonClaudeManager', () => {
364364
expect(store.getSnapshot().state.jsonClaude.sessions[sessionId]?.busy).toBe(true)
365365
})
366366

367+
it('repositions the message after the content it interrupted', () => {
368+
const store = new Store()
369+
const sessionId = 'sess-queued-reorder'
370+
const { mgr, proc } = startBusySession(store, sessionId)
371+
372+
// The user interjects while claude is mid-stream, so the bubble is
373+
// appended ahead of the assistant message that was already in
374+
// flight — then claude finishes it.
375+
mgr.send(sessionId, 'interjection')
376+
proc.stdout.emit(
377+
'data',
378+
Buffer.from(
379+
JSON.stringify({
380+
type: 'assistant',
381+
message: { id: 'msg_a', content: [{ type: 'text', text: 'still talking' }] }
382+
}) + '\n'
383+
)
384+
)
385+
// Assistant entries carry `blocks`, user entries carry `text`.
386+
const order = (): Array<string | undefined> =>
387+
(store.getSnapshot().state.jsonClaude.sessions[sessionId]?.entries ?? []).map(
388+
(e) =>
389+
e.kind === 'user'
390+
? e.text
391+
: e.blocks?.map((b) => ('text' in b ? b.text : '')).join('')
392+
)
393+
expect(order()).toEqual(['first turn', 'interjection', 'still talking'])
394+
395+
proc.stdout.emit(
396+
'data',
397+
Buffer.from(
398+
JSON.stringify({ type: 'system', subtype: 'status', status: 'requesting' }) +
399+
'\n'
400+
)
401+
)
402+
expect(order()).toEqual(['first turn', 'still talking', 'interjection'])
403+
})
404+
367405
it('leaves a message queued while a non-requesting status streams by', () => {
368406
const store = new Store()
369407
const sessionId = 'sess-queued-other-status'

src/main/json-claude-manager.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,32 @@ export class JsonClaudeManager {
504504
...(apiMessageId ? { apiMessageId } : {}),
505505
...(parentToolUseId ? { parentToolUseId } : {})
506506
})
507+
} else if (type === 'attachment') {
508+
// Mid-turn interjections are the one kind of user input claude
509+
// does NOT write as a `user` record: they land as an attachment
510+
// of type 'queued_command', positioned at the agent-loop
511+
// boundary where claude drained them (its `timestamp` field is
512+
// still the moment the user hit enter). Without this branch a
513+
// reload silently dropped every message the user sent while the
514+
// agent was working. Every other attachment subtype is internal
515+
// bookkeeping (token reminders, tool/agent listing deltas) and
516+
// stays ignored.
517+
const attachment = parsed['attachment'] as
518+
| { type?: unknown; prompt?: unknown }
519+
| undefined
520+
if (attachment?.type !== 'queued_command') continue
521+
const prompt = attachment.prompt
522+
if (typeof prompt !== 'string' || prompt.length === 0) continue
523+
const automated = parseAutomatedMessage(prompt)
524+
seededEntries.push({
525+
kind: 'user',
526+
text: automated ? automated.body : prompt,
527+
timestamp: Date.now(),
528+
entryId: `${sessionId}-seed-u-${counter++}`,
529+
...(automated ? { automation: automated.source } : {}),
530+
...(automated?.from ? { automationFrom: automated.from } : {}),
531+
...(transcriptUuid ? { transcriptUuid } : {})
532+
})
507533
} else if (type === 'system' && parsed['subtype'] === 'compact_boundary') {
508534
const meta = parsed['compactMetadata'] as
509535
| { trigger?: unknown; preTokens?: unknown; postTokens?: unknown }
@@ -833,17 +859,19 @@ export class JsonClaudeManager {
833859
const inst = this.instances.get(sessionId)
834860
if (!inst) return
835861
// Mid-turn injection: if a turn is already in flight, append the
836-
// user entry inline (so it lands in conversation order between
837-
// whatever assistant content was streaming and whatever comes
838-
// next) tagged isQueued, and write to stdin immediately. Claude's
839-
// stream-json input buffers between agent-loop steps, so the
840-
// message gets injected at the next safe boundary (typically
862+
// user entry tagged isQueued (so the user sees their message land,
863+
// with a cancel affordance) and write to stdin immediately.
864+
// Claude's stream-json input buffers between agent-loop steps, so
865+
// the message gets injected at the next safe boundary (typically
841866
// post-tool_result) within the current turn — matching the TUI's
842-
// interject-while-busy behavior. The isQueued flag clears when
843-
// claude drains its input queue into the next request (see the
844-
// `system/status: requesting` branch in handleLine), so the bubble
845-
// stops looking dashed the moment the message is really in the
846-
// conversation rather than at the end of the turn.
867+
// interject-while-busy behavior.
868+
//
869+
// The entry starts out wherever the user hit enter, which is
870+
// mid-stream of whatever claude was already saying. When claude
871+
// drains its input queue into the next request (the `system/status:
872+
// requesting` branch in handleLine) the entry both loses isQueued
873+
// and moves to the tail — its real position in the conversation,
874+
// just before the response that considers it.
847875
const session =
848876
this.store.getSnapshot().state.jsonClaude.sessions[sessionId]
849877
if (session?.busy) {
@@ -980,9 +1008,10 @@ export class JsonClaudeManager {
9801008
* message text is already in claude's stdin buffer by the time the
9811009
* user clicks cancel, so this is UI-only — claude will still
9821010
* process the message on the next agent-loop step. The promoted
983-
* entry never gets re-added because we only clear isQueued (we
984-
* don't re-create an entry). On reload, however, claude's
985-
* session.jsonl will reseed the message. */
1011+
* entry never gets re-added because unqueuing only touches entries
1012+
* still in the list. On reload, however, the message comes back:
1013+
* claude logged it to session.jsonl as a queued_command attachment,
1014+
* which parseTranscriptEntries seeds. */
9861015
cancelQueued(sessionId: string, entryId: string): void {
9871016
this.store.dispatch({
9881017
type: 'jsonClaude/entryRemoved',

src/shared/state/json-claude.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,73 @@ describe('jsonClaudeReducer', () => {
754754
expect(after[1].isQueued).toBeUndefined()
755755
})
756756

757+
it('userEntriesUnqueued moves queued entries to the tail, in order', () => {
758+
let state = seedSession(initialJsonClaude)
759+
state = jsonClaudeReducer(state, {
760+
type: 'jsonClaude/entriesSeeded',
761+
payload: {
762+
sessionId: SID,
763+
entries: [
764+
{ entryId: 'u0', kind: 'user', text: 'first turn', timestamp: 1 },
765+
// Two interjections typed mid-stream, so they landed in the
766+
// array before the assistant content claude was already
767+
// producing when the user hit enter.
768+
{
769+
entryId: 'q1',
770+
kind: 'user',
771+
text: 'queued one',
772+
timestamp: 2,
773+
isQueued: true
774+
},
775+
{
776+
entryId: 'q2',
777+
kind: 'user',
778+
text: 'queued two',
779+
timestamp: 3,
780+
isQueued: true
781+
},
782+
{ entryId: 'a1', kind: 'assistant', text: 'streamed', timestamp: 4 }
783+
]
784+
}
785+
})
786+
state = jsonClaudeReducer(state, {
787+
type: 'jsonClaude/userEntriesUnqueued',
788+
payload: { sessionId: SID }
789+
})
790+
expect(state.sessions[SID].entries.map((e) => e.entryId)).toEqual([
791+
'u0',
792+
'a1',
793+
'q1',
794+
'q2'
795+
])
796+
expect(state.sessions[SID].entries.every((e) => !e.isQueued)).toBe(true)
797+
})
798+
799+
it('userEntriesUnqueued leaves order alone when queued entries are already last', () => {
800+
let state = seedSession(initialJsonClaude)
801+
state = jsonClaudeReducer(state, {
802+
type: 'jsonClaude/entriesSeeded',
803+
payload: {
804+
sessionId: SID,
805+
entries: [
806+
{ entryId: 'a1', kind: 'assistant', text: 'done', timestamp: 1 },
807+
{
808+
entryId: 'q1',
809+
kind: 'user',
810+
text: 'queued',
811+
timestamp: 2,
812+
isQueued: true
813+
}
814+
]
815+
}
816+
})
817+
state = jsonClaudeReducer(state, {
818+
type: 'jsonClaude/userEntriesUnqueued',
819+
payload: { sessionId: SID }
820+
})
821+
expect(state.sessions[SID].entries.map((e) => e.entryId)).toEqual(['a1', 'q1'])
822+
})
823+
757824
it('entryRemoved drops the matching entry by id', () => {
758825
let state = seedSession(initialJsonClaude)
759826
state = jsonClaudeReducer(state, {

src/shared/state/json-claude.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,10 @@ export interface JsonClaudeChatEntry {
214214
* a cancel affordance. Cleared as soon as claude drains its
215215
* input queue into the next API request (the `system/status:
216216
* requesting` boundary), which is when the message genuinely
217-
* enters the conversation — not at the end of the whole turn. */
217+
* enters the conversation — not at the end of the whole turn.
218+
* That same boundary moves the entry to the end of the list, since
219+
* it was appended where the user hit enter rather than where claude
220+
* read it. */
218221
isQueued?: boolean
219222
/** For kind === 'user'. Set when Ness injected the turn itself rather
220223
* than the human typing it, so the renderer can style the bubble as an
@@ -992,12 +995,26 @@ export function jsonClaudeReducer(
992995
const session = state.sessions[event.payload.sessionId]
993996
if (!session) return state
994997
if (!session.entries.some((e) => e.isQueued)) return state
995-
const nextEntries = session.entries.map((entry) => {
996-
if (!entry.isQueued) return entry
998+
// Queued entries were appended where the user hit enter, which is
999+
// mid-stream of whatever claude was already saying. Claude doesn't
1000+
// read them until the agent-loop boundary this event marks, so
1001+
// move them to the tail — the position they actually occupy in the
1002+
// conversation, directly before the response that considers them.
1003+
// Relative order among several queued entries is preserved, and
1004+
// when they're already at the tail (nothing streamed in between)
1005+
// the array comes out identical, so no bubble visibly moves.
1006+
const kept: JsonClaudeChatEntry[] = []
1007+
const promoted: JsonClaudeChatEntry[] = []
1008+
for (const entry of session.entries) {
1009+
if (!entry.isQueued) {
1010+
kept.push(entry)
1011+
continue
1012+
}
9971013
const { isQueued: _drop, ...rest } = entry
9981014
void _drop
999-
return rest
1000-
})
1015+
promoted.push(rest)
1016+
}
1017+
const nextEntries = [...kept, ...promoted]
10011018
return {
10021019
...state,
10031020
sessions: {

0 commit comments

Comments
 (0)