diff --git a/crates/pi-walker/src/lib.rs b/crates/pi-walker/src/lib.rs index b6c5d3c1f..6e2da0373 100644 --- a/crates/pi-walker/src/lib.rs +++ b/crates/pi-walker/src/lib.rs @@ -1444,10 +1444,7 @@ pub struct PreDescendDecision { #[derive(Clone, Debug, PartialEq, Eq)] enum DirectoryIdentity { #[cfg(unix)] - Unix { - dev: u64, - ino: u64, - }, + Unix { dev: u64, ino: u64 }, #[cfg(not(unix))] Generic(std::path::PathBuf), } diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 1842c4354..a623323ae 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -72,6 +72,11 @@ export class EventController { // emits one read per completion — does not break it, so a run of consecutive // reads collapses into one group even across completion boundaries. #lastVisibleBlockCount = 0; + // Bumped on each assistant message_start. Scopes the placeholder key for a + // not-yet-id'd streamed tool block (see the toolCall loop) so a sealed, + // undeleted pending entry from an aborted message can never be reused by the + // next message's same content index. + #streamTurnNonce = 0; #renderedCustomMessages = new Set(); #lastIntent: string | undefined = undefined; #backgroundToolCallIds = new Set(); @@ -401,6 +406,7 @@ export class EventController { this.ctx.ui.requestRender(); } else if (event.message.role === "assistant") { this.#lastVisibleBlockCount = 0; + this.#streamTurnNonce++; this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx); this.ctx.streamingMessage = event.message; this.ctx.chatContainer.addChild(this.ctx.streamingComponent); @@ -607,18 +613,14 @@ export class EventController { if (this.ctx.streamingMessage.content.some(content => content.type === "toolCall")) { this.ctx.streamingComponent.markTranscriptBlockFinalized(); } - for (const content of this.ctx.streamingMessage.content) { + for (const [contentIndex, content] of this.ctx.streamingMessage.content.entries()) { if (content.type !== "toolCall") continue; - // Anthropic/OpenAI open a streamed tool block with an empty id (and - // `{}` args) before the id/arguments arrive; Gemini assembles the - // whole call first, so it never hits this. Keying `pendingTools` by - // "" would create a placeholder card, and the later real-id frame — - // `pendingTools.has(realId)` false — would create a SECOND card, - // orphaning the blank one (no `tool_execution_*` event ever carries - // "", so it is never matched, updated, or removed). Defer until the - // provider assigns the real id. - if (!content.id) continue; if (content.name === "read") { + // Read groups key by the real id (one group spans several reads), + // and the owned-dialect parser delivers id + args together when + // the call closes, so a parseable target without an id never + // occurs here. Defer if it somehow does. + if (!content.id) continue; if (!readArgsHaveTarget(content.arguments)) { // Args still streaming — defer until path is parseable so we can route to the // read group (regular files) vs ToolExecutionComponent (internal URLs). @@ -641,6 +643,26 @@ export class EventController { // Internal URL read falls through to ToolExecutionComponent below. } + // The owned-dialect tool parser (text-based tool calls for OAuth + // Anthropic / OpenAI) appends a tool block when it detects the call + // opening and only fills `id` + arguments once the call's text + // closes — so the live preview must stream while `content.id` is "". + // Key the preview by stable content position until the id lands, + // then migrate the pending entry + reveal state onto the real id + // (so `tool_execution_*`, which always carries the real id, matches + // the same component instead of orphaning a blank card). Native + // structured tool calls — every Gemini call, and Anthropic/OpenAI + // function calls — carry the id from the first frame, so + // `pendingKey === content.id` throughout and the migration no-ops. + const streamKey = `\u0000stream:${this.#streamTurnNonce}:${contentIndex}`; + const pendingKey = content.id || streamKey; + if (content.id && this.ctx.pendingTools.has(streamKey)) { + const migrated = this.ctx.pendingTools.get(streamKey); + this.ctx.pendingTools.delete(streamKey); + if (migrated) this.ctx.pendingTools.set(pendingKey, migrated); + this.#toolArgsReveal.rekey(streamKey, pendingKey); + } + // Preserve the raw partial JSON only for renderers that need to surface fields before the JSON object closes. // Bash uses this to show inline env assignments during streaming instead of popping them in at completion. // While the JSON is still open, ToolArgsRevealController paces the @@ -652,16 +674,16 @@ export class EventController { const rawInput = content.customWireName !== undefined; const tool = this.ctx.viewSession.getToolByName(content.name); if (partialJson) { - renderArgs = this.#toolArgsReveal.setTarget(content.id, partialJson, { + renderArgs = this.#toolArgsReveal.setTarget(pendingKey, partialJson, { rawInput, exposeRawPartialJson: exposesRawPartialJson(content.name, rawInput, tool), fullArgs: content.arguments, }); } else { - this.#toolArgsReveal.finish(content.id); + this.#toolArgsReveal.finish(pendingKey); renderArgs = content.arguments; } - if (!this.ctx.pendingTools.has(content.id)) { + if (!this.ctx.pendingTools.has(pendingKey)) { this.#resolveDisplaceablePoll(content.name); this.#resetReadGroup(); const component = new ToolExecutionComponent( @@ -680,13 +702,13 @@ export class EventController { ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); - this.ctx.pendingTools.set(content.id, component); - this.#toolArgsReveal.bind(content.id, component); + this.ctx.pendingTools.set(pendingKey, component); + this.#toolArgsReveal.bind(pendingKey, component); } else { - const component = this.ctx.pendingTools.get(content.id); + const component = this.ctx.pendingTools.get(pendingKey); if (component) { component.updateArgs(renderArgs, content.id); - this.#toolArgsReveal.bind(content.id, component); + this.#toolArgsReveal.bind(pendingKey, component); } } } diff --git a/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts b/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts index d5e1d3516..6df7548ab 100644 --- a/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts +++ b/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts @@ -177,6 +177,18 @@ export class ToolArgsRevealController { if (entry) entry.component = component; } + /** Migrate a live reveal entry from a placeholder key onto the real + * tool-call id once the owned-dialect parser assigns it. No-op when no + * entry exists under `from` (smoothing disabled, or the JSON already + * closed and `finish` cleared it). */ + rekey(from: string, to: string): void { + if (from === to) return; + const entry = this.#entries.get(from); + if (!entry) return; + this.#entries.delete(from); + this.#entries.set(to, entry); + } + /** Final arguments arrived (the JSON closed): drop the reveal so the * caller's final-args render wins immediately, mirroring how assistant * text snaps to the full message at message_end. */