feat(coding-agent/modes): tracked and migrate stream-key placeholders for tool calls

- Added `#streamTurnNonce` to prevent aborted streaming turns from corrupting content indexes of subsequent messages.
- Implemented temporary stream-key generation using content position and turn nonces for previewing tool calls without native IDs.
- Added migration logic to key pending tool previews by their real ID and rekey `ToolArgsRevealController` once the real ID is parsed.
This commit is contained in:
can1357
2026-07-01 00:34:21 +02:00
parent 7c0e64dbd9
commit bdb33a184d
3 changed files with 52 additions and 21 deletions
+1 -4
View File
@@ -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),
}
@@ -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<string>();
#lastIntent: string | undefined = undefined;
#backgroundToolCallIds = new Set<string>();
@@ -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);
}
}
}
@@ -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. */