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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ bun.lock
.notes.md
brunch.db*
todo.txt

# Claude Code worktrees
.claude/worktrees/
15 changes: 15 additions & 0 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ export function App() {
</details>
);
}
if (part.type === 'dynamic-tool') {
const toolPart = part as { type: 'dynamic-tool'; toolName: string; state: string; toolCallId: string };
const statusLabel =
toolPart.state === 'input-streaming' ? 'Streaming input...' :
toolPart.state === 'input-available' ? 'Running...' :
toolPart.state === 'output-available' ? 'Done' :
toolPart.state === 'output-error' ? 'Error' :
toolPart.state;
return (
<div key={i} style={{ margin: '4px 0', padding: 8, background: '#eef6ff', borderRadius: 4, border: '1px solid #c0d8f0', fontSize: 13 }}>
<span style={{ fontWeight: 600 }}>Tool: {toolPart.toolName}</span>
<span style={{ marginLeft: 8, color: '#666' }}>{statusLabel}</span>
</div>
);
}
if (part.type === 'text') {
return <p key={i} style={{ margin: '4px 0' }}>{part.text}</p>;
}
Expand Down
61 changes: 61 additions & 0 deletions src/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,67 @@ describe('POST /api/chat', () => {
});
});

describe('POST /api/chat — tool calls', () => {
it('emits tool-call SSE events for tool-using mock stream', async () => {
mockQuery.mockReturnValue(makeMockStream([
{
type: 'stream_event',
event: { type: 'message_start', message: { id: 'msg-1', role: 'assistant', content: [] } },
},
{
type: 'stream_event',
event: { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', name: 'get_weather', id: 'toolu_01' } },
},
{
type: 'stream_event',
event: { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"city":"NYC"}' } },
},
{
type: 'stream_event',
event: { type: 'content_block_stop', index: 0 },
},
{
type: 'stream_event',
event: { type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } },
},
{
type: 'stream_event',
event: { type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: 'Weather result' } },
},
{
type: 'stream_event',
event: { type: 'content_block_stop', index: 1 },
},
{
type: 'stream_event',
event: { type: 'message_stop' },
},
]));

const res = await request(app)
.post('/api/chat')
.send({ messages: [{ role: 'user', content: 'weather?' }] });

const events = parseSSELines(await collectSSE(res));

const toolStart = events.find((e: any) => e.type === 'tool-call-streaming-start');
expect(toolStart).toBeDefined();
expect(toolStart.toolName).toBe('get_weather');

const toolDelta = events.find((e: any) => e.type === 'tool-call-delta');
expect(toolDelta).toBeDefined();
expect(toolDelta.delta).toBe('{"city":"NYC"}');

const toolCall = events.find((e: any) => e.type === 'tool-call');
expect(toolCall).toBeDefined();
expect(toolCall.args).toBe('{"city":"NYC"}');

const textDelta = events.find((e: any) => e.type === 'text-delta');
expect(textDelta).toBeDefined();
expect(textDelta.delta).toBe('Weather result');
});
});

describe('POST /api/chat — turn persistence', () => {
it('creates a turn with user answer and advances HEAD', async () => {
mockQuery.mockReturnValue(mockTextStream('Hi there'));
Expand Down
62 changes: 62 additions & 0 deletions src/server/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,68 @@ describe('conductTurn', () => {
expect(error.message).toBe('API rate limit');
});

it('yields tool-call-start for tool_use content blocks', async () => {
mockQuery.mockReturnValue(makeMockStream([
{ type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-1' } } },
{ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', name: 'get_weather', id: 'toolu_01' } } },
{ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } },
{ type: 'stream_event', event: { type: 'message_stop' } },
]));

const project = getOrCreateProject(db);
const events: any[] = [];
for await (const event of conductTurn(db, project.id, 'weather?')) {
events.push(event);
}

const toolStart = events.find(e => e.type === 'tool-call-start');
expect(toolStart).toBeDefined();
expect(toolStart.toolName).toBe('get_weather');
expect(toolStart.toolCallId).toBe('toolu_01');
});

it('yields tool-call-delta for input_json_delta', async () => {
mockQuery.mockReturnValue(makeMockStream([
{ type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-1' } } },
{ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', name: 'get_weather', id: 'toolu_01' } } },
{ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"city":"NYC"}' } } },
{ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } },
{ type: 'stream_event', event: { type: 'message_stop' } },
]));

const project = getOrCreateProject(db);
const events: any[] = [];
for await (const event of conductTurn(db, project.id, 'weather?')) {
events.push(event);
}

const toolDelta = events.find(e => e.type === 'tool-call-delta');
expect(toolDelta).toBeDefined();
expect(toolDelta.toolCallId).toBe('toolu_01');
expect(toolDelta.delta).toBe('{"city":"NYC"}');
});

it('yields tool-call-end with toolCallId and toolName', async () => {
mockQuery.mockReturnValue(makeMockStream([
{ type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-1' } } },
{ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', name: 'get_weather', id: 'toolu_01' } } },
{ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"city":"NYC"}' } } },
{ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } },
{ type: 'stream_event', event: { type: 'message_stop' } },
]));

const project = getOrCreateProject(db);
const events: any[] = [];
for await (const event of conductTurn(db, project.id, 'weather?')) {
events.push(event);
}

const toolEnd = events.find(e => e.type === 'tool-call-end');
expect(toolEnd).toBeDefined();
expect(toolEnd.toolCallId).toBe('toolu_01');
expect(toolEnd.toolName).toBe('get_weather');
});

it('chains turns with parent pointers', async () => {
// First turn
mockQuery.mockReturnValue(makeMockStream([
Expand Down
30 changes: 28 additions & 2 deletions src/server/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export type DomainEvent =
| { type: 'stream-start'; messageId: string }
| { type: 'thinking'; delta: string }
| { type: 'text-delta'; delta: string }
| { type: 'tool-call-start'; toolName: string; toolCallId: string }
| { type: 'tool-call-delta'; toolCallId: string; delta: string }
| { type: 'tool-call-end'; toolCallId: string; toolName: string }
| { type: 'stream-end' }
| { type: 'turn-created'; turn: Turn }
| { type: 'error'; message: string };
Expand Down Expand Up @@ -41,8 +44,8 @@ interface SDKStreamEvent {
type: string;
index?: number;
message?: { id: string };
content_block?: { type: string };
delta?: { type: string; text?: string; thinking?: string };
content_block?: { type: string; name?: string; id?: string };
delta?: { type: string; text?: string; thinking?: string; partial_json?: string };
};
}

Expand Down Expand Up @@ -82,6 +85,8 @@ export async function* conductTurn(
},
});

const toolUseBlocks = new Map<number, { toolName: string; toolCallId: string }>();

for await (const sdkMessage of stream) {
if (sdkMessage.type !== 'stream_event') continue;
const event = (sdkMessage as SDKStreamEvent).event;
Expand All @@ -91,13 +96,34 @@ export async function* conductTurn(
yield { type: 'stream-start', messageId: event.message!.id };
break;

case 'content_block_start': {
const block = event.content_block!;
if (block.type === 'tool_use') {
toolUseBlocks.set(event.index!, { toolName: block.name!, toolCallId: block.id! });
yield { type: 'tool-call-start', toolName: block.name!, toolCallId: block.id! };
}
break;
}

case 'content_block_delta': {
const delta = event.delta!;
if (delta.type === 'thinking_delta' && delta.thinking) {
yield { type: 'thinking', delta: delta.thinking };
} else if (delta.type === 'text_delta' && delta.text) {
assistantText += delta.text;
yield { type: 'text-delta', delta: delta.text };
} else if (delta.type === 'input_json_delta' && delta.partial_json) {
const toolBlock = toolUseBlocks.get(event.index!);
yield { type: 'tool-call-delta', toolCallId: toolBlock?.toolCallId ?? '', delta: delta.partial_json };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/server/core.ts:116 — If toolUseBlocks.get(event.index!) returns undefined, this still yields a tool-call-delta with toolCallId: '', which can collide across calls and makes downstream correlation ambiguous. Consider not emitting a tool-call event unless there’s a registered tool block for that index.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

}
break;
}

case 'content_block_stop': {
const toolBlock = toolUseBlocks.get(event.index!);
if (toolBlock) {
yield { type: 'tool-call-end', toolCallId: toolBlock.toolCallId, toolName: toolBlock.toolName };
toolUseBlocks.delete(event.index!);
}
break;
}
Expand Down
89 changes: 88 additions & 1 deletion src/server/sse-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTranslator, formatSSE } from './sse-adapter.js';
import { createTranslator, createDomainAdapter, formatSSE } from './sse-adapter.js';

describe('formatSSE', () => {
it('wraps a JSON object in SSE data line', () => {
Expand Down Expand Up @@ -143,4 +143,91 @@ describe('translateEvent', () => {
const events = translateEvent(sdkMessage);
expect(events).toEqual([]);
});

it('translates tool_use content_block_start to tool-call-streaming-start', () => {
const sdkMessage = {
type: 'stream_event',
event: {
type: 'content_block_start',
index: 2,
content_block: { type: 'tool_use', name: 'get_weather', id: 'toolu_01' },
},
};
const events = translateEvent(sdkMessage);
expect(events).toEqual([{ type: 'tool-call-streaming-start', id: 'toolu_01', toolName: 'get_weather' }]);
});

it('translates input_json_delta to tool-call-delta', () => {
// Register a tool_use block first
translateEvent({
type: 'stream_event',
event: {
type: 'content_block_start',
index: 2,
content_block: { type: 'tool_use', name: 'get_weather', id: 'toolu_01' },
},
});
const sdkMessage = {
type: 'stream_event',
event: {
type: 'content_block_delta',
index: 2,
delta: { type: 'input_json_delta', partial_json: '{"city":"' },
},
};
const events = translateEvent(sdkMessage);
expect(events).toEqual([{ type: 'tool-call-delta', id: 'toolu_01', delta: '{"city":"' }]);
});

it('translates tool_use content_block_stop to tool-call (finished)', () => {
// Register and feed a tool_use block
translateEvent({
type: 'stream_event',
event: {
type: 'content_block_start',
index: 2,
content_block: { type: 'tool_use', name: 'get_weather', id: 'toolu_01' },
},
});
translateEvent({
type: 'stream_event',
event: {
type: 'content_block_delta',
index: 2,
delta: { type: 'input_json_delta', partial_json: '{"city":"NYC"}' },
},
});
const sdkMessage = {
type: 'stream_event',
event: { type: 'content_block_stop', index: 2 },
};
const events = translateEvent(sdkMessage);
expect(events).toEqual([{ type: 'tool-call', id: 'toolu_01', toolName: 'get_weather', args: '{"city":"NYC"}' }]);
});
});

describe('createDomainAdapter — tool-call events', () => {
it('translates tool-call-start to tool-call-streaming-start', () => {
const { translate } = createDomainAdapter();
translate({ type: 'stream-start', messageId: 'msg-1' });
const events = translate({ type: 'tool-call-start', toolName: 'search', toolCallId: 'tc-1' });
expect(events).toEqual([{ type: 'tool-call-streaming-start', id: 'tc-1', toolName: 'search' }]);
});

it('translates tool-call-delta with toolCallId', () => {
const { translate } = createDomainAdapter();
translate({ type: 'stream-start', messageId: 'msg-1' });
translate({ type: 'tool-call-start', toolName: 'search', toolCallId: 'tc-1' });
const events = translate({ type: 'tool-call-delta', toolCallId: 'tc-1', delta: '{"q":"test"}' });
expect(events).toEqual([{ type: 'tool-call-delta', id: 'tc-1', delta: '{"q":"test"}' }]);
});

it('translates tool-call-end with toolName and args', () => {
const { translate } = createDomainAdapter();
translate({ type: 'stream-start', messageId: 'msg-1' });
translate({ type: 'tool-call-start', toolName: 'search', toolCallId: 'tc-1' });
translate({ type: 'tool-call-delta', toolCallId: 'tc-1', delta: '{"q":"test"}' });
const events = translate({ type: 'tool-call-end', toolCallId: 'tc-1', toolName: 'search' });
expect(events).toEqual([{ type: 'tool-call', id: 'tc-1', toolName: 'search', args: '{"q":"test"}' }]);
});
});
Loading