mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge pull request #1176 from QwenLM/feat/acp-usage-metadata
Feat/acp usage metadata
This commit is contained in:
@@ -316,6 +316,23 @@ export const annotationsSchema = z.object({
|
||||
priority: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export const usageSchema = z.object({
|
||||
promptTokens: z.number().optional().nullable(),
|
||||
completionTokens: z.number().optional().nullable(),
|
||||
thoughtsTokens: z.number().optional().nullable(),
|
||||
totalTokens: z.number().optional().nullable(),
|
||||
cachedTokens: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type Usage = z.infer<typeof usageSchema>;
|
||||
|
||||
export const sessionUpdateMetaSchema = z.object({
|
||||
usage: usageSchema.optional().nullable(),
|
||||
durationMs: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
});
|
||||
@@ -500,10 +517,12 @@ export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
|
||||
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('parses path from ACP ENOENT message (quoted)', async () => {
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/remote/file.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to requested path when none provided', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-2',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.readTextFile('/fallback/path.txt'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/fallback/path.txt',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
limit: null,
|
||||
});
|
||||
|
||||
if (response.content.startsWith('ERROR: ENOENT:')) {
|
||||
// Treat ACP error strings as structured ENOENT errors without
|
||||
// assuming a specific platform format.
|
||||
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
|
||||
const err = new Error(response.content) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
const rawPath = match?.groups?.['path']?.trim();
|
||||
err['path'] = rawPath
|
||||
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
|
||||
: filePath;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
|
||||
@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usage metadata replay', () => {
|
||||
it('should emit usage metadata after assistant message content', async () => {
|
||||
const record: ChatRecord = {
|
||||
uuid: 'assistant-uuid',
|
||||
parentUuid: 'user-uuid',
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'assistant',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hello!' }],
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello!' },
|
||||
});
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: undefined,
|
||||
totalTokens: 150,
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
} from '@google/genai';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
@@ -52,6 +55,9 @@ export class HistoryReplayer {
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'assistant');
|
||||
}
|
||||
if (record.usageMetadata) {
|
||||
await this.replayUsageMetadata(record.usageMetadata);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
@@ -88,11 +94,22 @@ export class HistoryReplayer {
|
||||
toolName: functionName,
|
||||
callId,
|
||||
args: part.functionCall.args as Record<string, unknown>,
|
||||
status: 'in_progress',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays usage metadata.
|
||||
* @param usageMetadata - The usage metadata to replay
|
||||
*/
|
||||
private async replayUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
): Promise<void> {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a tool result record.
|
||||
*/
|
||||
@@ -118,6 +135,54 @@ export class HistoryReplayer {
|
||||
// Note: args aren't stored in tool_result records by default
|
||||
args: undefined,
|
||||
});
|
||||
|
||||
// Special handling: Task tool execution summary contains token usage
|
||||
const { resultDisplay } = result ?? {};
|
||||
if (
|
||||
!!resultDisplay &&
|
||||
typeof resultDisplay === 'object' &&
|
||||
'type' in resultDisplay &&
|
||||
(resultDisplay as { type?: unknown }).type === 'task_execution'
|
||||
) {
|
||||
await this.emitTaskUsageFromResultDisplay(
|
||||
resultDisplay as TaskResultDisplay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits token usage from a TaskResultDisplay execution summary, if present.
|
||||
*/
|
||||
private async emitTaskUsageFromResultDisplay(
|
||||
resultDisplay: TaskResultDisplay,
|
||||
): Promise<void> {
|
||||
const summary = resultDisplay.executionSummary;
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageMetadata: GenerateContentResponseUsageMetadata = {};
|
||||
|
||||
if (Number.isFinite(summary.inputTokens)) {
|
||||
usageMetadata.promptTokenCount = summary.inputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.outputTokens)) {
|
||||
usageMetadata.candidatesTokenCount = summary.outputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.thoughtTokens)) {
|
||||
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.cachedTokens)) {
|
||||
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.totalTokens)) {
|
||||
usageMetadata.totalTokenCount = summary.totalTokens;
|
||||
}
|
||||
|
||||
// Only emit if we captured at least one token metric
|
||||
if (Object.keys(usageMetadata).length > 0) {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, FunctionCall, Part } from '@google/genai';
|
||||
import type {
|
||||
Content,
|
||||
FunctionCall,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
} from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
@@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
/**
|
||||
@@ -79,6 +85,7 @@ export class Session implements SessionContext {
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly planEmitter: PlanEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
|
||||
// Implement SessionContext interface
|
||||
readonly sessionId: string;
|
||||
@@ -96,6 +103,7 @@ export class Session implements SessionContext {
|
||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||
this.planEmitter = new PlanEmitter(this);
|
||||
this.historyReplayer = new HistoryReplayer(this);
|
||||
this.messageEmitter = new MessageEmitter(this);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
@@ -251,6 +259,15 @@ export class Session implements SessionContext {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (usageMetadata) {
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
@@ -522,6 +541,7 @@ export class Session implements SessionContext {
|
||||
callId,
|
||||
toolName: fc.name,
|
||||
args,
|
||||
status: 'in_progress',
|
||||
};
|
||||
await this.toolCallEmitter.emitStart(startParams);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
SubAgentUsageEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { z } from 'zod';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
||||
*/
|
||||
export class SubAgentTracker {
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolStates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -76,6 +79,7 @@ export class SubAgentTracker {
|
||||
private readonly client: acp.Client,
|
||||
) {
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,16 +96,19 @@ export class SubAgentTracker {
|
||||
const onToolCall = this.createToolCallHandler(abortSignal);
|
||||
const onToolResult = this.createToolResultHandler(abortSignal);
|
||||
const onApproval = this.createApprovalHandler(abortSignal);
|
||||
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
|
||||
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
|
||||
return [
|
||||
() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
// Clean up any remaining states
|
||||
this.toolStates.clear();
|
||||
},
|
||||
@@ -252,6 +259,20 @@ export class SubAgentTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for usage metadata events.
|
||||
*/
|
||||
private createUsageMetadataHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentUsageEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts confirmation details to permission options for the client.
|
||||
*/
|
||||
|
||||
@@ -148,4 +148,59 @@ describe('MessageEmitter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUsageMetadata', () => {
|
||||
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
thoughtsTokenCount: 25,
|
||||
totalTokenCount: 175,
|
||||
cachedContentTokenCount: 10,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: 25,
|
||||
totalTokens: 175,
|
||||
cachedTokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include durationMs in _meta when provided', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 1,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'done' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
thoughtsTokens: 2,
|
||||
totalTokens: 17,
|
||||
cachedTokens: 1,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent message chunk.
|
||||
*/
|
||||
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
* Emits usage metadata.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
async emitUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
text: string = '',
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
const usage: Usage = {
|
||||
promptTokens: usageMetadata.promptTokenCount,
|
||||
completionTokens: usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokens: usageMetadata.thoughtsTokenCount,
|
||||
totalTokens: usageMetadata.totalTokenCount,
|
||||
cachedTokens: usageMetadata.cachedContentTokenCount,
|
||||
};
|
||||
|
||||
const meta =
|
||||
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
_meta: meta,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'unknown_tool', // Falls back to tool name
|
||||
content: [],
|
||||
locations: [],
|
||||
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-456',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'edit_file: Test tool description',
|
||||
content: [],
|
||||
locations: [{ path: '/test/file.ts', line: 10 }],
|
||||
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-fail',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'failing_tool', // Fallback to tool name
|
||||
content: [],
|
||||
locations: [], // Fallback to empty
|
||||
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '{"output":"test output"}',
|
||||
text: 'test output',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: '{"output":"Function output"}' },
|
||||
content: { type: 'text', text: 'Function output' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw result',
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: params.callId,
|
||||
status: 'in_progress',
|
||||
status: params.status || 'pending',
|
||||
title,
|
||||
content: [],
|
||||
locations,
|
||||
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
// Handle functionResponse parts - stringify the response
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
try {
|
||||
const responseText = JSON.stringify(part.functionResponse.response);
|
||||
const resp = part.functionResponse.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outputField = resp['output'];
|
||||
const errorField = resp['error'];
|
||||
const responseText =
|
||||
typeof outputField === 'string'
|
||||
? outputField
|
||||
: typeof errorField === 'string'
|
||||
? errorField
|
||||
: JSON.stringify(resp);
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ToolCallStartParams {
|
||||
callId: string;
|
||||
/** Arguments passed to the tool */
|
||||
args?: Record<string, unknown>;
|
||||
/** Status of the tool call */
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user