Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## [Unreleased]
- Anthropic streaming now distinguishes a tool call that merely passed through an incomplete JSON fragment from one orphaned by a duplicate content-block index. Membership in the truncation set alone is not evidence that a terminal `tool_use` call is incomplete, so normally completed calls remain executable while genuine orphaned calls stay blocked.
- OpenAI-family streams now give xAI Grok and the Grok Build (`grok-cli-responses`) wrapper the same 300-second default idle window as Anthropic, so long Grok reasoning gaps no longer surface as `OpenAI responses stream stalled while waiting for the next event` under the 120-second OpenAI default. Env overrides still win. The observed stall was `grok-build/grok-4.6` on `openai-responses`; keying only `xai` would have left that path on 120s because `streamGrokCli` keeps `model.provider === "grok-build"`.
- `getCachedUsageReport` now surfaces provider-level cached usage reports for stored API-key credentials, not only OAuth rows. `checkCredentials` fetches and caches usage for API-key providers (for example `zai`, whose login flow stores an API key by design), but the display lookup rejected every non-OAuth row, so `/usage` and account listings could never show usage data that had been successfully fetched and cached. The lookup builds the same cache identity `checkCredentials` writes, and the returned observation stays redacted — credential bytes never appear in the cached report.
- Anthropic clients now set an SDK request `timeout` derived from the first-event window (`resolveAnthropicSdkRequestTimeoutMs`; 300s by default for Anthropic, floored at the env/default first-event window, disabled by an explicit `streamFirstEventTimeoutMs: 0`). The Anthropic first-event watchdog deliberately arms only after response headers arrive, so a connection that silently died before headers — the exact failure mode of recent Anthropic stream instability right after a completed tool call — was previously bounded only by the SDK's 10-minute default per attempt multiplied by its internal retry budget, observable as an endless "Working…" spinner for up to an hour with no error, no retry indicator, and no automatic recovery. This mirrors the existing `resolveOpenAISdkRequestTimeoutMs` stalled-before-headers bound on the OpenAI family.
Expand Down
17 changes: 16 additions & 1 deletion packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2053,6 +2053,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
const trackBlockByAnthropicIndex = (anthropicIndex: number, block: Block) => {
const orphaned = blocksByAnthropicIndex.get(anthropicIndex);
if (orphaned) {
if (orphaned.type === "toolCall") {
orphaned.incompleteArguments = true;
orphaned.incompleteArgumentsReason = "ambiguous";
truncatedToolCalls.add(orphaned);
}
if (block.type === "toolCall") {
block.incompleteArguments = true;
block.incompleteArgumentsReason = "ambiguous";
}
throw new Error("Anthropic stream reused an active content block index");
}
blocksByAnthropicIndex.set(anthropicIndex, block);
Expand Down Expand Up @@ -2392,7 +2401,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
partial: output,
});
} else if (block.type === "toolCall") {
if (!isCompleteJson(block.partialJson)) truncatedToolCalls.add(block);
if (!isCompleteJson(block.partialJson)) {
truncatedToolCalls.add(block);
block.incompleteArguments = true;
block.incompleteArgumentsReason = "truncated";
}
if (block.partialJson.trim()) {
const parsedArguments: unknown = parseStreamingJson(block.partialJson);
if (
Expand Down Expand Up @@ -2892,6 +2905,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
delete (block as { index?: number }).index;
if (block.type === "toolCall") {
truncatedToolCalls.add(block);
block.incompleteArguments = true;
block.incompleteArgumentsReason = "truncated";
if (block.partialJson.trim()) {
block.arguments = parseStreamingJson(block.partialJson);
if (findUnnecessaryUnicodeEscape(block.partialJson)) {
Expand Down
12 changes: 9 additions & 3 deletions packages/ai/test/anthropic-stream-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ describe("anthropic stream envelope handling", () => {
{ type: "toolCall", id: "tool_streamed", name: "bash", arguments: { command: "echo later" } },
]);
});
it("finalizes an orphaned block when a duplicate content_block_start reuses an active index", async () => {
it("rejects a duplicate active content_block index before the replacement can end", async () => {
vi.spyOn(Messages.prototype, "create").mockImplementation(
() =>
createMockRequest([
Expand Down Expand Up @@ -602,13 +602,19 @@ describe("anthropic stream envelope handling", () => {
);

const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
for await (const _ of stream) {
// drain stream
const events: AssistantMessageEvent[] = [];
for await (const event of stream) {
events.push(event);
}
const result = await stream.result();

expect(result.stopReason).toBe("error");
expect(result.errorMessage ?? "").toMatch(/reused an active content block index/i);
expect(countEvents(events, "toolcall_start")).toBe(1);
expect(countEvents(events, "toolcall_delta")).toBe(1);
expect(countEvents(events, "toolcall_end")).toBe(0);
expect(countEvents(events, "error")).toBe(1);
expect(countEvents(events, "done")).toBe(0);
});

it("round-trips OAuth tool prefixes without stripping original tool names that contain the prefix", () => {
Expand Down
59 changes: 48 additions & 11 deletions packages/ai/test/anthropic-truncated-toolcall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe("Anthropic truncated tool calls", () => {
expect(tool && "index" in tool).toBe(false);
});

it("preserves truncation evidence when a duplicate index orphans a block", async () => {
it("marks replacement calls non-executable when a duplicate index aborts the stream", async () => {
const result = await run([
messageStart("msg_orphan"),
toolStart(0, "tool_orphan"),
Expand All @@ -148,10 +148,11 @@ describe("Anthropic truncated tool calls", () => {
expect(tools).toHaveLength(2);
expect(tools[0].id).toBe("tool_orphan");
expect(tools[0].incompleteArguments).toBe(true);
expect(tools[1].incompleteArguments).toBeFalsy();
expect(tools[1].incompleteArguments).toBe(true);
expect(result.stopReason).toBe("error");
});

it("does not transfer orphan truncation state to a same-ID replacement", async () => {
it("marks same-ID replacement calls non-executable when a duplicate index aborts the stream", async () => {
const result = await run([
messageStart("msg_same_id_orphan"),
toolStart(0, "tool_shared"),
Expand All @@ -167,10 +168,27 @@ describe("Anthropic truncated tool calls", () => {
expect(tools[0].id).toBe("tool_shared");
expect(tools[0].incompleteArguments).toBe(true);
expect(tools[1].id).toBe("tool_shared");
expect(tools[1].incompleteArguments).toBeFalsy();
expect(tools[1].incompleteArguments).toBe(true);
expect(result.stopReason).toBe("error");
});

it("keeps an incomplete same-ID orphan blocked on an explicit tool-use stop", async () => {
it("marks a complete duplicate-index orphan non-executable before the integrity error", async () => {
const result = await run([
messageStart("msg_complete_orphan"),
toolStart(0, "tool_complete_orphan"),
toolDelta(0, '{"path":"orphan.ts"}'),
toolStart(0, "tool_replacement"),
]);

const tools = toolCalls(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("reused an active content block index");
expect(tools).toHaveLength(2);
expect(tools[0]).toMatchObject({ incompleteArguments: true, incompleteArgumentsReason: "ambiguous" });
expect(tools[1]).toMatchObject({ incompleteArguments: true, incompleteArgumentsReason: "ambiguous" });
});

it("does not parse a replacement call after a duplicate index aborts the stream", async () => {
const result = await run([
messageStart("msg_same_id_tool_use"),
toolStart(0, "tool_shared"),
Expand All @@ -185,8 +203,9 @@ describe("Anthropic truncated tool calls", () => {
expect(tools).toHaveLength(2);
expect(tools[0].arguments).toEqual({ path: "a.ts", content: "partial" });
expect(tools[0].incompleteArguments).toBe(true);
expect(tools[1].arguments).toEqual({ path: "b.ts", content: "ok" });
expect(tools[1].incompleteArguments).toBeFalsy();
expect(tools[1].arguments).toEqual({});
expect(tools[1].incompleteArguments).toBe(true);
expect(result.stopReason).toBe("error");
});

it("flags incomplete arguments when message_stop omits the terminal reason", async () => {
Expand Down Expand Up @@ -227,7 +246,7 @@ describe("Anthropic truncated tool calls", () => {
expect(toolCalls(result)[0]?.incompleteArguments).toBeFalsy();
});

it("does not flag incomplete JSON when the turn ends for tool use", async () => {
it("flags incomplete JSON when the turn ends for tool use", async () => {
const result = await run([
messageStart("msg_tool_use"),
toolStart(0, "tool_use"),
Expand All @@ -237,20 +256,38 @@ describe("Anthropic truncated tool calls", () => {
]);

expect(result.stopReason).toBe("toolUse");
expect(toolCalls(result)[0]?.incompleteArguments).toBeFalsy();
expect(toolCalls(result)[0]).toMatchObject({
incompleteArguments: true,
incompleteArgumentsReason: "truncated",
});
});

it("finalizes but does not flag an open block when the turn ends for tool use", async () => {
it("keeps complete JSON executable and blocks an open buffer when the turn ends for tool use", async () => {
const completeResult = await run([
messageStart("msg_complete_tool_use"),
toolStart(0, "tool_complete_use"),
toolDelta(0, '{"path":"a.ts","content":"complete"}'),
{ type: "content_block_stop", index: 0 },
...terminal("tool_use"),
]);
const result = await run([
messageStart("msg_open_tool_use"),
toolStart(0, "tool_open_use"),
toolDelta(0, '{"path":"a.ts","content":"line1'),
...terminal("tool_use"),
]);

expect(completeResult.stopReason).toBe("toolUse");
expect(toolCalls(completeResult)[0]).toMatchObject({
arguments: { path: "a.ts", content: "complete" },
});
expect(toolCalls(completeResult)[0]?.incompleteArguments).toBeFalsy();
const [tool] = toolCalls(result);
expect(result.stopReason).toBe("toolUse");
expect(tool?.incompleteArguments).toBeFalsy();
expect(tool).toMatchObject({
incompleteArguments: true,
incompleteArgumentsReason: "truncated",
});
expect(tool && "partialJson" in tool).toBe(false);
expect(tool && "index" in tool).toBe(false);
});
Expand Down
9 changes: 9 additions & 0 deletions scripts/ci-dev-affected.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,15 @@ test("tab-worker graph changes always include install-methods and are Darwin rel
expect(tasks[2]?.command).toEqual(["bash", "-lc", 'TARGET_VARIANTS="baseline modern" bun scripts/ci-build-native.ts']);
});

test("Anthropic provider changes add their stream regressions without bypassing owner fallback coverage", () => {
const tasks = targeted(["packages/ai/src/providers/anthropic.ts"]);
const keys = tasks.map(task => task.key);
expect(keys).toContain("test:packages/ai/test/anthropic-truncated-toolcall.test.ts");
expect(keys).toContain("test:packages/ai/test/anthropic-stream-envelope.test.ts");
expect(keys).toContain("root-check");
expect(keys).toContain("native-linux-x64");
});

test("a CI workflow change plans yaml-parse + ci-selftest + ci-dry-run + workflow-permissions", () => {
const tasks = targeted([".github/workflows/dev-ci.yml"]);
expect(tasks.map(task => task.key).sort()).toEqual(["ci-dry-run", "ci-selftest", "workflow-permissions", "yaml-parse"]);
Expand Down
4 changes: 4 additions & 0 deletions scripts/ci-dev-affected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const NATIVE_BUILD_KEYS: ReadonlySet<string> = new Set(["native-build", "native-
// not follow the source-file basename convention. They supplement, rather than
// replace, direct-basename test selection and owner fallback tasks.
const BEHAVIORAL_OWNER_TESTS: Readonly<Record<string, readonly string[]>> = {
"packages/ai/src/providers/anthropic.ts": [
"packages/ai/test/anthropic-truncated-toolcall.test.ts",
"packages/ai/test/anthropic-stream-envelope.test.ts",
],
"packages/ai/test/fixtures/issue-3670-anthropic-cache-eval.json": ["packages/ai/test/anthropic-cache-eval.integration.test.ts"],
"crates/pi-natives/src/path_identity.rs": ["packages/natives/test/path-identity-posix.test.ts"],
"packages/coding-agent/src/main.ts": ["packages/coding-agent/test/startup-update-contract.test.ts"],
Expand Down
Loading