fix(daemon): suppress AMR DSML artifact echoes (#4068)

* fix(daemon): suppress AMR DSML artifact echoes

* fix(daemon): gate ACP artifact echo suppression

Generated-By: looper 0.9.7 (runner=fixer, agent=codex)

* fix(daemon): preserve ACP artifact echo tails

Generated-By: looper 0.9.7 (runner=fixer, agent=codex)

* fix(daemon): clear ACP artifact suppression after prose

Generated-By: looper 0.9.7 (runner=fixer, agent=codex)

* fix(daemon): preserve cumulative ACP artifact suppression

Generated-By: looper 0.9.7 (runner=fixer, agent=codex)

* fix(daemon): suppress mid-chunk ACP artifact echoes

Generated-By: looper 0.9.7 (runner=fixer, agent=codex)

* fix(daemon): keep ACP suppression for delayed echoes

Generated-By: looper 0.9.7 (runner=fixer, agent=codex)

* fix(daemon): preserve ACP suppressor across helper failures

Generated-By: looper 0.9.7 (runner=fixer, agent=codex)

* fix(daemon): catch prose-prefixed ACP artifact echoes

Generated-By: looper 0.9.7 (runner=fixer, agent=codex)
This commit is contained in:
Ray Xi
2026-06-10 13:25:19 +00:00
committed by GitHub
parent 2b20cb8762
commit 116014b8e2
7 changed files with 781 additions and 43 deletions
+148 -27
View File
@@ -1,6 +1,10 @@
import { spawn, type ChildProcess } from 'node:child_process';
import type { Writable } from 'node:stream';
import path from 'node:path';
import {
createDsmlArtifactTextSuppressor,
type ArtifactTextSuppressor,
} from './artifact-text-suppression.js';
const ACP_PROTOCOL_VERSION = 1;
const DEFAULT_TIMEOUT_MS = 15_000;
@@ -17,6 +21,13 @@ const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000;
// `OD_ACP_STAGE_TIMEOUT_MS=0` would call `setTimeout(..., 0)` and fail every
// ACP session on the next tick instead of disabling the watchdog.
const DEFAULT_STAGE_TIMEOUT_MS = 600_000;
const ACP_ARTIFACT_OPEN_PATTERN = String.raw`<\s*(?:\|?\s*DSML[\s,]+artifact\b|artifact\b)`;
const ACP_GENERATED_FILE_PREFIX_PATTERN =
String.raw`(?:here\s+is|here'?s)\s+the\s+generated\s+file\s*:?\s*(?:\r?\n|\s)*`;
const ACP_ARTIFACT_ECHO_START_RE = new RegExp(
String.raw`^\s*(?:${ACP_ARTIFACT_OPEN_PATTERN}|${ACP_GENERATED_FILE_PREFIX_PATTERN}${ACP_ARTIFACT_OPEN_PATTERN})`,
'i',
);
type JsonRpcId = string | number;
type JsonObject = Record<string, unknown>;
@@ -324,6 +335,42 @@ function currentModelFromSessionResult(result: JsonObject): string | null {
: null;
}
function acpUpdateStatus(update: JsonObject): string {
return typeof update.status === 'string'
? update.status.trim().toLowerCase().replace(/[\s_-]+/g, '')
: '';
}
function isAcpCompletedStatus(update: JsonObject): boolean {
const status = acpUpdateStatus(update);
return status === 'completed' || status === 'complete' || status === 'succeeded' || status === 'success';
}
function isAcpTerminalFailureStatus(update: JsonObject): boolean {
const status = acpUpdateStatus(update);
return status === 'failed' || status === 'failure' || status === 'error' || status === 'cancelled' || status === 'canceled';
}
function acpToolCallId(update: JsonObject): string | null {
return typeof update.toolCallId === 'string' && update.toolCallId.trim()
? update.toolCallId.trim()
: null;
}
function isAcpArtifactWriteLabel(update: JsonObject): boolean {
const label = [
typeof update.title === 'string' ? update.title : '',
typeof update.name === 'string' ? update.name : '',
].join(' ');
return /\b(?:edit|write|create|update|save|patch|replace)\b/i.test(label);
}
function isAcpArtifactWriteUpdate(update: JsonObject, writeToolCallIds: Set<string>): boolean {
if (!isAcpCompletedStatus(update)) return false;
const toolCallId = acpToolCallId(update);
return isAcpArtifactWriteLabel(update) || (toolCallId ? writeToolCallIds.has(toolCallId) : false);
}
export function createJsonLineStream(onMessage: (message: unknown, rawLine: string) => void) {
let buffer = '';
let pendingJson = '';
@@ -730,12 +777,18 @@ export function attachAcpSession({
let emittedThinkingStart = false;
let emittedFirstTokenStatus = false;
let emittedTextChunk = false;
let emittedVisibleTextChunk = false;
let emittedToolCall = false;
let emittedTextBuffer = '';
let finished = false;
let fatal = false;
let aborted = false;
let stageTimer: TimerHandle | null = null;
let dsmlArtifactSuppressor: ArtifactTextSuppressor | null = null;
let dsmlArtifactSuppressorToolCallId: string | null = null;
let dsmlArtifactSuppressorArmedAfterText = false;
let dsmlArtifactSuppressorSawIncrementalProse = false;
const acpArtifactWriteToolCallIds = new Set<string>();
const stageWatchdogDisabled = stageTimeoutMs <= 0;
const resetStageTimer = (label: string) => {
@@ -844,6 +897,33 @@ export function attachAcpSession({
nextId += 1;
};
const finishCleanPrompt = (usageSource?: unknown) => {
if (finished) return;
const flushedText = dsmlArtifactSuppressor?.flush() ?? '';
if (flushedText) {
emittedVisibleTextChunk = true;
send('agent', { type: 'text_delta', delta: flushedText });
}
const usage = formatUsage(usageSource);
if (usage) {
send('agent', {
type: 'usage',
usage,
durationMs: Date.now() - runStartedAt,
});
}
finished = true;
clearStageTimer();
stdin.end();
// Some ACP agents keep the child process alive after stdin closes,
// waiting for another prompt. Each Open Design run owns one process per
// turn, so close it once this prompt is cleanly complete.
const cleanExitTimer = setTimeout(() => {
if (!child.killed) child.kill('SIGTERM');
}, 500);
child.once('close', () => clearTimeout(cleanExitTimer));
};
const replyPermission = (raw: JsonObject) => {
const params = asObject(raw.params);
const optionId = choosePermissionOutcome(params?.options);
@@ -869,7 +949,7 @@ export function attachAcpSession({
};
const parser = createJsonLineStream((raw, rawLine) => {
if (aborted) return;
if (aborted || finished) return;
resetStageTimer('response');
const obj = asObject(raw);
if (!obj) return;
@@ -936,7 +1016,8 @@ export function attachAcpSession({
if (update.sessionUpdate === 'agent_message_chunk') {
const text = asObject(update.content)?.text;
if (typeof text === 'string' && text.length > 0) {
const delta = text.startsWith(emittedTextBuffer)
const isCumulativeSnapshot = text.startsWith(emittedTextBuffer);
const delta = isCumulativeSnapshot
? text.slice(emittedTextBuffer.length)
: text;
if (delta.length > 0) {
@@ -950,7 +1031,49 @@ export function attachAcpSession({
ttftMs: Date.now() - runStartedAt,
});
}
send('agent', { type: 'text_delta', delta });
if (dsmlArtifactSuppressor) {
const wasSuppressingArtifact = dsmlArtifactSuppressor.isSuppressing();
const hadPendingArtifactCandidate = dsmlArtifactSuppressor.hasPendingCandidate();
const strippedDelta = dsmlArtifactSuppressor.strip(delta);
const hasOpenArtifactCandidate =
dsmlArtifactSuppressor.isSuppressing() || dsmlArtifactSuppressor.hasPendingCandidate();
const consumedArtifactText = wasSuppressingArtifact || strippedDelta !== delta;
const shouldPreserveIncrementalProse =
!isCumulativeSnapshot &&
!wasSuppressingArtifact &&
!hadPendingArtifactCandidate &&
!hasOpenArtifactCandidate &&
(
strippedDelta === delta ||
(
!dsmlArtifactSuppressorArmedAfterText &&
dsmlArtifactSuppressorSawIncrementalProse &&
!ACP_ARTIFACT_ECHO_START_RE.test(delta)
)
);
const outputDelta = shouldPreserveIncrementalProse ? delta : strippedDelta;
if (outputDelta) {
emittedVisibleTextChunk = true;
send('agent', { type: 'text_delta', delta: outputDelta });
}
if (
strippedDelta === delta &&
!wasSuppressingArtifact &&
!hadPendingArtifactCandidate &&
!hasOpenArtifactCandidate
) {
dsmlArtifactSuppressorSawIncrementalProse = true;
}
if (consumedArtifactText && !hasOpenArtifactCandidate) {
dsmlArtifactSuppressor = null;
dsmlArtifactSuppressorToolCallId = null;
dsmlArtifactSuppressorArmedAfterText = false;
dsmlArtifactSuppressorSawIncrementalProse = false;
}
} else {
emittedVisibleTextChunk = true;
send('agent', { type: 'text_delta', delta });
}
}
}
return;
@@ -963,6 +1086,27 @@ export function attachAcpSession({
// when the model emits no closing assistant text. Track it so the prompt-complete
// handler does not misreport such a turn as "no output / model unavailable".
emittedToolCall = true;
const toolCallId = acpToolCallId(update);
if (toolCallId && isAcpArtifactWriteLabel(update)) {
acpArtifactWriteToolCallIds.add(toolCallId);
}
if (isAcpArtifactWriteUpdate(update, acpArtifactWriteToolCallIds)) {
dsmlArtifactSuppressor = createDsmlArtifactTextSuppressor();
dsmlArtifactSuppressorToolCallId = toolCallId;
dsmlArtifactSuppressorArmedAfterText = emittedTextBuffer.length > 0;
dsmlArtifactSuppressorSawIncrementalProse = false;
if (toolCallId) acpArtifactWriteToolCallIds.delete(toolCallId);
} else if (toolCallId && isAcpTerminalFailureStatus(update)) {
const ownsPendingWriteSuppression = toolCallId === dsmlArtifactSuppressorToolCallId;
const ownsPendingWriteCall = acpArtifactWriteToolCallIds.has(toolCallId);
acpArtifactWriteToolCallIds.delete(toolCallId);
if (ownsPendingWriteSuppression || ownsPendingWriteCall) {
dsmlArtifactSuppressor = null;
dsmlArtifactSuppressorToolCallId = null;
dsmlArtifactSuppressorArmedAfterText = false;
dsmlArtifactSuppressorSawIncrementalProse = false;
}
}
return;
}
return;
@@ -1023,30 +1167,7 @@ export function attachAcpSession({
);
return;
}
const usage = formatUsage(result.usage);
if (usage) {
send('agent', {
type: 'usage',
usage,
durationMs: Date.now() - runStartedAt,
});
}
finished = true;
clearStageTimer();
stdin.end();
// Some ACP agents (e.g. Devin for Terminal) keep the child process
// alive after stdin closes, waiting for the next prompt. Each Open
// Design run spawns its own agent process per turn, so the child must
// terminate for `child.on('close')` to fire and the chat run to
// finalize — otherwise the chat stays stuck in the "working" state.
// Give the child a short grace period to exit on its own first; if it
// doesn't, SIGTERM forces it. This mirrors the pattern in
// detectAcpModels() which already kills the child after a clean
// model-discovery probe completes (see line ~270 in this file).
const cleanExitTimer = setTimeout(() => {
if (!child.killed) child.kill('SIGTERM');
}, 500);
child.once('close', () => clearTimeout(cleanExitTimer));
finishCleanPrompt(result.usage);
return;
}
if (sessionId && model && model !== 'default' && obj.id === expectedId) {
@@ -0,0 +1,121 @@
type EventSink = (event: { type: 'text_delta'; delta: string }) => void;
const ARTIFACT_OPEN_RE = /(?:<\s*\|?\s*DSML[\s,]+artifact\b[^>]*>|<\s*artifact\b[^>]*>)/i;
const DSML_ARTIFACT_CLOSE_RE = /(?:<\/artifact>|<\/\s*\|?\s*DSML\s*>|<\s*\|?\s*\/\s*DSML\s*\|?\s*>)/i;
const DSML_OPEN_CANONICAL = 'dsmlartifact';
const ARTIFACT_OPEN_CANONICAL = 'artifact';
const ARTIFACT_CLOSE_CANONICALS = ['artifact', 'dsml'];
const MAX_CANDIDATE_LENGTH = 512;
export interface ArtifactTextSuppressor {
strip(text: string): string;
flush(): string;
isSuppressing(): boolean;
hasPendingCandidate(): boolean;
}
export function createDsmlArtifactTextSuppressor(): ArtifactTextSuppressor {
let suppressing = false;
let candidate = '';
function strip(text: string): string {
const current = `${candidate}${text}`;
candidate = '';
if (suppressing) {
const close = DSML_ARTIFACT_CLOSE_RE.exec(current);
if (!close || close.index === undefined) {
const closeCandidateStart = possibleArtifactCloseStart(current);
if (closeCandidateStart !== -1) candidate = current.slice(closeCandidateStart);
return '';
}
suppressing = false;
const end = close.index + close[0].length;
return strip(current.slice(end));
}
const open = ARTIFACT_OPEN_RE.exec(current);
if (open && open.index !== undefined) {
suppressing = true;
const prefix = current.slice(0, open.index);
const tail = current.slice(open.index + open[0].length);
return `${prefix}${strip(tail)}`;
}
const candidateStart = possibleDsmlArtifactOpenStart(current);
if (candidateStart === -1) return current;
candidate = current.slice(candidateStart);
return current.slice(0, candidateStart);
}
function flush(): string {
const text = candidate;
candidate = '';
return suppressing ? '' : text;
}
function isSuppressing(): boolean {
return suppressing;
}
function hasPendingCandidate(): boolean {
return candidate.length > 0;
}
return { strip, flush, isSuppressing, hasPendingCandidate };
}
export function emitWithTextSuppressor(
suppressor: ArtifactTextSuppressor,
onEvent: EventSink,
text: string,
): boolean {
const delta = suppressor.strip(text);
if (!delta) return false;
onEvent({ type: 'text_delta', delta });
return true;
}
function possibleDsmlArtifactOpenStart(text: string): number {
const min = Math.max(0, text.length - MAX_CANDIDATE_LENGTH);
let index = text.lastIndexOf('<');
while (index >= min) {
const tail = text.slice(index);
if (isPossibleDsmlArtifactOpen(tail)) return index;
if (index === 0) break;
index = text.lastIndexOf('<', index - 1);
}
return -1;
}
function isPossibleDsmlArtifactOpen(text: string): boolean {
if (!text.startsWith('<') || text.includes('>')) return false;
const compact = text.toLowerCase().replace(/[<|,\s]/g, '');
return compact.length === 0 ||
DSML_OPEN_CANONICAL.startsWith(compact) ||
compact.startsWith(DSML_OPEN_CANONICAL) ||
ARTIFACT_OPEN_CANONICAL.startsWith(compact) ||
compact.startsWith(ARTIFACT_OPEN_CANONICAL);
}
function possibleArtifactCloseStart(text: string): number {
const min = Math.max(0, text.length - MAX_CANDIDATE_LENGTH);
let index = text.lastIndexOf('<');
while (index >= min) {
const tail = text.slice(index);
if (isPossibleArtifactClose(tail)) return index;
if (index === 0) break;
index = text.lastIndexOf('<', index - 1);
}
return -1;
}
function isPossibleArtifactClose(text: string): boolean {
if (!text.startsWith('<') || text.includes('>')) return false;
const compact = text.toLowerCase().replace(/[<|/\s]/g, '');
return compact.length === 0 ||
ARTIFACT_CLOSE_CANONICALS.some((canonical) =>
canonical.startsWith(compact) || compact.startsWith(canonical),
);
}
+17 -15
View File
@@ -2033,26 +2033,28 @@ async function testAgentConnectionInternal(
const latencyMs = Date.now() - start;
const buffered = sink.getText().trim();
// ACP agents that don't shut down on stdin.end() (e.g. Devin for
// Terminal) are now SIGTERM'd from attachAcpSession after a clean
// prompt completion, which sets `winner.signal === 'SIGTERM'`. For
// that exact forced-shutdown shape we trust the ACP-level success
// signal so connection tests don't report `agent_spawn_failed`
// despite a healthy assistant response (see #1265 / #1286).
// ACP agents that don't shut down on stdin.end() are terminated after a
// clean prompt completion. Depending on the ACP bridge, this can surface
// either as SIGTERM or as a normal code 130 teardown. For those exact
// forced-shutdown shapes we trust the ACP-level success signal so
// connection tests don't report `agent_spawn_failed` despite a healthy
// assistant response (see #1265 / #1286).
//
// Scope the override narrowly: only `code === null` AND
// `signal === 'SIGTERM'` AND `acpCleanCompletion` count as a clean
// forced shutdown. Any other post-response process failure (non-zero
// exit code, SIGKILL, SIGSEGV, etc.) still falls through to
// `agent_spawn_failed`, preserving the existing connection-test
// failure behavior for genuine post-response problems.
// Scope the override narrowly: only the known daemon-triggered ACP
// teardown shapes plus `acpCleanCompletion` count as a clean forced
// shutdown. Any other post-response process failure (code 1, SIGKILL,
// SIGSEGV, etc.) still falls through to `agent_spawn_failed`, preserving
// the existing connection-test failure behavior for genuine
// post-response problems.
const acpCleanCompletion =
typeof acpSession?.completedSuccessfully === 'function' &&
acpSession.completedSuccessfully();
const acpForcedShutdown =
winner.code === null &&
winner.signal === 'SIGTERM' &&
acpCleanCompletion;
acpCleanCompletion &&
(
(winner.code === null && winner.signal === 'SIGTERM') ||
(winner.code === 130 && winner.signal === null)
);
const exitedCleanly =
(winner.code === 0 && !winner.signal) || acpForcedShutdown;
if (buffered) {
+9 -1
View File
@@ -4500,7 +4500,15 @@ export function classifyChatRunCloseStatus(params: {
if (params.cancelRequested) return 'canceled';
if (params.code === 0) return 'succeeded';
const acpForcedShutdown =
params.code === null && params.signal === 'SIGTERM' && params.acpCleanCompletion;
params.acpCleanCompletion &&
(
(params.code === null && params.signal === 'SIGTERM') ||
// Vela's ACP bridge can surface the daemon-triggered post-completion
// teardown as a normal exit code 130 instead of a SIGTERM signal. Once
// the ACP prompt already resolved cleanly, that is shutdown noise rather
// than an agent execution failure.
(params.code === 130 && params.signal === null)
);
if (acpForcedShutdown) return 'succeeded';
const artifactQuietShutdown =
params.artifactQuietShutdownRequested &&
+396
View File
@@ -305,6 +305,402 @@ test('attachAcpSession keeps incremental ACP message chunks unchanged', () => {
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 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[] = [];
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { createDsmlArtifactTextSuppressor } from '../src/artifact-text-suppression.js';
test('DSML artifact suppressor preserves non-DSML tags at the start of a chunk', () => {
const suppressor = createDsmlArtifactTextSuppressor();
assert.equal(
suppressor.strip('<question-form id="task-type">'),
'<question-form id="task-type">',
);
assert.equal(suppressor.flush(), '');
});
test('DSML artifact suppressor preserves partial non-DSML tags at the start of a chunk', () => {
const suppressor = createDsmlArtifactTextSuppressor();
assert.equal(suppressor.strip('<question'), '<question');
assert.equal(suppressor.flush(), '');
});
test('DSML artifact suppressor strips DSML artifact blocks', () => {
const suppressor = createDsmlArtifactTextSuppressor();
assert.equal(
suppressor.strip('Done\n\n< | DSML artifact identifier="page" type="text/html">'),
'Done\n\n',
);
assert.equal(
suppressor.strip('\n<!doctype html><html></html>\n</artifact>Tail'),
'Tail',
);
});
test('DSML artifact suppressor strips legacy artifact blocks', () => {
const suppressor = createDsmlArtifactTextSuppressor();
assert.equal(
suppressor.strip('Done\n\n<artifact identifier="page" type="text/html" title="Page">'),
'Done\n\n',
);
assert.equal(
suppressor.strip('\n<!doctype html><html></html>\n</artifact>Tail'),
'Tail',
);
});
test('DSML artifact suppressor strips split legacy artifact close tags', () => {
const suppressor = createDsmlArtifactTextSuppressor();
assert.equal(
suppressor.strip('Done\n\n<artifact identifier="page" type="text/html" title="Page">raw'),
'Done\n\n',
);
assert.equal(suppressor.strip('</art'), '');
assert.equal(suppressor.strip('ifact>Tail'), 'Tail');
});
test('DSML artifact suppressor strips split legacy artifact open tags', () => {
const suppressor = createDsmlArtifactTextSuppressor();
assert.equal(suppressor.strip('Done\n\n<art'), 'Done\n\n');
assert.equal(
suppressor.strip('ifact identifier="page" type="text/html">\n<html></html>'),
'',
);
assert.equal(suppressor.strip('</artifact>Tail'), 'Tail');
});
@@ -274,6 +274,28 @@ describe('classifyChatRunCloseStatus (#1451 close-handler classification)', () =
).toBe('succeeded');
});
it('returns succeeded on Vela ACP code 130 after clean ACP completion', () => {
expect(
classifyChatRunCloseStatus({
...base,
code: 130,
signal: null,
acpCleanCompletion: true,
}),
).toBe('succeeded');
});
it('returns failed on Vela ACP code 130 before clean ACP completion', () => {
expect(
classifyChatRunCloseStatus({
...base,
code: 130,
signal: null,
acpCleanCompletion: false,
}),
).toBe('failed');
});
it('returns failed when ACP shutdown was via SIGKILL (not the narrow override)', () => {
expect(
classifyChatRunCloseStatus({