Skip to content

Commit d048036

Browse files
authored
cli: ensure full command output is streamed before done (#11842)
1 parent 459f270 commit d048036

3 files changed

Lines changed: 124 additions & 19 deletions

File tree

apps/cli/src/agent/__tests__/json-event-emitter-streaming.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,10 @@ describe("JsonEventEmitter streaming deltas", () => {
302302

303303
emitter.emitCommandOutputChunk("line1\n")
304304
emitter.emitCommandOutputChunk("line1\nline2\n")
305-
emitter.emitCommandOutputDone(17)
305+
emitter.markCommandOutputExited(17)
306306

307-
// This completion say is expected from the extension, but should be suppressed
308-
// because we already streamed and completed via commandExecutionStatus.
307+
// This completion say is expected from the extension and should finalize
308+
// the status-driven command_output stream without duplicating content.
309309
emitMessage(emitter, {
310310
ts: 999,
311311
type: "say",
@@ -343,4 +343,47 @@ describe("JsonEventEmitter streaming deltas", () => {
343343
done: true,
344344
})
345345
})
346+
347+
it("flushes remaining output on final say completion after fast status:exited", () => {
348+
const { stdout, lines } = createMockStdout()
349+
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
350+
const commandId = 606
351+
352+
emitMessage(
353+
emitter,
354+
createAskMessage({
355+
ts: commandId,
356+
ask: "command",
357+
partial: false,
358+
text: "aws sts get-caller-identity",
359+
}),
360+
)
361+
362+
emitter.emitCommandOutputChunk("{\n")
363+
emitter.markCommandOutputExited(0)
364+
365+
emitMessage(emitter, {
366+
ts: 607,
367+
type: "say",
368+
say: "command_output",
369+
partial: false,
370+
text: '{\n "Account": "123"\n}\n',
371+
} as ClineMessage)
372+
373+
const output = lines()
374+
expect(output).toHaveLength(3)
375+
expect(output[1]).toMatchObject({
376+
type: "tool_result",
377+
id: commandId,
378+
subtype: "command",
379+
tool_result: { name: "execute_command", output: "{\n" },
380+
})
381+
expect(output[2]).toMatchObject({
382+
type: "tool_result",
383+
id: commandId,
384+
subtype: "command",
385+
tool_result: { name: "execute_command", output: ' "Account": "123"\n}\n', exitCode: 0 },
386+
done: true,
387+
})
388+
})
346389
})

apps/cli/src/agent/json-event-emitter.ts

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ const SKIP_SAY_TYPES = new Set([
9090

9191
/** Key offset for reasoning content to avoid collision with text content delta tracking */
9292
const REASONING_KEY_OFFSET = 1_000_000_000
93+
/** Grace period to wait for final say:command_output after status:exited */
94+
const COMMAND_OUTPUT_EXIT_GRACE_MS = 250
9395

9496
export class JsonEventEmitter {
9597
private mode: "json" | "stream-json"
@@ -115,8 +117,8 @@ export class JsonEventEmitter {
115117
private statusDrivenCommandOutputIds = new Set<number>()
116118
// Track command ids that already emitted a terminal command_output done event.
117119
private completedCommandOutputIds = new Set<number>()
118-
// Suppress the next say:command_output completion message after status-driven streaming.
119-
private suppressNextCommandOutputSay = false
120+
// Track exited commands awaiting final say:command_output completion.
121+
private pendingCommandCompletionByToolUseId = new Map<number, { exitCode?: number; timer: NodeJS.Timeout }>()
120122
// Track the completion result content
121123
private completionResultContent: string | undefined
122124
// Track the latest assistant text as a fallback for result.content.
@@ -338,6 +340,7 @@ export class JsonEventEmitter {
338340

339341
if (isDone) {
340342
event.done = true
343+
this.clearPendingCommandCompletion(commandId)
341344
this.previousCommandOutputByToolUseId.delete(commandId)
342345
this.statusDrivenCommandOutputIds.delete(commandId)
343346
this.completedCommandOutputIds.add(commandId)
@@ -368,6 +371,7 @@ export class JsonEventEmitter {
368371
})
369372

370373
if (isDone) {
374+
this.clearPendingCommandCompletion(commandId)
371375
this.previousCommandOutputByToolUseId.delete(commandId)
372376
this.statusDrivenCommandOutputIds.delete(commandId)
373377
this.completedCommandOutputIds.add(commandId)
@@ -387,17 +391,47 @@ export class JsonEventEmitter {
387391
this.emitCommandOutputEvent(commandId, outputSnapshot, false)
388392
}
389393

394+
public markCommandOutputExited(exitCode?: number): void {
395+
const commandId = this.activeCommandToolUseId
396+
if (commandId === undefined) {
397+
return
398+
}
399+
400+
this.statusDrivenCommandOutputIds.add(commandId)
401+
this.clearPendingCommandCompletion(commandId)
402+
403+
const timer = setTimeout(() => {
404+
// Fallback close if final say:command_output never arrives.
405+
if (!this.pendingCommandCompletionByToolUseId.has(commandId)) {
406+
return
407+
}
408+
this.pendingCommandCompletionByToolUseId.delete(commandId)
409+
this.emitCommandOutputEvent(commandId, undefined, true, exitCode)
410+
}, COMMAND_OUTPUT_EXIT_GRACE_MS)
411+
timer.unref?.()
412+
413+
this.pendingCommandCompletionByToolUseId.set(commandId, { exitCode, timer })
414+
}
415+
390416
public emitCommandOutputDone(exitCode?: number): void {
391417
const commandId = this.activeCommandToolUseId
392418
if (commandId === undefined) {
393419
return
394420
}
395421

396422
this.statusDrivenCommandOutputIds.add(commandId)
397-
this.suppressNextCommandOutputSay = true
398423
this.emitCommandOutputEvent(commandId, undefined, true, exitCode)
399424
}
400425

426+
private clearPendingCommandCompletion(commandId: number): void {
427+
const pending = this.pendingCommandCompletionByToolUseId.get(commandId)
428+
if (!pending) {
429+
return
430+
}
431+
clearTimeout(pending.timer)
432+
this.pendingCommandCompletionByToolUseId.delete(commandId)
433+
}
434+
401435
/**
402436
* Get content to send for a message (delta for streaming, full for json mode).
403437
*/
@@ -624,9 +658,19 @@ export class JsonEventEmitter {
624658
const toolInfo = parseToolInfo(msg.text)
625659

626660
if (subtype === "command") {
661+
if (this.activeCommandToolUseId !== undefined && this.activeCommandToolUseId !== msg.ts) {
662+
const previousCommandId = this.activeCommandToolUseId
663+
const pending = this.pendingCommandCompletionByToolUseId.get(previousCommandId)
664+
if (pending) {
665+
clearTimeout(pending.timer)
666+
this.pendingCommandCompletionByToolUseId.delete(previousCommandId)
667+
this.emitCommandOutputEvent(previousCommandId, undefined, true, pending.exitCode)
668+
}
669+
}
670+
627671
this.activeCommandToolUseId = msg.ts
628672
this.completedCommandOutputIds.delete(msg.ts)
629-
this.suppressNextCommandOutputSay = false
673+
this.clearPendingCommandCompletion(msg.ts)
630674

631675
if (isStreamingPartial) {
632676
const commandDelta = this.computeStructuredDelta(msg.ts, msg.text)
@@ -707,17 +751,26 @@ export class JsonEventEmitter {
707751
}
708752

709753
private handleCommandOutputMessage(msg: ClineMessage, isDone: boolean): void {
710-
if (this.suppressNextCommandOutputSay) {
711-
if (isDone) {
712-
this.suppressNextCommandOutputSay = false
754+
const commandId = this.activeCommandToolUseId ?? msg.ts
755+
if (this.completedCommandOutputIds.has(commandId)) {
756+
return
757+
}
758+
759+
const pending = this.pendingCommandCompletionByToolUseId.get(commandId)
760+
if (pending) {
761+
if (!isDone) {
762+
return
713763
}
764+
clearTimeout(pending.timer)
765+
this.pendingCommandCompletionByToolUseId.delete(commandId)
766+
this.emitCommandOutputEvent(commandId, msg.text, true, pending.exitCode)
714767
return
715768
}
716769

717-
const commandId = this.activeCommandToolUseId ?? msg.ts
718-
if (this.statusDrivenCommandOutputIds.has(commandId) || this.completedCommandOutputIds.has(commandId)) {
770+
if (this.statusDrivenCommandOutputIds.has(commandId)) {
719771
return
720772
}
773+
721774
this.emitCommandOutputEvent(commandId, msg.text, isDone)
722775
}
723776

@@ -841,7 +894,10 @@ export class JsonEventEmitter {
841894
this.previousCommandOutputByToolUseId.clear()
842895
this.statusDrivenCommandOutputIds.clear()
843896
this.completedCommandOutputIds.clear()
844-
this.suppressNextCommandOutputSay = false
897+
for (const pending of this.pendingCommandCompletionByToolUseId.values()) {
898+
clearTimeout(pending.timer)
899+
}
900+
this.pendingCommandCompletionByToolUseId.clear()
845901
this.completionResultContent = undefined
846902
this.lastAssistantText = undefined
847903
this.expectPromptEchoAsUser = true

apps/cli/src/commands/cli/stdin-stream.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -432,16 +432,22 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
432432
return
433433
}
434434

435-
if (
436-
parsedStatus.status === "exited" ||
437-
parsedStatus.status === "timeout" ||
438-
parsedStatus.status === "fallback"
439-
) {
435+
if (parsedStatus.status === "exited") {
440436
const exitCode =
441437
parsedStatus.status === "exited" && typeof parsedStatus.exitCode === "number"
442438
? parsedStatus.exitCode
443439
: undefined
444-
jsonEmitter.emitCommandOutputDone(exitCode)
440+
441+
if (typeof parsedStatus.output === "string") {
442+
jsonEmitter.emitCommandOutputChunk(parsedStatus.output)
443+
}
444+
445+
jsonEmitter.markCommandOutputExited(exitCode)
446+
return
447+
}
448+
449+
if (parsedStatus.status === "timeout" || parsedStatus.status === "fallback") {
450+
jsonEmitter.emitCommandOutputDone(undefined)
445451
return
446452
}
447453

0 commit comments

Comments
 (0)