mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
+148
-27
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user