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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user