Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions next/src/lib/agents/__tests__/argv.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,56 @@
import { describe, expect, it } from "vitest";
import { parseLine, makeParser } from "../argv";
import { parseLine, makeParser, sanitizeAgentStderrLine } from '../argv';

describe('parseLine codex lifecycle', () => {
it('reports the thread and generation start', () => {
expect(
parseLine(
'codex',
JSON.stringify({ type: 'thread.started', thread_id: 'thread_fixture' }),
),
).toEqual([{ kind: 'meta', key: 'session', value: 'thread_fixture' }]);

expect(parseLine('codex', JSON.stringify({ type: 'turn.started' }))).toEqual([
{ kind: 'meta', key: 'phase', value: 'generating' },
]);
});

it('reports diagnostic items as warnings instead of HTML', () => {
expect(
parseLine(
'codex',
JSON.stringify({
type: 'item.completed',
item: { type: 'error', message: 'Synthetic diagnostic' },
}),
),
).toEqual([
{ kind: 'meta', key: 'warning', value: 'Synthetic diagnostic' },
]);
});
});

describe('sanitizeAgentStderrLine', () => {
it('redacts the response body from Codex model refresh diagnostics', () => {
expect(
sanitizeAgentStderrLine(
'codex',
'ERROR codex_models_manager::manager: failed to refresh available models: decode failed; body: {"data":[{"id":"synthetic-model"}]}',
),
).toBe(
'ERROR codex_models_manager::manager: failed to refresh available models: decode failed; body: <redacted>',
);
});

it('preserves unrelated stderr', () => {
expect(sanitizeAgentStderrLine('codex', 'ordinary stderr')).toBe(
'ordinary stderr',
);
expect(sanitizeAgentStderrLine('claude', 'ordinary stderr')).toBe(
'ordinary stderr',
);
});
});

describe("parseLine opencode", () => {
it("extracts text from nested part payload", () => {
Expand Down Expand Up @@ -218,4 +269,3 @@ describe("parseLine bob", () => {
]);
});
});

29 changes: 28 additions & 1 deletion next/src/lib/agents/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ export function envFor(agent: string): NodeJS.ProcessEnv {
return base;
}

const CODEX_MODEL_REFRESH_DIAGNOSTIC =
'codex_models_manager::manager: failed to refresh available models';
const RESPONSE_BODY_MARKER = '; body: ';

export function sanitizeAgentStderrLine(agent: string, line: string): string {
if (agent !== 'codex' || !line.includes(CODEX_MODEL_REFRESH_DIAGNOSTIC)) {
return line;
}
const bodyIndex = line.indexOf(RESPONSE_BODY_MARKER);
if (bodyIndex === -1) return line;
return `${line.slice(0, bodyIndex + RESPONSE_BODY_MARKER.length)}<redacted>`;
}

export type AgentParse =
| { kind: "delta"; text: string }
| { kind: "meta"; key: string; value: unknown }
Expand Down Expand Up @@ -312,15 +325,29 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age
}

if (agent === "codex") {
if (obj.type === 'thread.started' && typeof obj.thread_id === 'string') {
out.push({ kind: 'meta', key: 'session', value: obj.thread_id });
}
if (obj.type === 'turn.started') {
out.push({ kind: 'meta', key: 'phase', value: 'generating' });
}
if (obj.type === "item.completed" && obj.item && typeof obj.item === "object") {
const item = obj.item as { item_type?: string; type?: string; text?: string };
const item = obj.item as {
item_type?: string;
type?: string;
text?: string;
message?: string;
};
const itemType = item.item_type ?? item.type;
if (
(itemType === "assistant_message" || itemType === "agent_message") &&
typeof item.text === "string"
) {
out.push({ kind: "delta", text: item.text });
}
if (itemType === 'error' && typeof item.message === 'string') {
out.push({ kind: 'meta', key: 'warning', value: item.message });
}
}
if (obj.type === "item.delta" && typeof obj.text === "string") {
out.push({ kind: "delta", text: obj.text });
Expand Down
34 changes: 32 additions & 2 deletions next/src/lib/agents/invoke.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import { resolveOnPath, resolveOpenclawAgentId, AGENTS } from "./detect";
import { buildArgv, envFor, makeParser, UnsupportedAgentProtocolError } from "./argv";
import {
buildArgv,
envFor,
makeParser,
sanitizeAgentStderrLine,
UnsupportedAgentProtocolError,
} from './argv';

export type InvokeOpts = {
agent: string;
Expand Down Expand Up @@ -202,6 +208,15 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream<InvokeEvent> {
const parse = makeParser(opts.agent);

let stdoutBuf = "";
let stderrBuf = '';
const flushStderr = () => {
if (!stderrBuf) return;
safeEnqueue({
type: 'stderr',
text: sanitizeAgentStderrLine(opts.agent, stderrBuf),
});
stderrBuf = '';
};
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
if (closed) return;
Expand Down Expand Up @@ -231,7 +246,20 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream<InvokeEvent> {

child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
safeEnqueue({ type: "stderr", text: chunk });
if (opts.agent !== 'codex') {
safeEnqueue({ type: "stderr", text: chunk });
return;
}
stderrBuf += chunk;
let nl: number;
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl);
stderrBuf = stderrBuf.slice(nl + 1);
safeEnqueue({
type: 'stderr',
text: `${sanitizeAgentStderrLine(opts.agent, line)}\n`,
});
}
});

child.on("error", (err) => {
Expand All @@ -240,6 +268,7 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream<InvokeEvent> {
});

child.on("close", (code) => {
flushStderr();
if (opts.agent === "openclaw") {
// OpenClaw's `agent --local --json` emits one pretty-printed JSON
// document on stdout. The visible reply is at
Expand Down Expand Up @@ -310,6 +339,7 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream<InvokeEvent> {
try {
child?.kill("SIGTERM");
} catch {}
flushStderr();
safeClose();
};
opts.signal?.addEventListener("abort", onAbort, { once: true });
Expand Down
36 changes: 36 additions & 0 deletions next/src/lib/use-convert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { conversionOutcome } from './use-convert';

describe('conversionOutcome', () => {
it('accepts a zero exit code with HTML', () => {
expect(conversionOutcome(0, '<html></html>')).toEqual({ status: 'done' });
});

it('rejects a zero exit code with no HTML', () => {
expect(conversionOutcome(0, ' ')).toEqual({
status: 'error',
message: 'Agent exited successfully but returned no HTML.',
});
});

it('rejects a non-zero exit code', () => {
expect(conversionOutcome(2, '<html></html>')).toEqual({
status: 'error',
message: 'Agent process exited with code 2.',
});
});

it('preserves a streamed agent error', () => {
expect(conversionOutcome(undefined, '', 'Synthetic transport error')).toEqual({
status: 'error',
message: 'Synthetic transport error',
});
});

it('rejects a stream without a terminal exit code', () => {
expect(conversionOutcome(undefined, '<html></html>')).toEqual({
status: 'error',
message: 'Agent process ended without an exit code.',
});
});
});
68 changes: 63 additions & 5 deletions next/src/lib/use-convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,37 @@ type ConvertReq = {
model?: string;
};

type ConversionOutcome =
| { status: 'done' }
| { status: 'error'; message: string };

export function conversionOutcome(
exitCode: number | null | undefined,
html: string,
agentError?: string,
): ConversionOutcome {
if (agentError) return { status: 'error', message: agentError };
if (exitCode === null || exitCode === undefined) {
return {
status: 'error',
message: 'Agent process ended without an exit code.',
};
}
if (exitCode !== 0) {
return {
status: 'error',
message: `Agent process exited with code ${exitCode}.`,
};
}
if (!html.trim()) {
return {
status: 'error',
message: 'Agent exited successfully but returned no HTML.',
};
}
return { status: 'done' };
}

/** prefix logged when the run is sent in diff-edit mode (vs full regeneration) */
const DIFF_LOG_PREFIX = "🔁 diff-edit 模式";

Expand Down Expand Up @@ -113,6 +144,8 @@ export function useConvert() {
const dec = new TextDecoder();
let buf = "";
let lastEvent = "";
let terminalCode: number | null | undefined;
let agentError: string | undefined;

while (true) {
const { value, done } = await reader.read();
Expand All @@ -139,15 +172,38 @@ export function useConvert() {
} catch {
continue;
}
const eventData = data as Record<string, unknown>;
if (event === 'done') {
terminalCode =
typeof eventData.code === 'number' ? eventData.code : null;
} else if (
event === 'error' &&
typeof eventData.message === 'string'
) {
agentError ??= eventData.message;
}
handleEvent(taskId, event, data, startedAt);
}
}
const endedAt = Date.now();
useStore.getState().patchStatsFor(taskId, { endedAt, durationMs: endedAt - startedAt });
useStore.getState().setStatusFor(taskId, "done");
// record the just-finished (content, html) as the new diff-edit baseline
// so the user's next edit goes through diff mode instead of full regen
useStore.getState().commitBaseFor(taskId);
const finalStore = useStore.getState();
finalStore.patchStatsFor(taskId, {
endedAt,
durationMs: endedAt - startedAt,
});
const html = finalStore.tasks.find((t) => t.id === taskId)?.html ?? '';
const outcome = conversionOutcome(terminalCode, html, agentError);
finalStore.setStatusFor(taskId, outcome.status);
if (outcome.status === 'done') {
// record the just-finished (content, html) as the new diff-edit baseline
// so the user's next edit goes through diff mode instead of full regen
finalStore.commitBaseFor(taskId);
} else if (!agentError) {
finalStore.pushLogFor(taskId, {
kind: 'error',
text: outcome.message,
});
}
} catch (err) {
if ((err as Error)?.name === "AbortError") {
useStore.getState().pushLogFor(taskId, { kind: "info", text: "已取消" });
Expand Down Expand Up @@ -296,6 +352,8 @@ function formatMeta(key: string, value: unknown): string {
if (key === "duration_ms") return `duration = ${value} ms`;
if (key === "cost_usd" && typeof value === "number") return `cost ≈ $${value.toFixed(4)}`;
if (key === "result") return `result = ${value}`;
if (key === 'phase' && value === 'generating') return 'Codex 已开始生成';
if (key === 'warning') return `warning: ${String(value)}`;
if (key === "rate_limit" && value && typeof value === "object") {
const r = value as { status?: string; rateLimitType?: string };
return `rate-limit: ${r.status} (${r.rateLimitType})`;
Expand Down