Skip to content
Open
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
16 changes: 10 additions & 6 deletions src/api/providers/__tests__/deepseek.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,10 +647,9 @@ describe("DeepSeekHandler", () => {
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
})

it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
// tool-call events are intentionally ignored because tool-input-start/delta/end
// already provide complete tool call information. Emitting tool-call would cause
// duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
it("should emit tool-call events as tool_call chunks", async () => {
// tool-call events are now emitted as tool_call chunks for provider compatibility.
// Task.ts deduplicates against tools already finalized via the streaming path.
async function* mockFullStream() {
yield {
type: "tool-call",
Expand Down Expand Up @@ -696,9 +695,14 @@ describe("DeepSeekHandler", () => {
chunks.push(chunk)
}

// tool-call events are ignored, so no tool_call chunks should be emitted
// tool-call events now emit tool_call chunks for provider compatibility
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
expect(toolCallChunks.length).toBe(0)
expect(toolCallChunks.length).toBe(1)
expect(toolCallChunks[0]).toMatchObject({
type: "tool_call",
id: "tool-call-1",
name: "read_file",
})
})
})

Expand Down
16 changes: 10 additions & 6 deletions src/api/providers/__tests__/mistral.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,10 +403,9 @@ describe("MistralHandler", () => {
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
})

it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
// tool-call events are intentionally ignored because tool-input-start/delta/end
// already provide complete tool call information. Emitting tool-call would cause
// duplicate tools in the UI for AI SDK providers.
it("should emit tool-call events as tool_call chunks", async () => {
// tool-call events are now emitted as tool_call chunks for provider compatibility.
// Task.ts deduplicates against tools already finalized via the streaming path.
async function* mockFullStream() {
yield {
type: "tool-call",
Expand Down Expand Up @@ -449,9 +448,14 @@ describe("MistralHandler", () => {
chunks.push(chunk)
}

// tool-call events are ignored, so no tool_call chunks should be emitted
// tool-call events now emit tool_call chunks for provider compatibility
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
expect(toolCallChunks.length).toBe(0)
expect(toolCallChunks.length).toBe(1)
expect(toolCallChunks[0]).toMatchObject({
type: "tool_call",
id: "tool-call-1",
name: "read_file",
})
})
})

Expand Down
16 changes: 10 additions & 6 deletions src/api/providers/__tests__/moonshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,10 +420,9 @@ describe("MoonshotHandler", () => {
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
})

it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
// tool-call events are intentionally ignored because tool-input-start/delta/end
// already provide complete tool call information. Emitting tool-call would cause
// duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
it("should emit tool-call events as tool_call chunks", async () => {
// tool-call events are now emitted as tool_call chunks for provider compatibility.
// Task.ts deduplicates against tools already finalized via the streaming path.
async function* mockFullStream() {
yield {
type: "tool-call",
Expand Down Expand Up @@ -468,9 +467,14 @@ describe("MoonshotHandler", () => {
chunks.push(chunk)
}

// tool-call events are ignored, so no tool_call chunks should be emitted
// tool-call events now emit tool_call chunks for provider compatibility
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
expect(toolCallChunks.length).toBe(0)
expect(toolCallChunks.length).toBe(1)
expect(toolCallChunks[0]).toMatchObject({
type: "tool_call",
id: "tool-call-1",
name: "read_file",
})
})
})
})
14 changes: 9 additions & 5 deletions src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ describe("OpenRouterHandler", () => {
expect(chunks[3]).toEqual({ type: "tool_call_end", id: "call_1" })
})

it("ignores tool-call events (handled by tool-input-start/delta/end)", async () => {
it("emits tool-call events as tool_call chunks", async () => {
const handler = new OpenRouterHandler(mockOptions)

const mockFullStream = (async function* () {
Expand All @@ -530,10 +530,14 @@ describe("OpenRouterHandler", () => {
chunks.push(chunk)
}

// tool-call is intentionally ignored by processAiSdkStreamPart,
// only usage chunk should be present
expect(chunks).toHaveLength(1)
expect(chunks[0]).toMatchObject({ type: "usage" })
// tool-call now emits a tool_call chunk for provider compatibility
expect(chunks).toHaveLength(2)
expect(chunks[0]).toMatchObject({
type: "tool_call",
id: "call_1",
name: "read_file",
})
expect(chunks[1]).toMatchObject({ type: "usage" })
})

it("handles API errors gracefully", async () => {
Expand Down
54 changes: 49 additions & 5 deletions src/api/transform/__tests__/ai-sdk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,10 +748,10 @@ describe("AI SDK conversion utilities", () => {
expect(chunks[0]).toEqual({ type: "tool_call_end", id: "call_1" })
})

it("ignores tool-call chunks to prevent duplicate tools in UI", () => {
// tool-call is intentionally ignored because tool-input-start/delta/end already
// provide complete tool call information. Emitting tool-call would cause duplicate
// tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
it("emits tool-call as complete tool_call chunk", () => {
// tool-call is emitted as a safety net for providers (e.g., OpenRouter)
// that may not emit tool-input-end. Task.ts deduplicates against tools
// already finalized via the streaming (start/delta/end) path.
const part = {
type: "tool-call" as const,
toolCallId: "call_1",
Expand All @@ -760,7 +760,51 @@ describe("AI SDK conversion utilities", () => {
}
const chunks = [...processAiSdkStreamPart(part)]

expect(chunks).toHaveLength(0)
expect(chunks).toHaveLength(1)
expect(chunks[0]).toEqual({
type: "tool_call",
id: "call_1",
name: "read_file",
arguments: '{"path":"test.ts"}',
})
})

it("emits tool-call with undefined input as empty object", () => {
const part = {
type: "tool-call" as const,
toolCallId: "call_2",
toolName: "attempt_completion",
input: undefined,
}
const chunks = [...processAiSdkStreamPart(part as any)]

expect(chunks).toHaveLength(1)
expect(chunks[0]).toEqual({
type: "tool_call",
id: "call_2",
name: "attempt_completion",
arguments: "{}",
})
})

it("passes through string input unchanged to avoid double-encoding", () => {
// If safeParseJSON fails in the AI SDK, input is the raw string.
// Passing it through avoids JSON.stringify turning it into a quoted literal.
const part = {
type: "tool-call" as const,
toolCallId: "call_3",
toolName: "execute_command",
input: '{"command":"ls -la"}',
}
const chunks = [...processAiSdkStreamPart(part as any)]

expect(chunks).toHaveLength(1)
expect(chunks[0]).toEqual({
type: "tool_call",
id: "call_3",
name: "execute_command",
arguments: '{"command":"ls -la"}',
})
})

it("processes source chunks with URL", () => {
Expand Down
22 changes: 18 additions & 4 deletions src/api/transform/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,10 +473,25 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
}
break

// Emit tool-call as a complete tool_call chunk.
// Some AI SDK providers (e.g., @openrouter/ai-sdk-provider) may not emit
// tool-input-end for incrementally streamed tool calls, or may emit only
// tool-call with no tool-input-* events at all (flush path). Emitting
// tool-call ensures the tool is always delivered. Task.ts deduplicates
// against tools already finalized via the streaming (start/delta/end) path.
case "tool-call": {
const toolCallPart = part as { toolCallId: string; toolName: string; input: unknown }
const input = toolCallPart.input
yield {
type: "tool_call",
id: toolCallPart.toolCallId,
name: toolCallPart.toolName,
arguments: typeof input === "string" ? input : JSON.stringify(input ?? {}),
}
break
}

// Ignore lifecycle events that don't need to yield chunks.
// Note: tool-call is intentionally ignored because tool-input-start/delta/end already
// provide complete tool call information. Emitting tool-call would cause duplicate
// tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
case "text-start":
case "text-end":
case "reasoning-start":
Expand All @@ -489,7 +504,6 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
case "file":
case "tool-result":
case "tool-error":
case "tool-call":
case "raw":
break
}
Expand Down
49 changes: 44 additions & 5 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3097,8 +3097,43 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
break

case "tool_call": {
// Legacy: Handle complete tool calls (for backward compatibility)
// Convert native tool call to ToolUse format
// Handle complete tool calls from AI SDK's tool-call event.
// This is the safety net for providers (e.g., OpenRouter) that
// may not emit tool-input-end for streamed tool calls, or that
// emit only tool-call with no tool-input-* events (flush path).

// Deduplicate: skip if this tool was already finalized via
// the streaming path (tool_call_start/delta/end).
const alreadyPresent = this.assistantMessageContent.some(
(block) =>
(block.type === "tool_use" || block.type === "mcp_tool_use") &&
!block.partial &&
(block as any).id === chunk.id,
)
if (alreadyPresent) {
break
}

// Check if this tool is currently being streamed (started but
// not yet finalized due to missing tool-input-end).
const streamingIndex = this.streamingToolCallIndices.get(chunk.id)
if (streamingIndex !== undefined) {
// Clean up parser state for the incomplete streaming call
NativeToolCallParser.finalizeStreamingToolCall(chunk.id)
this.streamingToolCallIndices.delete(chunk.id)
}

// Before adding a new tool in the flush-only path,
// finalize any preceding partial text block to prevent
// it from blocking tool presentation (mirrors tool_call_start)
if (streamingIndex === undefined) {
const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1]
if (lastBlock?.type === "text" && lastBlock.partial) {
lastBlock.partial = false
}
}

// Convert complete tool call to ToolUse format
const toolUse = NativeToolCallParser.parseToolCall({
id: chunk.id,
name: chunk.name as ToolName,
Expand All @@ -3111,11 +3146,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

// Store the tool call ID on the ToolUse object for later reference
// This is needed to create tool_result blocks that reference the correct tool_use_id
toolUse.id = chunk.id

// Add the tool use to assistant message content
this.assistantMessageContent.push(toolUse)
if (streamingIndex !== undefined) {
// Replace the partial tool_use block in-place
this.assistantMessageContent[streamingIndex] = toolUse
} else {
// Add the tool use to assistant message content
this.assistantMessageContent.push(toolUse)
}

// Mark that we have new content to process
this.userMessageContentReady = false
Expand Down
Loading
Loading