Files
NetMesh/infrastructure/ai/harness/turnDrivers/externalSdkTurnDriver.test.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

492 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import type { AISession, AgentActivity, ChatMessage, ExternalAgentConfig } from '../../types';
import { SessionStateStore } from '../sessionState';
import { ToolOutputStore } from '../toolOutputStore';
import { ToolResultDedup } from '../toolResultDedup';
import { externalSdkTurnDriver } from './externalSdkTurnDriver';
import { resolveEstimatedUsageFallback, upsertAgentActivity } from './externalSdkEventState';
import type { TurnUiCallbacks } from './types';
async function waitFor(predicate: () => boolean): Promise<void> {
for (let index = 0; index < 50; index += 1) {
if (predicate()) return;
await new Promise<void>(resolve => setImmediate(resolve));
}
throw new Error('condition not reached');
}
test('upsertAgentActivity replaces streaming updates by stable item id', () => {
const running: AgentActivity = {
id: 'plan-1',
type: 'plan_update',
status: 'running',
items: [{ text: 'Map events', completed: false }],
};
const completed: AgentActivity = {
...running,
status: 'completed',
items: [{ text: 'Map events', completed: true }],
};
const first = upsertAgentActivity(undefined, running);
const second = upsertAgentActivity(first, completed);
assert.equal(second.length, 1);
assert.deepEqual(second[0], completed);
});
test('upsertAgentActivity preserves the order of unrelated activities', () => {
const search: AgentActivity = {
id: 'search-1',
type: 'web_search',
status: 'completed',
query: 'Codex events',
};
const warning: AgentActivity = {
id: 'warning-1',
type: 'warning',
status: 'completed',
message: 'Search result unavailable',
};
assert.deepEqual(upsertAgentActivity([search], warning), [search, warning]);
});
test('estimated usage is used only when the SDK did not report actual usage', () => {
assert.deepEqual(resolveEstimatedUsageFallback('12345678', false), {
inputTokens: 2,
outputTokens: 0,
totalTokens: 2,
estimated: true,
});
assert.equal(resolveEstimatedUsageFallback('12345678', true), null);
});
test('plan updates replace the same activity across tool message boundaries', async () => {
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
let onDone: (() => void) | undefined;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
onAiSdkAgentEvent: (_requestId, callback) => {
onEvent = callback as (event: Record<string, unknown>) => void;
return () => {};
},
onAiSdkAgentDone: (_requestId, callback) => {
onDone = callback as () => void;
return () => {};
},
onAiSdkAgentError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentStream: async () => {
queueMicrotask(() => {
onEvent?.({
type: 'plan-update',
itemId: 'plan-1',
status: 'running',
items: [{ text: 'Run command', completed: false }],
});
onEvent?.({
type: 'tool-call',
toolName: 'shell',
toolCallId: 'tool-1',
args: { command: 'true' },
});
onEvent?.({
type: 'tool-result',
toolName: 'shell',
toolCallId: 'tool-1',
output: 'ok',
});
onEvent?.({
type: 'plan-update',
itemId: 'plan-1',
status: 'completed',
items: [{ text: 'Run command', completed: true }],
});
onDone?.();
});
return { ok: true };
},
};
const session: AISession = {
id: 'chat-1',
title: 'Test',
agentId: 'codex',
scope: { type: 'global' },
messages: [{ id: 'assistant-1', role: 'assistant', content: '', timestamp: 1 }],
createdAt: 1,
updatedAt: 1,
};
const ui: TurnUiCallbacks = {
addMessageToSession: (_sessionId, message) => {
session.messages.push(message);
},
updateLastMessage: (_sessionId, updater) => {
const lastIndex = session.messages.length - 1;
session.messages[lastIndex] = updater(session.messages[lastIndex]);
},
updateMessageById: (_sessionId, messageId, updater) => {
const messageIndex = session.messages.findIndex((message) => message.id === messageId);
if (messageIndex >= 0) {
session.messages[messageIndex] = updater(session.messages[messageIndex]);
}
},
reportStreamError: () => {},
setStreamingForScope: () => {},
getLatestSession: () => session,
};
const agentConfig: ExternalAgentConfig = {
id: 'codex',
name: 'Codex',
command: 'codex',
enabled: true,
sdkBackend: 'codex',
};
const controller = new AbortController();
await externalSdkTurnDriver.run({
backend: 'external-sdk',
chatSessionId: session.id,
assistantMsgId: 'assistant-1',
userText: 'Run the plan',
signal: controller.signal,
agentConfig,
attachedImages: [],
context: {
terminalSessions: [],
providers: [],
toolIntegrationMode: 'mcp',
selectedUserSkillSlugs: [],
permissionMode: 'confirm',
},
bridge,
ui,
}, {
turnId: 'turn-1',
chatSessionId: session.id,
sessionId: session.id,
backend: 'external-sdk',
signal: controller.signal,
emit: () => {},
toolOutputStore: new ToolOutputStore(),
toolResultDedup: new ToolResultDedup(),
sessionStateStore: new SessionStateStore(),
});
const planActivities = session.messages
.flatMap((message: ChatMessage) => message.agentActivities ?? [])
.filter((activity) => activity.id === 'plan-1');
assert.equal(planActivities.length, 1);
assert.equal(planActivities[0].status, 'completed');
assert.deepEqual(planActivities[0].type === 'plan_update' ? planActivities[0].items : [], [
{ text: 'Run command', completed: true },
]);
});
test('accepted steering persists a user bubble and routes buffered output to a continuation', async () => {
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
let onDone: (() => void) | undefined;
let releaseSteer: (() => void) | undefined;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
onAiSdkAgentEvent: (_requestId, callback) => {
onEvent = callback as (event: Record<string, unknown>) => void;
return () => {};
},
onAiSdkAgentDone: (_requestId, callback) => {
onDone = callback as () => void;
return () => {};
},
onAiSdkAgentError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentStream: async () => ({ ok: true }),
aiSdkAgentSteer: async () => {
onEvent?.({ type: 'text-delta', textDelta: 'after steer' });
await new Promise<void>(resolve => { releaseSteer = resolve; });
return { status: 'accepted' };
},
};
const session: AISession = {
id: 'chat-steer',
title: 'Steer',
agentId: 'codex',
scope: { type: 'global' },
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
createdAt: 1,
updatedAt: 1,
};
const ui: TurnUiCallbacks = {
addMessageToSession: (_sessionId, message) => session.messages.push(message),
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
updateMessageById: (_sessionId, messageId, updater) => {
const index = session.messages.findIndex(message => message.id === messageId);
if (index >= 0) session.messages[index] = updater(session.messages[index]);
},
reportStreamError: () => {},
setStreamingForScope: () => {},
getLatestSession: () => session,
};
const controller = new AbortController();
const run = externalSdkTurnDriver.run({
backend: 'external-sdk',
chatSessionId: session.id,
assistantMsgId: 'assistant-initial',
userText: 'initial prompt',
signal: controller.signal,
agentConfig: {
id: 'codex',
name: 'Codex',
command: 'codex',
enabled: true,
sdkBackend: 'codex',
codexRuntime: 'app-server',
},
attachedImages: [],
context: {
terminalSessions: [],
providers: [],
toolIntegrationMode: 'mcp',
selectedUserSkillSlugs: [],
permissionMode: 'confirm',
},
bridge,
ui,
}, {
turnId: 'turn-steer',
chatSessionId: session.id,
sessionId: session.id,
backend: 'external-sdk',
signal: controller.signal,
emit: () => {},
toolOutputStore: new ToolOutputStore(),
toolResultDedup: new ToolResultDedup(),
sessionStateStore: new SessionStateStore(),
});
await waitFor(() => Boolean(onEvent));
onEvent?.({ type: 'text-delta', textDelta: 'before steer' });
const steer = externalSdkTurnDriver.steer({
chatSessionId: session.id,
userMessageId: 'user-steer',
userText: 'change direction',
prompt: 'change direction',
attachments: [],
attachedImages: [],
});
await waitFor(() => Boolean(releaseSteer));
assert.equal(session.messages.length, 1);
releaseSteer?.();
const result = await steer;
assert.equal(result.status, 'accepted');
assert.deepEqual(session.messages.map(message => message.role), ['assistant', 'user', 'assistant']);
assert.equal(session.messages[0].content, 'before steer');
assert.equal(session.messages[1].content, 'change direction');
assert.equal(session.messages[2].content, 'after steer');
onDone?.();
await run;
});
test('accepted steering keeps buffered results for existing tool calls on their original assistant message', async () => {
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
let onDone: (() => void) | undefined;
let releaseSteer: (() => void) | undefined;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
onAiSdkAgentEvent: (_requestId, callback) => {
onEvent = callback as (event: Record<string, unknown>) => void;
return () => {};
},
onAiSdkAgentDone: (_requestId, callback) => {
onDone = callback as () => void;
return () => {};
},
onAiSdkAgentError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentStream: async () => ({ ok: true }),
aiSdkAgentSteer: async () => {
onEvent?.({
type: 'tool-result',
toolName: 'shell',
toolCallId: 'tool-before-steer',
output: 'completed before steer acceptance',
});
await new Promise<void>(resolve => { releaseSteer = resolve; });
return { status: 'accepted' };
},
};
const session: AISession = {
id: 'chat-steer-tool-result',
title: 'Steer tool result',
agentId: 'codex',
scope: { type: 'global' },
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
createdAt: 1,
updatedAt: 1,
};
const ui: TurnUiCallbacks = {
addMessageToSession: (_sessionId, message) => session.messages.push(message),
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
updateMessageById: (_sessionId, messageId, updater) => {
const index = session.messages.findIndex(message => message.id === messageId);
if (index >= 0) session.messages[index] = updater(session.messages[index]);
},
reportStreamError: () => {},
setStreamingForScope: () => {},
getLatestSession: () => session,
};
const controller = new AbortController();
const run = externalSdkTurnDriver.run({
backend: 'external-sdk',
chatSessionId: session.id,
assistantMsgId: 'assistant-initial',
userText: 'initial prompt',
signal: controller.signal,
agentConfig: {
id: 'codex', name: 'Codex', command: 'codex', enabled: true,
sdkBackend: 'codex', codexRuntime: 'app-server',
},
attachedImages: [],
context: {
terminalSessions: [], providers: [], toolIntegrationMode: 'mcp',
selectedUserSkillSlugs: [], permissionMode: 'confirm',
},
bridge,
ui,
}, {
turnId: 'turn-steer-tool-result', chatSessionId: session.id, sessionId: session.id,
backend: 'external-sdk', signal: controller.signal, emit: () => {},
toolOutputStore: new ToolOutputStore(), toolResultDedup: new ToolResultDedup(),
sessionStateStore: new SessionStateStore(),
});
await waitFor(() => Boolean(onEvent));
onEvent?.({
type: 'tool-call',
toolName: 'shell',
toolCallId: 'tool-before-steer',
args: { command: 'sleep 1' },
});
assert.equal(session.messages[0].executionStatus, 'running');
const steer = externalSdkTurnDriver.steer({
chatSessionId: session.id,
userMessageId: 'user-steer-tool-result',
userText: 'change direction',
prompt: 'change direction',
attachments: [],
attachedImages: [],
});
await waitFor(() => Boolean(releaseSteer));
assert.equal(session.messages.length, 1);
assert.equal(session.messages[0].executionStatus, 'running');
releaseSteer?.();
const result = await steer;
assert.equal(result.status, 'accepted');
assert.deepEqual(
session.messages.map(message => message.role),
['assistant', 'tool', 'user', 'assistant'],
);
assert.equal(session.messages[0].executionStatus, 'completed');
assert.equal(session.messages[0].toolCalls?.[0]?.id, 'tool-before-steer');
assert.equal(session.messages[1].toolResults?.[0]?.toolCallId, 'tool-before-steer');
assert.equal(session.messages[2].content, 'change direction');
assert.equal(session.messages[3].content, '');
onEvent?.({ type: 'text-delta', textDelta: 'continued after steer' });
// Text deltas are rAF-batched like Catty; wait one frame before asserting.
await new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve());
return;
}
setTimeout(resolve, 0);
});
assert.equal(session.messages[3].content, 'continued after steer');
onDone?.();
await run;
});
test('failed steering keeps buffered output on the original assistant message', async () => {
let onEvent: ((event: Record<string, unknown>) => void) | undefined;
let onDone: (() => void) | undefined;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
onAiSdkAgentEvent: (_requestId, callback) => {
onEvent = callback as (event: Record<string, unknown>) => void;
return () => {};
},
onAiSdkAgentDone: (_requestId, callback) => {
onDone = callback as () => void;
return () => {};
},
onAiSdkAgentError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentStream: async () => ({ ok: true }),
aiSdkAgentSteer: async () => {
onEvent?.({ type: 'text-delta', textDelta: 'kept output' });
return { status: 'not-steerable', turnKind: 'compact' };
},
};
const session: AISession = {
id: 'chat-steer-failed',
title: 'Steer failed',
agentId: 'codex',
scope: { type: 'global' },
messages: [{ id: 'assistant-initial', role: 'assistant', content: '', timestamp: 1 }],
createdAt: 1,
updatedAt: 1,
};
const ui: TurnUiCallbacks = {
addMessageToSession: (_sessionId, message) => session.messages.push(message),
updateLastMessage: () => { throw new Error('steering must not update the last message implicitly'); },
updateMessageById: (_sessionId, messageId, updater) => {
const index = session.messages.findIndex(message => message.id === messageId);
if (index >= 0) session.messages[index] = updater(session.messages[index]);
},
reportStreamError: () => {},
setStreamingForScope: () => {},
};
const controller = new AbortController();
const run = externalSdkTurnDriver.run({
backend: 'external-sdk',
chatSessionId: session.id,
assistantMsgId: 'assistant-initial',
userText: 'initial prompt',
signal: controller.signal,
agentConfig: {
id: 'codex', name: 'Codex', command: 'codex', enabled: true,
sdkBackend: 'codex', codexRuntime: 'app-server',
},
attachedImages: [],
context: {
terminalSessions: [], providers: [], toolIntegrationMode: 'mcp',
selectedUserSkillSlugs: [], permissionMode: 'confirm',
},
bridge,
ui,
}, {
turnId: 'turn-steer-failed', chatSessionId: session.id, sessionId: session.id,
backend: 'external-sdk', signal: controller.signal, emit: () => {},
toolOutputStore: new ToolOutputStore(), toolResultDedup: new ToolResultDedup(),
sessionStateStore: new SessionStateStore(),
});
await waitFor(() => Boolean(onEvent));
const result = await externalSdkTurnDriver.steer({
chatSessionId: session.id,
userMessageId: 'user-not-added',
userText: 'cannot apply',
prompt: 'cannot apply',
attachments: [],
attachedImages: [],
});
assert.deepEqual(result, { status: 'not-steerable', turnKind: 'compact' });
assert.equal(session.messages.length, 1);
assert.equal(session.messages[0].content, 'kept output');
onDone?.();
await run;
});