diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 7e86fecaa..12f5b02ab 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -23,7 +23,7 @@ Common options: - **Responses**: JSON objects with `type: "response"` indicating command success/failure - **Events**: Agent events streamed to stdout as JSON lines -All commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`. +All commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`. `bash_execution_update` events also include the `id` of their originating `bash` command. ### Framing @@ -455,15 +455,18 @@ Response: #### bash -Execute a shell command and add output to conversation context. +Execute a shell command and add output to conversation context. Output streams as `bash_execution_update` events while the command runs; the response contains the final result. ```json -{"type": "bash", "command": "ls -la"} +{"id": "req-1", "type": "bash", "command": "ls -la"} ``` +Include an `id` to associate streamed `bash_execution_update` events with this command. + Response: ```json { + "id": "req-1", "type": "response", "command": "bash", "success": true, @@ -494,7 +497,7 @@ If output was truncated, includes `fullOutputPath`: **How bash results reach the LLM:** -The `bash` command executes immediately and returns a `BashResult`. Internally, a `BashExecutionMessage` is created and stored in the agent's message state. This message does NOT emit an event. +The `bash` command executes immediately and returns a `BashResult`. Internally, a `BashExecutionMessage` is created and stored in the agent's message state. When the next `prompt` command is sent, all messages (including `BashExecutionMessage`) are transformed before being sent to the LLM. The `BashExecutionMessage` is converted to a `UserMessage` with this format: @@ -509,7 +512,6 @@ drwxr-xr-x ... This means: 1. Bash output is included in the LLM context on the **next prompt**, not immediately 2. Multiple bash commands can be executed before a prompt; all outputs will be included -3. No event is emitted for the `BashExecutionMessage` itself #### abort_bash @@ -829,7 +831,7 @@ Each command has: ## Events -Events are streamed to stdout as JSON lines during agent operation. Events do NOT include an `id` field (only responses do). +Events are streamed to stdout as JSON lines during agent operation. Events do not generally include an `id` field; `bash_execution_update` includes the `id` of its originating `bash` command when one was provided. ### Event Types @@ -843,6 +845,7 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO | `message_start` | Message begins | | `message_update` | Streaming update (text/thinking/toolcall deltas) | | `message_end` | Message completes | +| `bash_execution_update` | Direct RPC bash command output chunk | | `tool_execution_start` | Tool begins execution | | `tool_execution_update` | Tool execution progress (streaming output) | | `tool_execution_end` | Tool completes | @@ -951,6 +954,20 @@ Example streaming a text response: {"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world","partial":{...}}} ``` +### bash_execution_update + +Emitted once for each output chunk from a direct `bash` command. `id` matches the command's `id`, allowing clients to associate output with the correct command. + +Events stream all output while the command runs, even if the final `bash` response's `output` is truncated. + +```json +{ + "type": "bash_execution_update", + "id": "req-1", + "delta": "total 48\n" +} +``` + ### tool_execution_start / tool_execution_update / tool_execution_end Emitted when a tool begins, streams progress, and completes execution. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4fdaaaa67..ac69f6ff0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -176,7 +176,9 @@ export type AgentSessionEvent = source: "compaction"; reason: "manual" | "threshold" | "overflow"; } - | { type: "summarization_retry_finished" }; + | { type: "summarization_retry_finished" } + | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string } + | { type: "bash_execution_update"; id?: string; delta: string }; /** Listener function for agent session events */ export type AgentSessionEventListener = (event: AgentSessionEvent) => void; @@ -2760,12 +2762,13 @@ export class AgentSession { * @param command The bash command to execute * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) + * @param options.id Optional identifier included in bash execution update events * @param options.operations Custom BashOperations for remote execution */ async executeBash( command: string, onChunk?: (chunk: string) => void, - options?: { excludeFromContext?: boolean; operations?: BashOperations }, + options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations }, ): Promise { this._bashAbortController = new AbortController(); @@ -2780,7 +2783,10 @@ export class AgentSession { this.sessionManager.getCwd(), options?.operations ?? createLocalBashOperations({ shellPath }), { - onChunk, + onChunk: (delta) => { + onChunk?.(delta); + this._emit({ type: "bash_execution_update", id: options?.id, delta }); + }, signal: this._bashAbortController.signal, }, ); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4285297ec..d14582c88 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2985,6 +2985,10 @@ export class InteractiveMode { this.ui.requestRender(); break; + case "bash_execution_update": + // The bash execution callback handles TUI output rendering. + break; + case "tool_execution_start": { let component = this.pendingTools.get(event.toolCallId); if (!component) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 06eadccb9..e3988e81d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -558,6 +558,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { expect(result.output).toContain("hello from custom ops"); expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution"); }); + + it("streams bash output to the callback and session events", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const callbackDeltas: string[] = []; + const eventUpdates: Array<{ id: string | undefined; delta: string }> = []; + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "bash_execution_update") { + eventUpdates.push({ id: event.id, delta: event.delta }); + } + }); + const operations: BashOperations = { + exec: async (_command, _cwd, options) => { + options.onData(Buffer.from("hello ")); + options.onData(Buffer.from("world")); + return { exitCode: 0 }; + }, + }; + + await harness.session.executeBash("custom", (delta) => callbackDeltas.push(delta), { + id: "bash-1", + operations, + }); + unsubscribe(); + + expect(callbackDeltas).toEqual(["hello ", "world"]); + expect(eventUpdates).toEqual([ + { id: "bash-1", delta: "hello " }, + { id: "bash-1", delta: "world" }, + ]); + }); });