mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
236 lines
9.4 KiB
TypeScript
236 lines
9.4 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import { PassThrough } from 'node:stream';
|
|
import { attachAcpSession } from '../../src/agent-protocol/index.js';
|
|
|
|
type JsonObject = Record<string, unknown>;
|
|
type Terminal = 'completed' | 'fatal';
|
|
type RpcRequest = { id: string | number; method: string; params: JsonObject };
|
|
|
|
function object(value: unknown): JsonObject {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error('Expected an object in the ACP memory-peer fixture');
|
|
}
|
|
return value as JsonObject;
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((done) => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
// Same in-memory child seam as acp.test.ts. No executable, server, model,
|
|
// private parser stub, or generated host tool event is involved.
|
|
class MemoryAcpChild extends EventEmitter {
|
|
stdin = new PassThrough();
|
|
stdout = new PassThrough();
|
|
stderr = new PassThrough();
|
|
killed = false;
|
|
|
|
kill() {
|
|
this.killed = true;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export const TODO_ID = 'd1-native-todo';
|
|
export const TODO_INPUT = {
|
|
todos: [{ content: 'Generate the short video', status: 'in_progress' }],
|
|
};
|
|
|
|
export function textUpdate(text: string): JsonObject {
|
|
return { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } };
|
|
}
|
|
|
|
export function completedTodoUpdates(): JsonObject[] {
|
|
// Vela's documented frame shape uses kind, not a fabricated name field.
|
|
// These are synthetic protocol inputs, not proof of actual tool execution.
|
|
return [
|
|
{ sessionUpdate: 'tool_call', toolCallId: TODO_ID, kind: 'todowrite', title: 'todowrite', status: 'pending', rawInput: TODO_INPUT },
|
|
{ sessionUpdate: 'tool_call_update', toolCallId: TODO_ID, status: 'completed', rawOutput: 'Todo list updated' },
|
|
];
|
|
}
|
|
|
|
export async function runMemoryAcpTurn(options: {
|
|
amr: boolean;
|
|
prompt: string;
|
|
updates: JsonObject[];
|
|
outputTokens?: number;
|
|
onSend?: (event: string, payload: JsonObject, replayPromptResultSynchronously: () => void) => void;
|
|
}) {
|
|
const child = new MemoryAcpChild();
|
|
const terminal = deferred<Terminal>();
|
|
const terminals: Terminal[] = [];
|
|
const requests: RpcRequest[] = [];
|
|
const inbox: RpcRequest[] = [];
|
|
const waiters = new Map<string, ReturnType<typeof deferred<RpcRequest>>>();
|
|
const events: Array<{ event: string; payload: JsonObject; hostSynthesized: boolean }> = [];
|
|
const completionOrder: string[] = [];
|
|
let promptResultFrame: string | null = null;
|
|
let promptCompleteCount = 0;
|
|
let pendingStdin = '';
|
|
|
|
child.stdin.on('data', (chunk: Buffer | string) => {
|
|
pendingStdin += String(chunk);
|
|
let newline: number;
|
|
while ((newline = pendingStdin.indexOf('\n')) !== -1) {
|
|
const line = pendingStdin.slice(0, newline);
|
|
pendingStdin = pendingStdin.slice(newline + 1);
|
|
if (!line.trim()) continue;
|
|
const frame = object(JSON.parse(line));
|
|
if ((typeof frame.id !== 'number' && typeof frame.id !== 'string') || typeof frame.method !== 'string') {
|
|
throw new Error('Expected an outbound ACP request with its actual id');
|
|
}
|
|
const request = { id: frame.id, method: frame.method, params: object(frame.params) };
|
|
requests.push(request);
|
|
const waiter = waiters.get(request.method);
|
|
if (waiter) {
|
|
waiters.delete(request.method);
|
|
waiter.resolve(request);
|
|
} else {
|
|
inbox.push(request);
|
|
}
|
|
}
|
|
});
|
|
|
|
async function takeRequest(method: string): Promise<RpcRequest> {
|
|
const index = inbox.findIndex((request) => request.method === method);
|
|
if (index >= 0) return inbox.splice(index, 1)[0]!;
|
|
const waiter = deferred<RpcRequest>();
|
|
waiters.set(method, waiter);
|
|
return waiter.promise;
|
|
}
|
|
|
|
function reply(request: RpcRequest, result: JsonObject) {
|
|
child.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`);
|
|
}
|
|
|
|
const session = attachAcpSession({
|
|
child: child as unknown as Parameters<typeof attachAcpSession>[0]['child'],
|
|
prompt: options.prompt,
|
|
model: options.amr ? 'deepseek-v4-flash' : null,
|
|
mcpServers: [],
|
|
...(options.amr ? { modelUnavailableErrorCode: 'AMR_MODEL_UNAVAILABLE' as const } : {}),
|
|
send(event, payload, meta) {
|
|
const body = object(payload);
|
|
events.push({ event, payload: body, hostSynthesized: meta?.hostSynthesized === true });
|
|
if (event === 'agent' && body.type === 'text_delta') completionOrder.push('text_delta');
|
|
if (event === 'agent' && body.type === 'status' && body.label === 'streaming') completionOrder.push('streaming');
|
|
options.onSend?.(event, body, () => {
|
|
if (promptResultFrame === null) throw new Error('No actual prompt result is available to replay');
|
|
completionOrder.push('replay_prompt_result');
|
|
// Explicit synchronous EventEmitter delivery through the public
|
|
// stdout boundary. A nested PassThrough.write may queue instead;
|
|
// this does not model ordinary asynchronous process pipe scheduling.
|
|
child.stdout.emit('data', promptResultFrame);
|
|
});
|
|
},
|
|
onPromptComplete() {
|
|
promptCompleteCount += 1;
|
|
completionOrder.push('onPromptComplete');
|
|
},
|
|
onTerminal(kind) {
|
|
terminals.push(kind);
|
|
completionOrder.push(`terminal:${kind}`);
|
|
terminal.resolve(kind);
|
|
},
|
|
});
|
|
|
|
const observe = () => ({
|
|
visibleText: events.filter((entry) => entry.event === 'agent' && entry.payload.type === 'text_delta')
|
|
.map((entry) => String(entry.payload.delta)).join(''),
|
|
textDeltas: events.filter((entry) => entry.event === 'agent' && entry.payload.type === 'text_delta')
|
|
.map((entry) => String(entry.payload.delta)),
|
|
tools: events.filter((entry) => entry.event === 'agent' &&
|
|
(entry.payload.type === 'tool_use' || entry.payload.type === 'tool_result'))
|
|
.map((entry) => ({
|
|
origin: entry.hostSynthesized ? 'host_flush' : 'agent_frame',
|
|
type: entry.payload.type,
|
|
id: entry.payload.id ?? entry.payload.toolUseId,
|
|
name: entry.payload.name,
|
|
input: entry.payload.input,
|
|
content: entry.payload.content,
|
|
isError: entry.payload.isError,
|
|
})),
|
|
errors: events.filter((entry) => entry.event === 'error').map((entry) => {
|
|
const error = entry.payload.error == null ? {} : object(entry.payload.error);
|
|
const details = error.details == null ? {} : object(error.details);
|
|
return {
|
|
message: entry.payload.message,
|
|
code: error.code,
|
|
retryable: error.retryable,
|
|
kind: details.kind,
|
|
action: details.action,
|
|
};
|
|
}),
|
|
fatal: session.hasFatalError(),
|
|
completed: session.completedSuccessfully(),
|
|
promptCompleteCount,
|
|
terminals: [...terminals],
|
|
completionOrder: [...completionOrder],
|
|
});
|
|
|
|
let budgetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
const drive = async () => {
|
|
reply(await takeRequest('initialize'), { protocolVersion: 1 });
|
|
reply(await takeRequest('session/new'), { sessionId: 'd1-memory-session' });
|
|
if (options.amr) reply(await takeRequest('session/set_model'), {});
|
|
const promptRequest = await takeRequest('session/prompt');
|
|
for (const update of options.updates) {
|
|
child.stdout.write(`${JSON.stringify({
|
|
jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'd1-memory-session', update },
|
|
})}\n`);
|
|
}
|
|
const beforePromptResult = observe();
|
|
promptResultFrame = `${JSON.stringify({
|
|
jsonrpc: '2.0', id: promptRequest.id,
|
|
result: {
|
|
stopReason: 'end_turn',
|
|
usage: { inputTokens: 10, outputTokens: options.outputTokens ?? 10 },
|
|
},
|
|
})}\n`;
|
|
child.stdout.write(promptResultFrame);
|
|
// onPromptComplete runs BEFORE the production flush. onTerminal is the
|
|
// completion signal; awaiting it also lets the fatal callback's stack
|
|
// finish publishing its error DTO before observation.
|
|
await terminal.promise;
|
|
return {
|
|
...observe(),
|
|
beforePromptResult,
|
|
requestMethods: requests.map((request) => request.method),
|
|
selectedModel: requests.find((request) => request.method === 'session/set_model')?.params.modelId,
|
|
sentPrompt: promptRequest.params.prompt,
|
|
inputCompletedToolFrames: options.updates.filter((update) =>
|
|
update.sessionUpdate === 'tool_call_update' && update.status === 'completed').length,
|
|
};
|
|
};
|
|
const budget = new Promise<never>((_, reject) => {
|
|
// Failure budget only: normal completion never waits for this timer.
|
|
budgetTimer = setTimeout(() => reject(new Error('ACP memory-peer terminal signal did not arrive')), 5_000);
|
|
});
|
|
return await Promise.race([drive(), budget]);
|
|
} finally {
|
|
if (budgetTimer !== undefined) clearTimeout(budgetTimer);
|
|
// The returned observation was taken before cleanup. Abort cannot turn a
|
|
// missing terminal signal into a passing result.
|
|
session.abort();
|
|
child.stdin.removeAllListeners();
|
|
child.stdout.removeAllListeners();
|
|
child.stderr.removeAllListeners();
|
|
child.removeAllListeners();
|
|
child.stdin.destroy();
|
|
child.stdout.destroy();
|
|
child.stderr.destroy();
|
|
}
|
|
}
|
|
|
|
export type AcpTurnObservation = Awaited<ReturnType<typeof runMemoryAcpTurn>>;
|
|
|
|
export function safeObservation(turn: AcpTurnObservation): string {
|
|
// All visible/tool content is this file's synthetic fixture. Deliberately
|
|
// exclude cwd, outbound prompt, process environment and raw diagnostics.
|
|
const { sentPrompt: _prompt, ...safe } = turn;
|
|
return JSON.stringify(safe);
|
|
}
|