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
62 changes: 62 additions & 0 deletions apps/claude-sdk-cli/src/AgentMessageHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
29 changes: 5 additions & 24 deletions apps/claude-sdk-cli/src/runAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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);
}
});

Expand Down
189 changes: 189 additions & 0 deletions apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading