From 421de33ce32f5d9a5660e2e8e78da879b4c377b4 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 31 May 2026 17:00:28 +0800 Subject: [PATCH 1/3] init tokenusage --- packages/codingcode/src/agent/agent.ts | 9 +++-- packages/codingcode/src/client/direct.ts | 3 ++ packages/codingcode/src/client/http.ts | 3 ++ .../src/client/http/agent-runtime.ts | 3 ++ packages/codingcode/src/client/types.ts | 3 +- packages/codingcode/src/server/adapter.ts | 2 ++ packages/codingcode/test/agent-event.test.ts | 10 ++++++ .../codingcode/test/client/direct.test.ts | 18 ++++++++++ .../codingcode/test/server/adapter.test.ts | 22 ++++++++++++ packages/desktop/src/agent/AgentWorkspace.tsx | 6 +++- packages/desktop/src/hooks/useAgent.ts | 9 +++-- packages/desktop/src/stores/global.store.ts | 28 ++++++++++++++- packages/desktop/test/global-store.test.ts | 36 +++++++++++++++++++ .../test/useAgent-streamChunkToItem.test.ts | 10 ++++++ 14 files changed, 154 insertions(+), 8 deletions(-) diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index ac9f02d..2d8db5b 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -3,6 +3,7 @@ import type { Message, ToolCall } from '../core/types.js'; import { AgentError } from '../core/error.js'; import { Result } from '../core/result.js'; import type { ToolDescription } from '../tools/types.js'; +import type { LLMResponse } from '../llm/types.js'; import { ToolService } from '../tools/registry.js'; import { ToolExecutorService } from '../tools/executor.js'; import { ContextService } from '../context/context.js'; @@ -70,7 +71,8 @@ export type AgentEvent = | { readonly _tag: 'Error'; readonly error: AgentError } | { readonly _tag: 'Done'; readonly content: string } | { readonly _tag: 'TodoUpdate'; readonly items: ReadonlyArray<{ readonly step: string; readonly status: 'pending' | 'in_progress' | 'completed' }> } - | { readonly _tag: 'TurnId'; readonly turnId: number }; + | { readonly _tag: 'TurnId'; readonly turnId: number } + | { readonly _tag: 'Usage'; readonly prompt: number; readonly completion: number; readonly total: number }; export interface RunStreamOptions { state: SessionStoreState; @@ -96,7 +98,7 @@ export interface LLMStreamAdapter { signal?: AbortSignal; }): { stream: AsyncIterable; - response: Promise>; + response: Promise>; }; } @@ -242,6 +244,9 @@ export async function* runReActLoop( } messages.push(assistantMsg); yield { _tag: 'Assistant', content: resp.content, toolCalls }; + if (resp.usage) { + yield { _tag: 'Usage', prompt: resp.usage.prompt, completion: resp.usage.completion, total: resp.usage.total }; + } if (!toolCalls || toolCalls.length === 0) { // LLM done — record assistant, then check stop hook diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index 3f6fd4b..72f5faa 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -54,6 +54,9 @@ export async function* agentEventToStreamChunk( case 'TodoUpdate': yield { type: 'todo_update', items: event.items as any }; break; + case 'Usage': + yield { type: 'usage', prompt: event.prompt, completion: event.completion, total: event.total }; + break; } } } diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index 053690d..da7389d 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -47,6 +47,9 @@ export async function createHttpClient(serverUrl: string): Promise case 'todo_update': yield { type: 'todo_update', items: data.items as any }; break; + case 'usage': + yield { type: 'usage', prompt: data.prompt as number, completion: data.completion as number, total: data.total as number }; + break; case 'error': throw new Error(data.message as string); case 'done': diff --git a/packages/codingcode/src/client/http/agent-runtime.ts b/packages/codingcode/src/client/http/agent-runtime.ts index 4c02d8d..df9cf8a 100644 --- a/packages/codingcode/src/client/http/agent-runtime.ts +++ b/packages/codingcode/src/client/http/agent-runtime.ts @@ -65,6 +65,9 @@ export function createHttpAgentClient( case 'todo_update': yield { type: 'todo_update', items: data.items as any }; break; + case 'usage': + yield { type: 'usage', prompt: data.prompt as number, completion: data.completion as number, total: data.total as number }; + break; case 'error': throw new Error(data.message as string); case 'done': diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index 3c1cfda..b56579e 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -15,7 +15,8 @@ export type StreamChunk = | { type: 'tool_denied'; id: string; name: string; reason: string } | { type: 'error'; message: string } | { type: 'done' } - | { type: 'todo_update'; items: ReadonlyArray<{ step: string; status: string }> }; + | { type: 'todo_update'; items: ReadonlyArray<{ step: string; status: string }> } + | { type: 'usage'; prompt: number; completion: number; total: number }; export interface AgentClient { sendMessage(input: string, cwd?: string): AsyncGenerator; diff --git a/packages/codingcode/src/server/adapter.ts b/packages/codingcode/src/server/adapter.ts index 4f5ec2e..287c078 100644 --- a/packages/codingcode/src/server/adapter.ts +++ b/packages/codingcode/src/server/adapter.ts @@ -22,6 +22,8 @@ export function agentEventToSseEvent(event: AgentEvent): SseEvent | null { return { type: 'done' }; case 'TodoUpdate': return { type: 'todo_update', items: event.items as unknown as Record[] }; + case 'Usage': + return { type: 'usage', prompt: event.prompt, completion: event.completion, total: event.total }; case 'LlmChunk': case 'Assistant': case 'ReactiveCompact': diff --git a/packages/codingcode/test/agent-event.test.ts b/packages/codingcode/test/agent-event.test.ts index 822664b..7d51688 100644 --- a/packages/codingcode/test/agent-event.test.ts +++ b/packages/codingcode/test/agent-event.test.ts @@ -14,6 +14,16 @@ describe('AgentEvent type', () => { if (ev._tag === 'Done') expect(ev.content).toBe('result'); }); + it('should accept a Usage event', () => { + const ev: AgentEvent = { _tag: 'Usage', prompt: 1000, completion: 500, total: 1500 }; + expect(ev._tag).toBe('Usage'); + if (ev._tag === 'Usage') { + expect(ev.prompt).toBe(1000); + expect(ev.completion).toBe(500); + expect(ev.total).toBe(1500); + } + }); + it('should narrow correctly via discriminated union switch', () => { const ev: AgentEvent = { _tag: 'Error', error: { _tag: 'MaxStepsReached', maxSteps: 5, message: 'test' } }; switch (ev._tag) { diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts index 699d751..74af0e6 100644 --- a/packages/codingcode/test/client/direct.test.ts +++ b/packages/codingcode/test/client/direct.test.ts @@ -71,4 +71,22 @@ describe('agentEventToStreamChunk - approval interleaving', () => { expect(chunks[1]).toMatchObject({ type: 'approval_request', id: 'apr-2' }); expect(chunks[2]).toEqual({ type: 'done' }); }); + + it('yields usage chunks', async () => { + async function* source() { + yield { _tag: 'Step' as const, step: 1, max: 10 }; + yield { _tag: 'Assistant' as const, content: 'ok' }; + yield { _tag: 'Usage' as const, prompt: 1000, completion: 500, total: 1500 }; + } + + const chunks: any[] = []; + for await (const chunk of agentEventToStreamChunk(source())) { + chunks.push(chunk); + } + + expect(chunks).toEqual([ + { type: 'text', text: 'ok', messageId: 1 }, + { type: 'usage', prompt: 1000, completion: 500, total: 1500 }, + ]); + }); }); diff --git a/packages/codingcode/test/server/adapter.test.ts b/packages/codingcode/test/server/adapter.test.ts index 0f720ee..06896f0 100644 --- a/packages/codingcode/test/server/adapter.test.ts +++ b/packages/codingcode/test/server/adapter.test.ts @@ -50,12 +50,34 @@ describe('agentEventToSseEvent', () => { .toEqual({ type: 'todo_update', items }); }); + it('maps Usage to usage event', () => { + expect(agentEventToSseEvent({ _tag: 'Usage', prompt: 1000, completion: 500, total: 1500 })) + .toEqual({ type: 'usage', prompt: 1000, completion: 500, total: 1500 }); + }); + it('returns null for Assistant and ReactiveCompact', () => { expect(agentEventToSseEvent({ _tag: 'Assistant', content: 'ok' })).toBeNull(); expect(agentEventToSseEvent({ _tag: 'ReactiveCompact', attempt: 1, released: 100 })).toBeNull(); }); }); +describe('toSseEvents with Usage', () => { + it('Usage events flow through toSseEvents', async () => { + async function* source(): AsyncGenerator { + yield { _tag: 'Step', step: 1, max: 10 }; + yield { _tag: 'Assistant', content: 'ok' }; + yield { _tag: 'Usage', prompt: 1000, completion: 500, total: 1500 }; + } + const result: any[] = []; + for await (const s of toSseEvents(source())) result.push(s); + expect(result).toEqual([ + { type: 'step', step: 1 }, + { type: 'message', id: 1, content: 'ok', partial: false }, + { type: 'usage', prompt: 1000, completion: 500, total: 1500 }, + ]); + }); +}); + describe('toSseEvents', () => { it('text chunks carry messageId from preceding Step', async () => { async function* source(): AsyncGenerator { diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 976c7bf..86d16df 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -10,16 +10,20 @@ import ApprovalPanel from './ApprovalPanel' function ContextIndicator({ threadId }: { threadId: string }) { const contextUsage = useGlobalStore((s) => s.agent.contextUsage) + const usage = useGlobalStore((s) => s.agent.usageByThreadId[threadId]) const setContextUsage = useGlobalStore((s) => s.setContextUsage) if (!contextUsage) return null const pct = Math.min(contextUsage.used / contextUsage.contextWindow, 1) const color = pct < 0.4 ? '#4ec9b0' : pct < 0.75 ? '#e5c07b' : '#f44747' const r = 7 const circ = 2 * Math.PI * r + const detail = usage + ? `prompt: ${usage.prompt.toLocaleString()}, completion: ${usage.completion.toLocaleString()}, total: ${usage.total.toLocaleString()} / ${contextUsage.contextWindow.toLocaleString()} tokens` + : `${contextUsage.used.toLocaleString()} / ${contextUsage.contextWindow.toLocaleString()} tokens` return (