070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
1524 lines
51 KiB
TypeScript
1524 lines
51 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import { PassThrough } from 'node:stream';
|
|
import path from 'node:path';
|
|
import { test, vi } from 'vitest';
|
|
import { attachAcpSession, buildAcpSessionNewParams, createJsonLineStream, normalizeModels } from '../src/agent-protocol/index.js';
|
|
import { countNewArtifacts } from '../src/runtimes/run-artifacts.js';
|
|
|
|
const DEFAULT_MODEL_OPTION = { id: 'default', label: 'Default (CLI config)' };
|
|
|
|
test('ACP session params do not require MCP servers by default', () => {
|
|
assert.deepEqual(buildAcpSessionNewParams('/tmp/od-project'), {
|
|
cwd: path.resolve('/tmp/od-project'),
|
|
mcpServers: [],
|
|
});
|
|
});
|
|
|
|
test('ACP session params do not request global MCP config mutation', () => {
|
|
const params = buildAcpSessionNewParams('/tmp/od-project');
|
|
|
|
assert.equal('mcpConfigPath' in params, false);
|
|
assert.equal('writeMcpConfig' in params, false);
|
|
assert.equal('installMcpServers' in params, false);
|
|
});
|
|
|
|
test('ACP session params normalize explicit MCP servers to ACP stdio shape', () => {
|
|
const mcpServers = [{ name: 'open-design-live-artifacts', command: 'od', args: ['mcp', 'live-artifacts'] }];
|
|
|
|
assert.deepEqual(buildAcpSessionNewParams('/tmp/od-project', { mcpServers }), {
|
|
cwd: path.resolve('/tmp/od-project'),
|
|
mcpServers: [
|
|
{
|
|
type: 'stdio',
|
|
name: 'open-design-live-artifacts',
|
|
command: 'od',
|
|
args: ['mcp', 'live-artifacts'],
|
|
env: [],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('ACP session params preserve caller-provided type and env fields', () => {
|
|
const mcpServers = [
|
|
{ type: 'http', name: 'http-server', url: 'http://localhost:3000', headers: {}, env: [{ key: 'TOKEN', value: 'secret' }] },
|
|
];
|
|
|
|
const result = buildAcpSessionNewParams('/tmp/od-project', { mcpServers });
|
|
const server = result.mcpServers[0];
|
|
assert.ok(server);
|
|
assert.equal(server.type, 'http');
|
|
assert.equal(server.name, 'http-server');
|
|
assert.deepEqual(server.env, [{ key: 'TOKEN', value: 'secret' }]);
|
|
});
|
|
|
|
test('ACP model normalization prefers session configOptions models', () => {
|
|
const models = normalizeModels(
|
|
{
|
|
currentModelId: 'legacy-model',
|
|
availableModels: [{ modelId: 'legacy-model', name: 'Legacy Model' }],
|
|
},
|
|
DEFAULT_MODEL_OPTION,
|
|
[
|
|
{
|
|
id: 'model',
|
|
type: 'select',
|
|
category: 'model',
|
|
currentValue: 'swe-1-6-fast',
|
|
options: [
|
|
{ value: 'claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking' },
|
|
{ id: 'swe-1-6-fast', name: 'SWE-1.6 Fast' },
|
|
],
|
|
},
|
|
],
|
|
);
|
|
|
|
assert.deepEqual(models, [
|
|
DEFAULT_MODEL_OPTION,
|
|
{
|
|
id: 'claude-opus-4-6-thinking',
|
|
label: 'Claude Opus 4.6 Thinking (claude-opus-4-6-thinking)',
|
|
},
|
|
{
|
|
id: 'swe-1-6-fast',
|
|
label: 'SWE-1.6 Fast (swe-1-6-fast) • current',
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('ACP model normalization accepts category-less model configOptions', () => {
|
|
const models = normalizeModels(null, DEFAULT_MODEL_OPTION, [
|
|
{
|
|
id: 'models',
|
|
type: 'select',
|
|
name: 'Model',
|
|
currentValue: 'swe-1-6-fast',
|
|
options: [
|
|
{ value: 'claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking' },
|
|
{ value: 'swe-1-6-fast', name: 'SWE-1.6 Fast' },
|
|
],
|
|
},
|
|
]);
|
|
|
|
assert.deepEqual(models, [
|
|
DEFAULT_MODEL_OPTION,
|
|
{
|
|
id: 'claude-opus-4-6-thinking',
|
|
label: 'Claude Opus 4.6 Thinking (claude-opus-4-6-thinking)',
|
|
},
|
|
{
|
|
id: 'swe-1-6-fast',
|
|
label: 'SWE-1.6 Fast (swe-1-6-fast) • current',
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('attachAcpSession sets selected models through ACP config options', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: 'claude-opus-4-6-thinking',
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, {
|
|
sessionId: 'session-1',
|
|
configOptions: [
|
|
{
|
|
id: 'models',
|
|
type: 'select',
|
|
name: 'Model',
|
|
currentValue: 'swe-1-6-fast',
|
|
options: [
|
|
{ value: 'claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking' },
|
|
{ value: 'swe-1-6-fast', name: 'SWE-1.6 Fast' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
writeAcpResult(child, 3, {
|
|
configOptions: [
|
|
{
|
|
id: 'models',
|
|
type: 'select',
|
|
name: 'Model',
|
|
currentValue: 'claude-opus-4-6-thinking',
|
|
options: [],
|
|
},
|
|
],
|
|
});
|
|
writeAcpResult(child, 4, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const requests = parseRpcWrites(writes);
|
|
const configRequest = requests.find((entry) => entry.method === 'session/set_config_option');
|
|
assert.deepEqual(configRequest?.params, {
|
|
sessionId: 'session-1',
|
|
configId: 'models',
|
|
value: 'claude-opus-4-6-thinking',
|
|
});
|
|
assert.equal(requests.some((entry) => entry.method === 'session/set_model'), false);
|
|
assert.deepEqual(agentModelStatuses(events), [
|
|
'swe-1-6-fast',
|
|
'claude-opus-4-6-thinking',
|
|
]);
|
|
});
|
|
|
|
test('attachAcpSession keeps legacy session/set_model when no model config option exists', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: 'legacy-model',
|
|
mcpServers: [],
|
|
send: () => {},
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, {
|
|
sessionId: 'session-1',
|
|
models: { currentModelId: 'default' },
|
|
});
|
|
writeAcpResult(child, 3, { models: { currentModelId: 'legacy-model' } });
|
|
writeAcpResult(child, 4, {});
|
|
|
|
const requests = parseRpcWrites(writes);
|
|
const setModelRequest = requests.find((entry) => entry.method === 'session/set_model');
|
|
assert.deepEqual(setModelRequest?.params, {
|
|
sessionId: 'session-1',
|
|
modelId: 'legacy-model',
|
|
});
|
|
assert.equal(requests.some((entry) => entry.method === 'session/set_config_option'), false);
|
|
});
|
|
|
|
test('attachAcpSession includes image attachments as ACP resource links', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-acp-image-'));
|
|
const imagePath = path.join(tmpDir, 'screenshot.png');
|
|
fs.writeFileSync(imagePath, 'png');
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'describe this image',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
imagePaths: [imagePath],
|
|
mcpServers: [],
|
|
send: () => {},
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpResult(child, 3, {});
|
|
|
|
const requests = parseRpcWrites(writes);
|
|
const promptRequest = requests.find((entry) => entry.method === 'session/prompt');
|
|
assert.deepEqual(promptRequest?.params, {
|
|
sessionId: 'session-1',
|
|
prompt: [
|
|
{ type: 'text', text: 'describe this image' },
|
|
{ type: 'resource_link', uri: imagePath },
|
|
],
|
|
});
|
|
});
|
|
|
|
test('attachAcpSession converts cumulative ACP message snapshots into deltas', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'describe the project',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Agent Haven' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Agent Haven — managed AI agents' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Agent Haven — managed AI agents' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Agent Haven', ' — managed AI agents']);
|
|
});
|
|
|
|
test('attachAcpSession keeps incremental ACP message chunks unchanged', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'describe the project',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Agent Haven' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: ' — managed AI agents' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Agent Haven', ' — managed AI agents']);
|
|
});
|
|
|
|
test('attachAcpSession suppresses split duplicate DSML artifact text and preserves trailing prose', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-1',
|
|
title: 'edit',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Build passes. No AI slop detected.\n\n< | DSML artifact identifier="page" type="text/html">' },
|
|
});
|
|
assert.equal(child.stdin.writableEnded, false);
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: '\n<!doctype html><html><body>raw html</body></html>\n</art' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'ifact>Tail' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Build passes. No AI slop detected.\n\n', 'Tail']);
|
|
assert.equal(
|
|
events.some((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'usage'),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('attachAcpSession suppresses split duplicate legacy artifact text', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-1',
|
|
title: 'edit',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Ready to output.\n\n<art' },
|
|
});
|
|
assert.equal(child.stdin.writableEnded, false);
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'ifact identifier="page" type="text/html">\n<!doctype html><html></html></artifact>' },
|
|
});
|
|
assert.equal(child.stdin.writableEnded, false);
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Ready to output.\n\n']);
|
|
});
|
|
|
|
test('attachAcpSession suppresses DSML echo after opaque completed write update', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: 'tc-opaque',
|
|
title: 'edit',
|
|
status: 'pending',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'tc-opaque',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Done\n\n<artifact identifier="page">raw html</artifact>Tail' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: ' Later docs mention <artifact identifier="example">literal</artifact> syntax.' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, [
|
|
'Done\n\nTail',
|
|
' Later docs mention <artifact identifier="example">literal</artifact> syntax.',
|
|
]);
|
|
});
|
|
|
|
test('attachAcpSession mirrors artifact-write tool calls into countable tool_use/tool_result events', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build a page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
// A file-write tool call: announced as pending, then completes.
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: 'write-1',
|
|
title: 'Write index.html',
|
|
status: 'pending',
|
|
locations: [{ path: 'index.html' }],
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'write-1',
|
|
status: 'completed',
|
|
});
|
|
// A read-only tool call must NOT surface as an artifact.
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: 'grep-1',
|
|
title: 'grep',
|
|
status: 'completed',
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
// Re-shape into the run.events form (`{ event, data }`) that the daemon
|
|
// persists and that run-artifacts scans.
|
|
const runEvents = events
|
|
.filter((e) => e.event === 'agent')
|
|
.map((e) => ({ event: 'agent', data: e.payload }));
|
|
|
|
const toolUse = runEvents.find((e) => (e.data as { type?: string }).type === 'tool_use');
|
|
assert.ok(toolUse, 'expected a tool_use event for the ACP write tool call');
|
|
assert.equal((toolUse.data as { name?: string }).name, 'Write');
|
|
assert.equal(
|
|
(toolUse.data as { input?: { file_path?: string } }).input?.file_path,
|
|
'index.html',
|
|
);
|
|
|
|
const toolResult = runEvents.find((e) => (e.data as { type?: string }).type === 'tool_result');
|
|
assert.ok(toolResult, 'expected a paired tool_result event');
|
|
assert.equal((toolResult.data as { isError?: boolean }).isError, false);
|
|
|
|
// Before the fix this returned 0 for every ACP run; the read-only grep call
|
|
// must not inflate the count.
|
|
assert.equal(countNewArtifacts(runEvents), 1);
|
|
});
|
|
|
|
test('a truly PATHLESS ACP write is NOT coerced into an artifact (no false positive)', () => {
|
|
// A write tool call whose only label is "edit" — no locations, no path, ever.
|
|
// We must not fabricate an artifact extension: without a concrete path the
|
|
// call carries no evidence it touched an artifact, so it stays uncounted
|
|
// rather than inflating artifact_count for ordinary non-artifact edits.
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'edit something',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, { sessionUpdate: 'tool_call', toolCallId: 'edit-1', title: 'edit', status: 'pending' });
|
|
writeAcpUpdate(child, { sessionUpdate: 'tool_call_update', toolCallId: 'edit-1', status: 'completed' });
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const runEvents = events
|
|
.filter((e) => e.event === 'agent')
|
|
.map((e) => ({ event: 'agent', data: e.payload }));
|
|
|
|
const toolUse = runEvents.find((e) => (e.data as { type?: string }).type === 'tool_use');
|
|
const filePath = (toolUse?.data as { input?: { file_path?: string } } | undefined)?.input?.file_path ?? '';
|
|
assert.doesNotMatch(filePath, /\.html$/, 'must not fabricate a synthetic .html extension');
|
|
assert.equal(countNewArtifacts(runEvents), 0);
|
|
});
|
|
|
|
test('an ACP artifact path arriving only on the completing frame is still counted', () => {
|
|
// ACP frequently sends `locations` only on the terminal update. Emission is
|
|
// deferred to the terminal frame so that late path is used for classification
|
|
// (emitting on the first/pending frame would have missed it).
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build a page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, { sessionUpdate: 'tool_call', toolCallId: 'w-2', title: 'write', status: 'pending' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'w-2',
|
|
status: 'completed',
|
|
locations: [{ path: 'landing.html' }], // path only appears here
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const runEvents = events
|
|
.filter((e) => e.event === 'agent')
|
|
.map((e) => ({ event: 'agent', data: e.payload }));
|
|
const toolUse = runEvents.find((e) => (e.data as { type?: string }).type === 'tool_use');
|
|
assert.equal((toolUse?.data as { input?: { file_path?: string } } | undefined)?.input?.file_path, 'landing.html');
|
|
assert.equal(countNewArtifacts(runEvents), 1);
|
|
});
|
|
|
|
test('an ACP write whose title names a NON-artifact file is not counted', () => {
|
|
// Symmetry with the claude path: a real extension extracted from the title
|
|
// (e.g. "edit config.json") must be filtered out by isArtifactPath.
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'tweak config',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: 'cfg-1',
|
|
title: 'Edit config.json',
|
|
status: 'completed',
|
|
locations: [{ path: 'config.json' }],
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const runEvents = events
|
|
.filter((e) => e.event === 'agent')
|
|
.map((e) => ({ event: 'agent', data: e.payload }));
|
|
assert.equal(countNewArtifacts(runEvents), 0);
|
|
});
|
|
|
|
test('attachAcpSession suppresses incremental artifact echo after earlier assistant text', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Planning changes.\n' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-1',
|
|
title: 'edit',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Done.\n\n<artifact identifier="page">raw html</artifact>Tail' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Planning changes.\n', 'Done.\n\nTail']);
|
|
});
|
|
|
|
test('attachAcpSession clears ACP suppression after plain prose before later literal artifact text', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-1',
|
|
title: 'edit',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Build passes.\n\n' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Later docs mention <artifact identifier="example">literal</artifact> syntax.' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, [
|
|
'Build passes.\n\n',
|
|
'Later docs mention <artifact identifier="example">literal</artifact> syntax.',
|
|
]);
|
|
});
|
|
|
|
test('attachAcpSession keeps ACP suppression after plain incremental prose before echo chunk', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-1',
|
|
title: 'edit',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Build passes.\n\n' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: '<artifact identifier="page">raw html</artifact>Tail' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Build passes.\n\n', 'Tail']);
|
|
});
|
|
|
|
test('attachAcpSession suppresses prose-prefixed delayed artifact echo after plain prose', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-1',
|
|
title: 'edit',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Build passes.\n\n' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Here is the generated file:\n<artifact identifier="page">raw html</artifact>Tail' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Build passes.\n\n', 'Here is the generated file:\nTail']);
|
|
});
|
|
|
|
test('attachAcpSession keeps ACP suppression after unrelated failed tool before echo chunk', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'write-1',
|
|
title: 'edit',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'grep-1',
|
|
title: 'grep',
|
|
status: 'failed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Build passes.\n\n<artifact identifier="page">raw html</artifact>Tail' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Build passes.\n\nTail']);
|
|
});
|
|
|
|
test('attachAcpSession keeps ACP suppression across plain cumulative snapshots', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'build landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-1',
|
|
title: 'edit',
|
|
status: 'completed',
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Build passes.\n\n' },
|
|
});
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: 'Build passes.\n\n<artifact identifier="page">raw html</artifact>Tail' },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, ['Build passes.\n\n', 'Tail']);
|
|
});
|
|
|
|
test('attachAcpSession preserves literal artifact prose before any write echo is expected', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'document artifact syntax',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
const literal = 'To show the syntax, write <artifact identifier="page">literal</artifact> in docs.';
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { text: literal },
|
|
});
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const textDeltas = events
|
|
.filter((entry) => entry.event === 'agent' && (entry.payload as { type?: unknown }).type === 'text_delta')
|
|
.map((entry) => (entry.payload as { delta?: unknown }).delta);
|
|
|
|
assert.deepEqual(textDeltas, [literal]);
|
|
});
|
|
|
|
test('attachAcpSession exposes abort and sends session cancel after session creation', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
|
|
const session = attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: () => {},
|
|
});
|
|
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
|
|
assert.equal(typeof session.abort, 'function');
|
|
session.abort();
|
|
session.abort();
|
|
|
|
const parsed = parseRpcWrites(writes);
|
|
const cancelRequests = parsed.filter((entry) => entry.method === 'session/cancel');
|
|
assert.equal(cancelRequests.length, 1);
|
|
const cancelRequest = cancelRequests[0];
|
|
assert.ok(cancelRequest);
|
|
assert.deepEqual(cancelRequest.params, { sessionId: 'session-1' });
|
|
});
|
|
|
|
test('attachAcpSession.abort closes stdin so the agent shuts down on EOF', () => {
|
|
const child = new FakeAcpChild();
|
|
|
|
const session = attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: () => {},
|
|
});
|
|
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
|
|
assert.equal(child.stdin.writableEnded, false);
|
|
session.abort();
|
|
// EOF on stdin lets the vela ACP bridge tear down its OpenCode server
|
|
// without waiting for the caller's SIGTERM fallback.
|
|
assert.equal(child.stdin.writableEnded, true);
|
|
});
|
|
|
|
test('attachAcpSession.abort during startup ends stdin without sending session/cancel', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
|
|
const session = attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: () => {},
|
|
});
|
|
|
|
// Abort before session/new resolves (no sessionId yet) — e.g. the user
|
|
// cancels during ACP startup. stdin must still close so OpenCode tears down.
|
|
assert.equal(child.stdin.writableEnded, false);
|
|
session.abort();
|
|
assert.equal(child.stdin.writableEnded, true);
|
|
|
|
// No session to cancel yet, so no session/cancel RPC should be emitted.
|
|
const cancelRequests = parseRpcWrites(writes).filter(
|
|
(entry) => entry.method === 'session/cancel',
|
|
);
|
|
assert.equal(cancelRequests.length, 0);
|
|
});
|
|
|
|
test('attachAcpSession accepts pretty-printed ACP startup responses', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
child.stdout.write('{\n "id": 1,\n "result":\n {}\n}\n');
|
|
child.stdout.write('{\n "id": 2,\n "result":\n {\n "sessionId": "session-1"\n }\n}\n');
|
|
|
|
const methods = parseRpcWrites(writes)
|
|
.map((entry) => entry.method)
|
|
.filter(Boolean);
|
|
assert.deepEqual(methods, ['initialize', 'session/new', 'session/prompt']);
|
|
assert.equal(events.some((entry) => entry.event === 'error'), false);
|
|
});
|
|
|
|
test('attachAcpSession recovers when bracket-prefixed logs precede JSON frames', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
child.stdout.write('[vela] starting OpenCode bridge\n');
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
child.stdout.write('{not json but looks like an object log\n');
|
|
child.stdout.write('more startup text after the bad object log\n');
|
|
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
|
|
const methods = parseRpcWrites(writes)
|
|
.map((entry) => entry.method)
|
|
.filter(Boolean);
|
|
assert.deepEqual(methods, ['initialize', 'session/new', 'session/prompt']);
|
|
assert.equal(events.some((entry) => entry.event === 'error'), false);
|
|
});
|
|
|
|
test('attachAcpSession emits a waiting status after submitting the prompt', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'make a simple deck',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
|
|
const promptRequest = parseRpcWrites(writes).find((entry) => entry.method === 'session/prompt');
|
|
assert.ok(promptRequest, 'expected session/prompt to be submitted');
|
|
assert.ok(
|
|
hasAgentStatus(events, 'waiting_for_first_output'),
|
|
`expected waiting status after session/prompt, got ${JSON.stringify(events)}`,
|
|
);
|
|
});
|
|
|
|
test('attachAcpSession surfaces non-text ACP updates as status progress', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'make a simple deck',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpUpdate(child, { sessionUpdate: 'tool_call_update', toolCallId: 'call-1', status: 'in_progress' });
|
|
|
|
assert.ok(
|
|
hasAgentStatus(events, 'tool_call_update'),
|
|
`expected tool_call_update status progress, got ${JSON.stringify(events)}`,
|
|
);
|
|
});
|
|
|
|
function parseRpcWrites(writes: string[]): Array<Record<string, unknown>> {
|
|
return writes
|
|
.join('')
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
}
|
|
|
|
function writeAcpResult(child: FakeAcpChild, id: number, result: unknown): void {
|
|
child.stdout.write(`${JSON.stringify({ id, result })}\n`);
|
|
}
|
|
|
|
function writeAcpError(child: FakeAcpChild, id: number, error: unknown): void {
|
|
child.stdout.write(`${JSON.stringify({ id, error })}\n`);
|
|
}
|
|
|
|
function writeAcpUpdate(child: FakeAcpChild, update: unknown): void {
|
|
child.stdout.write(`${JSON.stringify({ method: 'session/update', params: { update } })}\n`);
|
|
}
|
|
|
|
function agentModelStatuses(events: Array<{ event: string; payload: unknown }>): unknown[] {
|
|
return events
|
|
.filter((entry) => {
|
|
const payload = entry.payload as { type?: unknown; label?: unknown };
|
|
return entry.event === 'agent' && payload.type === 'status' && payload.label === 'model';
|
|
})
|
|
.map((entry) => (entry.payload as { model?: unknown }).model);
|
|
}
|
|
|
|
function hasAgentStatus(events: Array<{ event: string; payload: unknown }>, label: string): boolean {
|
|
return events.some((entry) => {
|
|
const payload = entry.payload as { type?: unknown; label?: unknown };
|
|
return entry.event === 'agent' && payload.type === 'status' && payload.label === label;
|
|
});
|
|
}
|
|
|
|
test('attachAcpSession force-terminates the child after a clean prompt completion if it does not exit on stdin.end()', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const child = new FakeAcpChild();
|
|
|
|
const session = attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: () => {},
|
|
});
|
|
|
|
// Drive the protocol through to a clean prompt completion.
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 3, result: { usage: { inputTokens: 1, outputTokens: 2 } } })}\n`);
|
|
|
|
// Child has not exited yet (simulates Devin for Terminal keeping the
|
|
// process alive past stdin.end()).
|
|
assert.equal(child.killed, false);
|
|
|
|
// After the grace period elapses, attachAcpSession should SIGTERM the
|
|
// child so child.on('close') can fire and the chat run can finalize.
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
assert.equal(child.killed, true);
|
|
|
|
// The session reports the prompt completed successfully so the consumer
|
|
// can mark the run as 'succeeded' even though the underlying exit was
|
|
// signal-driven.
|
|
assert.equal(session.completedSuccessfully(), true);
|
|
assert.equal(session.hasFatalError(), false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test('attachAcpSession does not double-kill a child that exits cleanly on stdin.end()', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const child = new FakeAcpChild();
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: () => {},
|
|
});
|
|
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 3, result: {} })}\n`);
|
|
|
|
// Well-behaved agent exits on its own before the grace period elapses.
|
|
child.emit('close', 0, null);
|
|
assert.equal(child.killed, false);
|
|
|
|
// The grace-period timer should have been cleared by the close handler,
|
|
// so advancing time should not trigger a SIGTERM on the now-closed child.
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
assert.equal(child.killed, false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test('attachAcpSession.completedSuccessfully reflects abort and fatal-error states', () => {
|
|
const child = new FakeAcpChild();
|
|
|
|
const session = attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: () => {},
|
|
});
|
|
|
|
// Before any protocol traffic the session is not yet complete.
|
|
assert.equal(session.completedSuccessfully(), false);
|
|
|
|
// Drive through session creation, then abort before the prompt completes.
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
session.abort();
|
|
|
|
// Aborted runs are not "successful completions" even though `finished` is
|
|
// set internally — the consumer should treat them as canceled, not
|
|
// succeeded.
|
|
assert.equal(session.completedSuccessfully(), false);
|
|
});
|
|
|
|
test('attachAcpSession default stage timeout tolerates >3min of silence between chunks', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'write a large landing page',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
|
|
// Simulate an agent silently writing a large artifact for ~4 minutes —
|
|
// longer than the historical 180_000ms default that killed long
|
|
// generations mid-response.
|
|
await vi.advanceTimersByTimeAsync(240_000);
|
|
|
|
const errors = events.filter((e) => e.event === 'error');
|
|
assert.equal(errors.length, 0, `expected no stage-timeout error, got: ${JSON.stringify(errors)}`);
|
|
assert.equal(child.killed, false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test('attachAcpSession honors caller-supplied stageTimeoutMs override', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
stageTimeoutMs: 1_000,
|
|
});
|
|
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
|
|
const error = events.find((e) => e.event === 'error');
|
|
assert.ok(error, 'expected a stage-timeout error event');
|
|
const message = (error.payload as { message?: string }).message ?? '';
|
|
assert.match(message, /1000ms/);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test('attachAcpSession treats stageTimeoutMs <= 0 as a watchdog disable, not an immediate-failure schedule', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
// OD_ACP_STAGE_TIMEOUT_MS=0 escape hatch: operator wants to disable the
|
|
// inner stage watchdog entirely (e.g. when relying solely on the outer
|
|
// chat inactivity watchdog). Must NOT schedule a 0ms setTimeout that
|
|
// would fail every ACP session on the next tick.
|
|
stageTimeoutMs: 0,
|
|
});
|
|
|
|
// Drive past where the next-tick failure would have fired, plus a long
|
|
// silent period that would trip any positive default watchdog.
|
|
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
|
|
|
|
const errors = events.filter((e) => e.event === 'error');
|
|
assert.equal(
|
|
errors.length,
|
|
0,
|
|
`expected stageTimeoutMs=0 to disable the watchdog, got: ${JSON.stringify(errors)}`,
|
|
);
|
|
assert.equal(child.killed, false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
class FakeAcpChild extends EventEmitter {
|
|
stdin = new PassThrough();
|
|
stdout = new PassThrough();
|
|
stderr = new PassThrough();
|
|
killed = false;
|
|
|
|
kill() {
|
|
this.killed = true;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
test('attachAcpSession does not fail a tool-only AMR turn that emits no assistant text', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'change all card backgrounds to gray',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
modelUnavailableErrorCode: 'AMR_MODEL_UNAVAILABLE',
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
// The model does real work via a tool/file edit but emits no closing assistant text.
|
|
writeAcpUpdate(child, { sessionUpdate: 'tool_call', toolCallId: 'tc-1', title: 'edit', status: 'pending' });
|
|
writeAcpUpdate(child, { sessionUpdate: 'tool_call_update', toolCallId: 'tc-1', status: 'completed' });
|
|
writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } });
|
|
|
|
const errorEvents = events.filter((entry) => entry.event === 'error');
|
|
assert.deepEqual(errorEvents, [], 'a turn that produced tool calls must not be reported as model-unavailable');
|
|
});
|
|
|
|
test('attachAcpSession still fails an AMR turn that produces no text and no tool calls', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'do something',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
modelUnavailableErrorCode: 'AMR_MODEL_UNAVAILABLE',
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpResult(child, 3, {}); // empty turn: no updates at all
|
|
|
|
const errorEvents = events.filter((entry) => entry.event === 'error');
|
|
assert.equal(errorEvents.length, 1, 'a genuinely empty turn must still fail');
|
|
assert.match(
|
|
(errorEvents[0]?.payload as { message?: string }).message ?? '',
|
|
/without producing any assistant text/,
|
|
);
|
|
});
|
|
|
|
test('attachAcpSession promotes allowlisted OpenCode role-marker ACP errors', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
const details = {
|
|
kind: 'opencode_session_error',
|
|
source: 'opencode',
|
|
code: 'ROLE_MARKER_HALLUCINATION',
|
|
upstream_name: 'RoleMarkerHallucinationError',
|
|
message: 'Model emitted fabricated role marker ("## user"). Response was truncated to prevent unauthorized instruction injection.',
|
|
marker: '## user',
|
|
retryable: true,
|
|
};
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpError(child, 3, {
|
|
code: -32600,
|
|
message: 'OpenCode session failed: ROLE_MARKER_HALLUCINATION',
|
|
data: details,
|
|
});
|
|
|
|
const errorEvents = events.filter((entry) => entry.event === 'error');
|
|
assert.equal(errorEvents.length, 1);
|
|
assert.deepEqual(errorEvents[0]?.payload, {
|
|
message: details.message,
|
|
error: {
|
|
code: 'ROLE_MARKER_HALLUCINATION',
|
|
message: details.message,
|
|
retryable: true,
|
|
details: {
|
|
...details,
|
|
promoted_by: 'open_design_acp',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('attachAcpSession preserves structured OpenCode session error details from ACP failures', () => {
|
|
const child = new FakeAcpChild();
|
|
const events: Array<{ event: string; payload: unknown }> = [];
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
model: null,
|
|
mcpServers: [],
|
|
send: (event, payload) => events.push({ event, payload }),
|
|
});
|
|
|
|
const details = {
|
|
kind: 'opencode_session_error',
|
|
source: 'opencode',
|
|
code: 'SOME_UNKNOWN_UPSTREAM_ERROR',
|
|
message: 'Not Found',
|
|
statusCode: 404,
|
|
retryable: false,
|
|
url: 'https://example.invalid/v1/chat/completions',
|
|
suggestion: 'Check the configured AMR Link URL or model route.',
|
|
};
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'session-1' });
|
|
writeAcpError(child, 3, {
|
|
code: -32600,
|
|
message: 'OpenCode session failed: Not Found',
|
|
data: details,
|
|
});
|
|
|
|
const errorEvents = events.filter((entry) => entry.event === 'error');
|
|
assert.equal(errorEvents.length, 1);
|
|
assert.deepEqual(errorEvents[0]?.payload, {
|
|
message: 'json-rpc id 3: OpenCode session failed: Not Found',
|
|
error: {
|
|
code: 'AGENT_EXECUTION_FAILED',
|
|
message: 'json-rpc id 3: OpenCode session failed: Not Found',
|
|
retryable: false,
|
|
details,
|
|
},
|
|
});
|
|
});
|
|
|
|
test('attachAcpSession resumes via session/load when resumeSessionId is set', () => {
|
|
const child = new FakeAcpChild();
|
|
const writes: string[] = [];
|
|
child.stdin.on('data', (chunk) => writes.push(String(chunk)));
|
|
|
|
attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
resumeSessionId: 'oc-prev',
|
|
send: () => {},
|
|
});
|
|
|
|
writeAcpResult(child, 1, {}); // initialize ack -> drives the resume handshake
|
|
|
|
const requests = parseRpcWrites(writes);
|
|
const loadReq = requests.find((entry) => entry.method === 'session/load');
|
|
assert.ok(loadReq, 'expected a session/load request on resume');
|
|
assert.deepEqual(loadReq?.params, {
|
|
sessionId: 'oc-prev',
|
|
cwd: path.resolve('/tmp/od-project'),
|
|
});
|
|
assert.equal(requests.some((entry) => entry.method === 'session/new'), false);
|
|
});
|
|
|
|
test('attachAcpSession captures the durable session handle from the result', () => {
|
|
const child = new FakeAcpChild();
|
|
const session = attachAcpSession({
|
|
child: child as never,
|
|
prompt: 'hello',
|
|
cwd: '/tmp/od-project',
|
|
send: () => {},
|
|
});
|
|
|
|
writeAcpResult(child, 1, {});
|
|
writeAcpResult(child, 2, { sessionId: 'vela-opencode-1', openCodeSessionId: 'oc-handle' });
|
|
|
|
assert.equal(session.getDurableSessionId(), 'oc-handle');
|
|
});
|
|
|
|
test('createJsonLineStream replays absorbed complete frames when a value-position aggregate turns invalid', () => {
|
|
const received: Array<Record<string, unknown>> = [];
|
|
const parser = createJsonLineStream((message) => {
|
|
received.push(message as Record<string, unknown>);
|
|
});
|
|
|
|
// A truncated line ending in value position ("expecting a value") starts an
|
|
// aggregate; the next complete frame slots into that value hole, so the
|
|
// aggregate stays syntactically plausible and absorbs it. The third frame
|
|
// makes the aggregate invalid. The absorbed frame must still be delivered.
|
|
parser.feed('{"truncated":\n');
|
|
parser.feed(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
|
parser.feed(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
|
|
|
assert.deepEqual(received.map((message) => message.id), [1, 2]);
|
|
});
|
|
|
|
test('createJsonLineStream flush replays absorbed complete frames from a dead aggregate', () => {
|
|
const received: Array<Record<string, unknown>> = [];
|
|
const parser = createJsonLineStream((message) => {
|
|
received.push(message as Record<string, unknown>);
|
|
});
|
|
|
|
parser.feed('{"truncated":\n');
|
|
parser.feed(`${JSON.stringify({ id: 7, result: {} })}\n`);
|
|
parser.flush();
|
|
|
|
assert.deepEqual(received.map((message) => message.id), [7]);
|
|
});
|
|
|
|
test('createJsonLineStream replays absorbed complete frames when the aggregate exceeds its size bound', () => {
|
|
const received: Array<Record<string, unknown>> = [];
|
|
const parser = createJsonLineStream((message) => {
|
|
received.push(message as Record<string, unknown>);
|
|
});
|
|
|
|
parser.feed('{"big":\n');
|
|
parser.feed(`${JSON.stringify({ id: 3, result: {} })}\n`);
|
|
// An unterminated string keeps the aggregate classified `incomplete` while
|
|
// pushing it past the 128k size bound, forcing the bounds eviction path.
|
|
parser.feed(`{"pad":"${'x'.repeat(130_000)}\n`);
|
|
|
|
assert.deepEqual(received.map((message) => message.id), [3]);
|
|
});
|
|
|
|
test('createJsonLineStream still assembles a legitimate multiline JSON response', () => {
|
|
const received: Array<Record<string, unknown>> = [];
|
|
const parser = createJsonLineStream((message) => {
|
|
received.push(message as Record<string, unknown>);
|
|
});
|
|
|
|
parser.feed('{\n "id": 5,\n "result":\n {}\n}\n');
|
|
|
|
assert.deepEqual(received.map((message) => message.id), [5]);
|
|
});
|