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
11 changes: 8 additions & 3 deletions packages/client/__tests__/messages-slice.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { messagesReducer, finishCurrent, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js';
import type { MessagesState } from '../src/slices/messages.js';
import type { FinishedBlock, StreamingMessage } from '@mitzo/protocol';
import type { FinishedBlock, FinishedSubagentState, StreamingMessage } from '@mitzo/protocol';

const INITIAL = INITIAL_MESSAGES_STATE;

Expand Down Expand Up @@ -1180,8 +1180,13 @@ describe('finishCurrent', () => {
};

const finished = finishCurrent(current);
expect(finished.blocks[0].subagent).toBeDefined();
expect(finished.blocks[0].subagent!.messageId).toBe('sub-msg-1');
const sub = finished.blocks[0].subagent as FinishedSubagentState;
expect(sub).toBeDefined();
expect(sub.messageId).toBe('sub-msg-1');
// Verify streaming Map was converted to finished array
expect(Array.isArray(sub.blocks)).toBe(true);
expect(sub.blocks).toHaveLength(1);
expect(sub.blocks[0].content).toBe('subagent output');
});

it('works normally when subagent field is absent', () => {
Expand Down
261 changes: 235 additions & 26 deletions packages/client/__tests__/subagent-reducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { messagesReducer, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js';
import type { StreamingBlock } from '@mitzo/protocol';
import { messagesReducer, finishCurrent, INITIAL_MESSAGES_STATE } from '../src/slices/messages.js';
import type {
StreamingBlock,
StreamingSubagentState,
FinishedSubagentState,
} from '@mitzo/protocol';

describe('Subagent Reducer Actions', () => {
it('SUBAGENT_START initializes subagent state on parent tool block', () => {
Expand Down Expand Up @@ -33,10 +37,11 @@ describe('Subagent Reducer Actions', () => {

const newState = messagesReducer(state, action);

expect(newState.current?.blocks.get('b1')?.subagent).toBeDefined();
expect(newState.current?.blocks.get('b1')?.subagent?.messageId).toBe('msg-sub-1');
expect(newState.current?.blocks.get('b1')?.subagent?.running).toBe(true);
expect(newState.current?.blocks.get('b1')?.subagent?.blocks.size).toBe(0);
const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState;
expect(sub).toBeDefined();
expect(sub.messageId).toBe('msg-sub-1');
expect(sub.running).toBe(true);
expect(sub.blocks.size).toBe(0);
});

it('SUBAGENT_BLOCK_START adds block to subagent state', () => {
Expand Down Expand Up @@ -76,11 +81,10 @@ describe('Subagent Reducer Actions', () => {

const newState = messagesReducer(state, action);

expect(newState.current?.blocks.get('b1')?.subagent?.blocks.size).toBe(1);
expect(newState.current?.blocks.get('b1')?.subagent?.blockOrder).toEqual(['b-sub-1']);
expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-1')?.blockType).toBe(
'thinking',
);
const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState;
expect(sub.blocks.size).toBe(1);
expect(sub.blockOrder).toEqual(['b-sub-1']);
expect(sub.blocks.get('b-sub-1')?.blockType).toBe('thinking');
});

it('SUBAGENT_BLOCK_DELTA appends to subagent block content', () => {
Expand Down Expand Up @@ -130,9 +134,8 @@ describe('Subagent Reducer Actions', () => {

const newState = messagesReducer(state, action);

expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-1')?.content).toBe(
'Hello world',
);
const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState;
expect(sub.blocks.get('b-sub-1')?.content).toBe('Hello world');
});

it('SUBAGENT_BLOCK_END marks subagent block as done', () => {
Expand Down Expand Up @@ -181,7 +184,8 @@ describe('Subagent Reducer Actions', () => {

const newState = messagesReducer(state, action);

expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-1')?.done).toBe(true);
const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState;
expect(sub.blocks.get('b-sub-1')?.done).toBe(true);
});

it('SUBAGENT_TOOL_RESULT patches tool result in subagent block', () => {
Expand Down Expand Up @@ -234,12 +238,9 @@ describe('Subagent Reducer Actions', () => {

const newState = messagesReducer(state, action);

expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-2')?.toolResult).toBe(
'file contents',
);
expect(newState.current?.blocks.get('b1')?.subagent?.blocks.get('b-sub-2')?.toolError).toBe(
false,
);
const sub = newState.current?.blocks.get('b1')?.subagent as StreamingSubagentState;
expect(sub.blocks.get('b-sub-2')?.toolResult).toBe('file contents');
expect(sub.blocks.get('b-sub-2')?.toolError).toBe(false);
});

it('SUBAGENT_END finalizes subagent state with summary and usage', () => {
Expand Down Expand Up @@ -294,10 +295,218 @@ describe('Subagent Reducer Actions', () => {

const newState = messagesReducer(state, action);

const subagent = newState.current?.blocks.get('b1')?.subagent;
expect(subagent?.running).toBeUndefined();
expect(Array.isArray(subagent?.blocks)).toBe(true);
expect(subagent?.summary).toBe('Search complete');
expect(subagent?.usage?.inputTokens).toBe(100);
const sub = newState.current?.blocks.get('b1')?.subagent as FinishedSubagentState;
expect(sub.running).toBeUndefined();
expect(Array.isArray(sub.blocks)).toBe(true);
expect(sub.summary).toBe('Search complete');
expect(sub.usage?.inputTokens).toBe(100);
});

it('finishCurrent converts already-finished subagent via passthrough', () => {
// Simulate full lifecycle: SUBAGENT_START → blocks → SUBAGENT_END → MESSAGE_END
let state = {
...INITIAL_MESSAGES_STATE,
current: {
messageId: 'msg-1',
blocks: new Map<string, StreamingBlock>([
[
'b1',
{
blockId: 'b1',
blockType: 'tool_use',
content: '',
done: true,
toolName: 'Agent',
toolId: 't1',
subagent: {
messageId: 'msg-sub-1',
blocks: new Map([
[
'b-sub-1',
{
blockId: 'b-sub-1',
blockType: 'text',
content: 'Done',
done: true,
},
],
]),
blockOrder: ['b-sub-1'],
running: true as const,
},
},
],
]),
blockOrder: ['b1'],
},
};

// End the subagent first
state = messagesReducer(state, {
type: 'SUBAGENT_END',
parentBlockId: 'b1',
summary: 'All done',
});

// Now finish the message — finishSubagent should passthrough the already-finished state
const finished = finishCurrent(state.current!);
const sub = finished.blocks[0].subagent as FinishedSubagentState;
expect(sub).toBeDefined();
expect(Array.isArray(sub.blocks)).toBe(true);
expect(sub.blocks).toHaveLength(1);
expect(sub.blocks[0].content).toBe('Done');
expect(sub.summary).toBe('All done');
});

it('finishCurrent converts still-streaming subagent to finished', () => {
// Edge case: MESSAGE_END arrives without SUBAGENT_END
const state = {
...INITIAL_MESSAGES_STATE,
current: {
messageId: 'msg-1',
blocks: new Map<string, StreamingBlock>([
[
'b1',
{
blockId: 'b1',
blockType: 'tool_use',
content: '',
done: true,
toolName: 'Agent',
toolId: 't1',
subagent: {
messageId: 'msg-sub-1',
blocks: new Map([
[
'b-sub-1',
{
blockId: 'b-sub-1',
blockType: 'text',
content: 'Partial',
done: false,
},
],
]),
blockOrder: ['b-sub-1'],
running: true as const,
},
},
],
]),
blockOrder: ['b1'],
},
};

// Finish message without SUBAGENT_END — finishSubagent must convert Map→array
const finished = finishCurrent(state.current!);
const sub = finished.blocks[0].subagent as FinishedSubagentState;
expect(sub).toBeDefined();
expect(Array.isArray(sub.blocks)).toBe(true);
expect(sub.blocks).toHaveLength(1);
expect(sub.blocks[0].content).toBe('Partial');
expect(sub.summary).toBeUndefined();
});

it('finishSubagent skips stale blockOrder entries not in the Map', () => {
const state = {
...INITIAL_MESSAGES_STATE,
current: {
messageId: 'msg-1',
blocks: new Map<string, StreamingBlock>([
[
'b1',
{
blockId: 'b1',
blockType: 'tool_use',
content: '',
done: true,
toolName: 'Agent',
toolId: 't1',
subagent: {
messageId: 'msg-sub-1',
// blockOrder references 'ghost' which is NOT in the Map
blocks: new Map([
[
'b-sub-1',
{
blockId: 'b-sub-1',
blockType: 'text',
content: 'Real',
done: true,
},
],
]),
blockOrder: ['b-sub-1', 'ghost'],
running: true as const,
},
},
],
]),
blockOrder: ['b1'],
},
};

// Should not throw — stale entry is filtered out
const finished = finishCurrent(state.current!);
const sub = finished.blocks[0].subagent as FinishedSubagentState;
expect(sub.blocks).toHaveLength(1);
expect(sub.blocks[0].blockId).toBe('b-sub-1');
});

it('drops streaming events after SUBAGENT_END (out-of-order delivery)', () => {
let state = {
...INITIAL_MESSAGES_STATE,
current: {
messageId: 'msg-1',
blocks: new Map<string, StreamingBlock>([
[
'b1',
{
blockId: 'b1',
blockType: 'tool_use',
content: '',
done: true,
toolName: 'Agent',
toolId: 't1',
subagent: {
messageId: 'msg-sub-1',
blocks: new Map([
[
'b-sub-1',
{
blockId: 'b-sub-1',
blockType: 'text',
content: 'Done',
done: true,
},
],
]),
blockOrder: ['b-sub-1'],
running: true as const,
},
},
],
]),
blockOrder: ['b1'],
},
};

// End the subagent
state = messagesReducer(state, {
type: 'SUBAGENT_END',
parentBlockId: 'b1',
summary: 'Complete',
});

// Late-arriving streaming event should be silently dropped
const afterDelta = messagesReducer(state, {
type: 'SUBAGENT_BLOCK_DELTA',
parentBlockId: 'b1',
blockId: 'b-sub-1',
delta: ' extra',
});

// State unchanged — event was dropped by getStreamingSubagent guard
expect(afterDelta).toBe(state);
});
});
Loading
Loading