diff --git a/apps/claude-sdk-cli/src/AgentMessageHandler.ts b/apps/claude-sdk-cli/src/AgentMessageHandler.ts new file mode 100644 index 0000000..afddfd1 --- /dev/null +++ b/apps/claude-sdk-cli/src/AgentMessageHandler.ts @@ -0,0 +1,62 @@ +import type { SdkMessage } from '@shellicar/claude-sdk'; +import type { AppLayout } from './AppLayout.js'; +import type { logger } from './logger.js'; + +/** + * Handles the stateless SdkMessage cases: routes each message to the + * appropriate layout call. No accumulated state here. + * + * Stateful cases (tool_approval_request, tool_error, message_usage) stay + * in runAgent.ts until step 4b, when usageBeforeTools tracking and the + * async tool approval flow move here too. + * + * NOTE: message_compaction currently omits the "compacted at X/Y (Z%)" + * context-usage annotation. That annotation reads lastUsage, which is + * set by message_usage — a 4b case. The annotation is restored in 4b. + */ +export class AgentMessageHandler { + #layout: AppLayout; + #logger: typeof logger; + + public constructor(layout: AppLayout, log: typeof logger) { + this.#layout = layout; + this.#logger = log; + } + + public handle(msg: SdkMessage): void { + switch (msg.type) { + case 'query_summary': { + const parts = [`${msg.systemPrompts} system`, `${msg.userMessages} user`, `${msg.assistantMessages} assistant`, ...(msg.thinkingBlocks > 0 ? [`${msg.thinkingBlocks} thinking`] : [])]; + this.#layout.transitionBlock('meta'); + this.#layout.appendStreaming(parts.join(' · ')); + break; + } + case 'message_thinking': + this.#layout.transitionBlock('thinking'); + this.#layout.appendStreaming(msg.text); + break; + case 'message_text': + this.#layout.transitionBlock('response'); + this.#layout.appendStreaming(msg.text); + break; + case 'message_compaction_start': + this.#layout.transitionBlock('compaction'); + break; + case 'message_compaction': + this.#layout.transitionBlock('compaction'); + this.#layout.appendStreaming(msg.summary); + break; + case 'done': + this.#logger.info('done', { stopReason: msg.stopReason }); + if (msg.stopReason !== 'end_turn') { + this.#layout.appendStreaming(`\n\n[stop: ${msg.stopReason}]`); + } + break; + case 'error': + this.#layout.transitionBlock('response'); + this.#layout.appendStreaming(`\n\n[error: ${msg.message}]`); + this.#logger.error('error', { message: msg.message }); + break; + } + } +} diff --git a/apps/claude-sdk-cli/src/runAgent.ts b/apps/claude-sdk-cli/src/runAgent.ts index 589db5e..78723a2 100644 --- a/apps/claude-sdk-cli/src/runAgent.ts +++ b/apps/claude-sdk-cli/src/runAgent.ts @@ -16,6 +16,7 @@ import { createRef } from '@shellicar/claude-sdk-tools/Ref'; import type { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import { SearchFiles } from '@shellicar/claude-sdk-tools/SearchFiles'; import { Tail } from '@shellicar/claude-sdk-tools/Tail'; +import { AgentMessageHandler } from './AgentMessageHandler.js'; import type { AppLayout, PendingTool } from './AppLayout.js'; import { logger } from './logger.js'; import { getPermission, PermissionAction } from './permissions.js'; @@ -105,6 +106,8 @@ export async function runAgent(agent: IAnthropicAgent, prompt: string, layout: A return result; }; + const handler = new AgentMessageHandler(layout, logger); + layout.startStreaming(prompt); const model = 'claude-sonnet-4-6'; @@ -192,19 +195,6 @@ export async function runAgent(agent: IAnthropicAgent, prompt: string, layout: A layout.transitionBlock('tools'); layout.appendStreaming(`${msg.name} error\n\`\`\`json\n${JSON.stringify(msg.input, null, 2)}\n\`\`\`\n\n${msg.error}\n`); break; - case 'message_compaction_start': - layout.transitionBlock('compaction'); - break; - case 'message_compaction': - layout.transitionBlock('compaction'); - layout.appendStreaming(msg.summary); - if (lastUsage) { - const used = lastUsage.inputTokens + lastUsage.cacheCreationTokens + lastUsage.cacheReadTokens; - const pct = ((used / lastUsage.contextWindow) * 100).toFixed(1); - const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)); - layout.appendStreaming(`\n\n[compacted at ${fmt(used)} / ${fmt(lastUsage.contextWindow)} (${pct}%)]`); - } - break; case 'message_usage': { // Annotate the (now-sealed) tools block with how many tokens this batch added to the // context window: delta = (input+cacheCreate+cacheRead at N+1) - (same at N). @@ -237,17 +227,8 @@ export async function runAgent(agent: IAnthropicAgent, prompt: string, layout: A layout.updateUsage(msg); break; } - case 'done': - logger.info('done', { stopReason: msg.stopReason }); - if (msg.stopReason !== 'end_turn') { - layout.appendStreaming(`\n\n[stop: ${msg.stopReason}]`); - } - break; - case 'error': - layout.transitionBlock('response'); - layout.appendStreaming(`\n\n[error: ${msg.message}]`); - logger.error('error', { message: msg.message }); - break; + default: + handler.handle(msg); } }); diff --git a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts new file mode 100644 index 0000000..13860e1 --- /dev/null +++ b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AgentMessageHandler } from '../src/AgentMessageHandler.js'; +import type { AppLayout } from '../src/AppLayout.js'; +import { logger } from '../src/logger.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeLayout() { + return { + transitionBlock: vi.fn(), + appendStreaming: vi.fn(), + } as unknown as AppLayout; +} + +function makeHandler(layout: AppLayout) { + return new AgentMessageHandler(layout, logger); +} + +// --------------------------------------------------------------------------- +// query_summary +// --------------------------------------------------------------------------- + +describe('AgentMessageHandler — query_summary', () => { + it('transitions to meta block', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'query_summary', systemPrompts: 1, userMessages: 2, assistantMessages: 1, thinkingBlocks: 0 }); + const expected = 'meta'; + const actual = vi.mocked(layout.transitionBlock).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); + + it('streams the parts joined by ·', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'query_summary', systemPrompts: 1, userMessages: 2, assistantMessages: 1, thinkingBlocks: 0 }); + const expected = '1 system · 2 user · 1 assistant'; + const actual = vi.mocked(layout.appendStreaming).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); + + it('includes thinking block count when non-zero', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'query_summary', systemPrompts: 1, userMessages: 1, assistantMessages: 1, thinkingBlocks: 3 }); + const expected = true; + const actual = (vi.mocked(layout.appendStreaming).mock.calls[0]?.[0] ?? '').includes('3 thinking'); + expect(actual).toBe(expected); + }); + + it('omits thinking count when zero', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'query_summary', systemPrompts: 1, userMessages: 1, assistantMessages: 1, thinkingBlocks: 0 }); + const expected = false; + const actual = (vi.mocked(layout.appendStreaming).mock.calls[0]?.[0] ?? '').includes('thinking'); + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// message_thinking +// --------------------------------------------------------------------------- + +describe('AgentMessageHandler — message_thinking', () => { + it('transitions to thinking block', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'message_thinking', text: 'hmm' }); + const expected = 'thinking'; + const actual = vi.mocked(layout.transitionBlock).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); + + it('streams the thinking text', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'message_thinking', text: 'hmm' }); + const expected = 'hmm'; + const actual = vi.mocked(layout.appendStreaming).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// message_text +// --------------------------------------------------------------------------- + +describe('AgentMessageHandler — message_text', () => { + it('transitions to response block', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'message_text', text: 'hello' }); + const expected = 'response'; + const actual = vi.mocked(layout.transitionBlock).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); + + it('streams the text', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'message_text', text: 'hello' }); + const expected = 'hello'; + const actual = vi.mocked(layout.appendStreaming).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// message_compaction_start +// --------------------------------------------------------------------------- + +describe('AgentMessageHandler — message_compaction_start', () => { + it('transitions to compaction block', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'message_compaction_start' }); + const expected = 'compaction'; + const actual = vi.mocked(layout.transitionBlock).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); + + it('does not stream any text', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'message_compaction_start' }); + const expected = 0; + const actual = vi.mocked(layout.appendStreaming).mock.calls.length; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// message_compaction +// --------------------------------------------------------------------------- + +describe('AgentMessageHandler — message_compaction', () => { + it('transitions to compaction block', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'message_compaction', summary: 'context trimmed' }); + const expected = 'compaction'; + const actual = vi.mocked(layout.transitionBlock).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); + + it('streams the summary', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'message_compaction', summary: 'context trimmed' }); + const expected = 'context trimmed'; + const actual = vi.mocked(layout.appendStreaming).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// done +// --------------------------------------------------------------------------- + +describe('AgentMessageHandler — done', () => { + it('does not stream anything on end_turn', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'done', stopReason: 'end_turn' }); + const expected = 0; + const actual = vi.mocked(layout.appendStreaming).mock.calls.length; + expect(actual).toBe(expected); + }); + + it('streams a stop annotation for non-end_turn reasons', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'done', stopReason: 'max_tokens' }); + const expected = true; + const actual = (vi.mocked(layout.appendStreaming).mock.calls[0]?.[0] ?? '').includes('[stop: max_tokens]'); + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// error +// --------------------------------------------------------------------------- + +describe('AgentMessageHandler — error', () => { + it('transitions to response block', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'error', message: 'oops' }); + const expected = 'response'; + const actual = vi.mocked(layout.transitionBlock).mock.calls[0]?.[0]; + expect(actual).toBe(expected); + }); + + it('streams an error annotation', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'error', message: 'oops' }); + const expected = true; + const actual = (vi.mocked(layout.appendStreaming).mock.calls[0]?.[0] ?? '').includes('[error: oops]'); + expect(actual).toBe(expected); + }); +});