Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
584 lines
16 KiB
TypeScript
584 lines
16 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { formatSdkAgentErrorForDisplay, runSdkAgentTurn, steerSdkAgentTurn } from './sdkAgentAdapter';
|
|
import type { SdkAgentCallbacks } from './sdkAgentAdapter';
|
|
import type { ExternalAgentConfig } from './types';
|
|
|
|
function createCallbacks(errors: string[]): SdkAgentCallbacks {
|
|
return {
|
|
onTextDelta: () => {},
|
|
onThinkingDelta: () => {},
|
|
onThinkingDone: () => {},
|
|
onToolCall: () => {},
|
|
onToolResult: () => {},
|
|
onError: (error) => errors.push(error),
|
|
onDone: () => {},
|
|
};
|
|
}
|
|
|
|
const sdkConfig: ExternalAgentConfig = {
|
|
id: 'agent',
|
|
name: 'Agent',
|
|
command: 'agent',
|
|
enabled: true,
|
|
sdkBackend: 'codex',
|
|
};
|
|
|
|
test('steerSdkAgentTurn forwards the active request and preserves typed outcomes', async () => {
|
|
const calls: unknown[][] = [];
|
|
const result = await steerSdkAgentTurn({
|
|
aiSdkAgentSteer: async (...args: unknown[]) => {
|
|
calls.push(args);
|
|
return { status: 'not-steerable', turnKind: 'review' };
|
|
},
|
|
}, 'request-1', 'chat-1', 'change direction', [{
|
|
base64Data: 'aGVsbG8=',
|
|
mediaType: 'image/png',
|
|
filename: 'image.png',
|
|
}], 'user-1');
|
|
|
|
assert.deepEqual(result, { status: 'not-steerable', turnKind: 'review' });
|
|
assert.deepEqual(calls[0], [
|
|
'request-1',
|
|
'chat-1',
|
|
'change direction',
|
|
[{ base64Data: 'aGVsbG8=', mediaType: 'image/png', filename: 'image.png' }],
|
|
'user-1',
|
|
]);
|
|
assert.deepEqual(await steerSdkAgentTurn({}, 'request-1', 'chat-1', 'text', undefined, 'user-1'), {
|
|
status: 'unsupported',
|
|
});
|
|
});
|
|
|
|
test('formatSdkAgentErrorForDisplay preserves nested SDK agent error messages', () => {
|
|
assert.equal(
|
|
formatSdkAgentErrorForDisplay({
|
|
error: {
|
|
code: 'invalid_model',
|
|
message: 'Model is not available',
|
|
},
|
|
}),
|
|
'Model is not available',
|
|
);
|
|
});
|
|
|
|
test('formatSdkAgentErrorForDisplay stringifies unknown objects instead of [object Object]', () => {
|
|
assert.equal(
|
|
formatSdkAgentErrorForDisplay({ status: 502, detail: 'Proxy failed' }),
|
|
'{"status":502,"detail":"Proxy failed"}',
|
|
);
|
|
});
|
|
|
|
test('formatSdkAgentErrorForDisplay handles circular errors', () => {
|
|
const error: Record<string, unknown> = { status: 500 };
|
|
error.self = error;
|
|
|
|
assert.equal(
|
|
formatSdkAgentErrorForDisplay(error),
|
|
'{"status":500,"self":"[Circular]"}',
|
|
);
|
|
});
|
|
|
|
test('runSdkAgentTurn formats structured startup errors', async () => {
|
|
const errors: string[] = [];
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async () => ({
|
|
ok: false,
|
|
error: {
|
|
error: {
|
|
code: 'invalid_model',
|
|
message: 'Model is not available',
|
|
},
|
|
},
|
|
}),
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: () => () => {},
|
|
onAiSdkAgentDone: () => () => {},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-1',
|
|
'chat-1',
|
|
sdkConfig,
|
|
'hello',
|
|
createCallbacks(errors),
|
|
);
|
|
|
|
assert.deepEqual(errors, ['Model is not available']);
|
|
});
|
|
|
|
test('runSdkAgentTurn forwards configured SDK agent environment', async () => {
|
|
let streamArgs: unknown[] = [];
|
|
let done: (() => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async (...args: unknown[]) => {
|
|
streamArgs = args;
|
|
queueMicrotask(() => done?.());
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: () => () => {},
|
|
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
|
done = cb as () => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-env',
|
|
'chat-env',
|
|
{
|
|
...sdkConfig,
|
|
env: { CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude' },
|
|
},
|
|
'hello',
|
|
createCallbacks([]),
|
|
);
|
|
|
|
assert.deepEqual(streamArgs[13], {
|
|
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
|
});
|
|
assert.equal(streamArgs[2], 'codex');
|
|
});
|
|
|
|
test('runSdkAgentTurn forwards the configured agent command path', async () => {
|
|
let streamArgs: unknown[] = [];
|
|
let done: (() => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async (...args: unknown[]) => {
|
|
streamArgs = args;
|
|
queueMicrotask(() => done?.());
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: () => () => {},
|
|
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
|
done = cb as () => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-command',
|
|
'chat-command',
|
|
{
|
|
...sdkConfig,
|
|
command: '/opt/homebrew/bin/codex',
|
|
commandSource: 'manual',
|
|
},
|
|
'hello',
|
|
createCallbacks([]),
|
|
);
|
|
|
|
assert.equal(streamArgs[14], '/opt/homebrew/bin/codex');
|
|
});
|
|
|
|
test('runSdkAgentTurn does not forward auto-detected command paths', async () => {
|
|
let streamArgs: unknown[] = [];
|
|
let done: (() => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async (...args: unknown[]) => {
|
|
streamArgs = args;
|
|
queueMicrotask(() => done?.());
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: () => () => {},
|
|
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
|
done = cb as () => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-auto-command',
|
|
'chat-auto-command',
|
|
{
|
|
...sdkConfig,
|
|
command: '/opt/homebrew/bin/codex',
|
|
commandSource: 'auto',
|
|
},
|
|
'hello',
|
|
createCallbacks([]),
|
|
);
|
|
|
|
assert.equal(streamArgs[14], undefined);
|
|
});
|
|
|
|
test('runSdkAgentTurn stores SDK session ids with backend and path metadata', async () => {
|
|
const sessionIds: string[] = [];
|
|
let onEvent: ((event: unknown) => void) | null = null;
|
|
let done: (() => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async () => {
|
|
queueMicrotask(() => {
|
|
onEvent?.({
|
|
type: 'session-id',
|
|
sessionId: 'thread-1',
|
|
sdkBackend: 'codex',
|
|
binPath: '/opt/homebrew/bin/codex',
|
|
});
|
|
done?.();
|
|
});
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: (_requestId: unknown, cb: unknown) => {
|
|
onEvent = cb as (event: unknown) => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
|
done = cb as () => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-session-metadata',
|
|
'chat-session-metadata',
|
|
sdkConfig,
|
|
'hello',
|
|
{
|
|
...createCallbacks([]),
|
|
onSessionId: (sessionId) => sessionIds.push(sessionId),
|
|
},
|
|
);
|
|
|
|
assert.equal(sessionIds.length, 1);
|
|
assert.match(sessionIds[0], /^netcatty-sdk-session:/);
|
|
const payload = JSON.parse(decodeURIComponent(sessionIds[0].replace(/^netcatty-sdk-session:/, '')));
|
|
assert.deepEqual(payload, {
|
|
v: 1,
|
|
id: 'thread-1',
|
|
backend: 'codex',
|
|
binPath: '/opt/homebrew/bin/codex',
|
|
runtime: 'sdk',
|
|
});
|
|
});
|
|
|
|
test('runSdkAgentTurn forwards Cursor API key as agent environment', async () => {
|
|
let streamArgs: unknown[] = [];
|
|
let done: (() => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async (...args: unknown[]) => {
|
|
streamArgs = args;
|
|
queueMicrotask(() => done?.());
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: () => () => {},
|
|
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
|
done = cb as () => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-cursor-key',
|
|
'chat-cursor-key',
|
|
{
|
|
id: 'cursor',
|
|
name: 'Cursor',
|
|
command: 'cursor',
|
|
enabled: true,
|
|
sdkBackend: 'cursor',
|
|
apiKey: 'cur-test-key',
|
|
},
|
|
'hello',
|
|
createCallbacks([]),
|
|
);
|
|
|
|
assert.deepEqual(streamArgs[13], {
|
|
NETCATTY_CURSOR_AUTH_MODE: 'api-key',
|
|
CURSOR_API_KEY: 'cur-test-key',
|
|
});
|
|
assert.equal(streamArgs[2], 'cursor');
|
|
});
|
|
|
|
test('runSdkAgentTurn in cursor cli-login mode does not inject CURSOR_API_KEY', async () => {
|
|
let streamArgs: unknown[] = [];
|
|
let done: (() => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async (...args: unknown[]) => {
|
|
streamArgs = args;
|
|
queueMicrotask(() => done?.());
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: () => () => {},
|
|
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
|
done = cb as () => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-cursor-cli',
|
|
'chat-cursor-cli',
|
|
{
|
|
id: 'cursor',
|
|
name: 'Cursor',
|
|
command: '/Users/me/.local/bin/agent',
|
|
enabled: true,
|
|
sdkBackend: 'cursor',
|
|
cursorAuthMode: 'cli-login',
|
|
apiKey: 'should-not-inject',
|
|
},
|
|
'hello',
|
|
createCallbacks([]),
|
|
);
|
|
|
|
assert.deepEqual(streamArgs[13], {
|
|
NETCATTY_CURSOR_AUTH_MODE: 'cli-login',
|
|
NETCATTY_CURSOR_CLI_BIN: '/Users/me/.local/bin/agent',
|
|
});
|
|
});
|
|
|
|
|
|
test('runSdkAgentTurn formats structured async error events', async () => {
|
|
const errors: string[] = [];
|
|
let onError: ((error: unknown) => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async () => {
|
|
queueMicrotask(() => {
|
|
onError?.({
|
|
data: {
|
|
error: {
|
|
message: 'Proxy failed',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: () => () => {},
|
|
onAiSdkAgentDone: () => () => {},
|
|
onAiSdkAgentError: (_requestId: unknown, cb: unknown) => {
|
|
onError = cb as (error: unknown) => void;
|
|
return () => {};
|
|
},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-2',
|
|
'chat-1',
|
|
sdkConfig,
|
|
'hello',
|
|
createCallbacks(errors),
|
|
);
|
|
|
|
assert.deepEqual(errors, ['Proxy failed']);
|
|
});
|
|
|
|
test('runSdkAgentTurn formats structured stream error events', async () => {
|
|
const errors: string[] = [];
|
|
let onEvent: ((event: unknown) => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async () => {
|
|
queueMicrotask(() => {
|
|
onEvent?.({
|
|
type: 'error',
|
|
error: {
|
|
error: {
|
|
message: 'Stream failed',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: (_requestId: unknown, cb: unknown) => {
|
|
onEvent = cb as (event: unknown) => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentDone: () => () => {},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-3',
|
|
'chat-1',
|
|
sdkConfig,
|
|
'hello',
|
|
createCallbacks(errors),
|
|
);
|
|
|
|
assert.deepEqual(errors, ['Stream failed']);
|
|
});
|
|
|
|
test('runSdkAgentTurn forwards Codex activities and usage without treating warnings as fatal', async () => {
|
|
const activities: unknown[] = [];
|
|
const usages: unknown[] = [];
|
|
const errors: string[] = [];
|
|
let onEvent: ((event: unknown) => void) | null = null;
|
|
let done: (() => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async () => {
|
|
queueMicrotask(() => {
|
|
onEvent?.({
|
|
type: 'file-change',
|
|
itemId: 'patch-1',
|
|
status: 'completed',
|
|
changes: [{ path: 'src/app.ts', kind: 'update' }],
|
|
});
|
|
onEvent?.({
|
|
type: 'web-search',
|
|
itemId: 'search-1',
|
|
query: 'Codex SDK events',
|
|
status: 'completed',
|
|
});
|
|
onEvent?.({
|
|
type: 'plan-update',
|
|
itemId: 'plan-1',
|
|
items: [{ text: 'Map events', completed: true }],
|
|
status: 'completed',
|
|
});
|
|
onEvent?.({ type: 'warning', itemId: 'warning-1', message: 'Search result unavailable' });
|
|
onEvent?.({
|
|
type: 'usage',
|
|
inputTokens: 100,
|
|
cachedInputTokens: 40,
|
|
outputTokens: 25,
|
|
reasoningTokens: 10,
|
|
totalTokens: 125,
|
|
});
|
|
done?.();
|
|
});
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: (_requestId: unknown, cb: unknown) => {
|
|
onEvent = cb as (event: unknown) => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
|
done = cb as () => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-events',
|
|
'chat-events',
|
|
sdkConfig,
|
|
'hello',
|
|
{
|
|
...createCallbacks(errors),
|
|
onFileChange: (activity) => activities.push(activity),
|
|
onWebSearch: (activity) => activities.push(activity),
|
|
onPlanUpdate: (activity) => activities.push(activity),
|
|
onWarning: (activity) => activities.push(activity),
|
|
onUsage: (usage) => usages.push(usage),
|
|
},
|
|
);
|
|
|
|
assert.deepEqual((activities as Array<{ type: string }>).map((activity) => activity.type), [
|
|
'file_change',
|
|
'web_search',
|
|
'plan_update',
|
|
'warning',
|
|
]);
|
|
assert.deepEqual(usages, [{
|
|
inputTokens: 100,
|
|
cachedInputTokens: 40,
|
|
outputTokens: 25,
|
|
reasoningTokens: 10,
|
|
totalTokens: 125,
|
|
estimated: false,
|
|
}]);
|
|
assert.deepEqual(errors, []);
|
|
});
|
|
|
|
test('runSdkAgentTurn forwards CodeBuddy options, hooks, and elicitation events', async () => {
|
|
const errors: string[] = [];
|
|
const hooks: unknown[] = [];
|
|
const elicitations: unknown[] = [];
|
|
let streamArgs: unknown[] = [];
|
|
let onEvent: ((event: unknown) => void) | null = null;
|
|
let done: (() => void) | null = null;
|
|
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
|
aiSdkAgentStream: async (...args: unknown[]) => {
|
|
streamArgs = args;
|
|
queueMicrotask(() => {
|
|
onEvent?.({ type: 'hook', hookEvent: 'PreToolUse', toolName: 'Bash' });
|
|
onEvent?.({
|
|
type: 'elicitation-create',
|
|
elicitationId: 'el-1',
|
|
request: { message: 'Confirm?' },
|
|
});
|
|
onEvent?.({
|
|
type: 'elicitation-complete',
|
|
notification: { elicitationId: 'el-1' },
|
|
});
|
|
done?.();
|
|
});
|
|
return { ok: true };
|
|
},
|
|
aiSdkAgentCancel: async () => ({ ok: true }),
|
|
onAiSdkAgentEvent: (_requestId: unknown, cb: unknown) => {
|
|
onEvent = cb as (event: unknown) => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
|
done = cb as () => void;
|
|
return () => {};
|
|
},
|
|
onAiSdkAgentError: () => () => {},
|
|
};
|
|
const codebuddyConfig: ExternalAgentConfig = {
|
|
id: 'codebuddy',
|
|
name: 'CodeBuddy',
|
|
command: 'codebuddy',
|
|
enabled: true,
|
|
sdkBackend: 'codebuddy',
|
|
codebuddyOptions: { effort: 'high' },
|
|
};
|
|
|
|
await runSdkAgentTurn(
|
|
bridge,
|
|
'request-codebuddy',
|
|
'chat-codebuddy',
|
|
codebuddyConfig,
|
|
'hello',
|
|
{
|
|
...createCallbacks(errors),
|
|
onHook: (hookEvent, payload) => hooks.push({ hookEvent, payload }),
|
|
onElicitationCreate: (elicitationId, request) => {
|
|
elicitations.push({ elicitationId, request });
|
|
},
|
|
onElicitationComplete: (notification) => {
|
|
elicitations.push({ notification });
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(streamArgs[17], { effort: 'high' });
|
|
assert.equal((hooks[0] as { hookEvent: string }).hookEvent, 'PreToolUse');
|
|
assert.deepEqual(elicitations, [
|
|
{ elicitationId: 'el-1', request: { message: 'Confirm?' } },
|
|
{ notification: { elicitationId: 'el-1' } },
|
|
]);
|
|
assert.deepEqual(errors, []);
|
|
});
|