Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-06-02 21:30:28 +00:00
143 changed files with 6730 additions and 6069 deletions

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest';
import type { RequestPermissionRequest, CreateElicitationRequest, SessionNotification } from '@agentclientprotocol/sdk';
import { buildAcpClient, type AcpTurnContext } from '../acp-client.js';
/**
* buildAcpClient (v2.7 audit reshape): the shared ACP `Client` closures. These
* tests cover the pure routing decisions that don't require the permission-waiter
* broker machinery — the auto-select/decline fallbacks and the between-turns drop.
*/
describe('buildAcpClient — sessionUpdate', () => {
it('drops the update when no turn is active (resolveTurn → null)', async () => {
const client = buildAcpClient('/wt', () => null);
// Must resolve without throwing and without an onSessionUpdate to call.
await expect(client.sessionUpdate({ sessionId: 's', update: {} } as unknown as SessionNotification)).resolves.toBeUndefined();
});
it('forwards the update to the active turn', async () => {
const onSessionUpdate = vi.fn();
const turn: AcpTurnContext = { taskId: 't', sessionId: 's', modeId: undefined, agent: 'goose', onSessionUpdate };
const client = buildAcpClient('/wt', () => turn);
const note = { sessionId: 's', update: {} } as unknown as SessionNotification;
await client.sessionUpdate(note);
expect(onSessionUpdate).toHaveBeenCalledWith(note);
});
});
describe('buildAcpClient — requestPermission fallback (no UI routing)', () => {
function req(options: Array<{ optionId: string }>): RequestPermissionRequest {
return { options } as unknown as RequestPermissionRequest;
}
it('auto-selects the first option when there is no turn', async () => {
const client = buildAcpClient('/wt', () => null);
const res = await client.requestPermission(req([{ optionId: 'allow' }, { optionId: 'deny' }]));
expect(res).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } });
});
it('cancels when there is no turn and no options', async () => {
const client = buildAcpClient('/wt', () => null);
const res = await client.requestPermission(req([]));
expect(res).toEqual({ outcome: { outcome: 'cancelled' } });
});
it('auto-selects when the turn has no taskId (UI routing gated off)', async () => {
const turn: AcpTurnContext = { taskId: undefined, sessionId: 's', modeId: undefined, agent: 'goose', onSessionUpdate: () => {} };
const client = buildAcpClient('/wt', () => turn);
const res = await client.requestPermission(req([{ optionId: 'ok' }]));
expect(res).toEqual({ outcome: { outcome: 'selected', optionId: 'ok' } });
});
});
describe('buildAcpClient — elicitation fallback', () => {
it('declines when there is no turn', async () => {
const client = buildAcpClient('/wt', () => null);
const res = await client.unstable_createElicitation!({} as CreateElicitationRequest);
expect(res).toEqual({ action: 'decline' });
});
it('declines when the turn has no taskId', async () => {
const turn: AcpTurnContext = { taskId: undefined, sessionId: 's', modeId: undefined, agent: 'goose', onSessionUpdate: () => {} };
const client = buildAcpClient('/wt', () => turn);
const res = await client.unstable_createElicitation!({} as CreateElicitationRequest);
expect(res).toEqual({ action: 'decline' });
});
});
describe('buildAcpClient — createTerminal', () => {
it('returns the noop terminal id', async () => {
const client = buildAcpClient('/wt', () => null);
const res = await client.createTerminal!({} as never);
expect(res).toEqual({ terminalId: 'noop' });
});
});

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import type { Broker } from '@boocode/server/broker';
import { makeFrameEmitter } from '../frame-emitter.js';
import { makeDcpStreamStripper } from '../dcp-strip.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
/**
* makeFrameEmitter (v2.7 audit reshape): the AgentEvent → WS-frame mapping + turn
* accumulators extracted from AcpStreamContext. Pure-ish over an injected broker.
*/
function fakeBroker(): { broker: Broker; frames: Array<{ sid: string; frame: Record<string, unknown> }> } {
const frames: Array<{ sid: string; frame: Record<string, unknown> }> = [];
const broker = {
publishFrame: (sid: string, frame: unknown) => {
frames.push({ sid, frame: frame as Record<string, unknown> });
},
} as unknown as Broker;
return { broker, frames };
}
const toolSnap: AcpToolSnapshot = { toolCallId: 'c1', title: 'grep', status: 'completed', rawOutput: 'x' };
describe('makeFrameEmitter — streaming frames', () => {
it('maps text/reasoning/tool events to delta/reasoning_delta/tool_call frames', () => {
const { broker, frames } = fakeBroker();
const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
em.onEvent({ type: 'text', text: 'hello ' });
em.onEvent({ type: 'reasoning', text: 'mulling' });
em.onEvent({ type: 'tool_call', toolCall: toolSnap });
expect(frames.map((f) => f.frame.type)).toEqual(['delta', 'reasoning_delta', 'tool_call']);
expect(frames[0]!.frame).toMatchObject({ message_id: 'm1', chat_id: 'ch1', content: 'hello ' });
expect(frames[2]!.frame).toMatchObject({ message_id: 'm1', chat_id: 'ch1' });
expect(em.output).toBe('hello ');
expect(em.reasoningText).toBe('mulling');
expect(em.snapshots).toHaveLength(1);
});
it('publishes a tool_call frame for BOTH tool_call and tool_update events', () => {
const { broker, frames } = fakeBroker();
const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
em.onEvent({ type: 'tool_update', toolCall: toolSnap });
expect(frames).toHaveLength(1);
expect(frames[0]!.frame.type).toBe('tool_call');
});
it('publishes an agent_commands frame and merges the command cache', () => {
const { broker, frames } = fakeBroker();
const taskId = `task-fe-${Math.floor(performance.now())}-${frames.length}`;
const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1', taskId });
em.onEvent({ type: 'commands', commands: [{ name: 'plan' }] });
expect(frames).toHaveLength(1);
expect(frames[0]!.frame).toMatchObject({ type: 'agent_commands', task_id: taskId, session_id: 's1' });
});
it('does not publish a commands frame without a taskId', () => {
const { broker, frames } = fakeBroker();
const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
em.onEvent({ type: 'commands', commands: [{ name: 'plan' }] });
expect(frames).toHaveLength(0);
});
});
describe('makeFrameEmitter — no broker (one-shot accumulation)', () => {
it('accumulates output/reasoning/snapshots but publishes nothing', () => {
const em = makeFrameEmitter({ sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
em.onEvent({ type: 'text', text: 'abc' });
em.onEvent({ type: 'reasoning', text: 'r' });
em.onEvent({ type: 'tool_call', toolCall: toolSnap });
expect(em.output).toBe('abc');
expect(em.reasoningText).toBe('r');
expect(em.snapshots).toHaveLength(1);
});
});
describe('makeFrameEmitter — dcp stripping (opencode path contract)', () => {
it('strips a split dcp tag across deltas and flushes the tail on finalize', () => {
const { broker, frames } = fakeBroker();
const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1', dcp: makeDcpStreamStripper() });
for (const chunk of ['Answer.', '<dcp', '-message', '-id>m1</dcp', '-message-id>', ' tail']) {
em.onEvent({ type: 'text', text: chunk });
}
em.finalize();
expect(em.output).toBe('Answer. tail');
const published = frames.filter((f) => f.frame.type === 'delta').map((f) => f.frame.content).join('');
expect(published).toBe('Answer. tail');
});
it('finalize is a no-op without a dcp stripper', () => {
const { broker, frames } = fakeBroker();
const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
em.onEvent({ type: 'text', text: 'raw <dcp-message-id>m</dcp-message-id>' });
em.finalize();
// No stripping without a stripper — verbatim text (prior ACP-path behavior).
expect(em.output).toBe('raw <dcp-message-id>m</dcp-message-id>');
expect(frames).toHaveLength(1);
});
});

View File

@@ -1,83 +0,0 @@
import { describe, it, expect } from 'vitest';
import { normalizeAgentEvent } from '../normalize-agent-status.js';
describe('normalizeAgentEvent', () => {
describe('working bucket', () => {
const cases = [
'SessionStart',
'UserPromptSubmit',
'UserPromptSubmitted',
'PostToolUse',
'PostToolUseFailure',
'BeforeAgent',
'AfterTool',
'task_started',
];
for (const name of cases) {
it(`maps ${name} → working`, () => {
expect(normalizeAgentEvent(name)).toBe('working');
});
}
});
describe('blocked bucket', () => {
const cases = [
'PreToolUse',
'Notification',
'PermissionRequest',
'exec_approval_request',
'apply_patch_approval_request',
'request_user_input',
];
for (const name of cases) {
it(`maps ${name} → blocked`, () => {
expect(normalizeAgentEvent(name)).toBe('blocked');
});
}
});
describe('done bucket', () => {
const cases = [
'Stop',
'AfterAgent',
'SessionEnd',
'task_complete',
'agent-turn-complete',
];
for (const name of cases) {
it(`maps ${name} → done`, () => {
expect(normalizeAgentEvent(name)).toBe('done');
});
}
});
describe('unknown / nullish → null', () => {
it('returns null for an unrecognized event', () => {
expect(normalizeAgentEvent('SomeRandomEvent')).toBeNull();
});
it('returns null for empty string', () => {
expect(normalizeAgentEvent('')).toBeNull();
});
it('returns null for undefined', () => {
expect(normalizeAgentEvent(undefined)).toBeNull();
});
});
describe('case- and separator-insensitive matching', () => {
it('matches snake_case spelling of a PascalCase event', () => {
expect(normalizeAgentEvent('session_start')).toBe('working');
expect(normalizeAgentEvent('post_tool_use')).toBe('working');
expect(normalizeAgentEvent('pre_tool_use')).toBe('blocked');
});
it('matches camelCase spelling', () => {
expect(normalizeAgentEvent('userPromptSubmitted')).toBe('working');
expect(normalizeAgentEvent('postToolUse')).toBe('working');
expect(normalizeAgentEvent('preToolUse')).toBe('blocked');
expect(normalizeAgentEvent('sessionEnd')).toBe('done');
});
it('matches arbitrary case', () => {
expect(normalizeAgentEvent('STOP')).toBe('done');
expect(normalizeAgentEvent('notification')).toBe('blocked');
});
});
});