Skip to content
Open
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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@
"predev": "node scripts/bootstrap-pilotdeck-config.mjs",
"dev": "node scripts/dev-launcher.mjs",
"test": "npm run build && node --test --test-force-exit --test-timeout 60000 \"dist/tests/**/*.test.js\"",
"test:vitest": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"e2e:real-agent-lifecycle-hooks": "npm run build && PILOTDECK_RUN_REAL_AGENT_LIFECYCLE_E2E=1 node dist/tests/agent/e2e/run-real-agent-lifecycle-hooks.js"
},
"devDependencies": {
"@types/node": "^25.0.0",
"@types/ws": "^8.18.1",
"ink-testing-library": "^4.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.7"
},
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.65.0",
Expand Down
55 changes: 55 additions & 0 deletions tests/adapters/channel/ChannelAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import type { ChannelAdapter, ChannelStartDeps } from '../../../src/adapters/channel/protocol/ChannelAdapter.js';
import type { GatewayChannelKey } from '../../../src/gateway/index.js';

describe('ChannelAdapter interface contract', () => {
it('defines valid channelKey property', () => {
// ChannelAdapter is an interface; verify the type contract
// by checking the GatewayChannelKey type is a string
const adapter: ChannelAdapter = {
channelKey: 'test' as GatewayChannelKey,
start: async (_deps: ChannelStartDeps) => ({
stop: async (_reason?: string) => {},
}),
};

expect(adapter.channelKey).toBe('test');
expect(typeof adapter.start).toBe('function');
});

it('ChannelStartDeps config is optional', () => {
const adapter: ChannelAdapter = {
channelKey: 'cli' as GatewayChannelKey,
start: async (deps: ChannelStartDeps) => ({
stop: async () => {},
}),
};

expect(adapter.start).toBeInstanceOf(Function);
});
});

describe('ChannelHandle contract', () => {
it('stop can be called with or without reason', async () => {
const adapter: ChannelAdapter = {
channelKey: 'test' as GatewayChannelKey,
start: async (_deps: ChannelStartDeps) => {
const handle = {
stop: async (reason?: string) => {
if (reason) {
// cleanup with reason
}
},
};
return handle;
},
};

const handle = await adapter.start({
gateway: {} as any,
});

await handle.stop();
await handle.stop('shutdown');
});
});
78 changes: 78 additions & 0 deletions tests/adapters/channel/WebhookSessionMapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { WebhookSessionMapper } from '../../../src/adapters/channel/webhook/WebhookSessionMapper.js';

describe('WebhookSessionMapper', () => {
it('creates a general session for first-time chats', () => {
const mapper = new WebhookSessionMapper(
{ activeByChatId: {} },
() => 'fixed-uuid',
);
const result = mapper.resolve({
chatId: 'chat_001',
text: 'Hello',
});
expect(result.sessionKey).toBe('webhook:chat=chat_001:general');
expect(result.message).toBe('Hello');
expect(result.command).toBeUndefined();
});

it('creates a new session on /new command', () => {
const mapper = new WebhookSessionMapper(
{ activeByChatId: {} },
() => 'new-session-uuid',
);
const result = mapper.resolve({
chatId: 'chat_002',
text: '/new',
});
expect(result.sessionKey).toBe('webhook:chat=chat_002:s_new-session-uuid');
expect(result.command).toBe('new');
expect(result.message).toBe('');
});

it('creates a new session with message on /new <text>', () => {
const mapper = new WebhookSessionMapper(
{ activeByChatId: {} },
() => 'session-abc',
);
const result = mapper.resolve({
chatId: 'chat_003',
text: '/new initialize project',
});
expect(result.sessionKey).toBe('webhook:chat=chat_003:s_session-abc');
expect(result.command).toBe('new');
expect(result.message).toBe('initialize project');
});

it('reuses an existing active session', () => {
const mapper = new WebhookSessionMapper(
{ activeByChatId: { chat_004: 'existing-session-key' } },
);
const result = mapper.resolve({
chatId: 'chat_004',
text: 'Second message',
});
expect(result.sessionKey).toBe('existing-session-key');
expect(result.message).toBe('Second message');
});

it('trims whitespace from messages', () => {
const mapper = new WebhookSessionMapper();
const result = mapper.resolve({
chatId: 'chat_005',
text: ' Hello World ',
});
expect(result.message).toBe('Hello World');
});

it('snapshot returns copy of state', () => {
const mapper = new WebhookSessionMapper(
{ activeByChatId: { chat_a: 'session_a' } },
);
const snapshot = mapper.snapshot();
expect(snapshot.activeByChatId).toEqual({ chat_a: 'session_a' });
// Mutating snapshot should not affect original
snapshot.activeByChatId.chat_b = 'session_b';
expect(mapper.snapshot().activeByChatId.chat_b).toBeUndefined();
});
});
82 changes: 82 additions & 0 deletions tests/agent/loop/collectToolCalls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { collectToolCalls } from '../../../src/agent/loop/collectToolCalls.js';
import type { CanonicalMessage } from '../../../src/model/index.js';

describe('collectToolCalls', () => {
it('extracts tool call blocks from an assistant message', () => {
const message: CanonicalMessage = {
role: 'assistant',
content: [
{ type: 'text', text: 'Let me search for that.' },
{
type: 'tool_call',
id: 'call_0001',
name: 'web_search',
input: { query: 'weather in Tokyo' },
},
{
type: 'tool_call',
id: 'call_0002',
name: 'read_file',
input: { path: '/tmp/test.txt' },
},
],
};

const calls = collectToolCalls(message);
expect(calls).toHaveLength(2);
expect(calls[0]!.id).toBe('call_0001');
expect(calls[0]!.name).toBe('web_search');
expect(calls[1]!.id).toBe('call_0002');
expect(calls[1]!.name).toBe('read_file');
});

it('returns empty array for messages with no tool calls', () => {
const message: CanonicalMessage = {
role: 'assistant',
content: [
{ type: 'text', text: 'Hello!' },
],
};

const calls = collectToolCalls(message);
expect(calls).toHaveLength(0);
});

it('returns empty array for empty content', () => {
const message: CanonicalMessage = {
role: 'assistant',
content: [],
};

const calls = collectToolCalls(message);
expect(calls).toHaveLength(0);
});

it('handles tool calls with complex input objects', () => {
const message: CanonicalMessage = {
role: 'assistant',
content: [
{
type: 'tool_call',
id: 'call_0003',
name: 'edit_file',
input: {
path: '/tmp/file.txt',
operations: [
{ type: 'insert', position: 5, text: 'new content' },
],
},
},
],
};

const calls = collectToolCalls(message);
expect(calls).toHaveLength(1);
expect(calls[0]!.name).toBe('edit_file');
expect(calls[0]!.input).toEqual({
path: '/tmp/file.txt',
operations: [{ type: 'insert', position: 5, text: 'new content' }],
});
});
});
82 changes: 82 additions & 0 deletions tests/agent/loop/ensureToolResultPairing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import {
ensureToolResultPairing,
createMissingToolResult,
} from '../../../src/agent/loop/ensureToolResultPairing.js';
import type { CanonicalToolCall } from '../../../src/model/index.js';

describe('ensureToolResultPairing', () => {
const makeToolCall = (overrides: Partial<CanonicalToolCall> = {}): CanonicalToolCall => ({
id: 'call_1234',
name: 'test_tool',
input: { query: 'hello' },
...overrides,
});

it('pairs successful tool calls with results', () => {
const calls = [makeToolCall({ id: 'call_0001' })];
const results = [{
type: 'success' as const,
toolCallId: 'call_0001',
toolName: 'test_tool',
data: 'hello world',
}];

const paired = ensureToolResultPairing(calls, results, () => new Date());
expect(paired).toHaveLength(1);
expect(paired[0]!.type).toBe('success');
expect(paired[0]!.toolCallId).toBe('call_0001');
});

it('pairs error results with tool calls', () => {
const calls = [makeToolCall({ id: 'call_0002' })];
const results = [{
type: 'error' as const,
toolCallId: 'call_0002',
toolName: 'test_tool',
error: { code: 'runtime_error', message: 'Something went wrong' },
isError: true,
}];

const paired = ensureToolResultPairing(calls, results, () => new Date());
expect(paired).toHaveLength(1);
expect(paired[0]!.type).toBe('error');
expect(paired[0]!.toolCallId).toBe('call_0002');
});

it('creates missing results for calls without corresponding results', () => {
const calls = [
makeToolCall({ id: 'call_0003' }),
makeToolCall({ id: 'call_0004' }),
];
const results = [{
type: 'success' as const,
toolCallId: 'call_0003',
toolName: 'test_tool',
data: 'result',
}];

const paired = ensureToolResultPairing(calls, results, () => new Date());
expect(paired).toHaveLength(2);
expect(paired[0]!.toolCallId).toBe('call_0003');
expect(paired[1]!.toolCallId).toBe('call_0004');
expect(paired[1]!.type).toBe('error');
});
});

describe('createMissingToolResult', () => {
it('creates an error result for a missing tool call', () => {
const toolCall: CanonicalToolCall = {
id: 'call_0001',
name: 'test_tool',
input: { query: 'test' },
};
const now = () => new Date('2025-01-01T00:00:00Z');
const result = createMissingToolResult(toolCall, now, 'Execution failed');
expect(result.type).toBe('error');
expect(result.toolCallId).toBe('call_0001');
expect(result.toolName).toBe('test_tool');
expect(result.error.code).toBe('runtime_error');
expect(result.error.message).toBe('Execution failed');
});
});
Loading