-
-
Notifications
You must be signed in to change notification settings - Fork 376
Description
Problem
When the API stream is truncated mid-tool-call, `PartialMessageBuilder` silently emits a `toolcall_end` event with `arguments: { _raw: "" }`. Downstream consumers (GSD tool handlers, TTSR, activity log) receive what looks like a completed tool call with valid structure. There is no signal that the arguments are garbage.
Root Cause
File: `src/resources/extensions/claude-code-cli/partial-builder.ts`, lines 243–249
// content_block_stop handler
if (block.type === "toolCall") {
const jsonStr = this.toolJsonAccum.get(streamIndex) ?? "{}";
try {
block.arguments = JSON.parse(jsonStr);
} catch {
block.arguments = { _raw: jsonStr }; // ← silent fallback, looks healthy
}
return { type: "toolcall_end", contentIndex, toolCall: block, partial: this.partial };
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// emitted as a normal toolcall_end with no error indicator
}The catch block stores the raw string in a well-known key (`_raw`) but emits the same `toolcall_end` event type as a successful parse. No consumer can distinguish a truncated tool call from a completed one without inspecting every argument object for the `_raw` sentinel.
Downstream Consequences
-
GSD's
classifyProviderError()receives the JSON syntax error string from wherever pi ultimately surfaces it, doesn't match any transient pattern, and pauses auto-mode permanently (filed separately as classifyProviderError treats stream-truncation JSON parse errors as permanent — should be transient #2572). -
Tool handlers receiving `{ _raw: "..." }` either crash with a confusing validation error, silently no-op, or — for write-path tools like `gsd_plan_slice` — could attempt to persist malformed data.
-
TTSR rule matching in `src/resources/extensions/ttsr/index.ts:66` checks for `"partialJson" in contentBlock` and reads partial path hints — it operates on the streaming delta, not the finalized arguments, so it won't see `_raw`. Consistent labeling would make this pattern unnecessary.
-
Activity log (via `session-forensics.ts`) records the tool call with `arguments: {}` (the initial empty object) and the `partialJson` field, making recovery briefings misleading about what the last tool call was actually trying to do.
Expected Behavior
When argument JSON fails to parse at `content_block_stop`, the event should signal failure explicitly. Two options:
Option A — dedicated event type (preferred):
} catch (e) {
return {
type: "toolcall_error",
contentIndex,
toolCall: block,
partial: this.partial,
reason: "malformed_arguments",
rawJson: jsonStr,
};
}Option B — flag on existing event:
} catch (e) {
block.arguments = {};
return {
type: "toolcall_end",
contentIndex,
toolCall: block,
partial: this.partial,
malformed: true, // ← explicit signal
rawArguments: jsonStr,
};
}Option A is cleaner — it allows consumers to handle the case without inspecting every `toolcall_end` payload. Option B is a smaller diff.
Either way, consumers (stream-adapter, GSD) should propagate this as a stream-level error rather than a completed tool call.
Relation to Existing Issues
- classifyProviderError treats stream-truncation JSON parse errors as permanent — should be transient #2572 — GSD-side symptom: `classifyProviderError` treats the resulting JSON syntax error as permanent
- classifyProviderError treats 'terminated' as permanent — should be transient with auto-resume #2309 / PR fix: classify terminated/connection errors as transient #2432 — same root class: stream interruption surfacing as an unclassified error
Environment
- GSD version: 2.49.0
- Model: claude-sonnet-4-6
- Observed in: activity log `038-plan-slice-M001-axr2oo-S06.jsonl`, final assistant message
Auto-generated by `/gsd forensics`