Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -107,7 +107,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const result = spawnSync(
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
|
||||
@@ -19,22 +19,20 @@ async function main(): Promise<void> {
|
||||
|
||||
const defaultOutPath = path.resolve(repoRoot, "tmp", "dictation-baseline.transcript.txt");
|
||||
const audioPath =
|
||||
process.env.DICTATION_AUDIO_PATH ??
|
||||
(process.argv[2] ? path.resolve(process.argv[2]) : null);
|
||||
process.env.DICTATION_AUDIO_PATH ?? (process.argv[2] ? path.resolve(process.argv[2]) : null);
|
||||
const outPath = path.resolve(process.env.DICTATION_OUT_PATH ?? process.argv[3] ?? defaultOutPath);
|
||||
|
||||
if (!audioPath) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Usage: npx tsx packages/server/scripts/dictation-generate-baseline.ts <audioPath> [outPath]\n" +
|
||||
" Or set DICTATION_AUDIO_PATH / DICTATION_OUT_PATH.\n"
|
||||
" Or set DICTATION_AUDIO_PATH / DICTATION_OUT_PATH.\n",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const apiKey = requireEnv("OPENAI_API_KEY");
|
||||
const transcriptionModel =
|
||||
process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe";
|
||||
const transcriptionModel = process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe";
|
||||
const prompt =
|
||||
process.env.OPENAI_REALTIME_DICTATION_TRANSCRIPTION_PROMPT ??
|
||||
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
|
||||
|
||||
@@ -31,8 +31,10 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: LocalSpeechMo
|
||||
}
|
||||
|
||||
if (modelIds.length === 0) {
|
||||
const stt = (process.env.PASEO_LOCAL_STT_MODEL || DEFAULT_LOCAL_STT_MODEL) as LocalSpeechModelId;
|
||||
const tts = (process.env.PASEO_LOCAL_TTS_MODEL || DEFAULT_LOCAL_TTS_MODEL) as LocalSpeechModelId;
|
||||
const stt = (process.env.PASEO_LOCAL_STT_MODEL ||
|
||||
DEFAULT_LOCAL_STT_MODEL) as LocalSpeechModelId;
|
||||
const tts = (process.env.PASEO_LOCAL_TTS_MODEL ||
|
||||
DEFAULT_LOCAL_TTS_MODEL) as LocalSpeechModelId;
|
||||
modelIds.push(stt, tts);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
function main() {
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
const outPath = path.join(
|
||||
repoRoot,
|
||||
"packages/website/public/schemas/paseo.config.v1.json"
|
||||
);
|
||||
const outPath = path.join(repoRoot, "packages/website/public/schemas/paseo.config.v1.json");
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
|
||||
const schema = zodToJsonSchema(PersistedConfigSchema, {
|
||||
|
||||
@@ -258,10 +258,10 @@ function toCsv(rows: GeneratedSample[]): string {
|
||||
lines.push(
|
||||
cells
|
||||
.map((cell) => {
|
||||
const escaped = cell.replace(/"/g, "\"\"");
|
||||
const escaped = cell.replace(/"/g, '""');
|
||||
return escaped.includes(",") ? `"${escaped}"` : escaped;
|
||||
})
|
||||
.join(",")
|
||||
.join(","),
|
||||
);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
@@ -278,7 +278,7 @@ logger.info(
|
||||
speed: options.speed,
|
||||
text: options.text,
|
||||
},
|
||||
"Generating TTS matrix across all model/voice combinations"
|
||||
"Generating TTS matrix across all model/voice combinations",
|
||||
);
|
||||
|
||||
await ensureLocalSpeechModels({
|
||||
@@ -303,7 +303,7 @@ for (const modelId of options.modelIds) {
|
||||
precision: "int8",
|
||||
targetChunkMs: 50,
|
||||
},
|
||||
logger
|
||||
logger,
|
||||
);
|
||||
const result = await tts.synthesizeSpeech(options.text);
|
||||
const pcm16 = await readStreamToBuffer(result.stream);
|
||||
@@ -354,7 +354,9 @@ for (const modelId of options.modelIds) {
|
||||
logger.info({ modelId, numSpeakers }, "Discovered Sherpa speaker count");
|
||||
|
||||
for (let sid = 0; sid < numSpeakers; sid += 1) {
|
||||
const audio = (tts as { generate: (config: { text: string; sid: number; speed: number }) => unknown }).generate({
|
||||
const audio = (
|
||||
tts as { generate: (config: { text: string; sid: number; speed: number }) => unknown }
|
||||
).generate({
|
||||
text: options.text,
|
||||
sid,
|
||||
speed,
|
||||
@@ -402,7 +404,7 @@ const manifest = {
|
||||
|
||||
await writeFile(
|
||||
path.join(options.outputDir, "manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(path.join(options.outputDir, "samples.csv"), toCsv(generated));
|
||||
await writeFile(
|
||||
@@ -419,7 +421,7 @@ await writeFile(
|
||||
" voice-label = default for Pocket TTS",
|
||||
"",
|
||||
`Total files: ${generated.length}`,
|
||||
].join("\n")
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
@@ -428,5 +430,5 @@ logger.info(
|
||||
generatedCount: generated.length,
|
||||
models: Array.from(new Set(generated.map((item) => item.modelId))),
|
||||
},
|
||||
"TTS matrix generation complete"
|
||||
"TTS matrix generation complete",
|
||||
);
|
||||
|
||||
@@ -4,13 +4,9 @@ import { z } from "zod";
|
||||
|
||||
const server = new McpServer({ name: "paseo-test-mcp", version: "1.0.0" });
|
||||
|
||||
server.tool(
|
||||
"paseo_roundtrip_text",
|
||||
{ text: z.string() },
|
||||
async ({ text }) => ({
|
||||
content: [{ type: "text", text: `ECHO:${text}` }],
|
||||
})
|
||||
);
|
||||
server.tool("paseo_roundtrip_text", { text: z.string() }, async ({ text }) => ({
|
||||
content: [{ type: "text", text: `ECHO:${text}` }],
|
||||
}));
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -4,17 +4,14 @@ import { standardizePrompt } from "ai/internal";
|
||||
|
||||
async function processConversation() {
|
||||
try {
|
||||
const conversationPath =
|
||||
".debug.conversations/ce44c79a-0689-4210-8e00-72c0a627406d-2.json";
|
||||
const conversationPath = ".debug.conversations/ce44c79a-0689-4210-8e00-72c0a627406d-2.json";
|
||||
|
||||
process.stdout.write(`Loading conversation from: ${conversationPath}\n`);
|
||||
|
||||
const conversationData: any = JSON.parse(
|
||||
readFileSync(conversationPath, "utf-8")
|
||||
);
|
||||
const conversationData: any = JSON.parse(readFileSync(conversationPath, "utf-8"));
|
||||
|
||||
process.stdout.write(
|
||||
`\nLoaded conversation ${conversationData.conversationId} with ${conversationData.messages.length} messages\n\n`
|
||||
`\nLoaded conversation ${conversationData.conversationId} with ${conversationData.messages.length} messages\n\n`,
|
||||
);
|
||||
|
||||
const result = await standardizePrompt({
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe('supervision parity', () => {
|
||||
test('has exactly one runtime callsite for runSupervisor', () => {
|
||||
const daemonRunner = readFileSync(new URL('./daemon-runner.ts', import.meta.url), 'utf8')
|
||||
const devRunner = readFileSync(new URL('./dev-runner.ts', import.meta.url), 'utf8')
|
||||
describe("supervision parity", () => {
|
||||
test("has exactly one runtime callsite for runSupervisor", () => {
|
||||
const daemonRunner = readFileSync(new URL("./daemon-runner.ts", import.meta.url), "utf8");
|
||||
const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8");
|
||||
|
||||
const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length
|
||||
const devRunnerCalls = (devRunner.match(/\brunSupervisor\s*\(/g) ?? []).length
|
||||
const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length;
|
||||
const devRunnerCalls = (devRunner.match(/\brunSupervisor\s*\(/g) ?? []).length;
|
||||
|
||||
expect(daemonRunnerCalls + devRunnerCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
expect(daemonRunnerCalls + devRunnerCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe('supervisor lifecycle intents', () => {
|
||||
test('uses explicit shutdown and restart IPC intents', () => {
|
||||
const source = readFileSync(new URL('./supervisor.ts', import.meta.url), 'utf8')
|
||||
const legacyShutdownReason = ['cli', 'shutdown'].join('_')
|
||||
describe("supervisor lifecycle intents", () => {
|
||||
test("uses explicit shutdown and restart IPC intents", () => {
|
||||
const source = readFileSync(new URL("./supervisor.ts", import.meta.url), "utf8");
|
||||
const legacyShutdownReason = ["cli", "shutdown"].join("_");
|
||||
|
||||
expect(source).toContain('"paseo:shutdown"')
|
||||
expect(source).toContain('"paseo:restart"')
|
||||
expect(source).not.toContain(legacyShutdownReason)
|
||||
})
|
||||
})
|
||||
expect(source).toContain('"paseo:shutdown"');
|
||||
expect(source).toContain('"paseo:restart"');
|
||||
expect(source).not.toContain(legacyShutdownReason);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,15 +11,17 @@ async function main() {
|
||||
|
||||
const client = new Client(
|
||||
{ name: "test-client", version: "1.0.0" },
|
||||
{ capabilities: { elicitation: {} } }
|
||||
{ capabilities: { elicitation: {} } },
|
||||
);
|
||||
|
||||
// Listen for events
|
||||
client.setNotificationHandler(
|
||||
z.object({
|
||||
method: z.literal("codex/event"),
|
||||
params: z.object({ msg: z.any() }),
|
||||
}).passthrough(),
|
||||
z
|
||||
.object({
|
||||
method: z.literal("codex/event"),
|
||||
params: z.object({ msg: z.any() }),
|
||||
})
|
||||
.passthrough(),
|
||||
(data) => {
|
||||
const event = (data.params as { msg: unknown }).msg as {
|
||||
type?: string;
|
||||
@@ -41,7 +43,7 @@ async function main() {
|
||||
} else {
|
||||
process.stdout.write("Event: " + event.type + "\n");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
@@ -50,22 +52,26 @@ async function main() {
|
||||
process.stdout.write("\n=== Testing dynamic MCP server config ===\n\n");
|
||||
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name: "codex",
|
||||
arguments: {
|
||||
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
|
||||
sandbox: "danger-full-access",
|
||||
"approval-policy": "never",
|
||||
config: {
|
||||
mcp_servers: {
|
||||
"test-server": {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-server-time"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, undefined, { timeout: 60000 });
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: "codex",
|
||||
arguments: {
|
||||
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
|
||||
sandbox: "danger-full-access",
|
||||
"approval-policy": "never",
|
||||
config: {
|
||||
mcp_servers: {
|
||||
"test-server": {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-server-time"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
process.stdout.write("\n=== RESULT ===\n");
|
||||
const content = (result as { content: { text?: string }[] }).content;
|
||||
|
||||
@@ -49,7 +49,9 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
|
||||
const positional: string[] = [];
|
||||
let outPath: string | undefined;
|
||||
let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL);
|
||||
let model = LocalSttModelIdSchema.parse(
|
||||
process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL,
|
||||
);
|
||||
let modelsDir = defaultModelsDir;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -146,13 +148,15 @@ async function main(): Promise<void> {
|
||||
throw new Error(
|
||||
"Local STT service is unavailable. Check model files or run `npm run speech:download -- --model " +
|
||||
options.model +
|
||||
"`."
|
||||
"`.",
|
||||
);
|
||||
}
|
||||
|
||||
const audio = await readFile(options.wavPath);
|
||||
const manager = new STTManager("dev-local-wav-transcribe", logger, runtime.sttService);
|
||||
const result = await manager.transcribe(audio, "audio/wav", { label: "dev-local-wav-transcribe" });
|
||||
const result = await manager.transcribe(audio, "audio/wav", {
|
||||
label: "dev-local-wav-transcribe",
|
||||
});
|
||||
|
||||
const transcript = result.text.trim();
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ async function synthesizeInput(text: string, logger: pino.Logger): Promise<Buffe
|
||||
responseFormat: "pcm",
|
||||
voice: "alloy",
|
||||
},
|
||||
logger
|
||||
logger,
|
||||
);
|
||||
|
||||
const generated = await tts.synthesizeSpeech(text);
|
||||
@@ -98,7 +98,7 @@ async function runVoiceRoundTrip(params: {
|
||||
await client.sendVoiceAudioChunk(
|
||||
chunk.toString("base64"),
|
||||
"audio/pcm;rate=24000;bits=16",
|
||||
isLast
|
||||
isLast,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,16 +141,14 @@ async function main(): Promise<void> {
|
||||
const newAgentResult = await runVoiceRoundTrip({
|
||||
daemonUrl,
|
||||
voiceAgentId,
|
||||
speechText:
|
||||
"Use the speak tool and say exactly: new voice agent round trip successful.",
|
||||
speechText: "Use the speak tool and say exactly: new voice agent round trip successful.",
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const resumedResult = await runVoiceRoundTrip({
|
||||
daemonUrl,
|
||||
voiceAgentId: newAgentResult.voiceAgentId,
|
||||
speechText:
|
||||
"Use the speak tool and say exactly: resumed voice agent round trip successful.",
|
||||
speechText: "Use the speak tool and say exactly: resumed voice agent round trip successful.",
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
@@ -163,8 +161,8 @@ async function main(): Promise<void> {
|
||||
resumedResult,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
async function streamWithTimeout(
|
||||
iterator: AsyncGenerator<AgentStreamEvent>,
|
||||
timeoutMs: number,
|
||||
onEvent: (event: AgentStreamEvent) => Promise<void> | void
|
||||
onEvent: (event: AgentStreamEvent) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
@@ -171,7 +171,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "tool_call") {
|
||||
logger.info({ name: event.item.name, callId: event.item.callId }, "Timeline tool call");
|
||||
if (typeof event.item.name === "string" && event.item.name.toLowerCase().includes("speak")) {
|
||||
if (
|
||||
typeof event.item.name === "string" &&
|
||||
event.item.name.toLowerCase().includes("speak")
|
||||
) {
|
||||
sawSpeakTimeline = true;
|
||||
}
|
||||
}
|
||||
@@ -189,8 +192,8 @@ async function main(): Promise<void> {
|
||||
failureError,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await agentManager.closeAgent(agentId).catch(() => undefined);
|
||||
|
||||
@@ -75,7 +75,11 @@ async function main(): Promise<void> {
|
||||
|
||||
const offActivity = client.on("activity_log", (msg) => {
|
||||
if (msg.type !== "activity_log") return;
|
||||
if (msg.payload.type === "transcript" || msg.payload.type === "error" || msg.payload.type === "assistant") {
|
||||
if (
|
||||
msg.payload.type === "transcript" ||
|
||||
msg.payload.type === "error" ||
|
||||
msg.payload.type === "assistant"
|
||||
) {
|
||||
console.log("activity_log", {
|
||||
type: msg.payload.type,
|
||||
content: msg.payload.content,
|
||||
@@ -129,10 +133,10 @@ async function main(): Promise<void> {
|
||||
responseFormat: "pcm",
|
||||
voice: "alloy",
|
||||
},
|
||||
logger
|
||||
logger,
|
||||
);
|
||||
const generated = await tts.synthesizeSpeech(
|
||||
"Use the speak tool and say exactly round trip successful."
|
||||
"Use the speak tool and say exactly round trip successful.",
|
||||
);
|
||||
const pcm = await streamToBuffer(generated.stream as AsyncIterable<unknown>);
|
||||
|
||||
@@ -143,20 +147,20 @@ async function main(): Promise<void> {
|
||||
await client.sendVoiceAudioChunk(
|
||||
chunk.toString("base64"),
|
||||
"audio/pcm;rate=24000;bits=16",
|
||||
isLast
|
||||
isLast,
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
firstAudio,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for first audio_output")), 120000)
|
||||
setTimeout(() => reject(new Error("Timed out waiting for first audio_output")), 120000),
|
||||
),
|
||||
]);
|
||||
await Promise.race([
|
||||
lastAudio,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for final audio_output")), 120000)
|
||||
setTimeout(() => reject(new Error("Timed out waiting for final audio_output")), 120000),
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createRelayE2eeTransportFactory(args: {
|
||||
export function createEncryptedTransport(
|
||||
base: DaemonTransport,
|
||||
daemonPublicKeyB64: string,
|
||||
logger: TransportLogger
|
||||
logger: TransportLogger,
|
||||
): DaemonTransport {
|
||||
let channel: EncryptedChannel | null = null;
|
||||
let opened = false;
|
||||
@@ -124,9 +124,7 @@ export function createEncryptedTransport(
|
||||
emitClose(event);
|
||||
});
|
||||
base.onError((event) => {
|
||||
relayTransport.onerror?.(
|
||||
event instanceof Error ? event : new Error(String(event))
|
||||
);
|
||||
relayTransport.onerror?.(event instanceof Error ? event : new Error(String(event)));
|
||||
emitError(event);
|
||||
});
|
||||
|
||||
@@ -181,10 +179,7 @@ function emitHandlers<TArgs extends unknown[]>(
|
||||
}
|
||||
}
|
||||
|
||||
function invokeHandler<TArgs extends unknown[]>(
|
||||
handler: (...args: TArgs) => void,
|
||||
...args: TArgs
|
||||
) {
|
||||
function invokeHandler<TArgs extends unknown[]>(handler: (...args: TArgs) => void, ...args: TArgs) {
|
||||
try {
|
||||
handler(...args);
|
||||
} catch {
|
||||
|
||||
@@ -35,10 +35,8 @@ export class TerminalStreamManager {
|
||||
private readonly maxBufferedBytes: number;
|
||||
|
||||
constructor(private readonly config: TerminalStreamManagerConfig) {
|
||||
this.maxBufferedChunks =
|
||||
config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS;
|
||||
this.maxBufferedBytes =
|
||||
config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
||||
this.maxBufferedChunks = config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS;
|
||||
this.maxBufferedBytes = config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
@@ -53,10 +51,7 @@ export class TerminalStreamManager {
|
||||
this.ackOffsets.delete(input.streamId);
|
||||
}
|
||||
|
||||
subscribe(input: {
|
||||
streamId: number;
|
||||
handler: TerminalChunkHandler;
|
||||
}): () => void {
|
||||
subscribe(input: { streamId: number; handler: TerminalChunkHandler }): () => void {
|
||||
const { streamId, handler } = input;
|
||||
if (!this.handlers.has(streamId)) {
|
||||
this.handlers.set(streamId, new Set());
|
||||
@@ -111,10 +106,7 @@ export class TerminalStreamManager {
|
||||
}
|
||||
}
|
||||
|
||||
private flushBufferedChunks(input: {
|
||||
streamId: number;
|
||||
handler: TerminalChunkHandler;
|
||||
}): void {
|
||||
private flushBufferedChunks(input: { streamId: number; handler: TerminalChunkHandler }): void {
|
||||
const buffered = this.bufferedChunks.get(input.streamId);
|
||||
if (!buffered || buffered.chunks.length === 0) {
|
||||
return;
|
||||
@@ -143,10 +135,7 @@ export class TerminalStreamManager {
|
||||
queue.chunks.push(input.chunk);
|
||||
queue.bytes += input.chunk.data.byteLength;
|
||||
|
||||
while (
|
||||
queue.chunks.length > this.maxBufferedChunks ||
|
||||
queue.bytes > this.maxBufferedBytes
|
||||
) {
|
||||
while (queue.chunks.length > this.maxBufferedChunks || queue.bytes > this.maxBufferedBytes) {
|
||||
const removed = queue.chunks.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
|
||||
@@ -14,7 +14,7 @@ export type DaemonTransportFactory = (options: {
|
||||
|
||||
export type WebSocketFactory = (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string> }
|
||||
options?: { headers?: Record<string, string> },
|
||||
) => WebSocketLike;
|
||||
|
||||
export type WebSocketLike = {
|
||||
|
||||
@@ -6,7 +6,7 @@ export function copyArrayBufferViewToBuffer(data: ArrayBufferView): ArrayBuffer
|
||||
}
|
||||
|
||||
export function normalizeTransportPayload(
|
||||
data: string | Uint8Array | ArrayBuffer
|
||||
data: string | Uint8Array | ArrayBuffer,
|
||||
): string | ArrayBuffer {
|
||||
if (typeof data === "string" || data instanceof ArrayBuffer) {
|
||||
return data;
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("daemon-client transport helpers", () => {
|
||||
onMessage: () => () => {},
|
||||
},
|
||||
"daemon-public-key",
|
||||
{ warn: vi.fn() }
|
||||
{ warn: vi.fn() },
|
||||
);
|
||||
|
||||
expect(openHandler).not.toBeNull();
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
|
||||
export function defaultWebSocketFactory(
|
||||
url: string,
|
||||
_options?: { headers?: Record<string, string> }
|
||||
_options?: { headers?: Record<string, string> },
|
||||
): WebSocketLike {
|
||||
const globalWs = (globalThis as { WebSocket?: any }).WebSocket;
|
||||
if (!globalWs) {
|
||||
@@ -15,9 +15,7 @@ export function defaultWebSocketFactory(
|
||||
return new globalWs(url);
|
||||
}
|
||||
|
||||
export function createWebSocketTransportFactory(
|
||||
factory: WebSocketFactory
|
||||
): DaemonTransportFactory {
|
||||
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
|
||||
return ({ url, headers }) => {
|
||||
const ws = factory(url, { headers });
|
||||
if ("binaryType" in ws) {
|
||||
@@ -46,7 +44,7 @@ export function createWebSocketTransportFactory(
|
||||
export function bindWsHandler(
|
||||
ws: WebSocketLike,
|
||||
event: "open" | "close" | "error" | "message",
|
||||
handler: (...args: any[]) => void
|
||||
handler: (...args: any[]) => void,
|
||||
): () => void {
|
||||
if (typeof ws.addEventListener === "function") {
|
||||
ws.addEventListener(event, handler);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1298
-1296
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,7 @@
|
||||
* to understand how command output is delivered.
|
||||
*/
|
||||
|
||||
import {
|
||||
query,
|
||||
type SDKUserMessage,
|
||||
type SDKMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { query, type SDKUserMessage, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
class Pushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
@@ -103,7 +99,9 @@ async function investigateCommand(commandName: string): Promise<void> {
|
||||
break;
|
||||
|
||||
case "user":
|
||||
process.stdout.write(`User message content: ${JSON.stringify(message.message?.content, null, 2)}\n`);
|
||||
process.stdout.write(
|
||||
`User message content: ${JSON.stringify(message.message?.content, null, 2)}\n`,
|
||||
);
|
||||
break;
|
||||
|
||||
case "assistant":
|
||||
@@ -142,7 +140,7 @@ async function investigateCommand(commandName: string): Promise<void> {
|
||||
process.stdout.write(`Errors: ${JSON.stringify(message.errors)}\n`);
|
||||
}
|
||||
// Check for any other properties
|
||||
const resultKeys = Object.keys(message).filter(k => !["type", "subtype"].includes(k));
|
||||
const resultKeys = Object.keys(message).filter((k) => !["type", "subtype"].includes(k));
|
||||
if (resultKeys.length > 0) {
|
||||
process.stdout.write(`Other result properties: ${resultKeys.join(", ")}\n`);
|
||||
for (const key of resultKeys) {
|
||||
@@ -161,7 +159,6 @@ async function investigateCommand(commandName: string): Promise<void> {
|
||||
}
|
||||
|
||||
process.stdout.write(`\nTotal messages received: ${messageCount}\n`);
|
||||
|
||||
} finally {
|
||||
input.end();
|
||||
await claudeQuery.return?.();
|
||||
|
||||
@@ -136,7 +136,9 @@ async function executeCommand(commandName: string): Promise<void> {
|
||||
// Iterate the query to process the command
|
||||
let gotSystemInit = false;
|
||||
for await (const message of claudeQuery) {
|
||||
process.stdout.write(` [${message.type}] ${message.type === "system" ? message.subtype : ""}\n`);
|
||||
process.stdout.write(
|
||||
` [${message.type}] ${message.type === "system" ? message.subtype : ""}\n`,
|
||||
);
|
||||
|
||||
if (message.type === "system" && message.subtype === "init") {
|
||||
gotSystemInit = true;
|
||||
@@ -149,7 +151,9 @@ async function executeCommand(commandName: string): Promise<void> {
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text") {
|
||||
process.stdout.write(` Response: ${block.text.slice(0, 200)}${block.text.length > 200 ? "..." : ""}\n`);
|
||||
process.stdout.write(
|
||||
` Response: ${block.text.slice(0, 200)}${block.text.length > 200 ? "..." : ""}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +203,6 @@ async function main() {
|
||||
// Actually execute a command to demonstrate it works:
|
||||
// Using "context" as it's fast and doesn't require arguments
|
||||
await executeCommand("context");
|
||||
|
||||
} catch (error) {
|
||||
process.stderr.write(`ERROR: ${error}\n`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("computeShouldNotifyClient", () => {
|
||||
clientState: staleMobile,
|
||||
allClientStates: [activeOnAgent, staleMobile],
|
||||
agentId: "agent-1",
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("computeShouldNotifyClient", () => {
|
||||
clientState: unknownClient,
|
||||
allClientStates: [unknownClient],
|
||||
agentId: "agent-2",
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("computeShouldNotifyClient", () => {
|
||||
clientState: focusedWeb,
|
||||
allClientStates: [focusedWeb],
|
||||
agentId: "agent-3",
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("computeShouldNotifyClient", () => {
|
||||
clientState: activeButNotFocused,
|
||||
allClientStates: [activeButNotFocused],
|
||||
agentId: "agent-4",
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("computeShouldNotifyClient", () => {
|
||||
clientState: staleMobile,
|
||||
allClientStates: [staleMobile, activeWeb],
|
||||
agentId: "agent-5",
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ describe("computeShouldNotifyClient", () => {
|
||||
clientState: staleMobile,
|
||||
allClientStates: [staleMobile, staleWeb],
|
||||
agentId: "agent-6",
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("computeShouldNotifyClient", () => {
|
||||
clientState: staleWeb,
|
||||
allClientStates: [staleWeb, staleMobile],
|
||||
agentId: "agent-7",
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("computeShouldNotifyClient", () => {
|
||||
clientState: staleWeb,
|
||||
allClientStates: [staleWeb],
|
||||
agentId: "agent-8",
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe("computeShouldSendPush", () => {
|
||||
computeShouldSendPush({
|
||||
reason: "error",
|
||||
allClientStates: [],
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ describe("computeShouldSendPush", () => {
|
||||
appVisible: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -213,7 +213,7 @@ describe("computeShouldSendPush", () => {
|
||||
appVisible: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ describe("computeShouldSendPush", () => {
|
||||
appVisible: false,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,12 +18,9 @@ type ComputePushNotificationInput = {
|
||||
allClientStates: ClientAttentionState[];
|
||||
};
|
||||
|
||||
function hasActiveClientOnAgent(
|
||||
allClientStates: ClientAttentionState[],
|
||||
agentId: string
|
||||
): boolean {
|
||||
function hasActiveClientOnAgent(allClientStates: ClientAttentionState[], agentId: string): boolean {
|
||||
return allClientStates.some(
|
||||
(state) => state.focusedAgentId === agentId && state.appVisible && !state.isStale
|
||||
(state) => state.focusedAgentId === agentId && state.appVisible && !state.isStale,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,17 +30,17 @@ function hasActiveWebClient(allClientStates: ClientAttentionState[]): boolean {
|
||||
|
||||
function hasOtherCompetingClient(
|
||||
clientState: ClientAttentionState,
|
||||
allClientStates: ClientAttentionState[]
|
||||
allClientStates: ClientAttentionState[],
|
||||
): boolean {
|
||||
return allClientStates.some(
|
||||
(state) =>
|
||||
state !== clientState && (state.deviceType === "mobile" || state.deviceType === null)
|
||||
state !== clientState && (state.deviceType === "mobile" || state.deviceType === null),
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveForegroundMobileClient(allClientStates: ClientAttentionState[]): boolean {
|
||||
return allClientStates.some(
|
||||
(state) => state.deviceType === "mobile" && state.appVisible && !state.isStale
|
||||
(state) => state.deviceType === "mobile" && state.appVisible && !state.isStale,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,19 +13,18 @@ function toolCallItem(params: {
|
||||
detail?: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"];
|
||||
}): Extract<AgentTimelineItem, { type: "tool_call" }> {
|
||||
const status = params.status ?? "completed";
|
||||
const detail =
|
||||
params.detail ?? {
|
||||
type: "unknown" as const,
|
||||
input: params.input ?? null,
|
||||
output: params.output ?? null,
|
||||
};
|
||||
const detail = params.detail ?? {
|
||||
type: "unknown" as const,
|
||||
input: params.input ?? null,
|
||||
output: params.output ?? null,
|
||||
};
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: params.callId,
|
||||
name: params.name,
|
||||
status,
|
||||
detail,
|
||||
error: status === "failed" ? params.error ?? { message: "failed" } : null,
|
||||
error: status === "failed" ? (params.error ?? { message: "failed" }) : null,
|
||||
metadata: params.metadata,
|
||||
};
|
||||
}
|
||||
@@ -117,9 +116,7 @@ describe("curateAgentActivity", () => {
|
||||
|
||||
const result = curateAgentActivity(timeline);
|
||||
|
||||
expect(result).toBe(
|
||||
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
|
||||
);
|
||||
expect(result).toBe('[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}');
|
||||
});
|
||||
|
||||
it("collapses repeated tool updates by callId", () => {
|
||||
|
||||
@@ -46,7 +46,12 @@ function formatToolInputJson(input: unknown): string | null {
|
||||
}
|
||||
|
||||
function hasNonEmptyObject(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function mergeUnknownValue(existing: unknown | null, incoming: unknown | null): unknown | null {
|
||||
@@ -171,7 +176,7 @@ function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] {
|
||||
*/
|
||||
export function curateAgentActivity(
|
||||
timeline: AgentTimelineItem[],
|
||||
options?: { maxItems?: number }
|
||||
options?: { maxItems?: number },
|
||||
): string {
|
||||
if (timeline.length === 0) {
|
||||
return "No activity to display.";
|
||||
@@ -182,9 +187,7 @@ export function curateAgentActivity(
|
||||
|
||||
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||
const recentItems =
|
||||
maxItems > 0 && collapsed.length > maxItems
|
||||
? collapsed.slice(-maxItems)
|
||||
: collapsed;
|
||||
maxItems > 0 && collapsed.length > maxItems ? collapsed.slice(-maxItems) : collapsed;
|
||||
|
||||
const lines: string[] = [];
|
||||
const buffers = { message: "", thought: "" };
|
||||
|
||||
@@ -26,16 +26,8 @@ import { z } from "zod";
|
||||
import { ensureValidJson } from "../json-utils.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
AgentPermissionRequest,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentManager,
|
||||
ManagedAgent,
|
||||
WaitForAgentResult,
|
||||
} from "./agent-manager.js";
|
||||
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import {
|
||||
AgentPermissionRequestPayloadSchema,
|
||||
AgentPermissionResponseSchema,
|
||||
@@ -55,10 +47,7 @@ import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import { expandUserPath } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import {
|
||||
createAgentWorktree,
|
||||
runAsyncWorktreeBootstrap,
|
||||
} from "../worktree-bootstrap.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
@@ -72,16 +61,10 @@ const AgentProviderEnum = z.enum(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
||||
AgentProvider,
|
||||
...AgentProvider[],
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const AgentStatusEnum = z.enum([
|
||||
"initializing",
|
||||
"idle",
|
||||
"running",
|
||||
"error",
|
||||
"closed",
|
||||
]);
|
||||
const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
|
||||
|
||||
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||
const AGENT_WAIT_TIMEOUT_MS = 50000;
|
||||
@@ -92,7 +75,7 @@ async function waitForAgentWithTimeout(
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
waitForActive?: boolean;
|
||||
}
|
||||
},
|
||||
): Promise<WaitForAgentResult> {
|
||||
const timeoutController = new AbortController();
|
||||
const combinedController = new AbortController();
|
||||
@@ -111,18 +94,16 @@ async function waitForAgentWithTimeout(
|
||||
if (options.signal.aborted) {
|
||||
forwardAbort(options.signal.reason);
|
||||
} else {
|
||||
options.signal.addEventListener(
|
||||
"abort",
|
||||
() => forwardAbort(options.signal!.reason),
|
||||
{ once: true }
|
||||
);
|
||||
options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
timeoutController.signal.addEventListener(
|
||||
"abort",
|
||||
() => forwardAbort(timeoutController.signal.reason),
|
||||
{ once: true }
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -154,7 +135,7 @@ function startAgentRun(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
logger: Logger,
|
||||
options?: { replaceRunning?: boolean }
|
||||
options?: { replaceRunning?: boolean },
|
||||
): void {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const shouldReplace =
|
||||
@@ -175,7 +156,7 @@ function startAgentRun(
|
||||
}
|
||||
|
||||
function sanitizePermissionRequest(
|
||||
permission: AgentPermissionRequest | null | undefined
|
||||
permission: AgentPermissionRequest | null | undefined,
|
||||
): AgentPermissionRequest | null {
|
||||
if (!permission) {
|
||||
return null;
|
||||
@@ -202,7 +183,7 @@ function sanitizePermissionRequest(
|
||||
async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentId: string,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const record = await agentStorage.get(agentId);
|
||||
@@ -216,14 +197,14 @@ async function resolveAgentTitle(
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
) {
|
||||
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, { title });
|
||||
}
|
||||
|
||||
export async function createAgentManagementMcpServer(
|
||||
options: AgentManagementMcpOptions
|
||||
options: AgentManagementMcpOptions,
|
||||
): Promise<McpServer> {
|
||||
const { agentManager, agentStorage, logger } = options;
|
||||
const childLogger = logger.child({
|
||||
@@ -240,47 +221,35 @@ export async function createAgentManagementMcpServer(
|
||||
const inputSchema = {
|
||||
cwd: z
|
||||
.string()
|
||||
.describe(
|
||||
"Required working directory for the agent (absolute, relative, or ~)."
|
||||
),
|
||||
.describe("Required working directory for the agent (absolute, relative, or ~)."),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Title is required")
|
||||
.max(60, "Title must be 60 characters or fewer")
|
||||
.describe(
|
||||
"Short descriptive title (<= 60 chars) summarizing the agent's focus."
|
||||
),
|
||||
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
|
||||
agentType: AgentProviderEnum.optional().describe(
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'."
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'.",
|
||||
),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional task to start immediately after creation (non-blocking)."
|
||||
),
|
||||
initialMode: z
|
||||
.string()
|
||||
.describe("Required session mode to configure before the first run."),
|
||||
.describe("Optional task to start immediately after creation (non-blocking)."),
|
||||
initialMode: z.string().describe("Required session mode to configure before the first run."),
|
||||
worktreeName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
|
||||
),
|
||||
.describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."),
|
||||
baseBranch: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Required when worktreeName is set: the base branch to diff/merge against."
|
||||
),
|
||||
.describe("Required when worktreeName is set: the base branch to diff/merge against."),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -302,7 +271,7 @@ export async function createAgentManagementMcpServer(
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
lastMessage: z.string().nullable().optional(),
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||
@@ -396,7 +365,7 @@ export async function createAgentManagementMcpServer(
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId: snapshot.id },
|
||||
"Failed to record initial prompt"
|
||||
"Failed to record initial prompt",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -404,11 +373,9 @@ export async function createAgentManagementMcpServer(
|
||||
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
||||
|
||||
if (!background) {
|
||||
const result = await waitForAgentWithTimeout(
|
||||
agentManager,
|
||||
snapshot.id,
|
||||
{ waitForActive: true }
|
||||
);
|
||||
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
|
||||
waitForActive: true,
|
||||
});
|
||||
|
||||
const responseData = {
|
||||
agentId: snapshot.id,
|
||||
@@ -428,10 +395,7 @@ export async function createAgentManagementMcpServer(
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId: snapshot.id },
|
||||
"Failed to run initial prompt"
|
||||
);
|
||||
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +412,7 @@ export async function createAgentManagementMcpServer(
|
||||
permission: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -458,9 +422,7 @@ export async function createAgentManagementMcpServer(
|
||||
description:
|
||||
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
|
||||
inputSchema: {
|
||||
agentId: z
|
||||
.string()
|
||||
.describe("Agent identifier returned by the create_agent tool"),
|
||||
agentId: z.string().describe("Agent identifier returned by the create_agent tool"),
|
||||
},
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
@@ -498,27 +460,21 @@ export async function createAgentManagementMcpServer(
|
||||
signal.addEventListener("abort", forwardExternalAbort, {
|
||||
once: true,
|
||||
});
|
||||
cleanupFns.push(() =>
|
||||
signal.removeEventListener("abort", forwardExternalAbort)
|
||||
);
|
||||
cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort));
|
||||
}
|
||||
}
|
||||
|
||||
const unregister = waitTracker.register(agentId, (reason) => {
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort(
|
||||
new Error(reason ?? "wait_for_agent cancelled")
|
||||
);
|
||||
abortController.abort(new Error(reason ?? "wait_for_agent cancelled"));
|
||||
}
|
||||
});
|
||||
cleanupFns.push(unregister);
|
||||
|
||||
try {
|
||||
const result: WaitForAgentResult = await waitForAgentWithTimeout(
|
||||
agentManager,
|
||||
agentId,
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const validJson = ensureValidJson({
|
||||
agentId,
|
||||
@@ -534,7 +490,7 @@ export async function createAgentManagementMcpServer(
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -555,7 +511,7 @@ export async function createAgentManagementMcpServer(
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -584,10 +540,7 @@ export async function createAgentManagementMcpServer(
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId },
|
||||
"Failed to record user message"
|
||||
);
|
||||
childLogger.error({ err: error, agentId }, "Failed to record user message");
|
||||
}
|
||||
|
||||
startAgentRun(agentManager, agentId, prompt, childLogger, {
|
||||
@@ -627,7 +580,7 @@ export async function createAgentManagementMcpServer(
|
||||
content: [],
|
||||
structuredContent: validJson,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -653,7 +606,7 @@ export async function createAgentManagementMcpServer(
|
||||
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
||||
agentStorage,
|
||||
snapshot,
|
||||
childLogger
|
||||
childLogger,
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
@@ -662,7 +615,7 @@ export async function createAgentManagementMcpServer(
|
||||
snapshot: structuredSnapshot,
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -679,22 +632,21 @@ export async function createAgentManagementMcpServer(
|
||||
const snapshots = agentManager.listAgents();
|
||||
const agents = await Promise.all(
|
||||
snapshots.map((snapshot) =>
|
||||
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger)
|
||||
)
|
||||
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
|
||||
),
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ agents }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"cancel_agent",
|
||||
{
|
||||
title: "Cancel Agent Run",
|
||||
description:
|
||||
"Abort the agent's current run but keep the agent alive for future tasks.",
|
||||
description: "Abort the agent's current run but keep the agent alive for future tasks.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
},
|
||||
@@ -711,7 +663,7 @@ export async function createAgentManagementMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -733,23 +685,20 @@ export async function createAgentManagementMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_agent_activity",
|
||||
{
|
||||
title: "Get Agent Activity",
|
||||
description:
|
||||
"Return recent agent timeline entries as a curated summary.",
|
||||
description: "Return recent agent timeline entries as a curated summary.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional limit for number of activities to include (most recent first)."
|
||||
),
|
||||
.describe("Optional limit for number of activities to include (most recent first)."),
|
||||
},
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
@@ -786,7 +735,7 @@ export async function createAgentManagementMcpServer(
|
||||
content: contentWithCount,
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -810,7 +759,7 @@ export async function createAgentManagementMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true, newMode: modeId }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -826,7 +775,7 @@ export async function createAgentManagementMcpServer(
|
||||
agentId: z.string(),
|
||||
status: AgentStatusEnum,
|
||||
request: AgentPermissionRequestPayloadSchema,
|
||||
})
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -844,7 +793,7 @@ export async function createAgentManagementMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ permissions }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -868,7 +817,7 @@ export async function createAgentManagementMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
|
||||
@@ -226,7 +226,7 @@ describe("AgentManager", () => {
|
||||
manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: join(workdir, "does-not-exist"),
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow("Working directory does not exist");
|
||||
});
|
||||
|
||||
@@ -250,7 +250,7 @@ describe("AgentManager", () => {
|
||||
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.lastResumeOverrides = overrides;
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
@@ -331,7 +331,7 @@ describe("AgentManager", () => {
|
||||
class HistoryProbeSession extends TestAgentSession {
|
||||
constructor(
|
||||
config: AgentSessionConfig,
|
||||
private readonly historyText: string | null
|
||||
private readonly historyText: string | null,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
@@ -362,7 +362,7 @@ describe("AgentManager", () => {
|
||||
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const merged: AgentSessionConfig = {
|
||||
@@ -487,7 +487,7 @@ describe("AgentManager", () => {
|
||||
constructor(
|
||||
config: AgentSessionConfig,
|
||||
private readonly stableSessionId: string,
|
||||
initiallyReady = false
|
||||
initiallyReady = false,
|
||||
) {
|
||||
super(config);
|
||||
this.persistenceReady = initiallyReady;
|
||||
@@ -558,7 +558,7 @@ describe("AgentManager", () => {
|
||||
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.resumeSessionCalls += 1;
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
@@ -705,7 +705,7 @@ describe("AgentManager", () => {
|
||||
itemType: event.event.type === "timeline" ? event.event.item.type : undefined,
|
||||
});
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
|
||||
await manager.emitLiveTimelineItem(snapshot.id, {
|
||||
@@ -846,7 +846,7 @@ describe("AgentManager", () => {
|
||||
manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow("createAgent: agentId must be a UUID");
|
||||
});
|
||||
|
||||
@@ -868,8 +868,8 @@ describe("AgentManager", () => {
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
"not-a-uuid"
|
||||
)
|
||||
"not-a-uuid",
|
||||
),
|
||||
).rejects.toThrow("createAgent: agentId must be a UUID");
|
||||
});
|
||||
|
||||
@@ -1000,7 +1000,7 @@ describe("AgentManager", () => {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1087,7 +1087,7 @@ describe("AgentManager", () => {
|
||||
}
|
||||
lifecycleUpdates.push(event.agent.lifecycle);
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
|
||||
const firstRun = manager.streamAgent(snapshot.id, "first run");
|
||||
@@ -1124,9 +1124,7 @@ describe("AgentManager", () => {
|
||||
expect(runningIndexes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const firstReplacementRunningIndex = runningIndexes[1]!;
|
||||
expect(
|
||||
lifecycleUpdates.slice(0, firstReplacementRunningIndex).includes("idle")
|
||||
).toBe(false);
|
||||
expect(lifecycleUpdates.slice(0, firstReplacementRunningIndex).includes("idle")).toBe(false);
|
||||
|
||||
allowSecondRunToEnd.resolve();
|
||||
|
||||
@@ -1194,7 +1192,7 @@ describe("AgentManager", () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
|
||||
liveEvents.push({ type: "turn_started", provider: "codex" });
|
||||
@@ -1280,7 +1278,7 @@ describe("AgentManager", () => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
liveEvents.push({ type: "turn_started", provider: "codex" });
|
||||
});
|
||||
@@ -1288,7 +1286,7 @@ describe("AgentManager", () => {
|
||||
const beforeCancel = manager.getAgent(snapshot.id);
|
||||
expect(beforeCancel?.lifecycle).toBe("running");
|
||||
expect(Boolean(beforeCancel && "pendingRun" in beforeCancel && beforeCancel.pendingRun)).toBe(
|
||||
false
|
||||
false,
|
||||
);
|
||||
|
||||
const cancelled = await manager.cancelAgentRun(snapshot.id);
|
||||
@@ -1417,7 +1415,7 @@ describe("AgentManager", () => {
|
||||
resolveAutonomousTurnStarted();
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: true }
|
||||
{ agentId: snapshot.id, replayState: true },
|
||||
);
|
||||
|
||||
const foreground = manager.streamAgent(snapshot.id, "foreground run");
|
||||
@@ -1444,16 +1442,14 @@ describe("AgentManager", () => {
|
||||
|
||||
const replaying = manager.getAgent(snapshot.id);
|
||||
expect(replaying?.lifecycle).toBe("running");
|
||||
expect(
|
||||
foregroundEvents.some((event) => event.type === "turn_completed")
|
||||
).toBe(true);
|
||||
expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(
|
||||
foregroundEvents.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("AUTONOMOUS_DURING_FOREGROUND")
|
||||
)
|
||||
event.item.text.includes("AUTONOMOUS_DURING_FOREGROUND"),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
await autonomousTurnStarted;
|
||||
@@ -1535,7 +1531,7 @@ describe("AgentManager", () => {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1629,7 +1625,7 @@ describe("AgentManager", () => {
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const expectedFinalText =
|
||||
"```json\n{\"message\":\"Reserve space for archive button in sidebar agent list\"}\n```";
|
||||
'```json\n{"message":"Reserve space for archive button in sidebar agent list"}\n```';
|
||||
|
||||
class ChunkedAssistantSession implements AgentSession {
|
||||
readonly provider = "codex" as const;
|
||||
@@ -1651,7 +1647,7 @@ describe("AgentManager", () => {
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "```json\n{\"message\":\"Reserve space for archive button in side",
|
||||
text: '```json\n{"message":"Reserve space for archive button in side',
|
||||
},
|
||||
};
|
||||
yield {
|
||||
@@ -1659,7 +1655,7 @@ describe("AgentManager", () => {
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "bar agent list\"}\n```",
|
||||
text: 'bar agent list"}\n```',
|
||||
},
|
||||
};
|
||||
yield { type: "turn_completed", provider: this.provider };
|
||||
@@ -1870,7 +1866,7 @@ describe("AgentManager", () => {
|
||||
receivedEvents.push(event.agent.id);
|
||||
}
|
||||
},
|
||||
{ agentId: internalAgentId, replayState: false }
|
||||
{ agentId: internalAgentId, replayState: false },
|
||||
);
|
||||
|
||||
await manager.createAgent({
|
||||
@@ -1895,7 +1891,7 @@ describe("AgentManager", () => {
|
||||
expect(() =>
|
||||
manager.subscribe(() => {}, {
|
||||
agentId: "invalid-agent-id",
|
||||
})
|
||||
}),
|
||||
).toThrow("subscribe: agentId must be a UUID");
|
||||
});
|
||||
|
||||
@@ -2070,7 +2066,7 @@ describe("AgentManager", () => {
|
||||
.getTimeline(agent.id)
|
||||
.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]")
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
||||
);
|
||||
expect(systemErrors).toHaveLength(1);
|
||||
expect(systemErrors[0]?.text).toContain("invalid model id");
|
||||
@@ -2129,15 +2125,13 @@ describe("AgentManager", () => {
|
||||
title: "Detailed failure test",
|
||||
});
|
||||
|
||||
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow(
|
||||
"Provider execution failed"
|
||||
);
|
||||
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed");
|
||||
|
||||
const systemError = manager
|
||||
.getTimeline(agent.id)
|
||||
.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]")
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
||||
);
|
||||
expect(systemError?.text).toContain("Provider execution failed");
|
||||
expect(systemError?.text).toContain("code: 126");
|
||||
@@ -2273,10 +2267,7 @@ describe("AgentManager", () => {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
_requestId: string,
|
||||
response: { behavior: string }
|
||||
): Promise<void> {
|
||||
async respondToPermission(_requestId: string, response: { behavior: string }): Promise<void> {
|
||||
// Simulate what claude-agent.ts does: when plan permission is approved,
|
||||
// it calls setMode("acceptEdits") internally
|
||||
if (response.behavior === "allow") {
|
||||
@@ -2548,9 +2539,7 @@ describe("AgentManager", () => {
|
||||
});
|
||||
|
||||
const beforeHydrate = manager.getTimeline(snapshot.id);
|
||||
const userMessagesBefore = beforeHydrate.filter(
|
||||
(item) => item.type === "user_message"
|
||||
);
|
||||
const userMessagesBefore = beforeHydrate.filter((item) => item.type === "user_message");
|
||||
expect(userMessagesBefore).toHaveLength(2);
|
||||
|
||||
// hydrateTimeline replays provider history which includes user_message
|
||||
@@ -2559,17 +2548,13 @@ describe("AgentManager", () => {
|
||||
await manager.hydrateTimelineFromProvider(snapshot.id);
|
||||
|
||||
const afterHydrate = manager.getTimeline(snapshot.id);
|
||||
const userMessagesAfter = afterHydrate.filter(
|
||||
(item) => item.type === "user_message"
|
||||
);
|
||||
const userMessagesAfter = afterHydrate.filter((item) => item.type === "user_message");
|
||||
|
||||
// Should still have exactly 2 user messages, not 4
|
||||
expect(userMessagesAfter).toHaveLength(2);
|
||||
|
||||
// Non-user_message items from history should still be replayed
|
||||
const assistantMessages = afterHydrate.filter(
|
||||
(item) => item.type === "assistant_message"
|
||||
);
|
||||
const assistantMessages = afterHydrate.filter((item) => item.type === "assistant_message");
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -2719,17 +2704,10 @@ describe("AgentManager", () => {
|
||||
expect(userMessages).toHaveLength(2);
|
||||
expect(
|
||||
userMessages.map(
|
||||
(item) =>
|
||||
(item as Extract<AgentTimelineItem, { type: "user_message" }>).messageId
|
||||
)
|
||||
).toEqual([
|
||||
"msg_client_hello",
|
||||
"msg_provider_distinct",
|
||||
]);
|
||||
expect(userMessages.map((item) => item.text)).toEqual([
|
||||
"hello from user",
|
||||
"hello from user",
|
||||
]);
|
||||
(item) => (item as Extract<AgentTimelineItem, { type: "user_message" }>).messageId,
|
||||
),
|
||||
).toEqual(["msg_client_hello", "msg_provider_distinct"]);
|
||||
expect(userMessages.map((item) => item.text)).toEqual(["hello from user", "hello from user"]);
|
||||
});
|
||||
|
||||
test("recordUserMessage normalizes blank/whitespace messageId to undefined", async () => {
|
||||
@@ -2768,7 +2746,7 @@ describe("AgentManager", () => {
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
||||
item.type === "user_message"
|
||||
item.type === "user_message",
|
||||
);
|
||||
|
||||
expect(userMessages).toHaveLength(3);
|
||||
@@ -2814,20 +2792,18 @@ describe("AgentManager", () => {
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMsg = timeline.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
||||
item.type === "user_message"
|
||||
item.type === "user_message",
|
||||
);
|
||||
expect(userMsg).toBeDefined();
|
||||
expect(userMsg!.messageId).toBe(clientMsgId);
|
||||
|
||||
// Dispatched stream event should also carry the messageId
|
||||
const streamEvent = events.find(
|
||||
(e) => e.type === "timeline" && e.item.type === "user_message"
|
||||
);
|
||||
const streamEvent = events.find((e) => e.type === "timeline" && e.item.type === "user_message");
|
||||
expect(streamEvent).toBeDefined();
|
||||
if (streamEvent?.type === "timeline") {
|
||||
expect(
|
||||
(streamEvent.item as { type: "user_message"; messageId?: string }).messageId
|
||||
).toBe(clientMsgId);
|
||||
expect((streamEvent.item as { type: "user_message"; messageId?: string }).messageId).toBe(
|
||||
clientMsgId,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2866,11 +2842,15 @@ describe("AgentManager", () => {
|
||||
class EchoClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new EchoUserMessageSession(config);
|
||||
}
|
||||
async resumeSession(): Promise<AgentSession> { throw new Error("unused"); }
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
@@ -2891,21 +2871,17 @@ describe("AgentManager", () => {
|
||||
await manager.runAgent(snapshot.id, { text: "hello from user" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter(
|
||||
(item) => item.type === "user_message"
|
||||
);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
// Should be exactly 1 (canonical), not 2 (canonical + provider echo)
|
||||
expect(userMessages).toHaveLength(1);
|
||||
// The canonical one must carry the client messageId for optimistic matching
|
||||
expect(
|
||||
(userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).messageId
|
||||
(userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).messageId,
|
||||
).toBe("msg_client_echo_1");
|
||||
|
||||
// Assistant messages from the run should still appear
|
||||
const assistantMessages = timeline.filter(
|
||||
(item) => item.type === "assistant_message"
|
||||
);
|
||||
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
|
||||
expect(assistantMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -2937,11 +2913,15 @@ describe("AgentManager", () => {
|
||||
class DifferentMessageIdClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new DifferentMessageIdSession(config);
|
||||
}
|
||||
async resumeSession(): Promise<AgentSession> { throw new Error("unused"); }
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
@@ -2962,7 +2942,7 @@ describe("AgentManager", () => {
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
||||
item.type === "user_message"
|
||||
item.type === "user_message",
|
||||
);
|
||||
expect(userMessages).toHaveLength(2);
|
||||
expect(userMessages.map((item) => item.messageId)).toEqual([
|
||||
@@ -2995,11 +2975,15 @@ describe("AgentManager", () => {
|
||||
class NoMessageIdClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new NoMessageIdSession(config);
|
||||
}
|
||||
async resumeSession(): Promise<AgentSession> { throw new Error("unused"); }
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
@@ -3053,11 +3037,15 @@ describe("AgentManager", () => {
|
||||
class UnexpectedUserMsgClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new UnexpectedUserMessageSession(config);
|
||||
}
|
||||
async resumeSession(): Promise<AgentSession> { throw new Error("unused"); }
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
@@ -3073,14 +3061,12 @@ describe("AgentManager", () => {
|
||||
await manager.runAgent(snapshot.id, { text: "do something" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter(
|
||||
(item) => item.type === "user_message"
|
||||
);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
// Provider's user_message should be recorded (no canonical to dedup against)
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(
|
||||
(userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).text
|
||||
).toBe("continuation prompt");
|
||||
expect((userMessages[0] as Extract<AgentTimelineItem, { type: "user_message" }>).text).toBe(
|
||||
"continuation prompt",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
|
||||
function attachPersistenceCwd(
|
||||
handle: AgentPersistenceHandle | null,
|
||||
cwd: string
|
||||
cwd: string,
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!handle) {
|
||||
return null;
|
||||
@@ -247,10 +247,7 @@ type LiveEventStreamingSession = AgentSession & {
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
|
||||
const BUSY_STATUSES: AgentLifecycleStatus[] = [
|
||||
"initializing",
|
||||
"running",
|
||||
];
|
||||
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
|
||||
const AgentIdSchema = z.string().uuid();
|
||||
|
||||
function isAgentBusy(status: AgentLifecycleStatus): boolean {
|
||||
@@ -265,10 +262,7 @@ function isTurnTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function createAbortError(
|
||||
signal: AbortSignal | undefined,
|
||||
fallbackMessage: string
|
||||
): Error {
|
||||
function createAbortError(signal: AbortSignal | undefined, fallbackMessage: string): Error {
|
||||
const reason = signal?.reason;
|
||||
const message =
|
||||
typeof reason === "string"
|
||||
@@ -287,13 +281,10 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function supportsLiveEventStream(
|
||||
session: AgentSession
|
||||
): session is LiveEventStreamingSession {
|
||||
function supportsLiveEventStream(session: AgentSession): session is LiveEventStreamingSession {
|
||||
return (
|
||||
"streamLiveEvents" in session &&
|
||||
typeof (session as { streamLiveEvents?: unknown }).streamLiveEvents ===
|
||||
"function"
|
||||
typeof (session as { streamLiveEvents?: unknown }).streamLiveEvents === "function"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,10 +307,7 @@ export class AgentManager {
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly liveEventPumps = new Map<string, Promise<void>>();
|
||||
private readonly liveEventBacklog = new Map<string, AgentStreamEvent[]>();
|
||||
private readonly liveEventBacklogFlushTimers = new Map<
|
||||
string,
|
||||
ReturnType<typeof setTimeout>
|
||||
>();
|
||||
private readonly liveEventBacklogFlushTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
|
||||
@@ -363,9 +351,7 @@ export class AgentManager {
|
||||
|
||||
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
||||
const targetAgentId =
|
||||
options?.agentId == null
|
||||
? null
|
||||
: validateAgentId(options.agentId, "subscribe");
|
||||
options?.agentId == null ? null : validateAgentId(options.agentId, "subscribe");
|
||||
const record: SubscriptionRecord = {
|
||||
callback,
|
||||
agentId: targetAgentId,
|
||||
@@ -409,7 +395,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
options?: PersistedAgentQueryOptions
|
||||
options?: PersistedAgentQueryOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
if (options?.provider) {
|
||||
const client = this.requireClient(options.provider);
|
||||
@@ -430,19 +416,13 @@ export class AgentManager {
|
||||
});
|
||||
descriptors.push(...entries);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, provider },
|
||||
"Failed to list persisted agents for provider"
|
||||
);
|
||||
this.logger.warn({ err: error, provider }, "Failed to list persisted agents for provider");
|
||||
}
|
||||
}
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
return descriptors
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
||||
)
|
||||
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
@@ -467,10 +447,7 @@ export class AgentManager {
|
||||
} satisfies ProviderAvailability;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
{ err: error, provider },
|
||||
"Failed to check provider availability"
|
||||
);
|
||||
this.logger.warn({ err: error, provider }, "Failed to check provider availability");
|
||||
return {
|
||||
provider,
|
||||
available: false,
|
||||
@@ -488,7 +465,7 @@ export class AgentManager {
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
throw new Error(
|
||||
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`
|
||||
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -496,7 +473,7 @@ export class AgentManager {
|
||||
try {
|
||||
if (!session.listCommands) {
|
||||
throw new Error(
|
||||
`Provider '${normalizedConfig.provider}' does not support listing commands`
|
||||
`Provider '${normalizedConfig.provider}' does not support listing commands`,
|
||||
);
|
||||
}
|
||||
return await session.listCommands();
|
||||
@@ -506,7 +483,7 @@ export class AgentManager {
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, provider: normalizedConfig.provider },
|
||||
"Failed to close draft command listing session"
|
||||
"Failed to close draft command listing session",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -528,18 +505,9 @@ export class AgentManager {
|
||||
return rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
fetchTimeline(
|
||||
id: string,
|
||||
options?: AgentTimelineFetchOptions
|
||||
): AgentTimelineFetchResult {
|
||||
fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
|
||||
const agent = this.requireAgent(id);
|
||||
const {
|
||||
rows,
|
||||
epoch,
|
||||
nextSeq,
|
||||
minSeq,
|
||||
maxSeq,
|
||||
} = this.ensureTimelineState(agent);
|
||||
const { rows, epoch, nextSeq, minSeq, maxSeq } = this.ensureTimelineState(agent);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const requestedLimit = options?.limit;
|
||||
const limit =
|
||||
@@ -596,8 +564,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
if (direction === "tail") {
|
||||
const selected =
|
||||
selectAll || limit >= rows.length ? rows : rows.slice(rows.length - limit);
|
||||
const selected = selectAll || limit >= rows.length ? rows : rows.slice(rows.length - limit);
|
||||
const hasOlder = selected.length > 0 && selected[0]!.seq > minSeq;
|
||||
return {
|
||||
epoch,
|
||||
@@ -629,8 +596,7 @@ export class AgentManager {
|
||||
};
|
||||
}
|
||||
|
||||
const selected =
|
||||
selectAll ? rows.slice(startIdx) : rows.slice(startIdx, startIdx + limit);
|
||||
const selected = selectAll ? rows.slice(startIdx) : rows.slice(startIdx, startIdx + limit);
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
return {
|
||||
epoch,
|
||||
@@ -648,8 +614,7 @@ export class AgentManager {
|
||||
// direction === "before"
|
||||
const beforeSeq = cursor?.seq ?? nextSeq;
|
||||
const endExclusive = rows.findIndex((row) => row.seq >= beforeSeq);
|
||||
const boundedRows =
|
||||
endExclusive < 0 ? rows : rows.slice(0, endExclusive);
|
||||
const boundedRows = endExclusive < 0 ? rows : rows.slice(0, endExclusive);
|
||||
const selected =
|
||||
selectAll || limit >= boundedRows.length
|
||||
? boundedRows
|
||||
@@ -672,13 +637,10 @@ export class AgentManager {
|
||||
async createAgent(
|
||||
config: AgentSessionConfig,
|
||||
agentId?: string,
|
||||
options?: { labels?: Record<string, string> }
|
||||
options?: { labels?: Record<string, string> },
|
||||
): Promise<ManagedAgent> {
|
||||
// Generate agent ID early so we can use it in MCP config
|
||||
const resolvedAgentId = validateAgentId(
|
||||
agentId ?? this.idFactory(),
|
||||
"createAgent"
|
||||
);
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
const normalizedConfig = await this.normalizeConfig(config, {
|
||||
labels: options?.labels,
|
||||
agentId: resolvedAgentId,
|
||||
@@ -686,15 +648,14 @@ export class AgentManager {
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
throw new Error(`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`);
|
||||
throw new Error(
|
||||
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
|
||||
);
|
||||
}
|
||||
const session = await client.createSession(normalizedConfig);
|
||||
return this.registerSession(
|
||||
session,
|
||||
normalizedConfig,
|
||||
resolvedAgentId,
|
||||
{ labels: options?.labels }
|
||||
);
|
||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
||||
labels: options?.labels,
|
||||
});
|
||||
}
|
||||
|
||||
// Reconstruct an agent from provider persistence. Callers should explicitly
|
||||
@@ -708,11 +669,11 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(
|
||||
agentId ?? this.idFactory(),
|
||||
"resumeAgentFromPersistence"
|
||||
"resumeAgentFromPersistence",
|
||||
);
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const mergedConfig = {
|
||||
@@ -727,19 +688,14 @@ export class AgentManager {
|
||||
: overrides;
|
||||
const client = this.requireClient(handle.provider);
|
||||
const session = await client.resumeSession(handle, resumeOverrides);
|
||||
return this.registerSession(
|
||||
session,
|
||||
normalizedConfig,
|
||||
resolvedAgentId,
|
||||
options
|
||||
);
|
||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
|
||||
}
|
||||
|
||||
// Hot-reload an active agent session with config overrides while preserving
|
||||
// in-memory timeline state.
|
||||
async reloadAgentSession(
|
||||
agentId: string,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<ManagedAgent> {
|
||||
let existing = this.requireAgent(agentId);
|
||||
if (existing.lifecycle === "running" || existing.pendingRun) {
|
||||
@@ -777,10 +733,7 @@ export class AgentManager {
|
||||
try {
|
||||
await existing.session.close();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId },
|
||||
"Failed to close previous session during refresh"
|
||||
);
|
||||
this.logger.warn({ err: error, agentId }, "Failed to close previous session during refresh");
|
||||
}
|
||||
|
||||
// Preserve existing labels and timeline during reload.
|
||||
@@ -809,7 +762,7 @@ export class AgentManager {
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
pendingPermissions: agent.pendingPermissions.size,
|
||||
},
|
||||
"closeAgent: start"
|
||||
"closeAgent: start",
|
||||
);
|
||||
this.agents.delete(agentId);
|
||||
this.liveEventPumps.delete(agentId);
|
||||
@@ -856,10 +809,7 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string | null
|
||||
): Promise<void> {
|
||||
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const normalizedThinkingOptionId =
|
||||
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
||||
@@ -885,10 +835,7 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async setLabels(
|
||||
agentId: string,
|
||||
labels: Record<string, string>
|
||||
): Promise<void> {
|
||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.labels = { ...agent.labels, ...labels };
|
||||
await this.persistSnapshot(agent);
|
||||
@@ -916,7 +863,7 @@ export class AgentManager {
|
||||
async runAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions
|
||||
options?: AgentRunOptions,
|
||||
): Promise<AgentRunResult> {
|
||||
const events = this.streamAgent(agentId, prompt, options);
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
@@ -941,9 +888,7 @@ export class AgentManager {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const sessionId = agent.persistence?.sessionId;
|
||||
if (!sessionId) {
|
||||
throw new Error(
|
||||
`Agent ${agentId} has no persistence.sessionId after run completed`
|
||||
);
|
||||
throw new Error(`Agent ${agentId} has no persistence.sessionId after run completed`);
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
@@ -957,7 +902,7 @@ export class AgentManager {
|
||||
recordUserMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { messageId?: string; emitState?: boolean }
|
||||
options?: { messageId?: string; emitState?: boolean },
|
||||
): void {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const normalizedMessageId = normalizeMessageId(options?.messageId);
|
||||
@@ -969,14 +914,18 @@ export class AgentManager {
|
||||
const updatedAt = this.touchUpdatedAt(agent);
|
||||
agent.lastUserMessageAt = updatedAt;
|
||||
const row = this.recordTimeline(agent, item);
|
||||
this.dispatchStream(agentId, {
|
||||
type: "timeline",
|
||||
item,
|
||||
provider: agent.provider,
|
||||
}, {
|
||||
seq: row.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
});
|
||||
this.dispatchStream(
|
||||
agentId,
|
||||
{
|
||||
type: "timeline",
|
||||
item,
|
||||
provider: agent.provider,
|
||||
},
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
},
|
||||
);
|
||||
if (options?.emitState !== false) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
@@ -986,21 +935,22 @@ export class AgentManager {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.touchUpdatedAt(agent);
|
||||
const row = this.recordTimeline(agent, item);
|
||||
this.dispatchStream(agentId, {
|
||||
type: "timeline",
|
||||
item,
|
||||
provider: agent.provider,
|
||||
}, {
|
||||
seq: row.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
});
|
||||
this.dispatchStream(
|
||||
agentId,
|
||||
{
|
||||
type: "timeline",
|
||||
item,
|
||||
provider: agent.provider,
|
||||
},
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
},
|
||||
);
|
||||
await this.persistSnapshot(agent);
|
||||
}
|
||||
|
||||
async emitLiveTimelineItem(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem
|
||||
): Promise<void> {
|
||||
async emitLiveTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.dispatchStream(agentId, {
|
||||
@@ -1013,7 +963,7 @@ export class AgentManager {
|
||||
streamAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const existingAgent = this.requireAgent(agentId);
|
||||
this.logger.trace(
|
||||
@@ -1024,7 +974,7 @@ export class AgentManager {
|
||||
promptType: typeof prompt === "string" ? "string" : "structured",
|
||||
hasRunOptions: Boolean(options),
|
||||
},
|
||||
"streamAgent: requested"
|
||||
"streamAgent: requested",
|
||||
);
|
||||
if (existingAgent.pendingRun) {
|
||||
this.logger.trace(
|
||||
@@ -1032,7 +982,7 @@ export class AgentManager {
|
||||
agentId,
|
||||
lifecycle: existingAgent.lifecycle,
|
||||
},
|
||||
"streamAgent: rejected because pendingRun already exists"
|
||||
"streamAgent: rejected because pendingRun already exists",
|
||||
);
|
||||
throw new Error(`Agent ${agentId} already has an active run`);
|
||||
}
|
||||
@@ -1052,7 +1002,7 @@ export class AgentManager {
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"streamAgent.finalize: invoked"
|
||||
"streamAgent.finalize: invoked",
|
||||
);
|
||||
if (finalized) {
|
||||
return;
|
||||
@@ -1067,7 +1017,7 @@ export class AgentManager {
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"streamAgent.finalize: skipped because pendingRun no longer points to streamForwarder"
|
||||
"streamAgent.finalize: skipped because pendingRun no longer points to streamForwarder",
|
||||
);
|
||||
if (error) {
|
||||
agent.lastError = error;
|
||||
@@ -1078,8 +1028,7 @@ export class AgentManager {
|
||||
const mutableAgent = agent as ActiveManagedAgent;
|
||||
mutableAgent.pendingRun = null;
|
||||
const terminalError = error ?? mutableAgent.lastError;
|
||||
const shouldHoldBusyForReplacement =
|
||||
mutableAgent.pendingReplacement && !terminalError;
|
||||
const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError;
|
||||
mutableAgent.lifecycle = shouldHoldBusyForReplacement
|
||||
? "running"
|
||||
: terminalError
|
||||
@@ -1092,10 +1041,7 @@ export class AgentManager {
|
||||
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
||||
: null);
|
||||
if (persistenceHandle) {
|
||||
mutableAgent.persistence = attachPersistenceCwd(
|
||||
persistenceHandle,
|
||||
mutableAgent.cwd
|
||||
);
|
||||
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1105,7 +1051,7 @@ export class AgentManager {
|
||||
terminalError,
|
||||
pendingReplacement: mutableAgent.pendingReplacement,
|
||||
},
|
||||
"streamAgent.finalize: applying terminal state"
|
||||
"streamAgent.finalize: applying terminal state",
|
||||
);
|
||||
if (!shouldHoldBusyForReplacement) {
|
||||
this.emitState(mutableAgent);
|
||||
@@ -1133,14 +1079,13 @@ export class AgentManager {
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"streamAgent: forwarded terminal/turn event"
|
||||
"streamAgent: forwarded terminal/turn event",
|
||||
);
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
} catch (error) {
|
||||
finalizeError =
|
||||
error instanceof Error ? error.message : "Agent stream failed";
|
||||
finalizeError = error instanceof Error ? error.message : "Agent stream failed";
|
||||
self.handleStreamEvent(agent, {
|
||||
type: "turn_failed",
|
||||
provider: agent.provider,
|
||||
@@ -1167,7 +1112,7 @@ export class AgentManager {
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"streamAgent: started"
|
||||
"streamAgent: started",
|
||||
);
|
||||
|
||||
return streamForwarder;
|
||||
@@ -1176,7 +1121,7 @@ export class AgentManager {
|
||||
replaceAgentRun(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const snapshot = this.requireAgent(agentId);
|
||||
if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) {
|
||||
@@ -1200,7 +1145,7 @@ export class AgentManager {
|
||||
const latestActive = latest as ActiveManagedAgent;
|
||||
latestActive.pendingReplacement = false;
|
||||
const hasForegroundRun = Boolean(
|
||||
(latestActive as { pendingRun: AsyncGenerator<AgentStreamEvent> | null }).pendingRun
|
||||
(latestActive as { pendingRun: AsyncGenerator<AgentStreamEvent> | null }).pendingRun,
|
||||
);
|
||||
const lifecycle = (latestActive as { lifecycle: AgentLifecycleStatus }).lifecycle;
|
||||
if (!hasForegroundRun && lifecycle === "running") {
|
||||
@@ -1224,10 +1169,7 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!("pendingRun" in snapshot) || !snapshot.pendingRun) &&
|
||||
!snapshot.pendingReplacement
|
||||
) {
|
||||
if ((!("pendingRun" in snapshot) || !snapshot.pendingRun) && !snapshot.pendingReplacement) {
|
||||
throw new Error(`Agent ${agentId} has no pending run`);
|
||||
}
|
||||
|
||||
@@ -1274,7 +1216,8 @@ export class AgentManager {
|
||||
};
|
||||
|
||||
if (options?.signal) {
|
||||
abortHandler = () => finishErr(createAbortError(options.signal!, "wait_for_agent_start aborted"));
|
||||
abortHandler = () =>
|
||||
finishErr(createAbortError(options.signal!, "wait_for_agent_start aborted"));
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
@@ -1299,7 +1242,7 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ agentId, replayState: true }
|
||||
{ agentId, replayState: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1307,7 +1250,7 @@ export class AgentManager {
|
||||
async respondToPermission(
|
||||
agentId: string,
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await agent.session.respondToPermission(requestId, response);
|
||||
@@ -1327,8 +1270,7 @@ export class AgentManager {
|
||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const pendingRun = agent.pendingRun;
|
||||
const hasForegroundPendingRun =
|
||||
Boolean(pendingRun) && typeof pendingRun?.return === "function";
|
||||
const hasForegroundPendingRun = Boolean(pendingRun) && typeof pendingRun?.return === "function";
|
||||
const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundPendingRun;
|
||||
|
||||
if (!hasForegroundPendingRun && !isAutonomousRunning) {
|
||||
@@ -1338,10 +1280,7 @@ export class AgentManager {
|
||||
try {
|
||||
await agent.session.interrupt();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ err: error, agentId },
|
||||
"Failed to interrupt session"
|
||||
);
|
||||
this.logger.error({ err: error, agentId }, "Failed to interrupt session");
|
||||
}
|
||||
|
||||
if (hasForegroundPendingRun && pendingRun) {
|
||||
@@ -1350,10 +1289,7 @@ export class AgentManager {
|
||||
// and pendingRun is properly cleared before we return.
|
||||
await pendingRun.return(undefined as unknown as AgentStreamEvent);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ err: error, agentId },
|
||||
"Failed to cancel run"
|
||||
);
|
||||
this.logger.error({ err: error, agentId }, "Failed to cancel run");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1402,7 +1338,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private getLastAssistantMessageFromTimeline(
|
||||
timeline: readonly AgentTimelineItem[]
|
||||
timeline: readonly AgentTimelineItem[],
|
||||
): string | null {
|
||||
// Collect the last contiguous assistant messages (Claude streams chunks)
|
||||
const chunks: string[] = [];
|
||||
@@ -1426,22 +1362,21 @@ export class AgentManager {
|
||||
|
||||
async waitForAgentEvent(
|
||||
agentId: string,
|
||||
options?: WaitForAgentOptions
|
||||
options?: WaitForAgentOptions,
|
||||
): Promise<WaitForAgentResult> {
|
||||
const snapshot = this.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const hasPendingRun =
|
||||
"pendingRun" in snapshot && Boolean(snapshot.pendingRun);
|
||||
const hasPendingRun = "pendingRun" in snapshot && Boolean(snapshot.pendingRun);
|
||||
|
||||
const immediatePermission = this.peekPendingPermission(snapshot);
|
||||
if (immediatePermission) {
|
||||
return {
|
||||
status: snapshot.lifecycle,
|
||||
permission: immediatePermission,
|
||||
lastMessage: this.getLastAssistantMessage(agentId)
|
||||
lastMessage: this.getLastAssistantMessage(agentId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1452,14 +1387,14 @@ export class AgentManager {
|
||||
return {
|
||||
status: initialStatus,
|
||||
permission: null,
|
||||
lastMessage: this.getLastAssistantMessage(agentId)
|
||||
lastMessage: this.getLastAssistantMessage(agentId),
|
||||
};
|
||||
}
|
||||
if (waitForActive && !initialBusy && !hasPendingRun) {
|
||||
return {
|
||||
status: initialStatus,
|
||||
permission: null,
|
||||
lastMessage: this.getLastAssistantMessage(agentId)
|
||||
lastMessage: this.getLastAssistantMessage(agentId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1467,7 +1402,6 @@ export class AgentManager {
|
||||
throw createAbortError(options.signal, "wait_for_agent aborted");
|
||||
}
|
||||
|
||||
|
||||
return await new Promise<WaitForAgentResult>((resolve, reject) => {
|
||||
// Bug #1 Fix: Check abort signal AGAIN inside Promise constructor
|
||||
// to avoid race condition between pre-Promise check and abort listener registration
|
||||
@@ -1511,7 +1445,7 @@ export class AgentManager {
|
||||
resolve({
|
||||
status: currentStatus,
|
||||
permission,
|
||||
lastMessage: this.getLastAssistantMessage(agentId)
|
||||
lastMessage: this.getLastAssistantMessage(agentId),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1567,7 +1501,7 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
},
|
||||
{ agentId, replayState: true }
|
||||
{ agentId, replayState: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1589,27 +1523,23 @@ export class AgentManager {
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
attention?: AttentionState;
|
||||
}
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||
if (this.agents.has(resolvedAgentId)) {
|
||||
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
||||
}
|
||||
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
|
||||
resolvedAgentId,
|
||||
config
|
||||
);
|
||||
const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config);
|
||||
|
||||
const now = new Date();
|
||||
const initialTimeline = options?.timeline ? [...options.timeline] : [];
|
||||
const initialTimelineRows =
|
||||
options?.timelineRows?.length
|
||||
? options.timelineRows.map((row) => ({ ...row }))
|
||||
: this.buildTimelineRowsFromItems(
|
||||
initialTimeline,
|
||||
options?.timelineNextSeq ?? 1,
|
||||
(options?.updatedAt ?? options?.createdAt ?? now).toISOString()
|
||||
);
|
||||
const initialTimelineRows = options?.timelineRows?.length
|
||||
? options.timelineRows.map((row) => ({ ...row }))
|
||||
: this.buildTimelineRowsFromItems(
|
||||
initialTimeline,
|
||||
options?.timelineNextSeq ?? 1,
|
||||
(options?.updatedAt ?? options?.createdAt ?? now).toISOString(),
|
||||
);
|
||||
const derivedNextSeq =
|
||||
options?.timelineNextSeq ??
|
||||
(initialTimelineRows.length
|
||||
@@ -1674,7 +1604,7 @@ export class AgentManager {
|
||||
|
||||
private async resolveInitialPersistedTitle(
|
||||
agentId: string,
|
||||
config: AgentSessionConfig
|
||||
config: AgentSessionConfig,
|
||||
): Promise<string | null> {
|
||||
const existing = await this.registry?.get(agentId);
|
||||
if (existing) {
|
||||
@@ -1689,7 +1619,7 @@ export class AgentManager {
|
||||
private buildTimelineRowsFromItems(
|
||||
items: readonly AgentTimelineItem[],
|
||||
startSeq: number,
|
||||
timestamp: string
|
||||
timestamp: string,
|
||||
): AgentTimelineRow[] {
|
||||
let nextSeq = startSeq;
|
||||
return items.map((item) => {
|
||||
@@ -1726,7 +1656,7 @@ export class AgentManager {
|
||||
|
||||
private async persistSnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null; internal?: boolean }
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
): Promise<void> {
|
||||
if (!this.registry) {
|
||||
return;
|
||||
@@ -1754,9 +1684,7 @@ export class AgentManager {
|
||||
|
||||
try {
|
||||
const pending = agent.session.getPendingPermissions();
|
||||
agent.pendingPermissions = new Map(
|
||||
pending.map((request) => [request.id, request])
|
||||
);
|
||||
agent.pendingPermissions = new Map(pending.map((request) => [request.id, request]));
|
||||
} catch {
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
@@ -1776,7 +1704,7 @@ export class AgentManager {
|
||||
if (!agent.persistence && newInfo.sessionId) {
|
||||
agent.persistence = attachPersistenceCwd(
|
||||
{ provider: agent.provider, sessionId: newInfo.sessionId },
|
||||
agent.cwd
|
||||
agent.cwd,
|
||||
);
|
||||
}
|
||||
// Emit state if runtimeInfo changed so clients get the updated model
|
||||
@@ -1803,7 +1731,7 @@ export class AgentManager {
|
||||
return [];
|
||||
}
|
||||
return [[messageId, row.item.text]];
|
||||
})
|
||||
}),
|
||||
);
|
||||
try {
|
||||
for await (const event of agent.session.streamHistory()) {
|
||||
@@ -1824,7 +1752,7 @@ export class AgentManager {
|
||||
options?: {
|
||||
fromHistory?: boolean;
|
||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
||||
}
|
||||
},
|
||||
): void {
|
||||
// Only update timestamp for live events, not history replay
|
||||
if (!options?.fromHistory) {
|
||||
@@ -1853,10 +1781,7 @@ export class AgentManager {
|
||||
// These are already canonically recorded by recordUserMessage() and replaying them would
|
||||
// create duplicates. Match by messageId (not text) to avoid dropping legitimate
|
||||
// provider-origin messages that happen to reuse the same text.
|
||||
if (
|
||||
options?.fromHistory &&
|
||||
event.item.type === "user_message"
|
||||
) {
|
||||
if (options?.fromHistory && event.item.type === "user_message") {
|
||||
const eventMessageId = normalizeMessageId(event.item.messageId);
|
||||
if (eventMessageId) {
|
||||
const canonicalText = options?.canonicalUserMessagesById?.get(eventMessageId);
|
||||
@@ -1869,10 +1794,7 @@ export class AgentManager {
|
||||
break;
|
||||
}
|
||||
timelineRow = this.recordTimeline(agent, event.item);
|
||||
if (
|
||||
!options?.fromHistory &&
|
||||
event.item.type === "user_message"
|
||||
) {
|
||||
if (!options?.fromHistory && event.item.type === "user_message") {
|
||||
agent.lastUserMessageAt = new Date();
|
||||
this.emitState(agent);
|
||||
}
|
||||
@@ -1884,7 +1806,7 @@ export class AgentManager {
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"handleStreamEvent: turn_completed"
|
||||
"handleStreamEvent: turn_completed",
|
||||
);
|
||||
agent.lastUsage = event.usage;
|
||||
agent.lastError = undefined;
|
||||
@@ -1901,7 +1823,7 @@ export class AgentManager {
|
||||
agent,
|
||||
event.provider,
|
||||
this.formatTurnFailedMessage(event),
|
||||
options
|
||||
options,
|
||||
);
|
||||
for (const [requestId] of agent.pendingPermissions) {
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
@@ -1923,7 +1845,7 @@ export class AgentManager {
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"handleStreamEvent: turn_canceled"
|
||||
"handleStreamEvent: turn_canceled",
|
||||
);
|
||||
if (!agent.pendingRun) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "idle";
|
||||
@@ -1949,7 +1871,7 @@ export class AgentManager {
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"handleStreamEvent: turn_started"
|
||||
"handleStreamEvent: turn_started",
|
||||
);
|
||||
if (!agent.pendingRun) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "running";
|
||||
@@ -1976,12 +1898,16 @@ export class AgentManager {
|
||||
|
||||
// Skip dispatching individual stream events during history replay.
|
||||
if (!options?.fromHistory) {
|
||||
this.dispatchStream(agent.id, event, timelineRow
|
||||
? {
|
||||
seq: timelineRow.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
}
|
||||
: undefined);
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
event,
|
||||
timelineRow
|
||||
? {
|
||||
seq: timelineRow.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1992,7 +1918,7 @@ export class AgentManager {
|
||||
options?: {
|
||||
fromHistory?: boolean;
|
||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
||||
}
|
||||
},
|
||||
): void {
|
||||
if (options?.fromHistory) {
|
||||
return;
|
||||
@@ -2021,12 +1947,12 @@ export class AgentManager {
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private formatTurnFailedMessage(
|
||||
event: Extract<AgentStreamEvent, { type: "turn_failed" }>
|
||||
event: Extract<AgentStreamEvent, { type: "turn_failed" }>,
|
||||
): string {
|
||||
const base = event.error.trim();
|
||||
const parts = [base.length > 0 ? base : "Provider run failed"];
|
||||
@@ -2047,7 +1973,7 @@ export class AgentManager {
|
||||
options?: {
|
||||
fromHistory?: boolean;
|
||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
||||
}
|
||||
},
|
||||
): boolean {
|
||||
if (options?.fromHistory || event.type !== "timeline") {
|
||||
return false;
|
||||
@@ -2070,10 +1996,7 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
private recordTimeline(
|
||||
agent: ManagedAgent,
|
||||
item: AgentTimelineItem
|
||||
): AgentTimelineRow {
|
||||
private recordTimeline(agent: ManagedAgent, item: AgentTimelineItem): AgentTimelineRow {
|
||||
const timelineState = this.ensureTimelineState(agent);
|
||||
const row: AgentTimelineRow = {
|
||||
seq: timelineState.nextSeq,
|
||||
@@ -2174,7 +2097,7 @@ export class AgentManager {
|
||||
|
||||
private broadcastAgentAttention(
|
||||
agent: ManagedAgent,
|
||||
reason: "finished" | "error" | "permission"
|
||||
reason: "finished" | "error" | "permission",
|
||||
): void {
|
||||
this.onAgentAttention?.({
|
||||
agentId: agent.id,
|
||||
@@ -2186,7 +2109,7 @@ export class AgentManager {
|
||||
private dispatchStream(
|
||||
agentId: string,
|
||||
event: AgentStreamEvent,
|
||||
metadata?: { seq?: number; epoch?: string }
|
||||
metadata?: { seq?: number; epoch?: string },
|
||||
): void {
|
||||
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
|
||||
}
|
||||
@@ -2223,10 +2146,9 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async normalizeConfig(
|
||||
config: AgentSessionConfig,
|
||||
_options?: { labels?: Record<string, string>; agentId?: string }
|
||||
_options?: { labels?: Record<string, string>; agentId?: string },
|
||||
): Promise<AgentSessionConfig> {
|
||||
const normalized: AgentSessionConfig = { ...config };
|
||||
|
||||
@@ -2239,7 +2161,11 @@ export class AgentManager {
|
||||
throw new Error(`Working directory is not a directory: ${normalized.cwd}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
throw new Error(`Working directory does not exist: ${normalized.cwd}`);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
@@ -2252,8 +2178,7 @@ export class AgentManager {
|
||||
if (typeof normalized.model === "string") {
|
||||
const trimmed = normalized.model.trim();
|
||||
const normalizedId = trimmed.toLowerCase();
|
||||
normalized.model =
|
||||
trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined;
|
||||
normalized.model = trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -2308,7 +2233,7 @@ export class AgentManager {
|
||||
eventType: event.type,
|
||||
backlogSize: (this.liveEventBacklog.get(latest.id)?.length ?? 0) + 1,
|
||||
},
|
||||
"Live event pump: queued event because pendingRun is active"
|
||||
"Live event pump: queued event because pendingRun is active",
|
||||
);
|
||||
this.enqueueLiveEvent(latest.id, event);
|
||||
continue;
|
||||
@@ -2316,15 +2241,9 @@ export class AgentManager {
|
||||
this.flushLiveEventBacklog(latest);
|
||||
this.handleStreamEvent(latest, event);
|
||||
}
|
||||
this.logger.warn(
|
||||
{ agentId: agent.id },
|
||||
"Live event pump stream ended; restarting"
|
||||
);
|
||||
this.logger.warn({ agentId: agent.id }, "Live event pump stream ended; restarting");
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId: agent.id },
|
||||
"Live event pump failed"
|
||||
);
|
||||
this.logger.warn({ err: error, agentId: agent.id }, "Live event pump failed");
|
||||
}
|
||||
|
||||
// Keep pump alive unless the agent is gone.
|
||||
@@ -2377,10 +2296,7 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
if (latest.pendingRun) {
|
||||
this.scheduleLiveEventBacklogFlush(
|
||||
agentId,
|
||||
LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS
|
||||
);
|
||||
this.scheduleLiveEventBacklogFlush(agentId, LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
this.flushLiveEventBacklog(latest);
|
||||
@@ -2433,9 +2349,6 @@ export class AgentManager {
|
||||
} else {
|
||||
this.liveEventBacklog.set(agent.id, deferred);
|
||||
}
|
||||
this.scheduleLiveEventBacklogFlush(
|
||||
agent.id,
|
||||
LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS
|
||||
);
|
||||
this.scheduleLiveEventBacklogFlush(agent.id, LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ type McpClient = {
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
async function withTimeout<T>(
|
||||
options: { promise: Promise<T>; timeoutMs: number; label: string }
|
||||
): Promise<T> {
|
||||
async function withTimeout<T>(options: {
|
||||
promise: Promise<T>;
|
||||
timeoutMs: number;
|
||||
label: string;
|
||||
}): Promise<T> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
@@ -41,9 +43,10 @@ async function withTimeout<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPathExists(
|
||||
options: { targetPath: string; timeoutMs: number }
|
||||
): Promise<void> {
|
||||
async function waitForPathExists(options: {
|
||||
targetPath: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < options.timeoutMs) {
|
||||
if (existsSync(options.targetPath)) {
|
||||
@@ -51,9 +54,7 @@ async function waitForPathExists(
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}`
|
||||
);
|
||||
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}`);
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
@@ -108,191 +109,183 @@ async function waitForAgentCompletion(options: {
|
||||
}
|
||||
|
||||
describe("agent MCP end-to-end (offline)", () => {
|
||||
test(
|
||||
"create_agent runs initial prompt and affects filesystem",
|
||||
async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
|
||||
const port = await getAvailablePort();
|
||||
test("create_agent runs initial prompt and affects filesystem", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
|
||||
const port = await getAvailablePort();
|
||||
|
||||
const daemonConfig: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
};
|
||||
const daemonConfig: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`)
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`),
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const filePath = path.join(agentCwd, "mcp-smoke.txt");
|
||||
await writeFile(filePath, "ok", "utf8");
|
||||
|
||||
const initialPrompt = [
|
||||
"You must call the Bash command tool with the exact command `rm -f mcp-smoke.txt`.",
|
||||
"Run it and reply with done and stop.",
|
||||
"Do not respond before the command finishes.",
|
||||
].join("\n");
|
||||
|
||||
const result = (await client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: agentCwd,
|
||||
title: "MCP e2e smoke",
|
||||
agentType: "claude",
|
||||
initialMode: "bypassPermissions",
|
||||
initialPrompt,
|
||||
background: false,
|
||||
},
|
||||
})) as McpToolResult;
|
||||
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
|
||||
await waitForAgentCompletion({ client, agentId: agentId! });
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
const contents = await readFile(filePath, "utf8");
|
||||
throw new Error(
|
||||
`Expected mcp-smoke.txt to be removed, but it still exists with contents: ${contents}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await client.callTool({ name: "kill_agent", args: { agentId } });
|
||||
}
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
await rm(agentCwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("create_agent with worktree is async and boots terminals only after setup success", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
|
||||
const port = await getAvailablePort();
|
||||
|
||||
const daemonConfig: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`),
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8");
|
||||
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const setupCommand =
|
||||
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
|
||||
await writeFile(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [setupCommand],
|
||||
terminals: [
|
||||
{
|
||||
name: "Dev Server",
|
||||
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const filePath = path.join(agentCwd, "mcp-smoke.txt");
|
||||
await writeFile(filePath, "ok", "utf8");
|
||||
|
||||
const initialPrompt = [
|
||||
"You must call the Bash command tool with the exact command `rm -f mcp-smoke.txt`.",
|
||||
"Run it and reply with done and stop.",
|
||||
"Do not respond before the command finishes.",
|
||||
].join("\n");
|
||||
|
||||
const result = (await client.callTool({
|
||||
const result = (await withTimeout({
|
||||
promise: client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: agentCwd,
|
||||
title: "MCP e2e smoke",
|
||||
cwd: repoRoot,
|
||||
title: "MCP worktree setup terminals",
|
||||
agentType: "claude",
|
||||
initialMode: "bypassPermissions",
|
||||
initialPrompt,
|
||||
background: false,
|
||||
initialPrompt: "say done and stop",
|
||||
worktreeName: "mcp-worktree-setup-test",
|
||||
baseBranch: "main",
|
||||
background: true,
|
||||
},
|
||||
})) as McpToolResult;
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "create_agent should not block on setup",
|
||||
})) as McpToolResult;
|
||||
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
const worktreePath = (payload?.cwd as string | undefined) ?? "";
|
||||
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
|
||||
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
|
||||
|
||||
await waitForAgentCompletion({ client, agentId: agentId! });
|
||||
await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8");
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
const contents = await readFile(filePath, "utf8");
|
||||
throw new Error(
|
||||
`Expected mcp-smoke.txt to be removed, but it still exists with contents: ${contents}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await client.callTool({ name: "kill_agent", args: { agentId } });
|
||||
}
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
await rm(agentCwd, { recursive: true, force: true });
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(worktreePath, "setup-done.txt"),
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(worktreePath, "dev-terminal.txt"),
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await client.callTool({ name: "kill_agent", args: { agentId } });
|
||||
}
|
||||
},
|
||||
30_000
|
||||
);
|
||||
|
||||
test(
|
||||
"create_agent with worktree is async and boots terminals only after setup success",
|
||||
async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
|
||||
const port = await getAvailablePort();
|
||||
|
||||
const daemonConfig: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`)
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8");
|
||||
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const setupCommand =
|
||||
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
|
||||
await writeFile(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [setupCommand],
|
||||
terminals: [
|
||||
{
|
||||
name: "Dev Server",
|
||||
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const result = (await withTimeout({
|
||||
promise: client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: repoRoot,
|
||||
title: "MCP worktree setup terminals",
|
||||
agentType: "claude",
|
||||
initialMode: "bypassPermissions",
|
||||
initialPrompt: "say done and stop",
|
||||
worktreeName: "mcp-worktree-setup-test",
|
||||
baseBranch: "main",
|
||||
background: true,
|
||||
},
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "create_agent should not block on setup",
|
||||
})) as McpToolResult;
|
||||
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
const worktreePath = (payload?.cwd as string | undefined) ?? "";
|
||||
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
|
||||
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
|
||||
|
||||
await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8");
|
||||
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(worktreePath, "setup-done.txt"),
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(worktreePath, "dev-terminal.txt"),
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await client.callTool({ name: "kill_agent", args: { agentId } });
|
||||
}
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -72,79 +72,79 @@ function initGitRepo(repoDir: string): void {
|
||||
rmSync(codexSessionDir, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test(
|
||||
"generates a title using a real Codex agent",
|
||||
async () => {
|
||||
const agent = await manager.createAgent({
|
||||
test("generates a title using a real Codex agent", async () => {
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
modeId: "auto",
|
||||
cwd: repoDir,
|
||||
title: "Main Agent",
|
||||
}, "4e0a4508-e522-4fe9-8384-cf3bf889f16d");
|
||||
},
|
||||
"4e0a4508-e522-4fe9-8384-cf3bf889f16d",
|
||||
);
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: repoDir,
|
||||
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
|
||||
explicitTitle: null,
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: repoDir,
|
||||
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
|
||||
explicitTitle: null,
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
await storage.flush();
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.title).toBe("Metadata Title E2E");
|
||||
await storage.flush();
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.title).toBe("Metadata Title E2E");
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
},
|
||||
180000
|
||||
);
|
||||
await manager.closeAgent(agent.id);
|
||||
}, 180000);
|
||||
|
||||
test(
|
||||
"renames the worktree branch using a real Codex agent",
|
||||
async () => {
|
||||
const worktreeSlug = "metadata-worktree";
|
||||
const worktree = await createWorktree({
|
||||
branchName: worktreeSlug,
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug,
|
||||
paseoHome,
|
||||
});
|
||||
test("renames the worktree branch using a real Codex agent", async () => {
|
||||
const worktreeSlug = "metadata-worktree";
|
||||
const worktree = await createWorktree({
|
||||
branchName: worktreeSlug,
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
modeId: "auto",
|
||||
cwd: worktree.worktreePath,
|
||||
title: "Worktree Agent",
|
||||
}, "32bb765d-f637-44a2-9820-f2efd5261418");
|
||||
},
|
||||
"32bb765d-f637-44a2-9820-f2efd5261418",
|
||||
);
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: worktree.worktreePath,
|
||||
initialPrompt: "Use the exact branch 'feat/metadata-worktree'.",
|
||||
explicitTitle: "Explicit Title",
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: worktree.worktreePath,
|
||||
initialPrompt: "Use the exact branch 'feat/metadata-worktree'.",
|
||||
explicitTitle: "Explicit Title",
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
}).toString().trim();
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
const validation = validateBranchSlug(currentBranch);
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(currentBranch).toBe("feat/metadata-worktree");
|
||||
const validation = validateBranchSlug(currentBranch);
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(currentBranch).toBe("feat/metadata-worktree");
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
},
|
||||
180000
|
||||
);
|
||||
await manager.closeAgent(agent.id);
|
||||
}, 180000);
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ function normalizeAutoTitle(title: string): string | null {
|
||||
async function canRenameBranch(
|
||||
cwd: string,
|
||||
paseoHome: string | undefined,
|
||||
getCheckoutStatusImpl: typeof getCheckoutStatus
|
||||
getCheckoutStatusImpl: typeof getCheckoutStatus,
|
||||
): Promise<boolean> {
|
||||
let status: CheckoutStatusResult;
|
||||
try {
|
||||
@@ -77,7 +77,10 @@ async function canRenameBranch(
|
||||
}
|
||||
|
||||
export async function determineAgentMetadataNeeds(
|
||||
options: Pick<AgentMetadataGenerationOptions, "initialPrompt" | "explicitTitle" | "cwd" | "paseoHome" | "deps">
|
||||
options: Pick<
|
||||
AgentMetadataGenerationOptions,
|
||||
"initialPrompt" | "explicitTitle" | "cwd" | "paseoHome" | "deps"
|
||||
>,
|
||||
): Promise<AgentMetadataNeeds> {
|
||||
const prompt = options.initialPrompt?.trim();
|
||||
if (!prompt) {
|
||||
@@ -86,11 +89,7 @@ export async function determineAgentMetadataNeeds(
|
||||
|
||||
const needsTitle = !hasExplicitTitle(options.explicitTitle);
|
||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||
const needsBranch = await canRenameBranch(
|
||||
options.cwd,
|
||||
options.paseoHome,
|
||||
getCheckoutStatusImpl
|
||||
);
|
||||
const needsBranch = await canRenameBranch(options.cwd, options.paseoHome, getCheckoutStatusImpl);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
@@ -116,21 +115,17 @@ function buildMetadataSchema(needs: AgentMetadataNeeds): z.ZodObject<any> | null
|
||||
|
||||
function buildPrompt(needs: AgentMetadataNeeds): string {
|
||||
const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter(
|
||||
Boolean
|
||||
Boolean,
|
||||
) as string[];
|
||||
|
||||
const instructions: string[] = [
|
||||
"Generate metadata for a coding agent based on the user prompt.",
|
||||
];
|
||||
const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."];
|
||||
|
||||
if (needs.needsTitle) {
|
||||
instructions.push(
|
||||
`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`
|
||||
);
|
||||
instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
|
||||
}
|
||||
if (needs.needsBranch) {
|
||||
instructions.push(
|
||||
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens."
|
||||
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,7 +140,7 @@ function buildPrompt(needs: AgentMetadataNeeds): string {
|
||||
}
|
||||
|
||||
export async function generateAndApplyAgentMetadata(
|
||||
options: AgentMetadataGenerationOptions
|
||||
options: AgentMetadataGenerationOptions,
|
||||
): Promise<void> {
|
||||
const needs = await determineAgentMetadataNeeds(options);
|
||||
if (!needs.prompt) {
|
||||
@@ -186,13 +181,13 @@ export async function generateAndApplyAgentMetadata(
|
||||
) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Structured metadata generation failed"
|
||||
"Structured metadata generation failed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
options.logger.error(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Agent metadata generation failed"
|
||||
"Agent metadata generation failed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -210,7 +205,7 @@ export async function generateAndApplyAgentMetadata(
|
||||
if (!validation.valid) {
|
||||
options.logger.warn(
|
||||
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
|
||||
"Generated branch name is invalid"
|
||||
"Generated branch name is invalid",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -221,7 +216,7 @@ export async function generateAndApplyAgentMetadata(
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Failed to re-check branch eligibility"
|
||||
"Failed to re-check branch eligibility",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -241,20 +236,18 @@ export async function generateAndApplyAgentMetadata(
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId, branch: normalizedBranch },
|
||||
"Failed to rename branch"
|
||||
"Failed to rename branch",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleAgentMetadataGeneration(
|
||||
options: AgentMetadataGenerationOptions
|
||||
): void {
|
||||
export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void {
|
||||
queueMicrotask(() => {
|
||||
void generateAndApplyAgentMetadata(options).catch((error) => {
|
||||
options.logger.error(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Agent metadata generation crashed"
|
||||
"Agent metadata generation crashed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,7 @@ const NON_GIT_CHECKOUT_STATUS = {
|
||||
isPaseoOwnedWorktree: false,
|
||||
currentBranch: null,
|
||||
repoRoot: "/tmp/repo",
|
||||
} as Awaited<
|
||||
ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>
|
||||
>;
|
||||
} as Awaited<ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>>;
|
||||
|
||||
const ELIGIBLE_WORKTREE_CHECKOUT_STATUS = {
|
||||
isGit: true,
|
||||
@@ -32,20 +30,16 @@ const ELIGIBLE_WORKTREE_CHECKOUT_STATUS = {
|
||||
hasRemote: false,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: true,
|
||||
} as Awaited<
|
||||
ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>
|
||||
>;
|
||||
} as Awaited<ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>>;
|
||||
|
||||
function createDeps(
|
||||
generateStructuredAgentResponseWithFallback: NonNullable<
|
||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||
>
|
||||
>,
|
||||
): AgentMetadataGeneratorDeps {
|
||||
return {
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
getCheckoutStatus: vi
|
||||
.fn()
|
||||
.mockResolvedValue(NON_GIT_CHECKOUT_STATUS) as NonNullable<
|
||||
getCheckoutStatus: vi.fn().mockResolvedValue(NON_GIT_CHECKOUT_STATUS) as NonNullable<
|
||||
AgentMetadataGeneratorDeps["getCheckoutStatus"]
|
||||
>,
|
||||
};
|
||||
@@ -71,10 +65,7 @@ describe("agent metadata generator auto-title", () => {
|
||||
});
|
||||
|
||||
expect(setTitle).toHaveBeenCalledTimes(1);
|
||||
expect(setTitle).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
"x".repeat(MAX_AUTO_AGENT_TITLE_CHARS)
|
||||
);
|
||||
expect(setTitle).toHaveBeenCalledWith("agent-1", "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS));
|
||||
});
|
||||
|
||||
it("does not generate an auto title when an explicit title is provided", async () => {
|
||||
@@ -105,17 +96,13 @@ describe("agent metadata generator auto-title", () => {
|
||||
setTitle,
|
||||
notifyAgentState,
|
||||
} as unknown as AgentManager;
|
||||
const renameCurrentBranch = vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
previousBranch: "metadata-worktree",
|
||||
currentBranch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
|
||||
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
||||
previousBranch: "metadata-worktree",
|
||||
currentBranch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
|
||||
const generateStructured = vi.fn().mockResolvedValue({
|
||||
branch: "feature/metadata-worktree",
|
||||
}) as NonNullable<
|
||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||
>;
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
||||
const getCheckoutStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValue(ELIGIBLE_WORKTREE_CHECKOUT_STATUS) as NonNullable<
|
||||
@@ -139,7 +126,7 @@ describe("agent metadata generator auto-title", () => {
|
||||
|
||||
expect(renameCurrentBranch).toHaveBeenCalledWith(
|
||||
"/tmp/repo/metadata-worktree",
|
||||
"feature/metadata-worktree"
|
||||
"feature/metadata-worktree",
|
||||
);
|
||||
expect(notifyAgentState).toHaveBeenCalledWith("agent-branch");
|
||||
expect(setTitle).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "./agent-manager.js";
|
||||
import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js";
|
||||
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -11,17 +8,12 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
type ManagedAgentOverrides = Omit<
|
||||
Partial<ManagedAgent>,
|
||||
"config" | "pendingPermissions"
|
||||
> & {
|
||||
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
|
||||
config?: Partial<AgentSessionConfig>;
|
||||
pendingPermissions?: Map<string, AgentPermissionRequest>;
|
||||
};
|
||||
|
||||
function createManagedAgent(
|
||||
overrides: ManagedAgentOverrides = {}
|
||||
): ManagedAgent {
|
||||
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
|
||||
const now = new Date("2025-01-01T00:00:00.000Z");
|
||||
const baseConfig: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
@@ -47,14 +39,11 @@ function createManagedAgent(
|
||||
...restOverrides
|
||||
} = overrides;
|
||||
|
||||
const sessionValue =
|
||||
lifecycle === "closed" ? null : restOverrides.session ?? ({} as any);
|
||||
const sessionValue = lifecycle === "closed" ? null : (restOverrides.session ?? ({} as any));
|
||||
const pendingRunValue =
|
||||
restOverrides.pendingRun ??
|
||||
(lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
restOverrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
const lastErrorValue =
|
||||
restOverrides.lastError ??
|
||||
(lifecycle === "error" ? "encountered error" : undefined);
|
||||
restOverrides.lastError ?? (lifecycle === "error" ? "encountered error" : undefined);
|
||||
|
||||
const agent: ManagedAgent = {
|
||||
id: "agent-123",
|
||||
@@ -79,8 +68,7 @@ function createManagedAgent(
|
||||
{ id: "build", label: "Building", description: "Detailed" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
pendingPermissions:
|
||||
pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
|
||||
pendingPermissions: pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun: pendingRunValue as ManagedAgent["pendingRun"],
|
||||
timeline: [],
|
||||
runtimeInfo: {
|
||||
@@ -106,9 +94,7 @@ function createManagedAgent(
|
||||
};
|
||||
}
|
||||
|
||||
function createPermission(
|
||||
overrides: Partial<AgentPermissionRequest> = {}
|
||||
): AgentPermissionRequest {
|
||||
function createPermission(overrides: Partial<AgentPermissionRequest> = {}): AgentPermissionRequest {
|
||||
const base: AgentPermissionRequest = {
|
||||
id: "perm-1",
|
||||
provider: "claude",
|
||||
@@ -127,12 +113,12 @@ describe("toStoredAgentRecord", () => {
|
||||
it("captures lifecycle metadata, config, and persistence", () => {
|
||||
const agent = createManagedAgent({
|
||||
currentModeId: "focus",
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "persist-2",
|
||||
metadata: { resumedAt: new Date("2025-01-05T00:00:00.000Z"), note: "warm" },
|
||||
},
|
||||
});
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "persist-2",
|
||||
metadata: { resumedAt: new Date("2025-01-05T00:00:00.000Z"), note: "warm" },
|
||||
},
|
||||
});
|
||||
|
||||
const record = toStoredAgentRecord(agent, { title: "Refactor Agent" });
|
||||
|
||||
@@ -235,10 +221,7 @@ describe("toAgentPayload", () => {
|
||||
expect(payload.title).toBe("UI Payload");
|
||||
expect(payload.model).toBe(agent.config.model);
|
||||
expect(payload.thinkingOptionId).toBeNull();
|
||||
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
|
||||
"perm-a",
|
||||
"perm-b",
|
||||
]);
|
||||
expect(payload.pendingPermissions.map((item) => item.id)).toEqual(["perm-a", "perm-b"]);
|
||||
expect(payload.pendingPermissions[0]).not.toBe(permissionA);
|
||||
expect(payload.pendingPermissions[0].input).toEqual({ command: "ls" });
|
||||
expect(payload.pendingPermissions[1].metadata).toEqual({
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { AgentSnapshotPayload } from "../messages.js";
|
||||
import type {
|
||||
SerializableAgentConfig,
|
||||
StoredAgentRecord,
|
||||
} from "./agent-storage.js";
|
||||
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentMetadata,
|
||||
@@ -43,7 +40,7 @@ export function resolveEffectiveThinkingOptionId(options: {
|
||||
|
||||
export function toStoredAgentRecord(
|
||||
agent: ManagedAgent,
|
||||
options?: ProjectionOptions
|
||||
options?: ProjectionOptions,
|
||||
): StoredAgentRecord {
|
||||
const createdAt = options?.createdAt ?? agent.createdAt.toISOString();
|
||||
const config = buildSerializableConfig(agent.config);
|
||||
@@ -57,9 +54,7 @@ export function toStoredAgentRecord(
|
||||
createdAt,
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastActivityAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt
|
||||
? agent.lastUserMessageAt.toISOString()
|
||||
: null,
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
lastStatus: agent.lifecycle,
|
||||
@@ -68,9 +63,7 @@ export function toStoredAgentRecord(
|
||||
runtimeInfo,
|
||||
persistence,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
attentionReason: agent.attention.requiresAttention
|
||||
? agent.attention.attentionReason
|
||||
: null,
|
||||
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
||||
attentionTimestamp: agent.attention.requiresAttention
|
||||
? agent.attention.attentionTimestamp.toISOString()
|
||||
: null,
|
||||
@@ -80,7 +73,7 @@ export function toStoredAgentRecord(
|
||||
|
||||
export function toAgentPayload(
|
||||
agent: ManagedAgent,
|
||||
options?: ProjectionOptions
|
||||
options?: ProjectionOptions,
|
||||
): AgentSnapshotPayload {
|
||||
const runtimeInfo = sanitizeRuntimeInfo(agent.runtimeInfo);
|
||||
const thinkingOptionId = agent.config.thinkingOptionId ?? null;
|
||||
@@ -99,9 +92,7 @@ export function toAgentPayload(
|
||||
runtimeInfo,
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt
|
||||
? agent.lastUserMessageAt.toISOString()
|
||||
: null,
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
status: agent.lifecycle,
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
@@ -134,9 +125,7 @@ export function toAgentPayload(
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildSerializableConfig(
|
||||
config: AgentSessionConfig
|
||||
): SerializableAgentConfig | null {
|
||||
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
||||
const serializable: SerializableAgentConfig = {};
|
||||
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
||||
serializable.title = config.title ?? null;
|
||||
@@ -164,20 +153,18 @@ function buildSerializableConfig(
|
||||
}
|
||||
|
||||
function sanitizePendingPermissions(
|
||||
pending: Map<string, AgentPermissionRequest>
|
||||
pending: Map<string, AgentPermissionRequest>,
|
||||
): AgentPermissionRequest[] {
|
||||
return Array.from(pending.values()).map((request) => (
|
||||
{
|
||||
...request,
|
||||
input: sanitizeMetadata(request.input),
|
||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||
metadata: sanitizeMetadata(request.metadata),
|
||||
}
|
||||
));
|
||||
return Array.from(pending.values()).map((request) => ({
|
||||
...request,
|
||||
input: sanitizeMetadata(request.input),
|
||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||
metadata: sanitizeMetadata(request.metadata),
|
||||
}));
|
||||
}
|
||||
|
||||
function sanitizePersistenceHandle(
|
||||
handle: AgentPersistenceHandle | null
|
||||
handle: AgentPersistenceHandle | null,
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!handle) {
|
||||
return null;
|
||||
@@ -196,9 +183,7 @@ function sanitizePersistenceHandle(
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function cloneCapabilities(
|
||||
capabilities: AgentCapabilityFlags
|
||||
): AgentCapabilityFlags {
|
||||
function cloneCapabilities(capabilities: AgentCapabilityFlags): AgentCapabilityFlags {
|
||||
return { ...capabilities };
|
||||
}
|
||||
|
||||
@@ -232,19 +217,13 @@ function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isJsonObject(
|
||||
value: JsonValue
|
||||
): value is { [key: string]: JsonValue } {
|
||||
function isJsonObject(value: JsonValue): value is { [key: string]: JsonValue } {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -300,7 +279,7 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
|
||||
}
|
||||
|
||||
function sanitizeRuntimeInfo(
|
||||
runtimeInfo: AgentRuntimeInfo | undefined
|
||||
runtimeInfo: AgentRuntimeInfo | undefined,
|
||||
): AgentRuntimeInfo | undefined {
|
||||
if (!runtimeInfo) {
|
||||
return undefined;
|
||||
|
||||
@@ -193,7 +193,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
expect(result.title.length).toBeGreaterThan(0);
|
||||
expect(typeof result.count).toBe("number");
|
||||
},
|
||||
180000
|
||||
180000,
|
||||
);
|
||||
|
||||
test.runIf(hasClaudeCredentials)(
|
||||
@@ -234,6 +234,6 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
|
||||
expect(result.message.trim().toLowerCase()).toBe("hello");
|
||||
},
|
||||
180000
|
||||
180000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,10 +22,7 @@ function createScriptedCaller(responses: string[]) {
|
||||
describe("getStructuredAgentResponse", () => {
|
||||
it("retries on invalid JSON and succeeds", async () => {
|
||||
const schema = z.object({ title: z.string() });
|
||||
const { caller, prompts } = createScriptedCaller([
|
||||
"not json",
|
||||
'{"title":"ok"}',
|
||||
]);
|
||||
const { caller, prompts } = createScriptedCaller(["not json", '{"title":"ok"}']);
|
||||
|
||||
const result = await getStructuredAgentResponse({
|
||||
caller,
|
||||
@@ -42,10 +39,7 @@ describe("getStructuredAgentResponse", () => {
|
||||
|
||||
it("retries on schema mismatch with validation errors", async () => {
|
||||
const schema = z.object({ count: z.number() });
|
||||
const { caller, prompts } = createScriptedCaller([
|
||||
'{"count":"nope"}',
|
||||
'{"count":2}',
|
||||
]);
|
||||
const { caller, prompts } = createScriptedCaller(['{"count":"nope"}', '{"count":2}']);
|
||||
|
||||
const result = await getStructuredAgentResponse({
|
||||
caller,
|
||||
@@ -62,10 +56,7 @@ describe("getStructuredAgentResponse", () => {
|
||||
|
||||
it("fails after maxRetries with last response and validation errors", async () => {
|
||||
const schema = z.object({ count: z.number() });
|
||||
const { caller } = createScriptedCaller([
|
||||
'{"count":"nope"}',
|
||||
'{"count":"still"}',
|
||||
]);
|
||||
const { caller } = createScriptedCaller(['{"count":"nope"}', '{"count":"still"}']);
|
||||
|
||||
try {
|
||||
await getStructuredAgentResponse({
|
||||
@@ -82,7 +73,7 @@ describe("getStructuredAgentResponse", () => {
|
||||
name: "StructuredAgentResponseError",
|
||||
lastResponse: '{"count":"still"}',
|
||||
validationErrors: expect.arrayContaining([expect.stringContaining("count")]),
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -96,10 +87,7 @@ describe("getStructuredAgentResponse", () => {
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const { caller, prompts } = createScriptedCaller([
|
||||
'{"name": 123}',
|
||||
'{"name": "ok"}',
|
||||
]);
|
||||
const { caller, prompts } = createScriptedCaller(['{"name": 123}', '{"name": "ok"}']);
|
||||
|
||||
const result = await getStructuredAgentResponse({
|
||||
caller,
|
||||
@@ -115,9 +103,7 @@ describe("getStructuredAgentResponse", () => {
|
||||
|
||||
it("extracts JSON from markdown code fences", async () => {
|
||||
const schema = z.object({ message: z.string() });
|
||||
const { caller } = createScriptedCaller([
|
||||
'```json\n{"message": "hello"}\n```',
|
||||
]);
|
||||
const { caller } = createScriptedCaller(['```json\n{"message": "hello"}\n```']);
|
||||
|
||||
const result = await getStructuredAgentResponse({
|
||||
caller,
|
||||
@@ -131,9 +117,7 @@ describe("getStructuredAgentResponse", () => {
|
||||
|
||||
it("extracts JSON from plain code fences", async () => {
|
||||
const schema = z.object({ value: z.number() });
|
||||
const { caller } = createScriptedCaller([
|
||||
'```\n{"value": 42}\n```',
|
||||
]);
|
||||
const { caller } = createScriptedCaller(['```\n{"value": 42}\n```']);
|
||||
|
||||
const result = await getStructuredAgentResponse({
|
||||
caller,
|
||||
@@ -149,7 +133,9 @@ describe("getStructuredAgentResponse", () => {
|
||||
describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
const schema = z.object({ summary: z.string() });
|
||||
|
||||
function createManager(availability: Array<{ provider: string; available: boolean; error: string | null }>) {
|
||||
function createManager(
|
||||
availability: Array<{ provider: string; available: boolean; error: string | null }>,
|
||||
) {
|
||||
return {
|
||||
listProviderAvailability: async () => availability,
|
||||
} as unknown as AgentManager;
|
||||
@@ -272,7 +258,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
runner: async () => {
|
||||
throw new Error("failed");
|
||||
},
|
||||
})
|
||||
}),
|
||||
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export class StructuredAgentFallbackError extends Error {
|
||||
super(
|
||||
summary.length > 0
|
||||
? `Structured generation failed for all providers: ${summary}`
|
||||
: "Structured generation failed for all providers"
|
||||
: "Structured generation failed for all providers",
|
||||
);
|
||||
this.name = "StructuredAgentFallbackError";
|
||||
this.attempts = attempts;
|
||||
@@ -109,7 +109,7 @@ function isZodSchema(value: unknown): value is z.ZodTypeAny {
|
||||
function buildZodValidator<T>(schema: z.ZodTypeAny, schemaName: string): SchemaValidator<T> {
|
||||
const zodToJsonSchemaAny = zodToJsonSchema as unknown as (
|
||||
input: z.ZodTypeAny,
|
||||
name?: string
|
||||
name?: string,
|
||||
) => JsonSchema;
|
||||
const jsonSchema = zodToJsonSchemaAny(schema, schemaName);
|
||||
return {
|
||||
@@ -130,7 +130,9 @@ function buildZodValidator<T>(schema: z.ZodTypeAny, schemaName: string): SchemaV
|
||||
|
||||
function buildJsonSchemaValidator<T>(schema: JsonSchema): SchemaValidator<T> {
|
||||
const AjvConstructor = Ajv as unknown as {
|
||||
new (options?: AjvOptions): {
|
||||
new (
|
||||
options?: AjvOptions,
|
||||
): {
|
||||
compile: (input: JsonSchema) => ((value: unknown) => boolean) & {
|
||||
errors?: ErrorObject[] | null;
|
||||
};
|
||||
@@ -146,7 +148,8 @@ function buildJsonSchemaValidator<T>(schema: JsonSchema): SchemaValidator<T> {
|
||||
return { ok: true, value: value as T };
|
||||
}
|
||||
const errors = (validate.errors ?? []).map((error: ErrorObject) => {
|
||||
const path = error.instancePath && error.instancePath.length > 0 ? error.instancePath : "(root)";
|
||||
const path =
|
||||
error.instancePath && error.instancePath.length > 0 ? error.instancePath : "(root)";
|
||||
const message = error.message ?? "is invalid";
|
||||
return `${path}: ${message}`;
|
||||
});
|
||||
@@ -155,7 +158,10 @@ function buildJsonSchemaValidator<T>(schema: JsonSchema): SchemaValidator<T> {
|
||||
};
|
||||
}
|
||||
|
||||
function buildValidator<T>(schema: z.ZodType<T> | JsonSchema, schemaName: string): SchemaValidator<T> {
|
||||
function buildValidator<T>(
|
||||
schema: z.ZodType<T> | JsonSchema,
|
||||
schemaName: string,
|
||||
): SchemaValidator<T> {
|
||||
if (isZodSchema(schema)) {
|
||||
return buildZodValidator(schema, schemaName);
|
||||
}
|
||||
@@ -234,13 +240,13 @@ function extractFirstJsonSnippet(text: string): string | null {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\"") {
|
||||
if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\"") {
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
@@ -268,7 +274,7 @@ function extractFirstJsonSnippet(text: string): string | null {
|
||||
}
|
||||
|
||||
export async function getStructuredAgentResponse<T>(
|
||||
options: StructuredAgentResponseOptions<T>
|
||||
options: StructuredAgentResponseOptions<T>,
|
||||
): Promise<T> {
|
||||
const { caller, prompt, schema, maxRetries = 2, schemaName = "Response" } = options;
|
||||
const validator = buildValidator(schema, schemaName);
|
||||
@@ -308,21 +314,20 @@ export async function getStructuredAgentResponse<T>(
|
||||
attemptPrompt = buildRetryPrompt(basePrompt, lastErrors);
|
||||
}
|
||||
|
||||
throw new StructuredAgentResponseError(
|
||||
"Agent response did not match the required JSON schema",
|
||||
{
|
||||
lastResponse,
|
||||
validationErrors: lastErrors,
|
||||
}
|
||||
);
|
||||
throw new StructuredAgentResponseError("Agent response did not match the required JSON schema", {
|
||||
lastResponse,
|
||||
validationErrors: lastErrors,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateStructuredAgentResponse<T>(
|
||||
options: StructuredAgentGenerationOptions<T>
|
||||
options: StructuredAgentGenerationOptions<T>,
|
||||
): Promise<T> {
|
||||
const { manager, agentConfig, agentId, prompt, schema, maxRetries, schemaName } = options;
|
||||
const modeId =
|
||||
agentConfig.modeId ?? getAgentProviderDefinition(agentConfig.provider).defaultModeId ?? undefined;
|
||||
agentConfig.modeId ??
|
||||
getAgentProviderDefinition(agentConfig.provider).defaultModeId ??
|
||||
undefined;
|
||||
const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId);
|
||||
try {
|
||||
const caller: AgentCaller = async (nextPrompt) => {
|
||||
@@ -360,7 +365,7 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
options: StructuredAgentGenerationWithFallbackOptions<T>
|
||||
options: StructuredAgentGenerationWithFallbackOptions<T>,
|
||||
): Promise<T> {
|
||||
const {
|
||||
manager,
|
||||
@@ -380,12 +385,9 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
|
||||
const runStructured =
|
||||
runner ??
|
||||
((input: StructuredAgentGenerationOptions<T>) =>
|
||||
generateStructuredAgentResponse<T>(input));
|
||||
((input: StructuredAgentGenerationOptions<T>) => generateStructuredAgentResponse<T>(input));
|
||||
const availability = await manager.listProviderAvailability();
|
||||
const availabilityByProvider = new Map(
|
||||
availability.map((entry) => [entry.provider, entry])
|
||||
);
|
||||
const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry]));
|
||||
const attempts: StructuredGenerationAttempt[] = [];
|
||||
|
||||
for (const candidate of providers) {
|
||||
@@ -412,9 +414,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
provider: candidate.provider,
|
||||
cwd,
|
||||
...(candidate.model ? { model: candidate.model } : {}),
|
||||
...(candidate.thinkingOptionId
|
||||
? { thinkingOptionId: candidate.thinkingOptionId }
|
||||
: {}),
|
||||
...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
|
||||
@@ -37,10 +37,7 @@ export interface McpSseServerConfig {
|
||||
* Discriminated union by `type` field.
|
||||
* Each provider normalizes this to their expected format.
|
||||
*/
|
||||
export type McpServerConfig =
|
||||
| McpStdioServerConfig
|
||||
| McpHttpServerConfig
|
||||
| McpSseServerConfig;
|
||||
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig | McpSseServerConfig;
|
||||
|
||||
export type AgentMode = {
|
||||
id: string;
|
||||
@@ -425,7 +422,10 @@ export interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
createSession(config: AgentSessionConfig): Promise<AgentSession>;
|
||||
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
/**
|
||||
|
||||
@@ -25,9 +25,7 @@ type ManagedAgentOverrides = Omit<
|
||||
attention?: ManagedAgent["attention"];
|
||||
};
|
||||
|
||||
function createManagedAgent(
|
||||
overrides: ManagedAgentOverrides = {}
|
||||
): ManagedAgent {
|
||||
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
|
||||
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
|
||||
const provider = overrides.provider ?? "claude";
|
||||
const cwd = overrides.cwd ?? "/tmp/project";
|
||||
@@ -43,48 +41,39 @@ function createManagedAgent(
|
||||
systemPrompt: configOverrides.systemPrompt,
|
||||
mcpServers: configOverrides.mcpServers,
|
||||
};
|
||||
const session =
|
||||
lifecycle === "closed"
|
||||
? null
|
||||
: overrides.session ?? ({} as AgentSession);
|
||||
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
|
||||
const pendingRun =
|
||||
overrides.pendingRun ??
|
||||
(lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
overrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
|
||||
const agent: ManagedAgent = {
|
||||
id: overrides.id ?? "agent-test",
|
||||
provider,
|
||||
cwd,
|
||||
session,
|
||||
capabilities:
|
||||
overrides.capabilities ??
|
||||
{
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
capabilities: overrides.capabilities ?? {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
config,
|
||||
lifecycle,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
availableModes: overrides.availableModes ?? [],
|
||||
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
|
||||
pendingPermissions:
|
||||
overrides.pendingPermissions ??
|
||||
new Map<string, AgentPermissionRequest>(),
|
||||
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun,
|
||||
timeline: overrides.timeline ?? [],
|
||||
attention: overrides.attention ?? { requiresAttention: false },
|
||||
runtimeInfo:
|
||||
overrides.runtimeInfo ?? {
|
||||
provider,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
model: config.model ?? null,
|
||||
modeId: config.modeId ?? null,
|
||||
},
|
||||
runtimeInfo: overrides.runtimeInfo ?? {
|
||||
provider,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
model: config.model ?? null,
|
||||
modeId: config.modeId ?? null,
|
||||
},
|
||||
persistence: overrides.persistence ?? null,
|
||||
historyPrimed: overrides.historyPrimed ?? true,
|
||||
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
|
||||
@@ -132,7 +121,7 @@ describe("AgentStorage", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await storage.list();
|
||||
@@ -162,9 +151,7 @@ describe("AgentStorage", () => {
|
||||
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||
const agentId = "agent-created-at";
|
||||
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({ id: agentId, createdAt: firstTimestamp })
|
||||
);
|
||||
await storage.applySnapshot(createManagedAgent({ id: agentId, createdAt: firstTimestamp }));
|
||||
|
||||
const initialRecord = await storage.get(agentId);
|
||||
expect(initialRecord?.createdAt).toBe(firstTimestamp.toISOString());
|
||||
@@ -175,7 +162,7 @@ describe("AgentStorage", () => {
|
||||
createdAt: new Date("2025-02-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2025-02-01T00:00:00.000Z"),
|
||||
lifecycle: "running",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedRecord = await storage.get(agentId);
|
||||
@@ -189,7 +176,7 @@ describe("AgentStorage", () => {
|
||||
createManagedAgent({
|
||||
id: agentId,
|
||||
lifecycle: "idle",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const archivedAt = "2025-01-03T00:00:00.000Z";
|
||||
@@ -202,7 +189,7 @@ describe("AgentStorage", () => {
|
||||
id: agentId,
|
||||
lifecycle: "running",
|
||||
updatedAt: new Date("2025-01-04T00:00:00.000Z"),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const recordAfterSnapshot = await storage.get(agentId);
|
||||
@@ -215,7 +202,7 @@ describe("AgentStorage", () => {
|
||||
id: "agent-2",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/second",
|
||||
})
|
||||
}),
|
||||
);
|
||||
await storage.setTitle("agent-2", "Fix Login Bug");
|
||||
|
||||
@@ -228,16 +215,14 @@ describe("AgentStorage", () => {
|
||||
});
|
||||
|
||||
test("setTitle throws when the agent record does not exist", async () => {
|
||||
await expect(storage.setTitle("missing-agent", "Impossible"))
|
||||
.rejects.toThrow("Agent missing-agent not found");
|
||||
await expect(storage.setTitle("missing-agent", "Impossible")).rejects.toThrow(
|
||||
"Agent missing-agent not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("applySnapshot accepts explicit title overrides", async () => {
|
||||
const agentId = "agent-override";
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({ id: agentId }),
|
||||
{ title: "Provided Title" }
|
||||
);
|
||||
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
|
||||
|
||||
const record = await storage.get(agentId);
|
||||
expect(record?.title).toBe("Provided Title");
|
||||
@@ -250,7 +235,7 @@ describe("AgentStorage", () => {
|
||||
id: agentId,
|
||||
lifecycle: "idle",
|
||||
currentModeId: "plan",
|
||||
})
|
||||
}),
|
||||
);
|
||||
await storage.setTitle(agentId, "Important Bug Fix");
|
||||
|
||||
@@ -260,7 +245,7 @@ describe("AgentStorage", () => {
|
||||
lifecycle: "running",
|
||||
currentModeId: "build",
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get(agentId);
|
||||
@@ -291,7 +276,7 @@ describe("AgentStorage", () => {
|
||||
id: agentId,
|
||||
lifecycle: "running",
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
storageInternals.cache.set(agentId, {
|
||||
@@ -311,7 +296,7 @@ describe("AgentStorage", () => {
|
||||
createManagedAgent({
|
||||
id: "normal-agent",
|
||||
cwd: "/tmp/project",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Create an internal agent
|
||||
@@ -321,7 +306,7 @@ describe("AgentStorage", () => {
|
||||
cwd: "/tmp/project",
|
||||
config: { internal: true },
|
||||
}),
|
||||
{ internal: true }
|
||||
{ internal: true },
|
||||
);
|
||||
|
||||
// Registry should return all agents - filtering is done at the manager level
|
||||
@@ -336,7 +321,7 @@ describe("AgentStorage", () => {
|
||||
cwd: "/tmp/project",
|
||||
config: { internal: true },
|
||||
}),
|
||||
{ internal: true }
|
||||
{ internal: true },
|
||||
);
|
||||
|
||||
const record = await storage.get("internal-agent");
|
||||
@@ -351,7 +336,7 @@ describe("AgentStorage", () => {
|
||||
cwd: "/tmp/project",
|
||||
config: { internal: true },
|
||||
}),
|
||||
{ internal: true }
|
||||
{ internal: true },
|
||||
);
|
||||
|
||||
// Reload the registry from disk
|
||||
@@ -377,7 +362,7 @@ describe("AgentStorage", () => {
|
||||
id: agentId,
|
||||
cwd: "/tmp/project-a",
|
||||
provider: "codex",
|
||||
})
|
||||
}),
|
||||
);
|
||||
const record = await storage.get(agentId);
|
||||
expect(record).not.toBeNull();
|
||||
@@ -390,7 +375,7 @@ describe("AgentStorage", () => {
|
||||
await fs.writeFile(
|
||||
duplicatePathB,
|
||||
JSON.stringify({ ...recordA, cwd: "/tmp/project-b" }, null, 2),
|
||||
"utf8"
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Force a reload so the registry has to discover from disk (and may choose either copy).
|
||||
|
||||
@@ -65,13 +65,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
|
||||
export type SerializableAgentConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
| "title"
|
||||
| "modeId"
|
||||
| "model"
|
||||
| "thinkingOptionId"
|
||||
| "extra"
|
||||
| "systemPrompt"
|
||||
| "mcpServers"
|
||||
"title" | "modeId" | "model" | "thinkingOptionId" | "extra" | "systemPrompt" | "mcpServers"
|
||||
>;
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
@@ -141,7 +135,7 @@ export class AgentStorage {
|
||||
if (this.pendingWrites.get(agentId) === next) {
|
||||
this.pendingWrites.delete(agentId);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await next;
|
||||
@@ -162,10 +156,7 @@ export class AgentStorage {
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && code !== "ENOENT") {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId, filePath },
|
||||
"Failed to remove agent record file"
|
||||
);
|
||||
this.logger.warn({ err: error, agentId, filePath }, "Failed to remove agent record file");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,7 +168,7 @@ export class AgentStorage {
|
||||
|
||||
async applySnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null; internal?: boolean }
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
): Promise<void> {
|
||||
await this.load();
|
||||
await this.waitForPendingWrite(agent.id);
|
||||
@@ -187,11 +178,9 @@ export class AgentStorage {
|
||||
const hasInternalOverride =
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
|
||||
const record = toStoredAgentRecord(agent, {
|
||||
title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null,
|
||||
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
|
||||
createdAt: existing?.createdAt,
|
||||
internal: hasInternalOverride
|
||||
? options?.internal
|
||||
: (agent.internal ?? existing?.internal),
|
||||
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
|
||||
});
|
||||
|
||||
// Preserve soft-delete/archive status across snapshot flushes.
|
||||
@@ -341,9 +330,7 @@ export class AgentStorage {
|
||||
}
|
||||
|
||||
private async waitForPendingWrite(agentId: string): Promise<void> {
|
||||
await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(
|
||||
() => undefined
|
||||
);
|
||||
await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,10 +344,7 @@ function projectDirNameFromCwd(cwd: string): string {
|
||||
|
||||
async function writeFileAtomically(targetPath: string, payload: string) {
|
||||
const directory = path.dirname(targetPath);
|
||||
const tempPath = path.join(
|
||||
directory,
|
||||
`.agent.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
|
||||
);
|
||||
const tempPath = path.join(directory, `.agent.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
|
||||
await fs.writeFile(tempPath, payload, "utf8");
|
||||
await fs.rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface DictationDebugChunkWriter {
|
||||
|
||||
export function createDictationDebugChunkWriter(
|
||||
metadata: Pick<DictationDebugAudioMetadata, "sessionId" | "dictationId">,
|
||||
logger: pino.Logger
|
||||
logger: pino.Logger,
|
||||
): DictationDebugChunkWriter | null {
|
||||
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
|
||||
if (!debugDir) {
|
||||
@@ -35,7 +35,7 @@ export function createDictationDebugChunkWriter(
|
||||
const folder = join(
|
||||
debugDir,
|
||||
sanitizeForFilename(metadata.sessionId, "session"),
|
||||
`${timestamp}_${sanitizeForFilename(metadata.dictationId, "dictation")}`
|
||||
`${timestamp}_${sanitizeForFilename(metadata.dictationId, "dictation")}`,
|
||||
);
|
||||
|
||||
let folderCreated = false;
|
||||
@@ -58,7 +58,7 @@ export async function maybePersistDictationDebugAudio(
|
||||
audio: Buffer,
|
||||
metadata: DictationDebugAudioMetadata,
|
||||
logger: pino.Logger,
|
||||
chunkWriterFolder?: string | null
|
||||
chunkWriterFolder?: string | null,
|
||||
): Promise<string | null> {
|
||||
const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR");
|
||||
if (!debugDir) {
|
||||
@@ -71,7 +71,8 @@ export async function maybePersistDictationDebugAudio(
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const folder = chunkWriterFolder ?? join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
|
||||
const folder =
|
||||
chunkWriterFolder ?? join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
|
||||
await mkdir(folder, { recursive: true });
|
||||
|
||||
const filename = chunkWriterFolder
|
||||
|
||||
@@ -95,13 +95,15 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Short title",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error.issues.some((issue: { path: string[] }) => issue.path[0] === "initialPrompt")).toBe(true);
|
||||
expect(
|
||||
parsed.error.issues.some((issue: { path: string[] }) => issue.path[0] === "initialPrompt"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces createAgent validation failures", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockRejectedValue(
|
||||
new Error("Working directory does not exist: /path/that/does/not/exist")
|
||||
new Error("Working directory does not exist: /path/that/does/not/exist"),
|
||||
);
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
@@ -111,7 +113,7 @@ describe("create_agent MCP tool", () => {
|
||||
cwd: "/path/that/does/not/exist",
|
||||
title: "Short title",
|
||||
initialPrompt: "Do work",
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow("Working directory does not exist");
|
||||
});
|
||||
|
||||
@@ -140,7 +142,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth bug",
|
||||
}),
|
||||
undefined,
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -168,7 +170,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth",
|
||||
}),
|
||||
undefined,
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -216,7 +218,7 @@ describe("create_agent MCP tool", () => {
|
||||
cwd: subdir,
|
||||
}),
|
||||
undefined,
|
||||
{ labels: { source: "voice" } }
|
||||
{ labels: { source: "voice" } },
|
||||
);
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -244,7 +246,7 @@ describe("speak MCP tool", () => {
|
||||
expect.objectContaining({
|
||||
text: "Hello from voice agent.",
|
||||
callerAgentId: "voice-agent-1",
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -260,7 +262,7 @@ describe("speak MCP tool", () => {
|
||||
});
|
||||
const tool = (server as any)._registeredTools["speak"];
|
||||
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
|
||||
"No speak handler registered for caller agent"
|
||||
"No speak handler registered for caller agent",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,21 +3,10 @@ import { z } from "zod";
|
||||
import { ensureValidJson } from "../json-utils.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
import type {
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
AgentPermissionRequest,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentManager,
|
||||
ManagedAgent,
|
||||
WaitForAgentResult,
|
||||
} from "./agent-manager.js";
|
||||
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import {
|
||||
AgentPermissionRequestPayloadSchema,
|
||||
AgentPermissionResponseSchema,
|
||||
@@ -35,16 +24,10 @@ import {
|
||||
import { type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import type {
|
||||
VoiceCallerContext,
|
||||
VoiceSpeakHandler,
|
||||
} from "../voice-types.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import {
|
||||
createAgentWorktree,
|
||||
runAsyncWorktreeBootstrap,
|
||||
} from "../worktree-bootstrap.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
@@ -60,12 +43,8 @@ export interface AgentMcpServerOptions {
|
||||
* Optional resolver for session-bound speak handlers.
|
||||
* Used by hidden voice agents to narrate through daemon-managed TTS.
|
||||
*/
|
||||
resolveSpeakHandler?: (
|
||||
callerAgentId: string
|
||||
) => VoiceSpeakHandler | null;
|
||||
resolveCallerContext?: (
|
||||
callerAgentId: string
|
||||
) => VoiceCallerContext | null;
|
||||
resolveSpeakHandler?: (callerAgentId: string) => VoiceSpeakHandler | null;
|
||||
resolveCallerContext?: (callerAgentId: string) => VoiceCallerContext | null;
|
||||
enableVoiceTools?: boolean;
|
||||
voiceOnly?: boolean;
|
||||
logger: Logger;
|
||||
@@ -87,7 +66,7 @@ const CODEX_TO_CLAUDE_MODE: Record<string, string> = {
|
||||
function mapModeAcrossProviders(
|
||||
sourceMode: string,
|
||||
sourceProvider: AgentProvider,
|
||||
targetProvider: AgentProvider
|
||||
targetProvider: AgentProvider,
|
||||
): string {
|
||||
if (sourceProvider === targetProvider) {
|
||||
return sourceMode;
|
||||
@@ -116,16 +95,10 @@ const AgentProviderEnum = z.enum(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
||||
AgentProvider,
|
||||
...AgentProvider[],
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const AgentStatusEnum = z.enum([
|
||||
"initializing",
|
||||
"idle",
|
||||
"running",
|
||||
"error",
|
||||
"closed",
|
||||
]);
|
||||
const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
|
||||
|
||||
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||
const AGENT_WAIT_TIMEOUT_MS = 50000;
|
||||
@@ -162,7 +135,7 @@ async function waitForAgentWithTimeout(
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
waitForActive?: boolean;
|
||||
}
|
||||
},
|
||||
): Promise<WaitForAgentResult> {
|
||||
const timeoutController = new AbortController();
|
||||
const combinedController = new AbortController();
|
||||
@@ -182,11 +155,9 @@ async function waitForAgentWithTimeout(
|
||||
if (options.signal.aborted) {
|
||||
forwardAbort(options.signal.reason);
|
||||
} else {
|
||||
options.signal.addEventListener(
|
||||
"abort",
|
||||
() => forwardAbort(options.signal!.reason),
|
||||
{ once: true }
|
||||
);
|
||||
options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +165,7 @@ async function waitForAgentWithTimeout(
|
||||
timeoutController.signal.addEventListener(
|
||||
"abort",
|
||||
() => forwardAbort(timeoutController.signal.reason),
|
||||
{ once: true }
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -226,7 +197,7 @@ function startAgentRun(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
logger: Logger,
|
||||
options?: { replaceRunning?: boolean }
|
||||
options?: { replaceRunning?: boolean },
|
||||
): void {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const shouldReplace =
|
||||
@@ -241,16 +212,13 @@ function startAgentRun(
|
||||
// Events are broadcast via AgentManager subscribers.
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, agentId },
|
||||
"Agent stream failed"
|
||||
);
|
||||
logger.error({ err: error, agentId }, "Agent stream failed");
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function sanitizePermissionRequest(
|
||||
permission: AgentPermissionRequest | null | undefined
|
||||
permission: AgentPermissionRequest | null | undefined,
|
||||
): AgentPermissionRequest | null {
|
||||
if (!permission) {
|
||||
return null;
|
||||
@@ -277,16 +245,13 @@ function sanitizePermissionRequest(
|
||||
async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentId: string,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const record = await agentStorage.get(agentId);
|
||||
return record?.title ?? null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, agentId },
|
||||
"Failed to load agent title"
|
||||
);
|
||||
logger.error({ err: error, agentId }, "Failed to load agent title");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -294,15 +259,13 @@ async function resolveAgentTitle(
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
) {
|
||||
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, { title });
|
||||
}
|
||||
|
||||
export async function createAgentMcpServer(
|
||||
options: AgentMcpServerOptions
|
||||
): Promise<McpServer> {
|
||||
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise<McpServer> {
|
||||
const {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
@@ -314,7 +277,7 @@ export async function createAgentMcpServer(
|
||||
} = options;
|
||||
const childLogger = logger.child({ module: "agent", component: "mcp-server" });
|
||||
const waitTracker = new WaitForAgentTracker(logger);
|
||||
const callerContext = callerAgentId ? resolveCallerContext?.(callerAgentId) ?? null : null;
|
||||
const callerContext = callerAgentId ? (resolveCallerContext?.(callerAgentId) ?? null) : null;
|
||||
|
||||
const server = new McpServer({
|
||||
name: "agent-mcp",
|
||||
@@ -325,87 +288,67 @@ export async function createAgentMcpServer(
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional working directory. Defaults to the caller agent working directory."
|
||||
),
|
||||
.describe("Optional working directory. Defaults to the caller agent working directory."),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Title is required")
|
||||
.max(60, "Title must be 60 characters or fewer")
|
||||
.describe(
|
||||
"Short descriptive title (<= 60 chars) summarizing the agent's focus."
|
||||
),
|
||||
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
|
||||
agentType: AgentProviderEnum.optional().describe(
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'."
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'.",
|
||||
),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "initialPrompt is required")
|
||||
.describe(
|
||||
"Required first task to run immediately after creation."
|
||||
),
|
||||
.describe("Required first task to run immediately after creation."),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
|
||||
),
|
||||
};
|
||||
|
||||
const topLevelInputSchema = {
|
||||
cwd: z
|
||||
.string()
|
||||
.describe(
|
||||
"Required working directory for the agent (absolute, relative, or ~)."
|
||||
),
|
||||
.describe("Required working directory for the agent (absolute, relative, or ~)."),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Title is required")
|
||||
.max(60, "Title must be 60 characters or fewer")
|
||||
.describe(
|
||||
"Short descriptive title (<= 60 chars) summarizing the agent's focus."
|
||||
),
|
||||
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
|
||||
agentType: AgentProviderEnum.optional().describe(
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'."
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'.",
|
||||
),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "initialPrompt is required")
|
||||
.describe(
|
||||
"Required first task to run immediately after creation."
|
||||
),
|
||||
initialMode: z
|
||||
.string()
|
||||
.describe("Required session mode to configure before the first run."),
|
||||
.describe("Required first task to run immediately after creation."),
|
||||
initialMode: z.string().describe("Required session mode to configure before the first run."),
|
||||
worktreeName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
|
||||
),
|
||||
.describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."),
|
||||
baseBranch: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Required when worktreeName is set: the base branch to diff/merge against."
|
||||
),
|
||||
.describe("Required when worktreeName is set: the base branch to diff/merge against."),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
|
||||
),
|
||||
};
|
||||
|
||||
const createAgentInputSchema = callerAgentId
|
||||
? agentToAgentInputSchema
|
||||
: topLevelInputSchema;
|
||||
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
|
||||
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema);
|
||||
const topLevelCreateAgentArgsSchema = z.object({
|
||||
...topLevelInputSchema,
|
||||
@@ -447,7 +390,7 @@ export async function createAgentMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ ok: true }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -473,7 +416,7 @@ export async function createAgentMcpServer(
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
lastMessage: z.string().nullable().optional(),
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||
@@ -508,11 +451,7 @@ export async function createAgentMcpServer(
|
||||
});
|
||||
const parentMode = parentAgent.currentModeId;
|
||||
if (parentMode) {
|
||||
resolvedMode = mapModeAcrossProviders(
|
||||
parentMode,
|
||||
parentAgent.provider,
|
||||
provider
|
||||
);
|
||||
resolvedMode = mapModeAcrossProviders(parentMode, parentAgent.provider, provider);
|
||||
}
|
||||
} else {
|
||||
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||
@@ -520,12 +459,7 @@ export async function createAgentMcpServer(
|
||||
initialPrompt = topLevelArgs.initialPrompt;
|
||||
background = topLevelArgs.background ?? false;
|
||||
normalizedTitle = topLevelArgs.title.trim();
|
||||
const {
|
||||
cwd,
|
||||
initialMode,
|
||||
worktreeName,
|
||||
baseBranch,
|
||||
} = topLevelArgs;
|
||||
const { cwd, initialMode, worktreeName, baseBranch } = topLevelArgs;
|
||||
|
||||
resolvedCwd = expandUserPath(cwd);
|
||||
|
||||
@@ -559,7 +493,7 @@ export async function createAgentMcpServer(
|
||||
title: normalizedTitle ?? undefined,
|
||||
},
|
||||
undefined,
|
||||
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined
|
||||
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined,
|
||||
);
|
||||
|
||||
if (worktreeConfig) {
|
||||
@@ -599,10 +533,7 @@ export async function createAgentMcpServer(
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId: snapshot.id },
|
||||
"Failed to record initial prompt"
|
||||
);
|
||||
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to record initial prompt");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -610,11 +541,9 @@ export async function createAgentMcpServer(
|
||||
|
||||
// If not running in background, wait for completion
|
||||
if (!background) {
|
||||
const result = await waitForAgentWithTimeout(
|
||||
agentManager,
|
||||
snapshot.id,
|
||||
{ waitForActive: true }
|
||||
);
|
||||
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
|
||||
waitForActive: true,
|
||||
});
|
||||
|
||||
const responseData = {
|
||||
agentId: snapshot.id,
|
||||
@@ -635,10 +564,7 @@ export async function createAgentMcpServer(
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId: snapshot.id },
|
||||
"Failed to run initial prompt"
|
||||
);
|
||||
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
|
||||
}
|
||||
|
||||
// Return immediately if background=true
|
||||
@@ -656,7 +582,7 @@ export async function createAgentMcpServer(
|
||||
}),
|
||||
};
|
||||
return response;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -666,9 +592,7 @@ export async function createAgentMcpServer(
|
||||
description:
|
||||
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
|
||||
inputSchema: {
|
||||
agentId: z
|
||||
.string()
|
||||
.describe("Agent identifier returned by the create_agent tool"),
|
||||
agentId: z.string().describe("Agent identifier returned by the create_agent tool"),
|
||||
},
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
@@ -704,9 +628,7 @@ export async function createAgentMcpServer(
|
||||
forwardExternalAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", forwardExternalAbort, { once: true });
|
||||
cleanupFns.push(() =>
|
||||
signal.removeEventListener("abort", forwardExternalAbort)
|
||||
);
|
||||
cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,11 +640,9 @@ export async function createAgentMcpServer(
|
||||
cleanupFns.push(unregister);
|
||||
|
||||
try {
|
||||
const result: WaitForAgentResult = await waitForAgentWithTimeout(
|
||||
agentManager,
|
||||
agentId,
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const validJson = ensureValidJson({
|
||||
agentId,
|
||||
@@ -739,7 +659,7 @@ export async function createAgentMcpServer(
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -760,7 +680,7 @@ export async function createAgentMcpServer(
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -789,10 +709,7 @@ export async function createAgentMcpServer(
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId },
|
||||
"Failed to record user message"
|
||||
);
|
||||
childLogger.error({ err: error, agentId }, "Failed to record user message");
|
||||
}
|
||||
|
||||
startAgentRun(agentManager, agentId, prompt, childLogger, {
|
||||
@@ -820,7 +737,6 @@ export async function createAgentMcpServer(
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
// Return immediately if background=true
|
||||
// Re-fetch snapshot since the state may have changed
|
||||
const currentSnapshot = agentManager.getAgent(agentId);
|
||||
@@ -838,7 +754,7 @@ export async function createAgentMcpServer(
|
||||
structuredContent: validJson,
|
||||
};
|
||||
return response;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -864,7 +780,7 @@ export async function createAgentMcpServer(
|
||||
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
||||
agentStorage,
|
||||
snapshot,
|
||||
childLogger
|
||||
childLogger,
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
@@ -873,7 +789,7 @@ export async function createAgentMcpServer(
|
||||
snapshot: structuredSnapshot,
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -890,22 +806,21 @@ export async function createAgentMcpServer(
|
||||
const snapshots = agentManager.listAgents();
|
||||
const agents = await Promise.all(
|
||||
snapshots.map((snapshot) =>
|
||||
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger)
|
||||
)
|
||||
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
|
||||
),
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ agents }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"cancel_agent",
|
||||
{
|
||||
title: "Cancel Agent Run",
|
||||
description:
|
||||
"Abort the agent's current run but keep the agent alive for future tasks.",
|
||||
description: "Abort the agent's current run but keep the agent alive for future tasks.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
},
|
||||
@@ -922,7 +837,7 @@ export async function createAgentMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -944,15 +859,14 @@ export async function createAgentMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_agent_activity",
|
||||
{
|
||||
title: "Get Agent Activity",
|
||||
description:
|
||||
"Return recent agent timeline entries as a curated summary.",
|
||||
description: "Return recent agent timeline entries as a curated summary.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
limit: z
|
||||
@@ -971,9 +885,7 @@ export async function createAgentMcpServer(
|
||||
const timeline = agentManager.getTimeline(agentId);
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
|
||||
const activitiesToCurate = limit
|
||||
? timeline.slice(-limit)
|
||||
: timeline;
|
||||
const activitiesToCurate = limit ? timeline.slice(-limit) : timeline;
|
||||
|
||||
const curatedContent = curateAgentActivity(activitiesToCurate);
|
||||
const totalCount = timeline.length;
|
||||
@@ -981,9 +893,9 @@ export async function createAgentMcpServer(
|
||||
|
||||
let countHeader: string;
|
||||
if (limit && shownCount < totalCount) {
|
||||
countHeader = `Showing ${shownCount} of ${totalCount} ${totalCount === 1 ? 'activity' : 'activities'} (limited to ${limit})`;
|
||||
countHeader = `Showing ${shownCount} of ${totalCount} ${totalCount === 1 ? "activity" : "activities"} (limited to ${limit})`;
|
||||
} else {
|
||||
countHeader = `Showing all ${totalCount} ${totalCount === 1 ? 'activity' : 'activities'}`;
|
||||
countHeader = `Showing all ${totalCount} ${totalCount === 1 ? "activity" : "activities"}`;
|
||||
}
|
||||
|
||||
const contentWithCount = `${countHeader}\n\n${curatedContent}`;
|
||||
@@ -997,7 +909,7 @@ export async function createAgentMcpServer(
|
||||
content: contentWithCount,
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -1021,7 +933,7 @@ export async function createAgentMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true, newMode: modeId }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -1037,7 +949,7 @@ export async function createAgentMcpServer(
|
||||
agentId: z.string(),
|
||||
status: AgentStatusEnum,
|
||||
request: AgentPermissionRequestPayloadSchema,
|
||||
})
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -1055,7 +967,7 @@ export async function createAgentMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ permissions }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -1079,7 +991,7 @@ export async function createAgentMcpServer(
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../test-utils/index.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function isBinaryInstalled(binary: string): boolean {
|
||||
try {
|
||||
@@ -29,22 +26,18 @@ describe("provider model catalogs (e2e)", () => {
|
||||
await ctx.cleanup();
|
||||
}, 60_000);
|
||||
|
||||
test(
|
||||
"Claude catalog exposes Sonnet and Haiku variants",
|
||||
async () => {
|
||||
const result = await ctx.client.listProviderModels("claude");
|
||||
test("Claude catalog exposes Sonnet and Haiku variants", async () => {
|
||||
const result = await ctx.client.listProviderModels("claude");
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.models.length).toBeGreaterThan(0);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.models.length).toBeGreaterThan(0);
|
||||
|
||||
const descriptions = result.models.map(
|
||||
(model) => `${model.label} ${model.description ?? ""}`.toLowerCase()
|
||||
);
|
||||
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
|
||||
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
|
||||
},
|
||||
180_000
|
||||
);
|
||||
const descriptions = result.models.map((model) =>
|
||||
`${model.label} ${model.description ?? ""}`.toLowerCase(),
|
||||
);
|
||||
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
|
||||
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
|
||||
}, 180_000);
|
||||
|
||||
test.runIf(hasCodex)(
|
||||
"Codex catalog exposes gpt-5.1-codex",
|
||||
@@ -55,7 +48,7 @@ describe("provider model catalogs (e2e)", () => {
|
||||
const ids = result.models.map((model) => model.id);
|
||||
expect(ids.some((id) => id.startsWith("gpt-5.1-codex"))).toBe(true);
|
||||
},
|
||||
180_000
|
||||
180_000,
|
||||
);
|
||||
|
||||
test.runIf(hasOpenCode)(
|
||||
@@ -78,6 +71,6 @@ describe("provider model catalogs (e2e)", () => {
|
||||
const providerIds = new Set(result.models.map((m) => m.metadata?.providerId));
|
||||
expect(providerIds.size).toBeGreaterThan(0);
|
||||
},
|
||||
180_000
|
||||
180_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,11 @@ describe("resolveAgentModel", () => {
|
||||
opencode: { fetchModels: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "claude", cwd: "~/repo", logger: testLogger });
|
||||
const result = await resolveAgentModel({
|
||||
provider: "claude",
|
||||
cwd: "~/repo",
|
||||
logger: testLogger,
|
||||
});
|
||||
|
||||
expect(result).toBe("claude-3.5-sonnet");
|
||||
expect(fetchModels).toHaveBeenCalledWith({
|
||||
|
||||
@@ -11,7 +11,7 @@ type ResolveAgentModelOptions = {
|
||||
};
|
||||
|
||||
export async function resolveAgentModel(
|
||||
options: ResolveAgentModelOptions
|
||||
options: ResolveAgentModelOptions,
|
||||
): Promise<string | undefined> {
|
||||
const trimmed = options.requestedModel?.trim();
|
||||
if (trimmed) {
|
||||
@@ -28,7 +28,7 @@ export async function resolveAgentModel(
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, provider: options.provider },
|
||||
"Failed to resolve default model"
|
||||
"Failed to resolve default model",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@ import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../test-utils/index.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
import type { AgentStreamEventPayload } from "../messages.js";
|
||||
|
||||
describe("OpenCode reasoning events (e2e)", () => {
|
||||
@@ -23,111 +20,95 @@ describe("OpenCode reasoning events (e2e)", () => {
|
||||
await rm(agentCwd, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
test(
|
||||
"gpt-5 nano emits reasoning events through daemon",
|
||||
async () => {
|
||||
const allEvents: Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: string;
|
||||
}> = [];
|
||||
|
||||
// Subscribe to all events
|
||||
ctx.client.on((event) => {
|
||||
if (event.type === "agent_stream") {
|
||||
allEvents.push({
|
||||
event: event.event,
|
||||
timestamp: event.timestamp,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create agent with gpt-5 nano model
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd: agentCwd,
|
||||
model: "opencode/gpt-5-nano",
|
||||
title: "reasoning-test",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
|
||||
|
||||
// Send a message that should trigger reasoning
|
||||
await ctx.client.sendMessage(agent.id, "What is 2+2? Think step by step.");
|
||||
|
||||
// Wait for agent to complete
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 120_000);
|
||||
|
||||
|
||||
// Log all events
|
||||
|
||||
for (let i = 0; i < allEvents.length; i++) {
|
||||
const { event, timestamp } = allEvents[i];
|
||||
|
||||
|
||||
test("gpt-5 nano emits reasoning events through daemon", async () => {
|
||||
const allEvents: Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: string;
|
||||
}> = [];
|
||||
|
||||
// Subscribe to all events
|
||||
ctx.client.on((event) => {
|
||||
if (event.type === "agent_stream") {
|
||||
allEvents.push({
|
||||
event: event.event,
|
||||
timestamp: event.timestamp,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Group by type
|
||||
const byType = new Map<string, number>();
|
||||
for (const { event } of allEvents) {
|
||||
byType.set(event.type, (byType.get(event.type) ?? 0) + 1);
|
||||
// Create agent with gpt-5 nano model
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd: agentCwd,
|
||||
model: "opencode/gpt-5-nano",
|
||||
title: "reasoning-test",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
// Send a message that should trigger reasoning
|
||||
await ctx.client.sendMessage(agent.id, "What is 2+2? Think step by step.");
|
||||
|
||||
// Wait for agent to complete
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 120_000);
|
||||
|
||||
// Log all events
|
||||
|
||||
for (let i = 0; i < allEvents.length; i++) {
|
||||
const { event, timestamp } = allEvents[i];
|
||||
}
|
||||
|
||||
// Group by type
|
||||
const byType = new Map<string, number>();
|
||||
for (const { event } of allEvents) {
|
||||
byType.set(event.type, (byType.get(event.type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
for (const [type, count] of byType) {
|
||||
}
|
||||
|
||||
// Check timeline events breakdown
|
||||
const timelineEvents = allEvents.filter(({ event }) => event.type === "timeline");
|
||||
const itemTypes = new Map<string, number>();
|
||||
for (const { event } of timelineEvents) {
|
||||
if (event.type === "timeline") {
|
||||
itemTypes.set(event.item.type, (itemTypes.get(event.item.type) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [type, count] of byType) {
|
||||
for (const [type, count] of itemTypes) {
|
||||
}
|
||||
|
||||
// Find reasoning events
|
||||
const reasoningEvents = timelineEvents.filter(
|
||||
({ event }) => event.type === "timeline" && event.item.type === "reasoning",
|
||||
);
|
||||
|
||||
for (const { event } of reasoningEvents.slice(0, 5)) {
|
||||
if (event.type === "timeline") {
|
||||
}
|
||||
}
|
||||
|
||||
// Check timeline events breakdown
|
||||
const timelineEvents = allEvents.filter(
|
||||
({ event }) => event.type === "timeline"
|
||||
);
|
||||
const itemTypes = new Map<string, number>();
|
||||
for (const { event } of timelineEvents) {
|
||||
if (event.type === "timeline") {
|
||||
itemTypes.set(event.item.type, (itemTypes.get(event.item.type) ?? 0) + 1);
|
||||
// Check for duplicate consecutive events
|
||||
|
||||
let duplicateCount = 0;
|
||||
for (let i = 1; i < allEvents.length; i++) {
|
||||
const prev = allEvents[i - 1];
|
||||
const curr = allEvents[i];
|
||||
if (JSON.stringify(prev.event) === JSON.stringify(curr.event)) {
|
||||
duplicateCount++;
|
||||
if (duplicateCount <= 5) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [type, count] of itemTypes) {
|
||||
// HARD ASSERT: Agent completed
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
}
|
||||
// HARD ASSERT: Got events
|
||||
expect(allEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Find reasoning events
|
||||
const reasoningEvents = timelineEvents.filter(
|
||||
({ event }) => event.type === "timeline" && event.item.type === "reasoning"
|
||||
);
|
||||
|
||||
for (const { event } of reasoningEvents.slice(0, 5)) {
|
||||
if (event.type === "timeline") {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate consecutive events
|
||||
|
||||
let duplicateCount = 0;
|
||||
for (let i = 1; i < allEvents.length; i++) {
|
||||
const prev = allEvents[i - 1];
|
||||
const curr = allEvents[i];
|
||||
if (JSON.stringify(prev.event) === JSON.stringify(curr.event)) {
|
||||
duplicateCount++;
|
||||
if (duplicateCount <= 5) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HARD ASSERT: Agent completed
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
// HARD ASSERT: Got events
|
||||
expect(allEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Delete the agent
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
},
|
||||
180_000
|
||||
);
|
||||
// Delete the agent
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
@@ -26,11 +26,7 @@ export class Pcm16MonoResampler {
|
||||
throw new Error(`PCM16 chunk byteLength must be even, got ${pcm16le.length}`);
|
||||
}
|
||||
|
||||
const srcChunk = new Int16Array(
|
||||
pcm16le.buffer,
|
||||
pcm16le.byteOffset,
|
||||
pcm16le.byteLength / 2
|
||||
);
|
||||
const srcChunk = new Int16Array(pcm16le.buffer, pcm16le.byteOffset, pcm16le.byteLength / 2);
|
||||
|
||||
const hasCarry = this.carrySample !== null;
|
||||
const srcLen = srcChunk.length + (hasCarry ? 1 : 0);
|
||||
@@ -80,4 +76,3 @@ export class Pcm16MonoResampler {
|
||||
return Buffer.from(outArr.buffer, outArr.byteOffset, outArr.byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("resolveProviderCommandPrefix", () => {
|
||||
mode: "append",
|
||||
args: ["--chrome"],
|
||||
},
|
||||
resolveDefault
|
||||
resolveDefault,
|
||||
);
|
||||
|
||||
expect(resolveDefault).toHaveBeenCalledTimes(1);
|
||||
@@ -61,7 +61,7 @@ describe("resolveProviderCommandPrefix", () => {
|
||||
mode: "replace",
|
||||
argv: ["docker", "run", "--rm", "my-wrapper"],
|
||||
},
|
||||
resolveDefault
|
||||
resolveDefault,
|
||||
);
|
||||
|
||||
expect(resolveDefault).not.toHaveBeenCalled();
|
||||
@@ -117,12 +117,10 @@ describe("findExecutable", () => {
|
||||
test("uses the last line from login-shell which output", () => {
|
||||
findExecutableDependencies.shell = "/bin/zsh";
|
||||
findExecutableDependencies.execSync.mockReturnValue(
|
||||
"echo from profile\n/usr/local/bin/codex\n"
|
||||
"echo from profile\n/usr/local/bin/codex\n",
|
||||
);
|
||||
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBe(
|
||||
"/usr/local/bin/codex"
|
||||
);
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
|
||||
expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce();
|
||||
expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -143,10 +141,8 @@ describe("findExecutable", () => {
|
||||
findExecutableDependencies.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
|
||||
"/usr/local/bin/codex"
|
||||
);
|
||||
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith(
|
||||
"/usr/local/bin/codex"
|
||||
"/usr/local/bin/codex",
|
||||
);
|
||||
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ProviderRuntimeSettingsSchema = z
|
||||
|
||||
export const AgentProviderRuntimeSettingsMapSchema = z.record(
|
||||
AgentProviderSchema,
|
||||
ProviderRuntimeSettingsSchema
|
||||
ProviderRuntimeSettingsSchema,
|
||||
);
|
||||
|
||||
export type ProviderCommand = z.infer<typeof ProviderCommandSchema>;
|
||||
@@ -67,7 +67,7 @@ interface FindExecutableDependencies {
|
||||
function resolveExecutableFromWhichOutput(
|
||||
name: string,
|
||||
output: string,
|
||||
source: "login-shell" | "which"
|
||||
source: "login-shell" | "which",
|
||||
): string | null {
|
||||
const lines = output
|
||||
.split(/\r?\n/)
|
||||
@@ -81,7 +81,7 @@ function resolveExecutableFromWhichOutput(
|
||||
|
||||
if (!candidate.startsWith("/")) {
|
||||
console.warn(
|
||||
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`
|
||||
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ function resolveExecutableFromWhichOutput(
|
||||
|
||||
export function resolveProviderCommandPrefix(
|
||||
commandConfig: ProviderCommand | undefined,
|
||||
resolveDefaultCommand: () => string
|
||||
resolveDefaultCommand: () => string,
|
||||
): ProviderCommandPrefix {
|
||||
if (!commandConfig || commandConfig.mode === "default") {
|
||||
return {
|
||||
@@ -128,7 +128,7 @@ export function resolveShellEnv(): Record<string, string> {
|
||||
export function applyProviderEnv(
|
||||
baseEnv: Record<string, string | undefined>,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
shellEnv?: Record<string, string>
|
||||
shellEnv?: Record<string, string>,
|
||||
): Record<string, string | undefined> {
|
||||
return {
|
||||
...baseEnv,
|
||||
@@ -149,7 +149,7 @@ export function applyProviderEnv(
|
||||
*/
|
||||
export function findExecutable(
|
||||
name: string,
|
||||
dependencies?: FindExecutableDependencies
|
||||
dependencies?: FindExecutableDependencies,
|
||||
): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
@@ -183,10 +183,12 @@ export function findExecutable(
|
||||
const shell = deps.shell;
|
||||
if (shell) {
|
||||
try {
|
||||
const out = deps.execSync(`${shell} -lic "which ${trimmed}"`, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
const out = deps
|
||||
.execSync(`${shell} -lic "which ${trimmed}"`, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
})
|
||||
.trim();
|
||||
const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell");
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
@@ -200,7 +202,7 @@ export function findExecutable(
|
||||
return resolveExecutableFromWhichOutput(
|
||||
trimmed,
|
||||
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
|
||||
"which"
|
||||
"which",
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -213,7 +215,7 @@ export function isCommandAvailable(command: string): boolean {
|
||||
|
||||
export function isProviderCommandAvailable(
|
||||
commandConfig: ProviderCommand | undefined,
|
||||
resolveDefaultCommand: () => string
|
||||
resolveDefaultCommand: () => string,
|
||||
): boolean {
|
||||
try {
|
||||
const prefix = resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentMode } from "./agent-sdk-types.js";
|
||||
|
||||
export type AgentModeColorTier = 'default' | 'safe' | 'moderate' | 'dangerous' | 'readonly';
|
||||
export type AgentModeIcon = 'ShieldCheck' | 'ShieldAlert' | 'ShieldOff';
|
||||
export type AgentModeColorTier = "default" | "safe" | "moderate" | "dangerous" | "readonly";
|
||||
export type AgentModeIcon = "ShieldCheck" | "ShieldAlert" | "ShieldOff";
|
||||
|
||||
export interface AgentModeVisuals {
|
||||
icon: AgentModeIcon;
|
||||
@@ -25,29 +25,83 @@ export interface AgentProviderDefinition {
|
||||
}
|
||||
|
||||
const CLAUDE_MODES: AgentProviderModeDefinition[] = [
|
||||
{ id: "default", label: "Always Ask", description: "Prompts for permission the first time a tool is used", icon: "ShieldCheck", colorTier: "safe" },
|
||||
{ id: "acceptEdits", label: "Accept File Edits", description: "Automatically approves edit-focused tools without prompting", icon: "ShieldAlert", colorTier: "moderate" },
|
||||
{ id: "plan", label: "Plan Mode", description: "Analyze the codebase without executing tools or edits", icon: "ShieldCheck", colorTier: "readonly" },
|
||||
{ id: "bypassPermissions", label: "Bypass", description: "Skip all permission prompts (use with caution)", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||
{
|
||||
id: "default",
|
||||
label: "Always Ask",
|
||||
description: "Prompts for permission the first time a tool is used",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
label: "Accept File Edits",
|
||||
description: "Automatically approves edit-focused tools without prompting",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan Mode",
|
||||
description: "Analyze the codebase without executing tools or edits",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "readonly",
|
||||
},
|
||||
{
|
||||
id: "bypassPermissions",
|
||||
label: "Bypass",
|
||||
description: "Skip all permission prompts (use with caution)",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
];
|
||||
|
||||
const CODEX_MODES: AgentProviderModeDefinition[] = [
|
||||
{ id: "read-only", label: "Read Only", description: "Read files and answer questions. Manual approval required for edits, commands, or network ops.", icon: "ShieldCheck", colorTier: "readonly" },
|
||||
{ id: "auto", label: "Auto", description: "Edit files and run commands but still request approval before escalating scope.", icon: "ShieldAlert", colorTier: "moderate" },
|
||||
{ id: "full-access", label: "Full Access", description: "Edit files, run commands, and access the network without additional prompts.", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||
{
|
||||
id: "read-only",
|
||||
label: "Read Only",
|
||||
description:
|
||||
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "readonly",
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Edit files and run commands but still request approval before escalating scope.",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "full-access",
|
||||
label: "Full Access",
|
||||
description: "Edit files, run commands, and access the network without additional prompts.",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
];
|
||||
|
||||
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
|
||||
{ id: "build", label: "Build", description: "Allows edits and tool execution for implementation work", icon: "ShieldCheck", colorTier: "default" },
|
||||
{ id: "plan", label: "Plan", description: "Read-only planning mode that avoids file edits", icon: "ShieldCheck", colorTier: "readonly" },
|
||||
{
|
||||
id: "build",
|
||||
label: "Build",
|
||||
description: "Allows edits and tool execution for implementation work",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "default",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan",
|
||||
description: "Read-only planning mode that avoids file edits",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "readonly",
|
||||
},
|
||||
];
|
||||
|
||||
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description:
|
||||
"Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
|
||||
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
|
||||
defaultModeId: "default",
|
||||
modes: CLAUDE_MODES,
|
||||
voice: {
|
||||
@@ -59,8 +113,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description:
|
||||
"OpenAI's Codex workspace agent with sandbox controls and optional network access",
|
||||
description: "OpenAI's Codex workspace agent with sandbox controls and optional network access",
|
||||
defaultModeId: "auto",
|
||||
modes: CODEX_MODES,
|
||||
voice: {
|
||||
@@ -72,8 +125,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
{
|
||||
id: "opencode",
|
||||
label: "OpenCode",
|
||||
description:
|
||||
"Open-source coding assistant with multi-provider model support",
|
||||
description: "Open-source coding assistant with multi-provider model support",
|
||||
defaultModeId: "build",
|
||||
modes: OPENCODE_MODES,
|
||||
voice: {
|
||||
@@ -91,7 +143,10 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin
|
||||
return definition;
|
||||
}
|
||||
|
||||
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [string, ...string[]];
|
||||
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [
|
||||
string,
|
||||
...string[],
|
||||
];
|
||||
|
||||
export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS);
|
||||
|
||||
|
||||
@@ -17,14 +17,9 @@ import {
|
||||
type AgentProviderDefinition,
|
||||
} from "./provider-manifest.js";
|
||||
|
||||
export type {
|
||||
AgentProviderDefinition,
|
||||
};
|
||||
export type { AgentProviderDefinition };
|
||||
|
||||
export {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
getAgentProviderDefinition,
|
||||
};
|
||||
export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
|
||||
|
||||
export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
createClient: (logger: Logger) => AgentClient;
|
||||
@@ -37,21 +32,15 @@ type BuildProviderRegistryOptions = {
|
||||
|
||||
export function buildProviderRegistry(
|
||||
logger: Logger,
|
||||
options?: BuildProviderRegistryOptions
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, ProviderDefinition> {
|
||||
const runtimeSettings = options?.runtimeSettings;
|
||||
const claudeClient = new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.claude,
|
||||
});
|
||||
const codexClient = new CodexAppServerAgentClient(
|
||||
logger,
|
||||
runtimeSettings?.codex
|
||||
);
|
||||
const opencodeClient = new OpenCodeAgentClient(
|
||||
logger,
|
||||
runtimeSettings?.opencode
|
||||
);
|
||||
const codexClient = new CodexAppServerAgentClient(logger, runtimeSettings?.codex);
|
||||
const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode);
|
||||
|
||||
return {
|
||||
claude: {
|
||||
@@ -68,8 +57,7 @@ export function buildProviderRegistry(
|
||||
},
|
||||
opencode: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
|
||||
createClient: (logger: Logger) =>
|
||||
new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
fetchModels: (options) => opencodeClient.listModels(options),
|
||||
},
|
||||
};
|
||||
@@ -80,7 +68,7 @@ export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = null
|
||||
|
||||
export function createAllClients(
|
||||
logger: Logger,
|
||||
options?: BuildProviderRegistryOptions
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, AgentClient> {
|
||||
const registry = buildProviderRegistry(logger, options);
|
||||
return {
|
||||
@@ -92,10 +80,7 @@ export function createAllClients(
|
||||
|
||||
export async function shutdownProviders(
|
||||
logger: Logger,
|
||||
options?: BuildProviderRegistryOptions
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Promise<void> {
|
||||
await OpenCodeServerManager.getInstance(
|
||||
logger,
|
||||
options?.runtimeSettings?.opencode
|
||||
).shutdown();
|
||||
await OpenCodeServerManager.getInstance(logger, options?.runtimeSettings?.opencode).shutdown();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../../test-utils/index.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
|
||||
import type { AgentSlashCommand } from "../agent-sdk-types.js";
|
||||
|
||||
// Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary.
|
||||
|
||||
@@ -7,40 +7,36 @@ import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
|
||||
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
|
||||
describe("claude agent commands contract (real)", () => {
|
||||
test(
|
||||
"lists slash commands with the expected contract",
|
||||
async () => {
|
||||
expect(isCommandAvailable("claude")).toBe(true);
|
||||
test("lists slash commands with the expected contract", async () => {
|
||||
expect(isCommandAvailable("claude")).toBe(true);
|
||||
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "plan",
|
||||
});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "plan",
|
||||
});
|
||||
|
||||
try {
|
||||
expect(typeof session.listCommands).toBe("function");
|
||||
const commands = await session.listCommands!();
|
||||
try {
|
||||
expect(typeof session.listCommands).toBe("function");
|
||||
const commands = await session.listCommands!();
|
||||
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
expect(commands.map((command) => command.name)).toContain("rewind");
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
expect(commands.map((command) => command.name)).toContain("rewind");
|
||||
|
||||
for (const command of commands) {
|
||||
const typed = command as AgentSlashCommand;
|
||||
expect(typeof typed.name).toBe("string");
|
||||
expect(typed.name.length).toBeGreaterThan(0);
|
||||
expect(typed.name.startsWith("/")).toBe(false);
|
||||
expect(typeof typed.description).toBe("string");
|
||||
expect(typeof typed.argumentHint).toBe("string");
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
for (const command of commands) {
|
||||
const typed = command as AgentSlashCommand;
|
||||
expect(typeof typed.name).toBe("string");
|
||||
expect(typed.name.length).toBeGreaterThan(0);
|
||||
expect(typed.name.startsWith("/")).toBe(false);
|
||||
expect(typeof typed.description).toBe("string");
|
||||
expect(typeof typed.argumentHint).toBe("string");
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -4,11 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type {
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../provider-launch-config.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
|
||||
@@ -34,7 +30,7 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
async function nextStreamEvent(
|
||||
stream: AsyncGenerator<AgentStreamEvent>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
label: string,
|
||||
): Promise<IteratorResult<AgentStreamEvent>> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
@@ -58,15 +54,11 @@ async function collectUntilTerminal(
|
||||
options?: {
|
||||
timeoutMs?: number;
|
||||
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
|
||||
}
|
||||
},
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
while (true) {
|
||||
const next = await nextStreamEvent(
|
||||
stream,
|
||||
options?.timeoutMs ?? 45_000,
|
||||
"stream event"
|
||||
);
|
||||
const next = await nextStreamEvent(stream, options?.timeoutMs ?? 45_000, "stream event");
|
||||
if (next.done || !next.value) {
|
||||
return events;
|
||||
}
|
||||
@@ -82,7 +74,7 @@ async function collectUntilTerminal(
|
||||
async function collectUntil(
|
||||
stream: AsyncGenerator<AgentStreamEvent>,
|
||||
predicate: (event: AgentStreamEvent) => boolean,
|
||||
timeoutMs = 45_000
|
||||
timeoutMs = 45_000,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
while (true) {
|
||||
@@ -118,16 +110,10 @@ function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
function getLatestCompletedBashCall(
|
||||
events: AgentStreamEvent[]
|
||||
): ToolCallTimelineItem | undefined {
|
||||
function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelineItem | undefined {
|
||||
return [...getToolCalls(events)]
|
||||
.reverse()
|
||||
.find(
|
||||
(item) =>
|
||||
item.status === "completed" &&
|
||||
item.name.toLowerCase() === "bash"
|
||||
);
|
||||
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
|
||||
}
|
||||
|
||||
function getInternalQuery(session: AgentSession): unknown {
|
||||
@@ -150,10 +136,7 @@ async function createSession(params?: {
|
||||
return { cwd, session };
|
||||
}
|
||||
|
||||
async function cleanupSession(handle: {
|
||||
cwd: string;
|
||||
session: AgentSession;
|
||||
}): Promise<void> {
|
||||
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
|
||||
await handle.session.close().catch(() => undefined);
|
||||
rmSync(handle.cwd, { recursive: true, force: true });
|
||||
}
|
||||
@@ -163,252 +146,225 @@ describe("ClaudeAgentSession integration", () => {
|
||||
expect(isCommandAvailable("claude")).toBe(true);
|
||||
});
|
||||
|
||||
test(
|
||||
"streams a basic response turn end-to-end",
|
||||
async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-basic-response-",
|
||||
test("streams a basic response turn end-to-end", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-basic-response-",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream("Respond with exactly: HELLO_WORLD"),
|
||||
);
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "turn_started",
|
||||
provider: "claude",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream("Respond with exactly: HELLO_WORLD")
|
||||
);
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "turn_started",
|
||||
provider: "claude",
|
||||
});
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
compactText(event.item.text).includes("hello_world")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
|
||||
test(
|
||||
"runs a real Bash tool call and completes it",
|
||||
async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-basic-tool-",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: echo TOOL_TEST_OUTPUT",
|
||||
"After the command completes, reply with exactly: TOOL_DONE",
|
||||
].join(" ")
|
||||
)
|
||||
);
|
||||
|
||||
const bashCalls = getToolCalls(events).filter(
|
||||
(item) => item.name.toLowerCase() === "bash"
|
||||
);
|
||||
const completedBashCall = getLatestCompletedBashCall(events);
|
||||
|
||||
expect(bashCalls.length).toBeGreaterThan(0);
|
||||
expect(completedBashCall).toBeDefined();
|
||||
expect(completedBashCall?.detail.type).toBe("shell");
|
||||
expect(
|
||||
completedBashCall?.detail.type === "shell" &&
|
||||
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT")
|
||||
).toBe(true);
|
||||
expect(compactText(getAssistantText(events))).toContain("tool_done");
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
|
||||
test(
|
||||
"interrupts a running Bash turn and continues on the same query",
|
||||
async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-interrupt-continue-",
|
||||
});
|
||||
|
||||
try {
|
||||
const firstStream = handle.session.stream(
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: sleep 10",
|
||||
"Do not use a background task.",
|
||||
"Do not do anything after starting the command.",
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
const initialEvents = await collectUntil(
|
||||
firstStream,
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name.toLowerCase() === "bash",
|
||||
45_000
|
||||
);
|
||||
const firstQuery = getInternalQuery(handle.session);
|
||||
|
||||
expect(firstQuery).toBeTruthy();
|
||||
|
||||
await handle.session.interrupt();
|
||||
|
||||
const canceledEvents = await collectUntilTerminal(firstStream, {
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
|
||||
|
||||
expect(
|
||||
allFirstTurnEvents.some(
|
||||
(event) =>
|
||||
event.type === "turn_canceled" && event.provider === "claude"
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const followUpEvents = await collectUntilTerminal(
|
||||
handle.session.stream("Respond with exactly: AFTER_INTERRUPT_OK")
|
||||
);
|
||||
const secondQuery = getInternalQuery(handle.session);
|
||||
|
||||
expect(secondQuery).toBe(firstQuery);
|
||||
expect(compactText(getAssistantText(followUpEvents))).toContain(
|
||||
"after_interrupt_ok"
|
||||
);
|
||||
expect(followUpEvents.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
|
||||
test(
|
||||
"creates an autonomous live turn when a background task completes",
|
||||
async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-autonomous-",
|
||||
event.item.type === "assistant_message" &&
|
||||
compactText(event.item.text).includes("hello_world"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
try {
|
||||
const liveEventsStream = handle.session.streamLiveEvents();
|
||||
const foregroundEvents = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
[
|
||||
"Use the Task tool to start a background sub-agent.",
|
||||
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
|
||||
"Do not wait for task completion.",
|
||||
"Reply immediately with exactly: SPAWNED",
|
||||
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||
].join(" ")
|
||||
),
|
||||
{ timeoutMs: 45_000 }
|
||||
);
|
||||
test("runs a real Bash tool call and completes it", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-basic-tool-",
|
||||
});
|
||||
|
||||
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: echo TOOL_TEST_OUTPUT",
|
||||
"After the command completes, reply with exactly: TOOL_DONE",
|
||||
].join(" "),
|
||||
),
|
||||
);
|
||||
|
||||
const liveEvents = await collectUntilTerminal(liveEventsStream, {
|
||||
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
|
||||
const completedBashCall = getLatestCompletedBashCall(events);
|
||||
|
||||
expect(bashCalls.length).toBeGreaterThan(0);
|
||||
expect(completedBashCall).toBeDefined();
|
||||
expect(completedBashCall?.detail.type).toBe("shell");
|
||||
expect(
|
||||
completedBashCall?.detail.type === "shell" &&
|
||||
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
|
||||
).toBe(true);
|
||||
expect(compactText(getAssistantText(events))).toContain("tool_done");
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("interrupts a running Bash turn and continues on the same query", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-interrupt-continue-",
|
||||
});
|
||||
|
||||
try {
|
||||
const firstStream = handle.session.stream(
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: sleep 10",
|
||||
"Do not use a background task.",
|
||||
"Do not do anything after starting the command.",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const initialEvents = await collectUntil(
|
||||
firstStream,
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name.toLowerCase() === "bash",
|
||||
45_000,
|
||||
);
|
||||
const firstQuery = getInternalQuery(handle.session);
|
||||
|
||||
expect(firstQuery).toBeTruthy();
|
||||
|
||||
await handle.session.interrupt();
|
||||
|
||||
const canceledEvents = await collectUntilTerminal(firstStream, {
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
|
||||
|
||||
expect(
|
||||
allFirstTurnEvents.some(
|
||||
(event) => event.type === "turn_canceled" && event.provider === "claude",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const followUpEvents = await collectUntilTerminal(
|
||||
handle.session.stream("Respond with exactly: AFTER_INTERRUPT_OK"),
|
||||
);
|
||||
const secondQuery = getInternalQuery(handle.session);
|
||||
|
||||
expect(secondQuery).toBe(firstQuery);
|
||||
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
|
||||
expect(followUpEvents.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("creates an autonomous live turn when a background task completes", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-autonomous-",
|
||||
});
|
||||
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
||||
|
||||
try {
|
||||
const liveEventsStream = handle.session.streamLiveEvents();
|
||||
const foregroundEvents = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
[
|
||||
"Use the Task tool to start a background sub-agent.",
|
||||
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
|
||||
"Do not wait for task completion.",
|
||||
"Reply immediately with exactly: SPAWNED",
|
||||
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||
].join(" "),
|
||||
),
|
||||
{ timeoutMs: 45_000 },
|
||||
);
|
||||
|
||||
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
|
||||
|
||||
const liveEvents = await collectUntilTerminal(liveEventsStream, {
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
|
||||
).toBe(true);
|
||||
expect(compactText(getAssistantText(liveEvents))).toContain(
|
||||
autonomousWakeToken.toLowerCase(),
|
||||
);
|
||||
expect(liveEvents.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("surfaces permission requests and resumes after approval", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-permission-",
|
||||
modeId: "default",
|
||||
});
|
||||
const permissionFile = path.join(handle.cwd, "permission.txt");
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
[
|
||||
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
|
||||
"If approval is required, wait for approval.",
|
||||
"After the command succeeds, reply with exactly: PERM_DONE",
|
||||
].join(" "),
|
||||
),
|
||||
{
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
onEvent: async (event) => {
|
||||
if (event.type !== "permission_requested") {
|
||||
return;
|
||||
}
|
||||
await handle.session.respondToPermission(event.request.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
liveEvents.some(
|
||||
(event) => event.type === "turn_started" && event.provider === "claude"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
compactText(getAssistantText(liveEvents))
|
||||
).toContain(autonomousWakeToken.toLowerCase());
|
||||
expect(liveEvents.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
const permissionRequest = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested",
|
||||
);
|
||||
const permissionResolved = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
|
||||
event.type === "permission_resolved",
|
||||
);
|
||||
const completedBashCall = getLatestCompletedBashCall(events);
|
||||
|
||||
test(
|
||||
"surfaces permission requests and resumes after approval",
|
||||
async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-permission-",
|
||||
modeId: "default",
|
||||
expect(permissionRequest?.request.kind).toBe("tool");
|
||||
expect(permissionResolved).toMatchObject({
|
||||
type: "permission_resolved",
|
||||
provider: "claude",
|
||||
resolution: { behavior: "allow" },
|
||||
});
|
||||
const permissionFile = path.join(handle.cwd, "permission.txt");
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(
|
||||
handle.session.stream(
|
||||
[
|
||||
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
|
||||
"If approval is required, wait for approval.",
|
||||
"After the command succeeds, reply with exactly: PERM_DONE",
|
||||
].join(" ")
|
||||
),
|
||||
{
|
||||
timeoutMs: 45_000,
|
||||
onEvent: async (event) => {
|
||||
if (event.type !== "permission_requested") {
|
||||
return;
|
||||
}
|
||||
await handle.session.respondToPermission(event.request.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const permissionRequest = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||
event.type === "permission_requested"
|
||||
);
|
||||
const permissionResolved = events.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
|
||||
event.type === "permission_resolved"
|
||||
);
|
||||
const completedBashCall = getLatestCompletedBashCall(events);
|
||||
|
||||
expect(permissionRequest?.request.kind).toBe("tool");
|
||||
expect(permissionResolved).toMatchObject({
|
||||
type: "permission_resolved",
|
||||
provider: "claude",
|
||||
resolution: { behavior: "allow" },
|
||||
});
|
||||
expect(completedBashCall).toBeDefined();
|
||||
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
|
||||
expect(compactText(getAssistantText(events))).toContain("perm_done");
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
expect(completedBashCall).toBeDefined();
|
||||
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
|
||||
expect(compactText(getAssistantText(events))).toContain("perm_done");
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
});
|
||||
} finally {
|
||||
await cleanupSession(handle);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
+5
-7
@@ -182,7 +182,7 @@ function createScriptedQuery(params: {
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(
|
||||
stream: AsyncGenerator<AgentStreamEvent>
|
||||
stream: AsyncGenerator<AgentStreamEvent>,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
@@ -211,7 +211,7 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
options?: { timeoutMs?: number; intervalMs?: number }
|
||||
options?: { timeoutMs?: number; intervalMs?: number },
|
||||
): Promise<void> {
|
||||
const timeoutMs = options?.timeoutMs ?? 2_000;
|
||||
const intervalMs = options?.intervalMs ?? 5;
|
||||
@@ -259,9 +259,7 @@ describe("ClaudeAgentSession interrupt regression", () => {
|
||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||
|
||||
const firstTurnEvents = await collectUntilTerminal(firstTurn);
|
||||
expect(
|
||||
firstTurnEvents.find((event) => event.type === "turn_canceled")
|
||||
).toMatchObject({
|
||||
expect(firstTurnEvents.find((event) => event.type === "turn_canceled")).toMatchObject({
|
||||
type: "turn_canceled",
|
||||
provider: "claude",
|
||||
reason: "Interrupted",
|
||||
@@ -546,8 +544,8 @@ describe("ClaudeAgentSession autonomous turns", () => {
|
||||
expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_RESPONSE");
|
||||
expect(
|
||||
[autonomousStart.value, autonomousTimeline.value, autonomousComplete.value].some(
|
||||
(event) => event?.type === "turn_canceled"
|
||||
)
|
||||
(event) => event?.type === "turn_canceled",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
||||
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CLAUDE_MODEL_CATALOG } from "./claude/model-catalog.js";
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
function latestModelId(family: "sonnet" | "opus" | "haiku"): string {
|
||||
const latest = CLAUDE_MODEL_CATALOG.find(
|
||||
(model) => model.family === family && model.isLatestInFamily
|
||||
(model) => model.family === family && model.isLatestInFamily,
|
||||
);
|
||||
if (latest) {
|
||||
return latest.modelId;
|
||||
|
||||
@@ -127,13 +127,11 @@ function createSpyLogger(): {
|
||||
}
|
||||
|
||||
function extractStringLogArgs(calls: unknown[][]): string[] {
|
||||
return calls.flatMap((args) =>
|
||||
args.filter((arg): arg is string => typeof arg === "string")
|
||||
);
|
||||
return calls.flatMap((args) => args.filter((arg): arg is string => typeof arg === "string"));
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(
|
||||
stream: AsyncGenerator<AgentStreamEvent>
|
||||
stream: AsyncGenerator<AgentStreamEvent>,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
@@ -149,10 +147,7 @@ async function collectUntilTerminal(
|
||||
return events;
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
check: () => boolean,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
async function waitForCondition(check: () => boolean, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (check()) {
|
||||
@@ -219,7 +214,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -242,13 +237,9 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
try {
|
||||
await session.run("redaction check");
|
||||
|
||||
const queryLogCall = spy.debug.mock.calls.find(
|
||||
(args) => args[1] === "claude query"
|
||||
);
|
||||
const queryLogCall = spy.debug.mock.calls.find((args) => args[1] === "claude query");
|
||||
expect(queryLogCall).toBeDefined();
|
||||
const payload = queryLogCall?.[0] as
|
||||
| { options?: Record<string, unknown> }
|
||||
| undefined;
|
||||
const payload = queryLogCall?.[0] as { options?: Record<string, unknown> } | undefined;
|
||||
expect(payload?.options).toBeDefined();
|
||||
expect(payload?.options).not.toHaveProperty("env");
|
||||
expect(payload?.options).not.toHaveProperty("systemPrompt");
|
||||
@@ -256,9 +247,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
expect(payload?.options).toHaveProperty("hasEnv");
|
||||
expect(payload?.options).toHaveProperty("envKeyCount");
|
||||
|
||||
const serialized = JSON.stringify(
|
||||
spy.calls,
|
||||
(_key, value) => (typeof value === "function" ? "[function]" : value)
|
||||
const serialized = JSON.stringify(spy.calls, (_key, value) =>
|
||||
typeof value === "function" ? "[function]" : value,
|
||||
);
|
||||
expect(serialized).not.toContain(envSecret);
|
||||
expect(serialized).not.toContain(runtimeSecret);
|
||||
@@ -277,12 +267,10 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const spy = createSpyLogger();
|
||||
const session = await createSessionWithLogger(spy.logger);
|
||||
const internal = session as unknown as {
|
||||
query:
|
||||
| {
|
||||
interrupt: () => Promise<void>;
|
||||
return?: () => Promise<void>;
|
||||
}
|
||||
| null;
|
||||
query: {
|
||||
interrupt: () => Promise<void>;
|
||||
return?: () => Promise<void>;
|
||||
} | null;
|
||||
input: { end: () => void } | null;
|
||||
queryRestartNeeded: boolean;
|
||||
interruptActiveTurn: () => Promise<void>;
|
||||
@@ -300,12 +288,12 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
try {
|
||||
await internal.interruptActiveTurn();
|
||||
|
||||
const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter(
|
||||
(message) => message.includes("interruptActiveTurn")
|
||||
const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter((message) =>
|
||||
message.includes("interruptActiveTurn"),
|
||||
);
|
||||
const interruptDebugMessages = extractStringLogArgs(spy.debug.mock.calls).filter((message) =>
|
||||
message.includes("interruptActiveTurn"),
|
||||
);
|
||||
const interruptDebugMessages = extractStringLogArgs(
|
||||
spy.debug.mock.calls
|
||||
).filter((message) => message.includes("interruptActiveTurn"));
|
||||
|
||||
expect(interruptInfoMessages).toEqual([]);
|
||||
expect(interruptDebugMessages).toEqual([
|
||||
@@ -401,8 +389,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
for (const fixture of fixtures) {
|
||||
expect(
|
||||
readEventIdentifiers(
|
||||
fixture.message as unknown as Parameters<typeof readEventIdentifiers>[0]
|
||||
)
|
||||
fixture.message as unknown as Parameters<typeof readEventIdentifiers>[0],
|
||||
),
|
||||
).toEqual(fixture.expected);
|
||||
}
|
||||
});
|
||||
@@ -482,7 +470,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
index,
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: "{\"command\":\"echo ",
|
||||
partial_json: '{"command":"echo ',
|
||||
},
|
||||
},
|
||||
expectedCommand: "echo ",
|
||||
@@ -493,7 +481,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
index,
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: "delta\"}",
|
||||
partial_json: 'delta"}',
|
||||
},
|
||||
},
|
||||
expectedCommand: "echo delta",
|
||||
@@ -503,7 +491,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
type: "content_block_delta",
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: "{\"command\":\"ignored\"}",
|
||||
partial_json: '{"command":"ignored"}',
|
||||
},
|
||||
},
|
||||
expectedCommand: "echo delta",
|
||||
@@ -511,9 +499,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
] as const;
|
||||
|
||||
for (const fixture of deltaFixtures) {
|
||||
internal.mapPartialEvent(
|
||||
fixture.event as unknown as Record<string, unknown>
|
||||
);
|
||||
internal.mapPartialEvent(fixture.event as unknown as Record<string, unknown>);
|
||||
expect(readCommand()).toBe(fixture.expectedCommand);
|
||||
}
|
||||
|
||||
@@ -553,8 +539,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
index,
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json:
|
||||
"{\"file_path\":\"src/message.tsx\",\"old_string\":\"before",
|
||||
partial_json: '{"file_path":"src/message.tsx","old_string":"before',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -572,7 +557,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const internal = session as unknown as {
|
||||
buildToolOutput: (
|
||||
block: Record<string, unknown>,
|
||||
entry: Record<string, unknown> | undefined
|
||||
entry: Record<string, unknown> | undefined,
|
||||
) => Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
@@ -611,7 +596,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
},
|
||||
a: 0,
|
||||
},
|
||||
expectedOutput: "{\"a\":0,\"nested\":{\"a\":1,\"b\":2},\"z\":3}",
|
||||
expectedOutput: '{"a":0,"nested":{"a":1,"b":2},"z":3}',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -625,14 +610,14 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
content: fixture.content,
|
||||
is_error: false,
|
||||
},
|
||||
toolEntry
|
||||
toolEntry,
|
||||
);
|
||||
expect(output).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "command",
|
||||
command: "echo hello",
|
||||
output: fixture.expectedOutput,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -647,12 +632,16 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
runTracker: {
|
||||
bindIdentifiers: (
|
||||
run: { id: string },
|
||||
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null }
|
||||
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null },
|
||||
) => void;
|
||||
};
|
||||
routeMessage: (input: {
|
||||
message: AgentStreamEvent | Record<string, unknown>;
|
||||
identifiers: { taskId: string | null; parentMessageId: string | null; messageId: string | null };
|
||||
identifiers: {
|
||||
taskId: string | null;
|
||||
parentMessageId: string | null;
|
||||
messageId: string | null;
|
||||
};
|
||||
metadataOnly: boolean;
|
||||
}) => { run: { id: string } | null; reason: string };
|
||||
turnState: "autonomous";
|
||||
@@ -727,14 +716,18 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
runTracker: {
|
||||
bindIdentifiers: (
|
||||
run: { id: string },
|
||||
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null }
|
||||
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null },
|
||||
) => void;
|
||||
};
|
||||
activeForegroundTurn: { runId: string; queue: unknown } | null;
|
||||
turnState: "foreground";
|
||||
routeMessage: (input: {
|
||||
message: Record<string, unknown>;
|
||||
identifiers: { taskId: string | null; parentMessageId: string | null; messageId: string | null };
|
||||
identifiers: {
|
||||
taskId: string | null;
|
||||
parentMessageId: string | null;
|
||||
messageId: string | null;
|
||||
};
|
||||
metadataOnly: boolean;
|
||||
}) => { run: { id: string } | null; reason: string };
|
||||
};
|
||||
@@ -846,8 +839,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const session = await createSession();
|
||||
@@ -857,7 +850,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error("Timed out waiting for foreground terminal event")),
|
||||
1_000
|
||||
1_000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
@@ -867,7 +860,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const assistantText = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
event.type === "timeline" && event.item.type === "assistant_message",
|
||||
)
|
||||
.map((event) => event.item.text)
|
||||
.join("");
|
||||
@@ -938,7 +931,10 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const internal = session as unknown as {
|
||||
createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string };
|
||||
activeForegroundTurn: { runId: string; queue: unknown } | null;
|
||||
runTracker: { getRun: (runId: string) => unknown; complete: (run: unknown, state: "completed") => void };
|
||||
runTracker: {
|
||||
getRun: (runId: string) => unknown;
|
||||
complete: (run: unknown, state: "completed") => void;
|
||||
};
|
||||
pushEvent: (event: AgentStreamEvent) => void;
|
||||
};
|
||||
|
||||
@@ -1068,7 +1064,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
mock.interrupt.mockImplementation(async () => {
|
||||
@@ -1079,41 +1075,28 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
|
||||
streamCase = "success";
|
||||
const successEvents = await collectUntilTerminal(session.stream("success prompt"));
|
||||
expect(successEvents.some((event) => event.type === "turn_completed")).toBe(
|
||||
true
|
||||
);
|
||||
expect(successEvents.some((event) => event.type === "turn_failed")).toBe(
|
||||
false
|
||||
);
|
||||
expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(
|
||||
false
|
||||
);
|
||||
expect(successEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(successEvents.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(false);
|
||||
|
||||
streamCase = "error";
|
||||
const errorEvents = await collectUntilTerminal(session.stream("error prompt"));
|
||||
expect(errorEvents.some((event) => event.type === "turn_failed")).toBe(true);
|
||||
expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(
|
||||
false
|
||||
);
|
||||
expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(false);
|
||||
|
||||
streamCase = "interrupt";
|
||||
const interruptStream = session.stream("interrupt prompt");
|
||||
const interruptEvents: AgentStreamEvent[] = [];
|
||||
for await (const event of interruptStream) {
|
||||
interruptEvents.push(event);
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message"
|
||||
) {
|
||||
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
||||
await session.interrupt();
|
||||
}
|
||||
if (event.type === "turn_canceled") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(interruptEvents.some((event) => event.type === "turn_canceled")).toBe(
|
||||
true
|
||||
);
|
||||
expect(interruptEvents.some((event) => event.type === "turn_canceled")).toBe(true);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
@@ -1216,7 +1199,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1225,7 +1208,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const assistantText = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
event.type === "timeline" && event.item.type === "assistant_message",
|
||||
)
|
||||
.map((event) => event.item.text)
|
||||
.join("");
|
||||
@@ -1335,7 +1318,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1344,7 +1327,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const assistantText = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
event.type === "timeline" && event.item.type === "assistant_message",
|
||||
)
|
||||
.map((event) => event.item.text)
|
||||
.join("");
|
||||
@@ -1358,5 +1341,4 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+7
-11
@@ -47,7 +47,7 @@ function buildQueryMock(events: unknown[]): QueryMock {
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(
|
||||
stream: AsyncGenerator<AgentStreamEvent>
|
||||
stream: AsyncGenerator<AgentStreamEvent>,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
@@ -235,7 +235,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
},
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
])
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -255,16 +255,14 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
const timelineToolCalls = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "tool_call"
|
||||
event.type === "timeline" && event.item.type === "tool_call",
|
||||
)
|
||||
.map((event) => event.item)
|
||||
.filter((item) => item.callId === "task-call-1");
|
||||
|
||||
expect(timelineToolCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const subAgentUpdates = timelineToolCalls.filter(
|
||||
(item) => item.detail.type === "sub_agent"
|
||||
);
|
||||
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
|
||||
expect(subAgentUpdates.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const latest = subAgentUpdates[subAgentUpdates.length - 1];
|
||||
@@ -298,7 +296,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
}));
|
||||
const projected = projectTimelineRows(rows, "claude", "projected");
|
||||
const projectedTaskCalls = projected.filter(
|
||||
(entry) => entry.item.type === "tool_call" && entry.item.callId === "task-call-1"
|
||||
(entry) => entry.item.type === "tool_call" && entry.item.callId === "task-call-1",
|
||||
);
|
||||
|
||||
expect(projectedTaskCalls).toHaveLength(1);
|
||||
@@ -318,13 +316,11 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
const timelineToolCalls = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "tool_call"
|
||||
event.type === "timeline" && event.item.type === "tool_call",
|
||||
)
|
||||
.map((event) => event.item)
|
||||
.filter((item) => item.callId === "task-tail-1");
|
||||
const subAgentUpdates = timelineToolCalls.filter(
|
||||
(item) => item.detail.type === "sub_agent"
|
||||
);
|
||||
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
|
||||
const latest = subAgentUpdates[subAgentUpdates.length - 1];
|
||||
expect(latest).toBeDefined();
|
||||
if (!latest || latest.detail.type !== "sub_agent") {
|
||||
|
||||
@@ -129,9 +129,9 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([
|
||||
{ type: "assistant_message", text: "No response requested." },
|
||||
]);
|
||||
const mapBlocks = vi
|
||||
.fn()
|
||||
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
|
||||
|
||||
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
|
||||
@@ -241,34 +241,30 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
describe("ClaudeAgentClient.listModels", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test(
|
||||
"returns models with required fields",
|
||||
async () => {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const models = await client.listModels();
|
||||
test("returns models with required fields", async () => {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const models = await client.listModels();
|
||||
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("claude");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
expect(typeof model.label).toBe("string");
|
||||
expect(model.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("claude");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
expect(typeof model.label).toBe("string");
|
||||
expect(model.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const modelIds = models.map((model) => model.id);
|
||||
expect(
|
||||
modelIds.some(
|
||||
(id) =>
|
||||
id.includes("claude") ||
|
||||
id.includes("sonnet") ||
|
||||
id.includes("opus") ||
|
||||
id.includes("haiku")
|
||||
)
|
||||
).toBe(true);
|
||||
},
|
||||
60_000
|
||||
);
|
||||
const modelIds = models.map((model) => model.id);
|
||||
expect(
|
||||
modelIds.some(
|
||||
(id) =>
|
||||
id.includes("claude") ||
|
||||
id.includes("sonnet") ||
|
||||
id.includes("opus") ||
|
||||
id.includes("haiku"),
|
||||
),
|
||||
).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+12
-21
@@ -5,10 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentStreamEvent,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
@@ -103,7 +100,7 @@ function collectTimelineText(events: AgentStreamEvent[]): string {
|
||||
|
||||
async function readNextEvent(
|
||||
iterator: AsyncIterator<AgentStreamEvent>,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
): Promise<AgentStreamEvent> {
|
||||
const outcome = await Promise.race([
|
||||
iterator.next().then((result) => ({ kind: "result" as const, result })),
|
||||
@@ -168,7 +165,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
"utf8",
|
||||
);
|
||||
|
||||
previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
@@ -272,12 +269,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
};
|
||||
|
||||
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
|
||||
const historyPath = path.join(
|
||||
configDir,
|
||||
"projects",
|
||||
sanitized,
|
||||
"history-session.jsonl"
|
||||
);
|
||||
const historyPath = path.join(configDir, "projects", sanitized, "history-session.jsonl");
|
||||
|
||||
const session = await client.resumeSession(handle, { cwd });
|
||||
|
||||
@@ -312,7 +304,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
content: APPENDED_ASSISTANT_MARKER,
|
||||
},
|
||||
})}`,
|
||||
"utf8"
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const appendedEvents: AgentStreamEvent[] = [];
|
||||
@@ -322,13 +314,13 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "task_notification"
|
||||
event.item.name === "task_notification",
|
||||
);
|
||||
const sawAssistant = appendedEvents.some(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes(APPENDED_ASSISTANT_MARKER)
|
||||
event.item.text.includes(APPENDED_ASSISTANT_MARKER),
|
||||
);
|
||||
if (sawTaskNotification && sawAssistant) {
|
||||
break;
|
||||
@@ -339,11 +331,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "task_notification"
|
||||
event.item.name === "task_notification",
|
||||
);
|
||||
const turnStartedEvent = appendedEvents.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_started" }> =>
|
||||
event.type === "turn_started"
|
||||
event.type === "turn_started",
|
||||
);
|
||||
|
||||
expect(taskNotificationEvent).toBeTruthy();
|
||||
@@ -420,9 +412,8 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(sdkMocks.lastQuery).toBeTruthy();
|
||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
|
||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith(
|
||||
"history-user-uuid",
|
||||
{ dryRun: false }
|
||||
);
|
||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
|
||||
dryRun: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,7 @@ import { mkdtempSync, rmSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
query,
|
||||
type SDKMessage,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
class Pushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
|
||||
@@ -89,7 +89,7 @@ export function listClaudeCatalogModels(): AgentModelDefinition[] {
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ import { parsePartialJsonObject } from "./partial-json.js";
|
||||
|
||||
describe("parsePartialJsonObject", () => {
|
||||
it("parses complete objects", () => {
|
||||
expect(
|
||||
parsePartialJsonObject("{\"command\":\"pwd\",\"cwd\":\"/tmp/repo\"}")
|
||||
).toEqual({
|
||||
expect(parsePartialJsonObject('{"command":"pwd","cwd":"/tmp/repo"}')).toEqual({
|
||||
value: {
|
||||
command: "pwd",
|
||||
cwd: "/tmp/repo",
|
||||
@@ -16,7 +14,7 @@ describe("parsePartialJsonObject", () => {
|
||||
});
|
||||
|
||||
it("keeps partial string values without field-specific logic", () => {
|
||||
expect(parsePartialJsonObject("{\"command\":\"echo ")).toEqual({
|
||||
expect(parsePartialJsonObject('{"command":"echo ')).toEqual({
|
||||
value: {
|
||||
command: "echo ",
|
||||
},
|
||||
@@ -25,11 +23,7 @@ describe("parsePartialJsonObject", () => {
|
||||
});
|
||||
|
||||
it("returns parsed prefix fields from incomplete objects", () => {
|
||||
expect(
|
||||
parsePartialJsonObject(
|
||||
"{\"file_path\":\"src/message.tsx\",\"old_string\":\"before"
|
||||
)
|
||||
).toEqual({
|
||||
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
|
||||
value: {
|
||||
file_path: "src/message.tsx",
|
||||
old_string: "before",
|
||||
@@ -39,11 +33,7 @@ describe("parsePartialJsonObject", () => {
|
||||
});
|
||||
|
||||
it("parses nested partial values generically", () => {
|
||||
expect(
|
||||
parsePartialJsonObject(
|
||||
"{\"payload\":{\"path\":\"src/index.ts\",\"content\":\"hello"
|
||||
)
|
||||
).toEqual({
|
||||
expect(parsePartialJsonObject('{"payload":{"path":"src/index.ts","content":"hello')).toEqual({
|
||||
value: {
|
||||
payload: {
|
||||
path: "src/index.ts",
|
||||
@@ -55,6 +45,6 @@ describe("parsePartialJsonObject", () => {
|
||||
});
|
||||
|
||||
it("returns null for non-object payloads", () => {
|
||||
expect(parsePartialJsonObject("\"text\"")).toBeNull();
|
||||
expect(parsePartialJsonObject('"text"')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,8 @@ function skipWhitespace(input: string, index: number): number {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
function parsePartialString(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<string> | null {
|
||||
if (input[index] !== "\"") {
|
||||
function parsePartialString(input: string, index: number): PartialParseResult<string> | null {
|
||||
if (input[index] !== '"') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -25,7 +22,7 @@ function parsePartialString(
|
||||
|
||||
while (currentIndex < input.length) {
|
||||
const character = input[currentIndex];
|
||||
if (character === "\"") {
|
||||
if (character === '"') {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex + 1,
|
||||
@@ -44,7 +41,7 @@ function parsePartialString(
|
||||
}
|
||||
|
||||
switch (escapeCharacter) {
|
||||
case "\"":
|
||||
case '"':
|
||||
case "\\":
|
||||
case "/":
|
||||
value += escapeCharacter;
|
||||
@@ -105,7 +102,7 @@ function parsePartialString(
|
||||
|
||||
function parsePartialLiteral(
|
||||
input: string,
|
||||
index: number
|
||||
index: number,
|
||||
): PartialParseResult<boolean | null> | null {
|
||||
const remaining = input.slice(index);
|
||||
if (remaining.startsWith("true")) {
|
||||
@@ -120,13 +117,8 @@ function parsePartialLiteral(
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePartialNumber(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<number> | null {
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(
|
||||
input.slice(index)
|
||||
);
|
||||
function parsePartialNumber(input: string, index: number): PartialParseResult<number> | null {
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(input.slice(index));
|
||||
if (!match?.[0]) {
|
||||
return null;
|
||||
}
|
||||
@@ -138,10 +130,7 @@ function parsePartialNumber(
|
||||
};
|
||||
}
|
||||
|
||||
function parsePartialArray(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<unknown[]> | null {
|
||||
function parsePartialArray(input: string, index: number): PartialParseResult<unknown[]> | null {
|
||||
if (input[index] !== "[") {
|
||||
return null;
|
||||
}
|
||||
@@ -225,7 +214,7 @@ function parsePartialArray(
|
||||
|
||||
function parsePartialObject(
|
||||
input: string,
|
||||
index: number
|
||||
index: number,
|
||||
): PartialParseResult<Record<string, unknown>> | null {
|
||||
if (input[index] !== "{") {
|
||||
return null;
|
||||
@@ -327,10 +316,7 @@ function parsePartialObject(
|
||||
};
|
||||
}
|
||||
|
||||
function parsePartialJsonValue(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<unknown> | null {
|
||||
function parsePartialJsonValue(input: string, index: number): PartialParseResult<unknown> | null {
|
||||
const currentIndex = skipWhitespace(input, index);
|
||||
const character = input[currentIndex];
|
||||
|
||||
@@ -338,7 +324,7 @@ function parsePartialJsonValue(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (character === "\"") {
|
||||
if (character === '"') {
|
||||
return parsePartialString(input, currentIndex);
|
||||
}
|
||||
if (character === "{") {
|
||||
@@ -358,7 +344,7 @@ function parsePartialJsonValue(
|
||||
}
|
||||
|
||||
export function parsePartialJsonObject(
|
||||
input: string
|
||||
input: string,
|
||||
): { value: Record<string, unknown>; complete: boolean } | null {
|
||||
const currentIndex = skipWhitespace(input, 0);
|
||||
const parsed = parsePartialObject(input, currentIndex);
|
||||
@@ -368,7 +354,6 @@ export function parsePartialJsonObject(
|
||||
|
||||
return {
|
||||
value: parsed.value,
|
||||
complete:
|
||||
parsed.complete && skipWhitespace(input, parsed.nextIndex) === input.length,
|
||||
complete: parsed.complete && skipWhitespace(input, parsed.nextIndex) === input.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { mapClaudeRunningToolCall } from "./tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "../../../../shared/tool-call-display.js";
|
||||
|
||||
import type {
|
||||
AgentMetadata,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
|
||||
type ClaudeContentChunk = { type: string; [key: string]: unknown };
|
||||
|
||||
@@ -45,9 +41,7 @@ function readTrimmedString(value: unknown): string | undefined {
|
||||
|
||||
function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { type?: unknown }).type === "string"
|
||||
value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,10 +66,7 @@ export class ClaudeSidechainTracker {
|
||||
} satisfies SubAgentActivityState);
|
||||
this.activeSidechains.set(parentToolUseId, state);
|
||||
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(
|
||||
state,
|
||||
parentToolUseId
|
||||
);
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
let actionUpdated = false;
|
||||
for (const action of actionCandidates) {
|
||||
@@ -104,7 +95,7 @@ export class ClaudeSidechainTracker {
|
||||
...(state.description ? { description: state.description } : {}),
|
||||
log: state.actions
|
||||
.map((action) =>
|
||||
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`
|
||||
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
|
||||
)
|
||||
.join("\n"),
|
||||
actions: state.actions.map((action) => ({
|
||||
@@ -136,7 +127,7 @@ export class ClaudeSidechainTracker {
|
||||
|
||||
private updateSubAgentContextFromTaskInput(
|
||||
state: SubAgentActivityState,
|
||||
parentToolUseId: string
|
||||
parentToolUseId: string,
|
||||
): boolean {
|
||||
const taskInput = this.getToolInput(parentToolUseId);
|
||||
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
|
||||
@@ -237,17 +228,14 @@ export class ClaudeSidechainTracker {
|
||||
|
||||
private appendSubAgentAction(
|
||||
state: SubAgentActivityState,
|
||||
candidate: SubAgentActionCandidate
|
||||
candidate: SubAgentActionCandidate,
|
||||
): boolean {
|
||||
const normalizedToolName = readTrimmedString(candidate.toolName);
|
||||
if (!normalizedToolName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = this.deriveSubAgentActionSummary(
|
||||
normalizedToolName,
|
||||
candidate.input
|
||||
);
|
||||
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
|
||||
const existingIndex = state.actionIndexByKey.get(candidate.key);
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
@@ -256,10 +244,7 @@ export class ClaudeSidechainTracker {
|
||||
return false;
|
||||
}
|
||||
const nextSummary = existing.summary ?? summary;
|
||||
if (
|
||||
existing.toolName === normalizedToolName &&
|
||||
existing.summary === nextSummary
|
||||
) {
|
||||
if (existing.toolName === normalizedToolName && existing.summary === nextSummary) {
|
||||
return false;
|
||||
}
|
||||
state.actions[existingIndex] = {
|
||||
@@ -299,10 +284,7 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
}
|
||||
|
||||
private deriveSubAgentActionSummary(
|
||||
toolName: string,
|
||||
input: unknown
|
||||
): string | undefined {
|
||||
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
|
||||
const runningToolCall = mapClaudeRunningToolCall({
|
||||
name: toolName,
|
||||
callId: `sub-agent-summary-${toolName}`,
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ describe("task-notification-tool-call", () => {
|
||||
it("detects task notification user content in string payloads", () => {
|
||||
expect(
|
||||
isTaskNotificationUserContent(
|
||||
"<task-notification>\n<task-id>bg-1</task-id>\n</task-notification>"
|
||||
)
|
||||
"<task-notification>\n<task-id>bg-1</task-id>\n</task-notification>",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isTaskNotificationUserContent("hello")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ const TAG_NAME_PATTERN = /[.*+?^${}()|[\]\\]/g;
|
||||
|
||||
const OptionalNonEmptyTrimmedStringSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.trim() : value),
|
||||
z.string().min(1).optional()
|
||||
z.string().min(1).optional(),
|
||||
);
|
||||
|
||||
const TaskNotificationEnvelopeSchema = z.object({
|
||||
@@ -130,10 +130,7 @@ function extractUserContentText(content: unknown): string | null {
|
||||
|
||||
function readTaskNotificationTagValue(input: ReadTaskNotificationTagInput): string | null {
|
||||
const escapedTagName = input.tagName.replace(TAG_NAME_PATTERN, "\\$&");
|
||||
const pattern = new RegExp(
|
||||
`<${escapedTagName}>\\s*([\\s\\S]*?)\\s*</${escapedTagName}>`,
|
||||
"i"
|
||||
);
|
||||
const pattern = new RegExp(`<${escapedTagName}>\\s*([\\s\\S]*?)\\s*</${escapedTagName}>`, "i");
|
||||
const match = input.text.match(pattern);
|
||||
if (!match) {
|
||||
return null;
|
||||
@@ -142,7 +139,7 @@ function readTaskNotificationTagValue(input: ReadTaskNotificationTagInput): stri
|
||||
}
|
||||
|
||||
function parseTaskNotificationFromUserContent(
|
||||
input: MapTaskNotificationUserContentToToolCallInput
|
||||
input: MapTaskNotificationUserContentToToolCallInput,
|
||||
): TaskNotificationEnvelope | null {
|
||||
const parsedInput = MapTaskNotificationUserContentToToolCallInputSchema.safeParse(input);
|
||||
if (!parsedInput.success) {
|
||||
@@ -166,9 +163,7 @@ function parseTaskNotificationFromUserContent(
|
||||
});
|
||||
}
|
||||
|
||||
function parseTaskNotificationFromSystemRecord(
|
||||
record: unknown
|
||||
): TaskNotificationEnvelope | null {
|
||||
function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificationEnvelope | null {
|
||||
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
|
||||
if (!parsedRecord.success) {
|
||||
return null;
|
||||
@@ -187,24 +182,18 @@ function parseTaskNotificationFromSystemRecord(
|
||||
messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id),
|
||||
taskId:
|
||||
toNonEmptyString(systemRecord.task_id) ??
|
||||
(rawText
|
||||
? readTaskNotificationTagValue({ text: rawText, tagName: "task-id" })
|
||||
: null),
|
||||
(rawText ? readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }) : null),
|
||||
status:
|
||||
toNonEmptyString(systemRecord.status) ??
|
||||
(rawText
|
||||
? readTaskNotificationTagValue({ text: rawText, tagName: "status" })
|
||||
: null),
|
||||
(rawText ? readTaskNotificationTagValue({ text: rawText, tagName: "status" }) : null),
|
||||
summary:
|
||||
toNonEmptyString(systemRecord.summary) ??
|
||||
(rawText
|
||||
? readTaskNotificationTagValue({ text: rawText, tagName: "summary" })
|
||||
: null),
|
||||
(rawText ? readTaskNotificationTagValue({ text: rawText, tagName: "summary" }) : null),
|
||||
outputFile:
|
||||
toNonEmptyString(systemRecord.output_file) ??
|
||||
(rawText
|
||||
? readTaskNotificationTagValue({ text: rawText, tagName: "output-file" }) ??
|
||||
readTaskNotificationTagValue({ text: rawText, tagName: "output_file" })
|
||||
? (readTaskNotificationTagValue({ text: rawText, tagName: "output-file" }) ??
|
||||
readTaskNotificationTagValue({ text: rawText, tagName: "output_file" }))
|
||||
: null),
|
||||
rawText,
|
||||
});
|
||||
@@ -231,12 +220,7 @@ function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string
|
||||
}
|
||||
|
||||
const seed =
|
||||
[
|
||||
envelope.status,
|
||||
envelope.summary,
|
||||
envelope.outputFile,
|
||||
envelope.rawText,
|
||||
]
|
||||
[envelope.status, envelope.summary, envelope.outputFile, envelope.rawText]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.join("|") || "task_notification";
|
||||
const digest = createHash("sha1").update(seed).digest("hex").slice(0, 12);
|
||||
@@ -254,7 +238,7 @@ function buildTaskNotificationLabel(envelope: TaskNotificationEnvelope): string
|
||||
}
|
||||
|
||||
function buildTaskNotificationStatus(
|
||||
input: BuildTaskNotificationStatusInput
|
||||
input: BuildTaskNotificationStatusInput,
|
||||
): TaskNotificationLifecycle {
|
||||
const normalizedStatus = input.status?.toLowerCase() ?? null;
|
||||
if (normalizedStatus === "failed" || normalizedStatus === "error") {
|
||||
@@ -270,7 +254,7 @@ function buildTaskNotificationStatus(
|
||||
}
|
||||
|
||||
function toTaskNotificationToolCall(
|
||||
envelope: TaskNotificationEnvelope
|
||||
envelope: TaskNotificationEnvelope,
|
||||
): TaskNotificationToolCallItem {
|
||||
const lifecycle = buildTaskNotificationStatus({
|
||||
status: envelope.status,
|
||||
@@ -330,7 +314,7 @@ export function isTaskNotificationUserContent(content: unknown): boolean {
|
||||
}
|
||||
|
||||
export function mapTaskNotificationUserContentToToolCall(
|
||||
input: MapTaskNotificationUserContentToToolCallInput
|
||||
input: MapTaskNotificationUserContentToToolCallInput,
|
||||
): TaskNotificationToolCallItem | null {
|
||||
const parsed = parseTaskNotificationFromUserContent(input);
|
||||
if (!parsed) {
|
||||
@@ -340,7 +324,7 @@ export function mapTaskNotificationUserContentToToolCall(
|
||||
}
|
||||
|
||||
export function mapTaskNotificationSystemRecordToToolCall(
|
||||
record: unknown
|
||||
record: unknown,
|
||||
): TaskNotificationToolCallItem | null {
|
||||
const parsed = parseTaskNotificationFromSystemRecord(record);
|
||||
if (!parsed) {
|
||||
@@ -350,7 +334,7 @@ export function mapTaskNotificationSystemRecordToToolCall(
|
||||
}
|
||||
|
||||
export function coerceTaskNotificationHistoryRecordToSystemMessage(
|
||||
record: unknown
|
||||
record: unknown,
|
||||
): TaskNotificationSystemMessageLike | null {
|
||||
const parsed = parseTaskNotificationFromSystemRecord(record);
|
||||
if (!parsed) {
|
||||
|
||||
@@ -60,7 +60,12 @@ const ClaudeToolDetailPass2Schema = z.union([
|
||||
toolDetailBranchByName("Bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByName(
|
||||
"exec_command",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
toShellToolDetail,
|
||||
),
|
||||
toolDetailBranchByName("Read", ToolReadInputSchema, z.unknown(), (input, output) => {
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
@@ -79,53 +84,116 @@ const ClaudeToolDetailPass2Schema = z.union([
|
||||
}),
|
||||
toolDetailBranchByName("Write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByName("create_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByName(
|
||||
"write_file",
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
toWriteToolDetail,
|
||||
),
|
||||
toolDetailBranchByName(
|
||||
"create_file",
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
toWriteToolDetail,
|
||||
),
|
||||
toolDetailBranchByName("Edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName("MultiEdit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName("multi_edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName("edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName(
|
||||
"apply_patch",
|
||||
ToolEditInputSchema,
|
||||
ToolEditOutputSchema,
|
||||
toEditToolDetail,
|
||||
),
|
||||
toolDetailBranchByName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByName(
|
||||
"str_replace_editor",
|
||||
ToolEditInputSchema,
|
||||
ToolEditOutputSchema,
|
||||
toEditToolDetail
|
||||
toEditToolDetail,
|
||||
),
|
||||
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "web_search" })
|
||||
toolDetailBranchByName(
|
||||
"WebSearch",
|
||||
ToolSearchInputSchema,
|
||||
ToolWebSearchOutputSchema.nullable(),
|
||||
(input, output) => toSearchToolDetail({ input, output, toolName: "web_search" }),
|
||||
),
|
||||
toolDetailBranchByName("web_search", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "web_search" })
|
||||
toolDetailBranchByName(
|
||||
"web_search",
|
||||
ToolSearchInputSchema,
|
||||
ToolWebSearchOutputSchema.nullable(),
|
||||
(input, output) => toSearchToolDetail({ input, output, toolName: "web_search" }),
|
||||
),
|
||||
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail({ input, toolName: "search" })
|
||||
toSearchToolDetail({ input, toolName: "search" }),
|
||||
),
|
||||
toolDetailBranchByName("Grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "grep" })
|
||||
toolDetailBranchByName(
|
||||
"Grep",
|
||||
ToolSearchInputSchema,
|
||||
ToolGrepOutputSchema.nullable(),
|
||||
(input, output) => toSearchToolDetail({ input, output, toolName: "grep" }),
|
||||
),
|
||||
toolDetailBranchByName("grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "grep" })
|
||||
toolDetailBranchByName(
|
||||
"grep",
|
||||
ToolSearchInputSchema,
|
||||
ToolGrepOutputSchema.nullable(),
|
||||
(input, output) => toSearchToolDetail({ input, output, toolName: "grep" }),
|
||||
),
|
||||
toolDetailBranchByName("Glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "glob" })
|
||||
toolDetailBranchByName(
|
||||
"Glob",
|
||||
ToolSearchInputSchema,
|
||||
ToolGlobOutputSchema.nullable(),
|
||||
(input, output) => toSearchToolDetail({ input, output, toolName: "glob" }),
|
||||
),
|
||||
toolDetailBranchByName("glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "glob" })
|
||||
toolDetailBranchByName(
|
||||
"glob",
|
||||
ToolSearchInputSchema,
|
||||
ToolGlobOutputSchema.nullable(),
|
||||
(input, output) => toSearchToolDetail({ input, output, toolName: "glob" }),
|
||||
),
|
||||
toolDetailBranchByName(
|
||||
"WebFetch",
|
||||
ToolWebFetchInputSchema,
|
||||
ToolWebFetchOutputSchema,
|
||||
toFetchToolDetail,
|
||||
),
|
||||
toolDetailBranchByName(
|
||||
"web_fetch",
|
||||
ToolWebFetchInputSchema,
|
||||
ToolWebFetchOutputSchema,
|
||||
toFetchToolDetail,
|
||||
),
|
||||
toolDetailBranchByName(
|
||||
"WebFetchTool",
|
||||
ToolWebFetchInputSchema,
|
||||
ToolWebFetchOutputSchema,
|
||||
toFetchToolDetail,
|
||||
),
|
||||
toolDetailBranchByName(
|
||||
"web_fetch_tool",
|
||||
ToolWebFetchInputSchema,
|
||||
ToolWebFetchOutputSchema,
|
||||
toFetchToolDetail,
|
||||
),
|
||||
toolDetailBranchByName(
|
||||
"webfetch",
|
||||
ToolWebFetchInputSchema,
|
||||
ToolWebFetchOutputSchema,
|
||||
toFetchToolDetail,
|
||||
),
|
||||
toolDetailBranchByName("WebFetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName("web_fetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName("WebFetchTool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName("web_fetch_tool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName("webfetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName(
|
||||
"Skill",
|
||||
z.object({ skill: z.string() }).passthrough(),
|
||||
z.union([
|
||||
z.object({ output: z.string() }).passthrough().transform((value) => value.output),
|
||||
z.string(),
|
||||
]).nullable(),
|
||||
z
|
||||
.union([
|
||||
z
|
||||
.object({ output: z.string() })
|
||||
.passthrough()
|
||||
.transform((value) => value.output),
|
||||
z.string(),
|
||||
])
|
||||
.nullable(),
|
||||
(input, output) => {
|
||||
const skillName = input?.skill;
|
||||
if (!skillName) {
|
||||
@@ -137,7 +205,7 @@ const ClaudeToolDetailPass2Schema = z.union([
|
||||
icon: "sparkles" as const,
|
||||
...(output ? { text: output } : {}),
|
||||
} satisfies ToolCallDetail;
|
||||
}
|
||||
},
|
||||
),
|
||||
ClaudeSpeakToolDetailSchema,
|
||||
]);
|
||||
@@ -145,7 +213,7 @@ const ClaudeToolDetailPass2Schema = z.union([
|
||||
export function deriveClaudeToolDetail(
|
||||
name: string,
|
||||
input: unknown,
|
||||
output: unknown
|
||||
output: unknown,
|
||||
): ToolCallDetail {
|
||||
const pass1 = ClaudeToolEnvelopeSchema.safeParse({
|
||||
name,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "Bash",
|
||||
input: { command: "pwd", cwd: "/tmp/repo" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.type).toBe("tool_call");
|
||||
@@ -43,7 +43,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "Bash",
|
||||
input: { command: "echo " },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
@@ -59,7 +59,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(readItem.detail).toEqual({
|
||||
type: "read",
|
||||
@@ -72,7 +72,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "write_file",
|
||||
input: { file_path: "src/new.ts" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(writeItem.detail).toEqual({
|
||||
type: "write",
|
||||
@@ -85,7 +85,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "apply_patch",
|
||||
input: { file_path: "src/index.ts" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(editItem.detail).toEqual({
|
||||
type: "edit",
|
||||
@@ -98,7 +98,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "web_search",
|
||||
input: { query: "tool call mapping" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
@@ -114,7 +114,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
output: { content: "hello" },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
@@ -139,7 +139,7 @@ describe("claude tool-call mapper", () => {
|
||||
{ type: "output_text", content: "beta" },
|
||||
],
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(arrayContent.detail?.type).toBe("read");
|
||||
@@ -157,7 +157,7 @@ describe("claude tool-call mapper", () => {
|
||||
content: { type: "output_text", text: "gamma" },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(objectContent.detail?.type).toBe("read");
|
||||
@@ -174,7 +174,7 @@ describe("claude tool-call mapper", () => {
|
||||
input: { command: "false" },
|
||||
output: null,
|
||||
error: { message: "Command failed" },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("failed");
|
||||
@@ -189,7 +189,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "write_file",
|
||||
input: { file_path: "src/new.ts", content: "export const x = 1;" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(writeItem.detail?.type).toBe("write");
|
||||
if (writeItem.detail?.type === "write") {
|
||||
@@ -202,7 +202,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "apply_patch",
|
||||
input: { file_path: "src/index.ts", patch: "@@\\n-old\\n+new\\n" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(editItem.detail?.type).toBe("edit");
|
||||
if (editItem.detail?.type === "edit") {
|
||||
@@ -216,7 +216,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "web_search",
|
||||
input: { query: "tool call mapping" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
@@ -232,7 +232,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "my_custom_tool",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
@@ -256,7 +256,7 @@ describe("claude tool-call mapper", () => {
|
||||
filenames: ["a.txt", "b.txt"],
|
||||
truncated: false,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
@@ -292,7 +292,7 @@ describe("claude tool-call mapper", () => {
|
||||
numLines: 1,
|
||||
numMatches: 1,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
@@ -330,7 +330,7 @@ describe("claude tool-call mapper", () => {
|
||||
],
|
||||
durationSeconds: 1.5,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
@@ -363,7 +363,7 @@ describe("claude tool-call mapper", () => {
|
||||
durationMs: 250,
|
||||
url: "https://example.com/article",
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
@@ -385,7 +385,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "mcp__paseo__speak",
|
||||
input: { text: "Voice response from Claude." },
|
||||
output: { ok: true },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.name).toBe("speak");
|
||||
@@ -403,7 +403,7 @@ describe("claude tool-call mapper", () => {
|
||||
name: "mcp__paseo_voice__speak",
|
||||
input: { text: "Hey! I can hear you." },
|
||||
output: { ok: true },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.name).toBe("speak");
|
||||
|
||||
@@ -12,12 +12,7 @@ type MapperParams = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ClaudeToolCallStatusSchema = z.enum([
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled",
|
||||
]);
|
||||
const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
|
||||
|
||||
const ClaudeRawToolCallSchema = z
|
||||
.object({
|
||||
@@ -32,10 +27,7 @@ const ClaudeRawToolCallSchema = z
|
||||
.passthrough();
|
||||
|
||||
const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({
|
||||
callId:
|
||||
typeof raw.callId === "string" && raw.callId.trim().length > 0
|
||||
? raw.callId
|
||||
: null,
|
||||
callId: typeof raw.callId === "string" && raw.callId.trim().length > 0 ? raw.callId : null,
|
||||
name: raw.name.trim(),
|
||||
input: raw.input ?? null,
|
||||
output: raw.output ?? null,
|
||||
@@ -253,7 +245,7 @@ function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimeli
|
||||
function mapClaudeToolCall(
|
||||
params: MapperParams,
|
||||
status: z.infer<typeof ClaudeToolCallStatusSchema>,
|
||||
error: unknown | null
|
||||
error: unknown | null,
|
||||
): ToolCallTimelineItem | null {
|
||||
const pass1 = ClaudeToolCallPass1Schema.safeParse({
|
||||
...params,
|
||||
@@ -277,26 +269,20 @@ function mapClaudeToolCall(
|
||||
return toToolCallTimelineItem(pass2.data);
|
||||
}
|
||||
|
||||
export function mapClaudeRunningToolCall(
|
||||
params: MapperParams
|
||||
): ToolCallTimelineItem | null {
|
||||
export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem | null {
|
||||
return mapClaudeToolCall(params, "running", null);
|
||||
}
|
||||
|
||||
export function mapClaudeCompletedToolCall(
|
||||
params: MapperParams
|
||||
): ToolCallTimelineItem | null {
|
||||
export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem | null {
|
||||
return mapClaudeToolCall(params, "completed", null);
|
||||
}
|
||||
|
||||
export function mapClaudeFailedToolCall(
|
||||
params: MapperParams & { error: unknown }
|
||||
params: MapperParams & { error: unknown },
|
||||
): ToolCallTimelineItem | null {
|
||||
return mapClaudeToolCall(params, "failed", params.error);
|
||||
}
|
||||
|
||||
export function mapClaudeCanceledToolCall(
|
||||
params: MapperParams
|
||||
): ToolCallTimelineItem | null {
|
||||
export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem | null {
|
||||
return mapClaudeToolCall(params, "canceled", null);
|
||||
}
|
||||
|
||||
@@ -21,21 +21,25 @@ function isCodexInstalled(): boolean {
|
||||
}
|
||||
|
||||
describe("Codex app-server provider (e2e)", () => {
|
||||
test.runIf(isCodexInstalled())("lists models and runs a simple prompt", async () => {
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
const models = await client.listModels();
|
||||
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
|
||||
test.runIf(isCodexInstalled())(
|
||||
"lists models and runs a simple prompt",
|
||||
async () => {
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
const models = await client.listModels();
|
||||
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
|
||||
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
|
||||
const result = await session.run("Say hello in one sentence.");
|
||||
expect(result.finalText.length).toBeGreaterThan(0);
|
||||
await session.close();
|
||||
}, 30000);
|
||||
const result = await session.run("Say hello in one sentence.");
|
||||
expect(result.finalText.length).toBeGreaterThan(0);
|
||||
await session.close();
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("Codex app-server provider", () => {
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "image", mimeType: "image/png", data: ONE_BY_ONE_PNG_BASE64 },
|
||||
],
|
||||
logger
|
||||
logger,
|
||||
);
|
||||
const localImage = input.find((item) => (item as { type?: string })?.type === "localImage") as
|
||||
| { type: "localImage"; path?: string }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../../test-utils/index.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
|
||||
import { getFullAccessConfig } from "../../daemon-e2e/agent-configs.js";
|
||||
|
||||
function tmpDir(prefix: string): string {
|
||||
@@ -32,14 +29,31 @@ describe("codex agent commands E2E", () => {
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(promptsDir, "hello.md"),
|
||||
["---", "description: Test prompt", "argument-hint: NAME=<name>", "---", "", "Say hello to $NAME and then output exactly PASEO_OK.", ""].join("\n"),
|
||||
"utf8"
|
||||
[
|
||||
"---",
|
||||
"description: Test prompt",
|
||||
"argument-hint: NAME=<name>",
|
||||
"---",
|
||||
"",
|
||||
"Say hello to $NAME and then output exactly PASEO_OK.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
mkdirSync(path.join(skillsDir, "my-skill"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(skillsDir, "my-skill", "SKILL.md"),
|
||||
["---", "name: my-skill", "description: Test skill", "user-invocable: true", "---", "", "When invoked, respond with exactly PASEO_SKILL_OK.", ""].join("\n"),
|
||||
"utf8"
|
||||
[
|
||||
"---",
|
||||
"name: my-skill",
|
||||
"description: Test skill",
|
||||
"user-invocable: true",
|
||||
"---",
|
||||
"",
|
||||
"When invoked, respond with exactly PASEO_SKILL_OK.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
|
||||
@@ -85,8 +99,16 @@ describe("codex agent commands E2E", () => {
|
||||
const promptPath = path.join(promptsDir, "paseo-test-sayok.md");
|
||||
writeFileSync(
|
||||
promptPath,
|
||||
["---", "description: Say OK", "argument-hint: NAME=<name>", "---", "", "Output exactly: PASEO_OK $NAME", ""].join("\n"),
|
||||
"utf8"
|
||||
[
|
||||
"---",
|
||||
"description: Say OK",
|
||||
"argument-hint: NAME=<name>",
|
||||
"---",
|
||||
"",
|
||||
"Output exactly: PASEO_OK $NAME",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
@@ -95,10 +117,7 @@ describe("codex agent commands E2E", () => {
|
||||
title: "Codex Prompt Execute Test Agent",
|
||||
});
|
||||
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
"/prompts:paseo-test-sayok NAME=world"
|
||||
);
|
||||
await ctx.client.sendMessage(agent.id, "/prompts:paseo-test-sayok NAME=world");
|
||||
const state = await ctx.client.waitForFinish(agent.id, 30_000);
|
||||
|
||||
expect(state.status).toBe("idle");
|
||||
@@ -115,7 +134,7 @@ describe("codex agent commands E2E", () => {
|
||||
writeFileSync(
|
||||
path.join(promptsDir, "paseo-test-sayok.md"),
|
||||
["---", "description: Say OK", "---", "", "Output exactly: PASEO_OK", ""].join("\n"),
|
||||
"utf8"
|
||||
"utf8",
|
||||
);
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
|
||||
@@ -149,10 +168,7 @@ describe("codex agent commands E2E", () => {
|
||||
});
|
||||
|
||||
const token = `RAW_PROMPT_TOKEN_${Date.now()}`;
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
`/not-a-real-command respond with exactly: ${token}`
|
||||
);
|
||||
await ctx.client.sendMessage(agent.id, `/not-a-real-command respond with exactly: ${token}`);
|
||||
const state = await ctx.client.waitForFinish(agent.id, 30_000);
|
||||
|
||||
expect(state.status).toBe("idle");
|
||||
|
||||
@@ -337,7 +337,8 @@ describe("codex rollout parsing", () => {
|
||||
payload: {
|
||||
type: "custom_tool_call_output",
|
||||
call_id: "call_patch_custom_1",
|
||||
output: '{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}',
|
||||
output:
|
||||
'{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -19,10 +19,7 @@ function resolveCodexSessionRoot(): string | null {
|
||||
return path.join(codexHome, "sessions");
|
||||
}
|
||||
|
||||
async function findRolloutFile(
|
||||
threadId: string,
|
||||
root: string
|
||||
): Promise<string | null> {
|
||||
async function findRolloutFile(threadId: string, root: string): Promise<string | null> {
|
||||
const stack: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
|
||||
while (stack.length > 0) {
|
||||
const { dir, depth } = stack.pop()!;
|
||||
@@ -37,8 +34,7 @@ async function findRolloutFile(
|
||||
if (entry.isFile()) {
|
||||
const matchesThread = entry.name.includes(threadId);
|
||||
const matchesPrefix = entry.name.startsWith("rollout-");
|
||||
const matchesExtension =
|
||||
entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
|
||||
const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
|
||||
if (matchesThread && matchesPrefix && matchesExtension) {
|
||||
return entryPath;
|
||||
}
|
||||
@@ -50,20 +46,19 @@ async function findRolloutFile(
|
||||
return null;
|
||||
}
|
||||
|
||||
const RolloutContentItemSchema = z
|
||||
.union([
|
||||
z.object({ type: z.literal("input_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("output_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("text"), text: z.string() }),
|
||||
z
|
||||
.object({
|
||||
type: z.string(),
|
||||
text: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
]);
|
||||
const RolloutContentItemSchema = z.union([
|
||||
z.object({ type: z.literal("input_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("output_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("text"), text: z.string() }),
|
||||
z
|
||||
.object({
|
||||
type: z.string(),
|
||||
text: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
|
||||
|
||||
@@ -162,34 +157,33 @@ const RolloutEventUserMessagePayloadSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type RolloutResponseReasoningPayload = z.infer<
|
||||
typeof RolloutResponseReasoningPayloadSchema
|
||||
>;
|
||||
type RolloutResponseReasoningPayload = z.infer<typeof RolloutResponseReasoningPayloadSchema>;
|
||||
type ParsedRolloutRecord =
|
||||
| { kind: "timeline"; item: AgentTimelineItem }
|
||||
| { kind: "call"; name: string; callId?: string; input?: unknown }
|
||||
| { kind: "output"; callId: string; output: unknown }
|
||||
| { kind: "ignore" };
|
||||
|
||||
const RolloutMessageContentSchema = z
|
||||
.union([
|
||||
z.string().transform((content) => content.trim()),
|
||||
z.array(
|
||||
const RolloutMessageContentSchema = z.union([
|
||||
z.string().transform((content) => content.trim()),
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
text: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
).transform((content) =>
|
||||
.passthrough(),
|
||||
)
|
||||
.transform((content) =>
|
||||
content
|
||||
.map((block) => block.text ?? block.message ?? "")
|
||||
.map((text) => text.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
.trim(),
|
||||
),
|
||||
]);
|
||||
]);
|
||||
|
||||
function extractMessageText(content: unknown): string {
|
||||
const parsed = RolloutMessageContentSchema.safeParse(content);
|
||||
@@ -199,17 +193,16 @@ function extractMessageText(content: unknown): string {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
const RolloutEventMessageTextSchema = z
|
||||
.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
message: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((message) => message.message ?? message.text ?? ""),
|
||||
]);
|
||||
const RolloutEventMessageTextSchema = z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
message: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((message) => message.message ?? message.text ?? ""),
|
||||
]);
|
||||
|
||||
function extractEventMessageText(message: unknown): string {
|
||||
const parsed = RolloutEventMessageTextSchema.safeParse(message);
|
||||
@@ -225,10 +218,7 @@ function isSyntheticRolloutUserMessage(text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
const lower = normalized.toLowerCase();
|
||||
if (
|
||||
lower.startsWith("# agents.md instructions for") &&
|
||||
lower.includes("<instructions>")
|
||||
) {
|
||||
if (lower.startsWith("# agents.md instructions for") && lower.includes("<instructions>")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.startsWith("<environment_context>")) {
|
||||
@@ -291,7 +281,7 @@ function mapCodexTerminalInteractionToToolCall(params: {
|
||||
const processId = nonEmptyString(params.processId);
|
||||
const callId = processId
|
||||
? `terminal-session-${processId}`
|
||||
: nonEmptyString(params.fallbackCallId) ?? "terminal-interaction";
|
||||
: (nonEmptyString(params.fallbackCallId) ?? "terminal-interaction");
|
||||
const label = nonEmptyString(params.command);
|
||||
return {
|
||||
type: "tool_call",
|
||||
@@ -309,10 +299,13 @@ function mapCodexTerminalInteractionToToolCall(params: {
|
||||
}
|
||||
|
||||
function buildTerminalCommandBySessionId(
|
||||
parsedRecords: ParsedRolloutRecord[]
|
||||
parsedRecords: ParsedRolloutRecord[],
|
||||
): Map<string, string> {
|
||||
const outputsByCallId = parsedRecords
|
||||
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
|
||||
.filter(
|
||||
(record): record is Extract<ParsedRolloutRecord, { kind: "output" }> =>
|
||||
record.kind === "output",
|
||||
)
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
|
||||
|
||||
const commandsBySessionId = new Map<string, string>();
|
||||
@@ -325,7 +318,7 @@ function buildTerminalCommandBySessionId(
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
input: record.input ?? null,
|
||||
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
|
||||
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
|
||||
});
|
||||
if (!mapped || mapped.detail.type !== "shell") {
|
||||
continue;
|
||||
@@ -350,91 +343,86 @@ function readOutputPayloadValue(payload: Record<string, unknown>): unknown {
|
||||
return Object.keys(rest).length > 0 ? rest : undefined;
|
||||
}
|
||||
|
||||
const FunctionCallInputNormalizationSchema = z
|
||||
.union([
|
||||
z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
|
||||
z
|
||||
.object({ command: z.array(z.string()) })
|
||||
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
|
||||
z.unknown().transform((input) => ({ name: "unknown", input })),
|
||||
]);
|
||||
const FunctionCallInputNormalizationSchema = z.union([
|
||||
z
|
||||
.object({ cmd: z.string() })
|
||||
.transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
|
||||
z
|
||||
.object({ command: z.array(z.string()) })
|
||||
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
|
||||
z.unknown().transform((input) => ({ name: "unknown", input })),
|
||||
]);
|
||||
|
||||
const RolloutResponseRecordSchema = z
|
||||
.union([
|
||||
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractMessageText(payload.content);
|
||||
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
|
||||
const shouldEmit = text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
|
||||
return shouldEmit
|
||||
? { kind: "timeline", item: { type: itemType, text } }
|
||||
const RolloutResponseRecordSchema = z.union([
|
||||
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractMessageText(payload.content);
|
||||
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
|
||||
const shouldEmit =
|
||||
text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
|
||||
return shouldEmit ? { kind: "timeline", item: { type: itemType, text } } : { kind: "ignore" };
|
||||
}),
|
||||
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractReasoningText(payload);
|
||||
return text.length > 0
|
||||
? { kind: "timeline", item: { type: "reasoning", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z
|
||||
.union([RolloutResponseFunctionCallPayloadSchema, RolloutResponseCustomToolCallPayloadSchema])
|
||||
.transform((payload): ParsedRolloutRecord => {
|
||||
const rawName = payload.name ?? "unknown";
|
||||
const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined);
|
||||
const parsedArguments = parseJsonLikeValue(rawInput);
|
||||
const normalized =
|
||||
rawName === "exec_command" || rawName === "shell"
|
||||
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
|
||||
: { name: rawName, input: parsedArguments };
|
||||
return {
|
||||
kind: "call",
|
||||
name: normalized.name,
|
||||
callId: payload.call_id,
|
||||
input: normalized.input,
|
||||
};
|
||||
}),
|
||||
z
|
||||
.union([
|
||||
RolloutResponseFunctionCallOutputPayloadSchema,
|
||||
RolloutResponseCustomToolCallOutputPayloadSchema,
|
||||
])
|
||||
.transform((payload): ParsedRolloutRecord => {
|
||||
if (!payload.call_id) {
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
const output = readOutputPayloadValue(payload);
|
||||
return output !== undefined
|
||||
? { kind: "output", callId: payload.call_id, output }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractReasoningText(payload);
|
||||
return text.length > 0
|
||||
? { kind: "timeline", item: { type: "reasoning", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z
|
||||
.union([
|
||||
RolloutResponseFunctionCallPayloadSchema,
|
||||
RolloutResponseCustomToolCallPayloadSchema,
|
||||
])
|
||||
.transform((payload): ParsedRolloutRecord => {
|
||||
const rawName = payload.name ?? "unknown";
|
||||
const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined);
|
||||
const parsedArguments = parseJsonLikeValue(rawInput);
|
||||
const normalized =
|
||||
rawName === "exec_command" || rawName === "shell"
|
||||
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
|
||||
: { name: rawName, input: parsedArguments };
|
||||
return {
|
||||
kind: "call",
|
||||
name: normalized.name,
|
||||
callId: payload.call_id,
|
||||
input: normalized.input,
|
||||
};
|
||||
}),
|
||||
z
|
||||
.union([
|
||||
RolloutResponseFunctionCallOutputPayloadSchema,
|
||||
RolloutResponseCustomToolCallOutputPayloadSchema,
|
||||
])
|
||||
.transform((payload): ParsedRolloutRecord => {
|
||||
if (!payload.call_id) {
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
const output = readOutputPayloadValue(payload);
|
||||
return output !== undefined
|
||||
? { kind: "output", callId: payload.call_id, output }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||
]);
|
||||
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||
]);
|
||||
|
||||
const RolloutEventRecordSchema = z
|
||||
.union([
|
||||
RolloutEventAgentReasoningPayloadSchema.transform(
|
||||
(payload): ParsedRolloutRecord =>
|
||||
payload.text
|
||||
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
|
||||
: { kind: "ignore" }
|
||||
),
|
||||
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractEventMessageText(payload.message);
|
||||
return text.length > 0
|
||||
? { kind: "timeline", item: { type: "assistant_message", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractEventMessageText(payload.message);
|
||||
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
|
||||
return shouldEmit
|
||||
? { kind: "timeline", item: { type: "user_message", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||
]);
|
||||
const RolloutEventRecordSchema = z.union([
|
||||
RolloutEventAgentReasoningPayloadSchema.transform(
|
||||
(payload): ParsedRolloutRecord =>
|
||||
payload.text
|
||||
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
|
||||
: { kind: "ignore" },
|
||||
),
|
||||
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractEventMessageText(payload.message);
|
||||
return text.length > 0
|
||||
? { kind: "timeline", item: { type: "assistant_message", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractEventMessageText(payload.message);
|
||||
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
|
||||
return shouldEmit
|
||||
? { kind: "timeline", item: { type: "user_message", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||
]);
|
||||
|
||||
const RolloutRecordSchema = z
|
||||
.object({
|
||||
@@ -446,12 +434,10 @@ const RolloutRecordSchema = z
|
||||
.transform((entry) =>
|
||||
entry.type === "response_item"
|
||||
? RolloutResponseRecordSchema.parse(entry.payload ?? entry.item)
|
||||
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg)
|
||||
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg),
|
||||
);
|
||||
|
||||
function parseJsonRolloutTimeline(
|
||||
parsed: unknown
|
||||
): AgentTimelineItem[] | null {
|
||||
function parseJsonRolloutTimeline(parsed: unknown): AgentTimelineItem[] | null {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
@@ -464,8 +450,7 @@ function parseJsonRolloutTimeline(
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const messagePayloadResult =
|
||||
RolloutResponseMessagePayloadSchema.safeParse(entry);
|
||||
const messagePayloadResult = RolloutResponseMessagePayloadSchema.safeParse(entry);
|
||||
if (messagePayloadResult.success) {
|
||||
const payload = messagePayloadResult.data;
|
||||
const text = extractMessageText(payload.content);
|
||||
@@ -482,8 +467,7 @@ function parseJsonRolloutTimeline(
|
||||
continue;
|
||||
}
|
||||
|
||||
const reasoningPayloadResult =
|
||||
RolloutResponseReasoningPayloadSchema.safeParse(entry);
|
||||
const reasoningPayloadResult = RolloutResponseReasoningPayloadSchema.safeParse(entry);
|
||||
if (reasoningPayloadResult.success) {
|
||||
const text = extractReasoningText(reasoningPayloadResult.data);
|
||||
if (text) {
|
||||
@@ -506,9 +490,7 @@ function timelineTextFingerprint(item: AgentTimelineItem): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeMirroredTextTimelineItems(
|
||||
timeline: AgentTimelineItem[]
|
||||
): AgentTimelineItem[] {
|
||||
function dedupeMirroredTextTimelineItems(timeline: AgentTimelineItem[]): AgentTimelineItem[] {
|
||||
const deduped: AgentTimelineItem[] = [];
|
||||
for (const item of timeline) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
@@ -526,9 +508,7 @@ function dedupeMirroredTextTimelineItems(
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function parseRolloutFile(
|
||||
filePath: string
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
export async function parseRolloutFile(filePath: string): Promise<AgentTimelineItem[]> {
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
@@ -562,7 +542,10 @@ export async function parseRolloutFile(
|
||||
.map((result) => result.data);
|
||||
|
||||
const outputsByCallId = parsedRecords
|
||||
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
|
||||
.filter(
|
||||
(record): record is Extract<ParsedRolloutRecord, { kind: "output" }> =>
|
||||
record.kind === "output",
|
||||
)
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
|
||||
const terminalCommandsBySessionId = buildTerminalCommandBySessionId(parsedRecords);
|
||||
|
||||
@@ -577,8 +560,7 @@ export async function parseRolloutFile(
|
||||
? (record.input as { session_id?: unknown; sessionId?: unknown })
|
||||
: null;
|
||||
const sessionId =
|
||||
readTerminalSessionId(input?.session_id) ??
|
||||
readTerminalSessionId(input?.sessionId);
|
||||
readTerminalSessionId(input?.session_id) ?? readTerminalSessionId(input?.sessionId);
|
||||
return [
|
||||
mapCodexTerminalInteractionToToolCall({
|
||||
processId: sessionId,
|
||||
@@ -591,11 +573,11 @@ export async function parseRolloutFile(
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
input: record.input ?? null,
|
||||
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
|
||||
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
|
||||
});
|
||||
return mapped ? [mapped] : [];
|
||||
})()
|
||||
: []
|
||||
: [],
|
||||
);
|
||||
return dedupeMirroredTextTimelineItems(timeline);
|
||||
}
|
||||
@@ -608,7 +590,7 @@ export type CodexPersistedTimelineOptions = {
|
||||
export async function loadCodexPersistedTimeline(
|
||||
sessionId: string,
|
||||
options?: CodexPersistedTimelineOptions,
|
||||
logger?: Logger
|
||||
logger?: Logger,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const rolloutPath = options?.rolloutPath ?? null;
|
||||
if (rolloutPath) {
|
||||
@@ -633,11 +615,7 @@ export async function loadCodexPersistedTimeline(
|
||||
if (preferredRoot) {
|
||||
rolloutFile = await findRolloutFile(sessionId, preferredRoot);
|
||||
}
|
||||
if (
|
||||
!rolloutFile &&
|
||||
fallbackRoot &&
|
||||
fallbackRoot !== preferredRoot
|
||||
) {
|
||||
if (!rolloutFile && fallbackRoot && fallbackRoot !== preferredRoot) {
|
||||
rolloutFile = await findRolloutFile(sessionId, fallbackRoot);
|
||||
}
|
||||
if (!rolloutFile) {
|
||||
@@ -646,10 +624,7 @@ export async function loadCodexPersistedTimeline(
|
||||
|
||||
return await parseRolloutFile(rolloutFile);
|
||||
} catch (error) {
|
||||
logger?.warn(
|
||||
{ err: error, sessionId },
|
||||
"Failed to load persisted timeline"
|
||||
);
|
||||
logger?.warn({ err: error, sessionId }, "Failed to load persisted timeline");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const CodexLooseEditOutputSchema = z.unknown().transform((value) => {
|
||||
|
||||
export function normalizeCodexFilePath(
|
||||
filePath: string,
|
||||
cwd: string | null | undefined
|
||||
cwd: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
@@ -81,26 +81,41 @@ function normalizePathForCwd(cwd: string | null): (filePath: string) => string |
|
||||
}
|
||||
|
||||
const CodexToolDetailPass2Schema = z.union([
|
||||
toolDetailBranchByNameWithCwd("Bash", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
|
||||
toShellToolDetail(input, output)
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"Bash",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
(input, output) => toShellToolDetail(input, output),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("shell", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
|
||||
toShellToolDetail(input, output)
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"shell",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
(input, output) => toShellToolDetail(input, output),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("bash", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
|
||||
toShellToolDetail(input, output)
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"bash",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
(input, output) => toShellToolDetail(input, output),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("exec", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
|
||||
toShellToolDetail(input, output)
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"exec",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
(input, output) => toShellToolDetail(input, output),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"exec_command",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
(input, output) => toShellToolDetail(input, output)
|
||||
(input, output) => toShellToolDetail(input, output),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("command", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
|
||||
toShellToolDetail(input, output)
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"command",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
(input, output) => toShellToolDetail(input, output),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, z.unknown(), (input, output, cwd) => {
|
||||
const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
|
||||
@@ -117,43 +132,55 @@ const CodexToolDetailPass2Schema = z.union([
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
|
||||
normalizePath: normalizePathForCwd(cwd),
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("write", ToolWriteInputSchema, ToolWriteOutputSchema, (input, output, cwd) =>
|
||||
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"write",
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
(input, output, cwd) =>
|
||||
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"write_file",
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
(input, output, cwd) => toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
(input, output, cwd) =>
|
||||
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"create_file",
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
(input, output, cwd) => toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
(input, output, cwd) =>
|
||||
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, CodexLooseEditOutputSchema, (input, output, cwd) =>
|
||||
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"edit",
|
||||
ToolEditInputSchema,
|
||||
CodexLooseEditOutputSchema,
|
||||
(input, output, cwd) =>
|
||||
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"apply_patch",
|
||||
ToolEditInputSchema,
|
||||
CodexLooseEditOutputSchema,
|
||||
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
(input, output, cwd) =>
|
||||
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd(
|
||||
"apply_diff",
|
||||
ToolEditInputSchema,
|
||||
CodexLooseEditOutputSchema,
|
||||
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
(input, output, cwd) =>
|
||||
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail({ input, toolName: "search" })
|
||||
toSearchToolDetail({ input, toolName: "search" }),
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail({ input, toolName: "web_search" })
|
||||
toSearchToolDetail({ input, toolName: "web_search" }),
|
||||
),
|
||||
CodexSpeakToolDetailSchema,
|
||||
]);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mapCodexRolloutToolCall,
|
||||
mapCodexToolCallFromThreadItem,
|
||||
} from "./tool-call-mapper.js";
|
||||
import { mapCodexRolloutToolCall, mapCodexToolCallFromThreadItem } from "./tool-call-mapper.js";
|
||||
|
||||
function expectMapped<T>(item: T | null): T {
|
||||
expect(item).toBeTruthy();
|
||||
@@ -75,15 +72,13 @@ describe("codex tool-call mapper", () => {
|
||||
command: "echo hello",
|
||||
cwd: "/tmp/repo",
|
||||
aggregatedOutput:
|
||||
"Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = \"tool\";",
|
||||
'Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = "tool";',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
expect(item?.detail?.type).toBe("shell");
|
||||
if (item?.detail?.type === "shell") {
|
||||
expect(item.detail.output).toBe(
|
||||
"214 export type AgentPermissionRequestKind = \"tool\";"
|
||||
);
|
||||
expect(item.detail.output).toBe('214 export type AgentPermissionRequestKind = "tool";');
|
||||
expect(item.detail.output).not.toContain("Chunk ID:");
|
||||
expect(item.detail.output).not.toContain("Wall time:");
|
||||
expect(item.detail.output).not.toContain("Process exited with code");
|
||||
@@ -102,7 +97,7 @@ describe("codex tool-call mapper", () => {
|
||||
arguments: { path: "/tmp/repo/README.md" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
expect(readItem?.detail).toEqual({
|
||||
type: "read",
|
||||
@@ -118,7 +113,7 @@ describe("codex tool-call mapper", () => {
|
||||
arguments: { file_path: "/tmp/repo/src/new.ts" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
expect(writeItem?.detail).toEqual({
|
||||
type: "write",
|
||||
@@ -134,7 +129,7 @@ describe("codex tool-call mapper", () => {
|
||||
arguments: { file_path: "/tmp/repo/src/index.ts" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
expect(editItem?.detail).toEqual({
|
||||
type: "edit",
|
||||
@@ -165,7 +160,7 @@ describe("codex tool-call mapper", () => {
|
||||
arguments: { path: "/tmp/repo/README.md" },
|
||||
result: { content: "hello" },
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
@@ -189,10 +184,13 @@ describe("codex tool-call mapper", () => {
|
||||
tool: "read_file",
|
||||
arguments: { path: "/tmp/repo/README.md" },
|
||||
result: {
|
||||
content: [{ type: "text", text: "line one" }, { type: "text", text: "line two" }],
|
||||
content: [
|
||||
{ type: "text", text: "line one" },
|
||||
{ type: "text", text: "line two" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
@@ -212,7 +210,7 @@ describe("codex tool-call mapper", () => {
|
||||
status: "completed",
|
||||
changes: [{ path: "/tmp/repo/src/index.ts", kind: "modify", diff: hugeDiff }],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
@@ -231,9 +229,15 @@ describe("codex tool-call mapper", () => {
|
||||
type: "fileChange",
|
||||
id: "codex-content-1",
|
||||
status: "completed",
|
||||
changes: [{ path: "/tmp/repo/src/content-only.ts", kind: "modify", content: "line one\nline two\n" }],
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/content-only.ts",
|
||||
kind: "modify",
|
||||
content: "line one\nline two\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
@@ -258,7 +262,7 @@ describe("codex tool-call mapper", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
@@ -284,7 +288,7 @@ describe("codex tool-call mapper", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
@@ -306,7 +310,7 @@ describe("codex tool-call mapper", () => {
|
||||
arguments: { file_path: "/tmp/repo/src/new.ts", content: "export {}" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
expect(writeItem?.detail?.type).toBe("write");
|
||||
if (writeItem?.detail?.type === "write") {
|
||||
@@ -322,7 +326,7 @@ describe("codex tool-call mapper", () => {
|
||||
arguments: { file_path: "/tmp/repo/src/index.ts", patch: "@@\\n-a\\n+b\\n" },
|
||||
result: null,
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
expect(editItem?.detail?.type).toBe("edit");
|
||||
if (editItem?.detail?.type === "edit") {
|
||||
@@ -368,7 +372,7 @@ describe("codex tool-call mapper", () => {
|
||||
name: "my_custom_tool",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
@@ -397,7 +401,7 @@ describe("codex tool-call mapper", () => {
|
||||
input: patch,
|
||||
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
|
||||
cwd: "/tmp/repo",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
@@ -434,7 +438,7 @@ describe("codex tool-call mapper", () => {
|
||||
},
|
||||
output: null,
|
||||
cwd: "/tmp/repo",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
@@ -466,7 +470,7 @@ describe("codex tool-call mapper", () => {
|
||||
status: "completed",
|
||||
changes: [{ path: "/tmp/repo/src/from-file-change.ts", kind: "modify", content: patch }],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
@@ -487,7 +491,7 @@ describe("codex tool-call mapper", () => {
|
||||
status: "completed",
|
||||
changes: [{ path: "/tmp/repo/src/path-only.ts", kind: "modify" }],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item?.detail.type).toBe("unknown");
|
||||
@@ -506,7 +510,7 @@ describe("codex tool-call mapper", () => {
|
||||
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
|
||||
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
|
||||
cwd: "/tmp/repo",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.detail.type).toBe("unknown");
|
||||
@@ -542,7 +546,7 @@ describe("codex tool-call mapper", () => {
|
||||
name: "paseo.speak",
|
||||
input: { text: "Rollout speech text." },
|
||||
output: { ok: true },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.name).toBe("speak");
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
extractCodexShellOutput,
|
||||
truncateDiffText,
|
||||
} from "../tool-call-mapper-utils.js";
|
||||
import {
|
||||
deriveCodexToolDetail,
|
||||
normalizeCodexFilePath,
|
||||
} from "./tool-call-detail-parser.js";
|
||||
import { extractCodexShellOutput, truncateDiffText } from "../tool-call-mapper-utils.js";
|
||||
import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js";
|
||||
|
||||
type CodexMapperOptions = { cwd?: string | null };
|
||||
|
||||
@@ -17,12 +11,7 @@ const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "abor
|
||||
const COMPLETED_STATUSES = new Set(["completed", "complete", "done", "success", "succeeded"]);
|
||||
const CodexCommandValueSchema = z.union([z.string(), z.array(z.string())]);
|
||||
|
||||
const CodexToolCallStatusSchema = z.enum([
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled",
|
||||
]);
|
||||
const CodexToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
|
||||
|
||||
const CodexRolloutToolCallParamsSchema = z
|
||||
.object({
|
||||
@@ -80,7 +69,15 @@ const CodexEditToolNameSchema = z.union([
|
||||
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
|
||||
const CodexSpeakToolNameSchema = z.literal("paseo.speak");
|
||||
|
||||
const CodexToolKindSchema = z.enum(["shell", "read", "write", "edit", "search", "speak", "unknown"]);
|
||||
const CodexToolKindSchema = z.enum([
|
||||
"shell",
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"search",
|
||||
"speak",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
const CodexToolCallPass2BaseSchema = CodexNormalizedToolCallPass1Schema.extend({
|
||||
toolKind: CodexToolKindSchema,
|
||||
@@ -247,9 +244,7 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
||||
|
||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const wrapperMatch = trimmed.match(
|
||||
/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/
|
||||
);
|
||||
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
||||
if (!wrapperMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
@@ -407,7 +402,7 @@ function codexApplyPatchToUnifiedDiff(text: string): string {
|
||||
}
|
||||
|
||||
function classifyDiffLikeText(
|
||||
text: string
|
||||
text: string,
|
||||
): { isDiff: true; text: string } | { isDiff: false; text: string } {
|
||||
if (looksLikeUnifiedDiff(text)) {
|
||||
return { isDiff: true, text };
|
||||
@@ -418,9 +413,7 @@ function classifyDiffLikeText(
|
||||
return { isDiff: false, text };
|
||||
}
|
||||
|
||||
function asEditTextFields(
|
||||
text: string | undefined
|
||||
): { unifiedDiff?: string; newString?: string } {
|
||||
function asEditTextFields(text: string | undefined): { unifiedDiff?: string; newString?: string } {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return {};
|
||||
}
|
||||
@@ -489,9 +482,7 @@ function normalizeRolloutEditInput(input: unknown): unknown {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function asEditFileOutputFields(
|
||||
text: string | undefined
|
||||
): { patch?: string; content?: string } {
|
||||
function asEditFileOutputFields(text: string | undefined): { patch?: string; content?: string } {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return {};
|
||||
}
|
||||
@@ -525,7 +516,7 @@ function hasRenderableEditDetail(detail: ToolCallTimelineItem["detail"]): boolea
|
||||
function resolveStatus(
|
||||
rawStatus: string | undefined,
|
||||
error: unknown,
|
||||
output: unknown
|
||||
output: unknown,
|
||||
): ToolCallTimelineItem["status"] {
|
||||
if (error !== undefined && error !== null) {
|
||||
return "failed";
|
||||
@@ -569,7 +560,7 @@ function toNullableObject(value: Record<string, unknown>): Record<string, unknow
|
||||
}
|
||||
|
||||
function toToolCallFromNormalizedEnvelope(
|
||||
envelope: CodexNormalizedToolCallEnvelope
|
||||
envelope: CodexNormalizedToolCallEnvelope,
|
||||
): ToolCallTimelineItem | null {
|
||||
const pass2Envelope = CodexToolCallPass2EnvelopeSchema.safeParse(envelope);
|
||||
if (!pass2Envelope.success) {
|
||||
@@ -583,7 +574,7 @@ function toToolCallFromNormalizedEnvelope(
|
||||
}
|
||||
|
||||
function mapCommandExecutionItem(
|
||||
item: z.infer<typeof CodexCommandExecutionItemSchema>
|
||||
item: z.infer<typeof CodexCommandExecutionItemSchema>,
|
||||
): CodexNormalizedToolCallEnvelope {
|
||||
const command = normalizeCommandExecutionCommand(item.command);
|
||||
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
|
||||
@@ -625,7 +616,7 @@ type CodexFileChangeEntry = {
|
||||
function parseFileChangePath(
|
||||
entry: Record<string, unknown>,
|
||||
options?: CodexMapperOptions,
|
||||
fallbackPath?: string
|
||||
fallbackPath?: string,
|
||||
): string | undefined {
|
||||
const rawPath =
|
||||
(typeof entry.path === "string" && entry.path.trim().length > 0
|
||||
@@ -668,7 +659,7 @@ function parseFileChangeDiff(entry: Record<string, unknown>): string | undefined
|
||||
function toFileChangeEntry(
|
||||
entry: Record<string, unknown>,
|
||||
options?: CodexMapperOptions,
|
||||
fallbackPath?: string
|
||||
fallbackPath?: string,
|
||||
): CodexFileChangeEntry | null {
|
||||
const path = parseFileChangePath(entry, options, fallbackPath);
|
||||
if (!path) {
|
||||
@@ -683,7 +674,7 @@ function toFileChangeEntry(
|
||||
|
||||
function parseFileChangeEntries(
|
||||
changes: unknown,
|
||||
options?: CodexMapperOptions
|
||||
options?: CodexMapperOptions,
|
||||
): CodexFileChangeEntry[] {
|
||||
if (!changes) {
|
||||
return [];
|
||||
@@ -691,9 +682,7 @@ function parseFileChangeEntries(
|
||||
|
||||
if (Array.isArray(changes)) {
|
||||
return changes
|
||||
.map((entry) =>
|
||||
isRecord(entry) ? toFileChangeEntry(entry, options) : null
|
||||
)
|
||||
.map((entry) => (isRecord(entry) ? toFileChangeEntry(entry, options) : null))
|
||||
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
|
||||
}
|
||||
|
||||
@@ -729,7 +718,7 @@ function parseFileChangeEntries(
|
||||
|
||||
function mapFileChangeItem(
|
||||
item: z.infer<typeof CodexFileChangeItemSchema>,
|
||||
options?: CodexMapperOptions
|
||||
options?: CodexMapperOptions,
|
||||
): CodexNormalizedToolCallEnvelope {
|
||||
const files = parseFileChangeEntries(item.changes, options);
|
||||
|
||||
@@ -786,7 +775,7 @@ function mapFileChangeItem(
|
||||
|
||||
function mapMcpToolCallItem(
|
||||
item: z.infer<typeof CodexMcpToolCallItemSchema>,
|
||||
options?: CodexMapperOptions
|
||||
options?: CodexMapperOptions,
|
||||
): CodexNormalizedToolCallEnvelope | null {
|
||||
const tool = item.tool.trim();
|
||||
if (!tool) {
|
||||
@@ -810,7 +799,7 @@ function mapMcpToolCallItem(
|
||||
}
|
||||
|
||||
function mapWebSearchItem(
|
||||
item: z.infer<typeof CodexWebSearchItemSchema>
|
||||
item: z.infer<typeof CodexWebSearchItemSchema>,
|
||||
): CodexNormalizedToolCallEnvelope {
|
||||
const input = item.query !== undefined ? { query: item.query } : null;
|
||||
const output = item.action ?? null;
|
||||
@@ -831,7 +820,7 @@ function mapWebSearchItem(
|
||||
|
||||
function mapThreadItemToNormalizedEnvelope(
|
||||
item: z.infer<typeof CodexThreadItemSchema>,
|
||||
options?: CodexMapperOptions
|
||||
options?: CodexMapperOptions,
|
||||
): CodexNormalizedToolCallEnvelope | null {
|
||||
switch (item.type) {
|
||||
case "commandExecution":
|
||||
@@ -855,7 +844,7 @@ function mapThreadItemToNormalizedEnvelope(
|
||||
|
||||
export function mapCodexToolCallFromThreadItem(
|
||||
item: unknown,
|
||||
options?: CodexMapperOptions
|
||||
options?: CodexMapperOptions,
|
||||
): ToolCallTimelineItem | null {
|
||||
const parsed = CodexThreadItemSchema.safeParse(item);
|
||||
if (!parsed.success) {
|
||||
@@ -885,11 +874,10 @@ export function mapCodexRolloutToolCall(params: {
|
||||
const normalizedInput =
|
||||
normalizedName === "apply_patch" || normalizedName === "apply_diff"
|
||||
? normalizeRolloutEditInput(parsed.data.input ?? null)
|
||||
: parsed.data.input ?? null;
|
||||
: (parsed.data.input ?? null);
|
||||
|
||||
const pass1 = CodexNormalizedToolCallPass1Schema.safeParse({
|
||||
callId:
|
||||
typeof parsed.data.callId === "string" ? parsed.data.callId.trim() : "",
|
||||
callId: typeof parsed.data.callId === "string" ? parsed.data.callId.trim() : "",
|
||||
name: normalizedName,
|
||||
input: normalizedInput,
|
||||
output: parsed.data.output ?? null,
|
||||
|
||||
@@ -44,9 +44,7 @@ interface TurnResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function collectTurnEvents(
|
||||
iterator: AsyncGenerator<AgentStreamEvent>
|
||||
): Promise<TurnResult> {
|
||||
async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Promise<TurnResult> {
|
||||
const result: TurnResult = {
|
||||
events: [],
|
||||
assistantMessages: [],
|
||||
@@ -108,16 +106,20 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const models = await client.listModels();
|
||||
|
||||
logger.info({ modelCount: models.length, elapsed: Date.now() - startTime }, "beforeAll: Retrieved models");
|
||||
logger.info(
|
||||
{ modelCount: models.length, elapsed: Date.now() - startTime },
|
||||
"beforeAll: Retrieved models",
|
||||
);
|
||||
|
||||
// Prefer fast models for tests - nano models are typically fastest
|
||||
const fastModel = models.find((m) =>
|
||||
m.id.includes("gpt-4.1-nano") ||
|
||||
m.id.includes("gpt-5-nano") ||
|
||||
m.id.includes("gpt-5.1-codex-mini") ||
|
||||
m.id.includes("gpt-4o-mini") ||
|
||||
m.id.includes("gpt-3.5") ||
|
||||
m.id.includes("free")
|
||||
const fastModel = models.find(
|
||||
(m) =>
|
||||
m.id.includes("gpt-4.1-nano") ||
|
||||
m.id.includes("gpt-5-nano") ||
|
||||
m.id.includes("gpt-5.1-codex-mini") ||
|
||||
m.id.includes("gpt-4o-mini") ||
|
||||
m.id.includes("gpt-3.5") ||
|
||||
m.id.includes("free"),
|
||||
);
|
||||
|
||||
if (fastModel) {
|
||||
@@ -126,155 +128,139 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
// Fallback to any available model
|
||||
TEST_MODEL = models[0].id;
|
||||
} else {
|
||||
throw new Error("No OpenCode models available. Please authenticate with a provider (e.g., set OPENAI_API_KEY).");
|
||||
throw new Error(
|
||||
"No OpenCode models available. Please authenticate with a provider (e.g., set OPENAI_API_KEY).",
|
||||
);
|
||||
}
|
||||
|
||||
logger.info({ model: TEST_MODEL, totalElapsed: Date.now() - startTime }, "beforeAll: Selected OpenCode test model");
|
||||
logger.info(
|
||||
{ model: TEST_MODEL, totalElapsed: Date.now() - startTime },
|
||||
"beforeAll: Selected OpenCode test model",
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
test(
|
||||
"creates a session with valid id and provider",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
test("creates a session with valid id and provider", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
// HARD ASSERT: Session has required fields
|
||||
expect(typeof session.id).toBe("string");
|
||||
expect(session.id.length).toBeGreaterThan(0);
|
||||
expect(session.provider).toBe("opencode");
|
||||
// HARD ASSERT: Session has required fields
|
||||
expect(typeof session.id).toBe("string");
|
||||
expect(session.id.length).toBeGreaterThan(0);
|
||||
expect(session.provider).toBe("opencode");
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60_000
|
||||
);
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
test(
|
||||
"single turn completes with streaming deltas",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
test("single turn completes with streaming deltas", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const iterator = session.stream("Say hello");
|
||||
const turn = await collectTurnEvents(iterator);
|
||||
const iterator = session.stream("Say hello");
|
||||
const turn = await collectTurnEvents(iterator);
|
||||
|
||||
// HARD ASSERT: Turn completed successfully
|
||||
expect(turn.turnCompleted).toBe(true);
|
||||
expect(turn.turnFailed).toBe(false);
|
||||
// HARD ASSERT: Turn completed successfully
|
||||
expect(turn.turnCompleted).toBe(true);
|
||||
expect(turn.turnFailed).toBe(false);
|
||||
|
||||
// HARD ASSERT: Got at least one assistant message
|
||||
expect(turn.assistantMessages.length).toBeGreaterThan(0);
|
||||
// HARD ASSERT: Got at least one assistant message
|
||||
expect(turn.assistantMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// HARD ASSERT: Each delta is non-empty
|
||||
for (const msg of turn.assistantMessages) {
|
||||
expect(msg.text.length).toBeGreaterThan(0);
|
||||
}
|
||||
// HARD ASSERT: Each delta is non-empty
|
||||
for (const msg of turn.assistantMessages) {
|
||||
expect(msg.text.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// HARD ASSERT: Concatenated deltas form non-empty response
|
||||
const fullResponse = turn.assistantMessages.map((m) => m.text).join("");
|
||||
expect(fullResponse.length).toBeGreaterThan(0);
|
||||
// HARD ASSERT: Concatenated deltas form non-empty response
|
||||
const fullResponse = turn.assistantMessages.map((m) => m.text).join("");
|
||||
expect(fullResponse.length).toBeGreaterThan(0);
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
120_000
|
||||
);
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 120_000);
|
||||
|
||||
test(
|
||||
"listModels returns models with required fields",
|
||||
async () => {
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const models = await client.listModels();
|
||||
test("listModels returns models with required fields", async () => {
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const models = await client.listModels();
|
||||
|
||||
// HARD ASSERT: Returns an array
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
// HARD ASSERT: Returns an array
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
|
||||
// HARD ASSERT: At least one model is returned (OpenCode has connected providers)
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
// HARD ASSERT: At least one model is returned (OpenCode has connected providers)
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
// HARD ASSERT: Each model has required fields with correct types
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("opencode");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
expect(typeof model.label).toBe("string");
|
||||
expect(model.label.length).toBeGreaterThan(0);
|
||||
// HARD ASSERT: Each model has required fields with correct types
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("opencode");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
expect(typeof model.label).toBe("string");
|
||||
expect(model.label.length).toBeGreaterThan(0);
|
||||
|
||||
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
|
||||
expect(model.id).toContain("/");
|
||||
}
|
||||
},
|
||||
60_000
|
||||
);
|
||||
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
|
||||
expect(model.id).toContain("/");
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test(
|
||||
"available modes include build and plan",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
test("available modes include build and plan", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const modes = await session.getAvailableModes();
|
||||
const modes = await session.getAvailableModes();
|
||||
|
||||
expect(modes.some((mode) => mode.id === "build")).toBe(true);
|
||||
expect(modes.some((mode) => mode.id === "plan")).toBe(true);
|
||||
expect(modes.some((mode) => mode.id === "build")).toBe(true);
|
||||
expect(modes.some((mode) => mode.id === "plan")).toBe(true);
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60_000
|
||||
);
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
test(
|
||||
"plan mode blocks edits while build mode can write files",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const planFile = path.join(cwd, "plan-mode-output.txt");
|
||||
const buildFile = path.join(cwd, "build-mode-output.txt");
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
test("plan mode blocks edits while build mode can write files", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const planFile = path.join(cwd, "plan-mode-output.txt");
|
||||
const buildFile = path.join(cwd, "build-mode-output.txt");
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
|
||||
const planSession = await client.createSession({
|
||||
...buildConfig(cwd),
|
||||
modeId: "plan",
|
||||
});
|
||||
const planSession = await client.createSession({
|
||||
...buildConfig(cwd),
|
||||
modeId: "plan",
|
||||
});
|
||||
|
||||
const planTurn = await collectTurnEvents(
|
||||
planSession.stream(
|
||||
"Create a file named plan-mode-output.txt in the current directory containing exactly hello."
|
||||
)
|
||||
);
|
||||
const planTurn = await collectTurnEvents(
|
||||
planSession.stream(
|
||||
"Create a file named plan-mode-output.txt in the current directory containing exactly hello.",
|
||||
),
|
||||
);
|
||||
|
||||
expect(planTurn.turnCompleted).toBe(true);
|
||||
expect(planTurn.turnFailed).toBe(false);
|
||||
expect(existsSync(planFile)).toBe(false);
|
||||
expect(planTurn.turnCompleted).toBe(true);
|
||||
expect(planTurn.turnFailed).toBe(false);
|
||||
expect(existsSync(planFile)).toBe(false);
|
||||
|
||||
const planResponse = planTurn.assistantMessages.map((message) => message.text).join("");
|
||||
expect(planResponse.toLowerCase()).toContain("plan mode");
|
||||
const planResponse = planTurn.assistantMessages.map((message) => message.text).join("");
|
||||
expect(planResponse.toLowerCase()).toContain("plan mode");
|
||||
|
||||
await planSession.close();
|
||||
await planSession.close();
|
||||
|
||||
const buildSession = await client.createSession({
|
||||
...buildConfig(cwd),
|
||||
modeId: "build",
|
||||
});
|
||||
const buildSession = await client.createSession({
|
||||
...buildConfig(cwd),
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
const buildTurn = await collectTurnEvents(
|
||||
buildSession.stream(
|
||||
"Create a file named build-mode-output.txt in the current directory containing exactly hello."
|
||||
)
|
||||
);
|
||||
const buildTurn = await collectTurnEvents(
|
||||
buildSession.stream(
|
||||
"Create a file named build-mode-output.txt in the current directory containing exactly hello.",
|
||||
),
|
||||
);
|
||||
|
||||
expect(buildTurn.turnCompleted).toBe(true);
|
||||
expect(buildTurn.turnFailed).toBe(false);
|
||||
expect(existsSync(buildFile)).toBe(true);
|
||||
expect(readFileSync(buildFile, "utf8")).toContain("hello");
|
||||
|
||||
await buildSession.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180_000
|
||||
);
|
||||
expect(buildTurn.turnCompleted).toBe(true);
|
||||
expect(buildTurn.turnFailed).toBe(false);
|
||||
expect(existsSync(buildFile)).toBe(true);
|
||||
expect(readFileSync(buildFile, "utf8")).toContain("hello");
|
||||
|
||||
await buildSession.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
@@ -146,15 +146,16 @@ const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform(
|
||||
error: part.error,
|
||||
}));
|
||||
|
||||
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform((part) =>
|
||||
mapOpencodeToolCall({
|
||||
toolName: part.toolName,
|
||||
callId: part.callId,
|
||||
status: part.status,
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
error: part.error,
|
||||
})
|
||||
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform(
|
||||
(part) =>
|
||||
mapOpencodeToolCall({
|
||||
toolName: part.toolName,
|
||||
callId: part.callId,
|
||||
status: part.status,
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
error: part.error,
|
||||
}),
|
||||
);
|
||||
|
||||
function resolveOpenCodeBinary(): string {
|
||||
@@ -163,7 +164,7 @@ function resolveOpenCodeBinary(): string {
|
||||
return found;
|
||||
}
|
||||
throw new Error(
|
||||
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH."
|
||||
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,9 +199,7 @@ function stringifyUnknownError(error: unknown): string {
|
||||
|
||||
function isAlreadyPresentMcpError(error: unknown): boolean {
|
||||
const normalized = stringifyUnknownError(error).toLowerCase();
|
||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) =>
|
||||
normalized.includes(token)
|
||||
);
|
||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
async function findAvailablePort(): Promise<number> {
|
||||
@@ -219,10 +218,7 @@ async function findAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePartDedupeKey(
|
||||
part: AgentMetadata,
|
||||
partType: "text" | "reasoning"
|
||||
): string | null {
|
||||
function resolvePartDedupeKey(part: AgentMetadata, partType: "text" | "reasoning"): string | null {
|
||||
const partId = part.id;
|
||||
if (typeof partId === "string" && partId.trim().length > 0) {
|
||||
return `${partType}:${partId}`;
|
||||
@@ -272,7 +268,7 @@ export class OpenCodeServerManager {
|
||||
|
||||
static getInstance(
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): OpenCodeServerManager {
|
||||
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
|
||||
if (!OpenCodeServerManager.instance) {
|
||||
@@ -284,7 +280,7 @@ export class OpenCodeServerManager {
|
||||
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
|
||||
requestedRuntimeSettings: nextSettingsKey,
|
||||
},
|
||||
"OpenCode server manager already initialized with different runtime settings"
|
||||
"OpenCode server manager already initialized with different runtime settings",
|
||||
);
|
||||
}
|
||||
return OpenCodeServerManager.instance;
|
||||
@@ -331,7 +327,7 @@ export class OpenCodeServerManager {
|
||||
const url = `http://127.0.0.1:${this.port}`;
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveOpenCodeBinary
|
||||
resolveOpenCodeBinary,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -339,9 +335,9 @@ export class OpenCodeServerManager {
|
||||
launchPrefix.command,
|
||||
[...launchPrefix.args, "serve", "--port", String(this.port)],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: applyProviderEnv(process.env, this.runtimeSettings),
|
||||
}
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: applyProviderEnv(process.env, this.runtimeSettings),
|
||||
},
|
||||
);
|
||||
|
||||
let started = false;
|
||||
@@ -410,10 +406,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.serverManager = OpenCodeServerManager.getInstance(
|
||||
this.logger,
|
||||
runtimeSettings
|
||||
);
|
||||
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
@@ -448,7 +441,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
|
||||
if (!cwd) {
|
||||
@@ -479,7 +472,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
// Set a timeout for the API call to fail fast if OpenCode isn't responding
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers")), 10_000);
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
"OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers",
|
||||
),
|
||||
),
|
||||
10_000,
|
||||
);
|
||||
});
|
||||
|
||||
const response = await Promise.race([
|
||||
@@ -501,7 +502,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
// Fail fast if no providers are connected
|
||||
if (connectedProviderIds.size === 0) {
|
||||
throw new Error("OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).");
|
||||
throw new Error(
|
||||
"OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).",
|
||||
);
|
||||
}
|
||||
|
||||
const models: AgentModelDefinition[] = [];
|
||||
@@ -543,7 +546,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
return models;
|
||||
}
|
||||
|
||||
async listPersistedAgents(_options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
|
||||
async listPersistedAgents(
|
||||
_options?: ListPersistedAgentsOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
// TODO: Implement by listing sessions from OpenCode
|
||||
return [];
|
||||
}
|
||||
@@ -589,7 +594,7 @@ function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
||||
|
||||
export function translateOpenCodeEvent(
|
||||
event: unknown,
|
||||
state: OpenCodeEventTranslationState
|
||||
state: OpenCodeEventTranslationState,
|
||||
): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
@@ -728,12 +733,16 @@ export function translateOpenCodeEvent(
|
||||
});
|
||||
}
|
||||
} else if (partType === "step-finish") {
|
||||
const tokens = part.tokens as { input?: number; output?: number; reasoning?: number } | undefined;
|
||||
const tokens = part.tokens as
|
||||
| { input?: number; output?: number; reasoning?: number }
|
||||
| undefined;
|
||||
const cost = part.cost as number | undefined;
|
||||
|
||||
if (tokens) {
|
||||
state.accumulatedUsage.inputTokens = (state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
|
||||
state.accumulatedUsage.outputTokens = (state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
|
||||
state.accumulatedUsage.inputTokens =
|
||||
(state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
|
||||
state.accumulatedUsage.outputTokens =
|
||||
(state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
|
||||
}
|
||||
if (cost !== undefined) {
|
||||
state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost;
|
||||
@@ -823,11 +832,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private emittedStructuredMessageIds = new Set<string>();
|
||||
private availableModesCache: AgentMode[] | null = null;
|
||||
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
sessionId: string
|
||||
) {
|
||||
constructor(config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.sessionId = sessionId;
|
||||
@@ -890,7 +895,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
async *stream(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
this.abortController = new AbortController();
|
||||
await this.ensureMcpServersConfigured();
|
||||
@@ -986,12 +991,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
if (role === "user") {
|
||||
// Extract user message text from parts
|
||||
const textParts = parts.filter(
|
||||
(p) => (p as { type?: string }).type === "text"
|
||||
);
|
||||
const text = textParts
|
||||
.map((p) => (p as { text?: string }).text ?? "")
|
||||
.join("");
|
||||
const textParts = parts.filter((p) => (p as { type?: string }).type === "text");
|
||||
const text = textParts.map((p) => (p as { text?: string }).text ?? "").join("");
|
||||
|
||||
if (text) {
|
||||
yield {
|
||||
@@ -1041,7 +1042,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
if (!emittedAssistantText) {
|
||||
const text = stringifyStructuredAssistantMessage(
|
||||
(info as { structured?: unknown }).structured
|
||||
(info as { structured?: unknown }).structured,
|
||||
);
|
||||
if (text) {
|
||||
yield {
|
||||
@@ -1172,38 +1173,33 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async configureMcpServers(
|
||||
mcpServers: Record<string, McpServerConfig>
|
||||
): Promise<void> {
|
||||
private async configureMcpServers(mcpServers: Record<string, McpServerConfig>): Promise<void> {
|
||||
for (const [name, serverConfig] of Object.entries(mcpServers)) {
|
||||
const mappedConfig = toOpenCodeMcpConfig(serverConfig);
|
||||
await this.registerMcpServer(name, mappedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private async registerMcpServer(
|
||||
name: string,
|
||||
config: OpenCodeMcpConfig
|
||||
): Promise<void> {
|
||||
private async registerMcpServer(name: string, config: OpenCodeMcpConfig): Promise<void> {
|
||||
await this.runMcpOperation("add", name, () =>
|
||||
this.client.mcp.add({
|
||||
directory: this.config.cwd,
|
||||
name,
|
||||
config,
|
||||
})
|
||||
}),
|
||||
);
|
||||
await this.runMcpOperation("connect", name, () =>
|
||||
this.client.mcp.connect({
|
||||
directory: this.config.cwd,
|
||||
name,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async runMcpOperation(
|
||||
operation: "add" | "connect",
|
||||
name: string,
|
||||
run: () => Promise<{ error?: unknown }>
|
||||
run: () => Promise<{ error?: unknown }>,
|
||||
): Promise<void> {
|
||||
const response = await run();
|
||||
const error = response.error;
|
||||
@@ -1216,7 +1212,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to ${operation} OpenCode MCP server '${name}': ${stringifyUnknownError(error)}`
|
||||
`Failed to ${operation} OpenCode MCP server '${name}': ${stringifyUnknownError(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
translateOpenCodeEvent,
|
||||
type OpenCodeEventTranslationState,
|
||||
} from "../opencode-agent.js";
|
||||
import { translateOpenCodeEvent, type OpenCodeEventTranslationState } from "../opencode-agent.js";
|
||||
|
||||
function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
|
||||
return {
|
||||
@@ -30,7 +27,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
const streamed = translateOpenCodeEvent(
|
||||
@@ -47,7 +44,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
const completed = translateOpenCodeEvent(
|
||||
@@ -64,11 +61,11 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
const assistantEvents = [...streamed, ...completed].filter(
|
||||
(event) => event.type === "timeline" && event.item.type === "assistant_message"
|
||||
(event) => event.type === "timeline" && event.item.type === "assistant_message",
|
||||
);
|
||||
|
||||
expect(assistantEvents).toEqual([
|
||||
@@ -94,7 +91,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
const completed = translateOpenCodeEvent(
|
||||
@@ -111,7 +108,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
expect(completed).toEqual([
|
||||
@@ -140,7 +137,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
const completed = translateOpenCodeEvent(
|
||||
@@ -157,11 +154,11 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
const reasoningEvents = [...streamed, ...completed].filter(
|
||||
(event) => event.type === "timeline" && event.item.type === "reasoning"
|
||||
(event) => event.type === "timeline" && event.item.type === "reasoning",
|
||||
);
|
||||
|
||||
expect(reasoningEvents).toEqual([
|
||||
@@ -189,7 +186,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
const second = translateOpenCodeEvent(
|
||||
@@ -205,7 +202,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
state,
|
||||
);
|
||||
|
||||
expect(first).toEqual([
|
||||
|
||||
@@ -20,9 +20,24 @@ import {
|
||||
} from "../tool-call-detail-primitives.js";
|
||||
|
||||
const OpencodeKnownToolDetailSchema = z.union([
|
||||
toolDetailBranchByToolName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByToolName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByToolName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
|
||||
toolDetailBranchByToolName(
|
||||
"shell",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
toShellToolDetail,
|
||||
),
|
||||
toolDetailBranchByToolName(
|
||||
"bash",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
toShellToolDetail,
|
||||
),
|
||||
toolDetailBranchByToolName(
|
||||
"exec_command",
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
toShellToolDetail,
|
||||
),
|
||||
toolDetailBranchByToolName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
@@ -31,24 +46,49 @@ const OpencodeKnownToolDetailSchema = z.union([
|
||||
const parsedOutput = ToolReadOutputSchema.safeParse(output);
|
||||
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
|
||||
}),
|
||||
toolDetailBranchByToolName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByToolName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByToolName("create_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
|
||||
toolDetailBranchByToolName(
|
||||
"write",
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
toWriteToolDetail,
|
||||
),
|
||||
toolDetailBranchByToolName(
|
||||
"write_file",
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
toWriteToolDetail,
|
||||
),
|
||||
toolDetailBranchByToolName(
|
||||
"create_file",
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
toWriteToolDetail,
|
||||
),
|
||||
toolDetailBranchByToolName("edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByToolName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByToolName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByToolName(
|
||||
"apply_patch",
|
||||
ToolEditInputSchema,
|
||||
ToolEditOutputSchema,
|
||||
toEditToolDetail,
|
||||
),
|
||||
toolDetailBranchByToolName(
|
||||
"apply_diff",
|
||||
ToolEditInputSchema,
|
||||
ToolEditOutputSchema,
|
||||
toEditToolDetail,
|
||||
),
|
||||
toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail({ input, toolName: "search" })
|
||||
toSearchToolDetail({ input, toolName: "search" }),
|
||||
),
|
||||
toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail({ input, toolName: "web_search" })
|
||||
toSearchToolDetail({ input, toolName: "web_search" }),
|
||||
),
|
||||
]);
|
||||
|
||||
export function deriveOpencodeToolDetail(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
output: unknown
|
||||
output: unknown,
|
||||
): ToolCallDetail {
|
||||
const parsed = OpencodeKnownToolDetailSchema.safeParse({
|
||||
toolName,
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "running",
|
||||
input: { command: "pwd", cwd: "/tmp/repo" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("running");
|
||||
@@ -39,7 +39,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "running",
|
||||
input: { file_path: "README.md" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(readItem.detail).toEqual({
|
||||
type: "read",
|
||||
@@ -53,7 +53,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "running",
|
||||
input: { file_path: "src/new.ts" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(writeItem.detail).toEqual({
|
||||
type: "write",
|
||||
@@ -67,7 +67,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "running",
|
||||
input: { file_path: "src/index.ts" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(editItem.detail).toEqual({
|
||||
type: "edit",
|
||||
@@ -81,7 +81,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "running",
|
||||
input: { query: "opencode mapper" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
@@ -98,7 +98,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "complete",
|
||||
input: { file_path: "README.md" },
|
||||
output: { content: "hello" },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
@@ -124,7 +124,7 @@ describe("opencode tool-call mapper", () => {
|
||||
{ type: "output_text", output: "beta" },
|
||||
],
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(arrayContent.detail?.type).toBe("read");
|
||||
@@ -143,7 +143,7 @@ describe("opencode tool-call mapper", () => {
|
||||
content: { type: "output_text", text: "gamma" },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(objectContent.detail?.type).toBe("read");
|
||||
@@ -161,7 +161,7 @@ describe("opencode tool-call mapper", () => {
|
||||
input: { command: "false" },
|
||||
output: null,
|
||||
error: "command failed",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("failed");
|
||||
@@ -177,7 +177,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "completed",
|
||||
input: { file_path: "src/new.ts", content: "const x = 1;" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(writeItem.detail?.type).toBe("write");
|
||||
if (writeItem.detail?.type === "write") {
|
||||
@@ -191,7 +191,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "completed",
|
||||
input: { file_path: "src/index.ts", diff: "@@\\n-old\\n+new\\n" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(editItem.detail?.type).toBe("edit");
|
||||
if (editItem.detail?.type === "edit") {
|
||||
@@ -206,7 +206,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "completed",
|
||||
input: { query: "opencode mapper" },
|
||||
output: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
@@ -223,7 +223,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "completed",
|
||||
input: { foo: "bar" },
|
||||
output: { ok: true },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
@@ -243,7 +243,7 @@ describe("opencode tool-call mapper", () => {
|
||||
status: "completed",
|
||||
input: { text: "Voice response from OpenCode." },
|
||||
output: { ok: true },
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.name).toBe("paseo_voice.speak");
|
||||
|
||||
@@ -17,12 +17,7 @@ const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
|
||||
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
|
||||
const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]);
|
||||
|
||||
const OpencodeToolCallStatusSchema = z.enum([
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled",
|
||||
]);
|
||||
const OpencodeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
|
||||
|
||||
const OpencodeRawToolCallSchema = z
|
||||
.object({
|
||||
@@ -41,9 +36,7 @@ const OpencodeNormalizedToolCallPass1Schema = OpencodeRawToolCallSchema.transfor
|
||||
const output = raw.output ?? null;
|
||||
const error = raw.error ?? null;
|
||||
const callId =
|
||||
typeof raw.callId === "string" && raw.callId.trim().length > 0
|
||||
? raw.callId.trim()
|
||||
: null;
|
||||
typeof raw.callId === "string" && raw.callId.trim().length > 0 ? raw.callId.trim() : null;
|
||||
let status: z.infer<typeof OpencodeToolCallStatusSchema>;
|
||||
|
||||
if (error !== null) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ToolShellInputSchema = z
|
||||
])
|
||||
.transform((value) => {
|
||||
const parsedCommand = CommandValueSchema.safeParse(
|
||||
"command" in value ? value.command : value.cmd
|
||||
"command" in value ? value.command : value.cmd,
|
||||
);
|
||||
const command = parsedCommand.success
|
||||
? typeof parsedCommand.data === "string"
|
||||
@@ -127,7 +127,10 @@ export const ToolShellOutputSchema = z.union([
|
||||
]);
|
||||
|
||||
export const ToolPathInputSchema = z.union([
|
||||
z.object({ path: z.string() }).passthrough().transform((value) => ({ filePath: value.path })),
|
||||
z
|
||||
.object({ path: z.string() })
|
||||
.passthrough()
|
||||
.transform((value) => ({ filePath: value.path })),
|
||||
z
|
||||
.object({ file_path: z.string() })
|
||||
.passthrough()
|
||||
@@ -154,7 +157,11 @@ export const ToolReadInputSchema = z.union([
|
||||
limit: z.number().finite().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({ filePath: value.file_path, offset: value.offset, limit: value.limit })),
|
||||
.transform((value) => ({
|
||||
filePath: value.file_path,
|
||||
offset: value.offset,
|
||||
limit: value.limit,
|
||||
})),
|
||||
z
|
||||
.object({
|
||||
filePath: z.string(),
|
||||
@@ -189,7 +196,11 @@ const ToolReadChunkSchema = z.union([
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
const ToolReadContentSchema = z.union([z.string(), ToolReadChunkSchema, z.array(ToolReadChunkSchema)]);
|
||||
const ToolReadContentSchema = z.union([
|
||||
z.string(),
|
||||
ToolReadChunkSchema,
|
||||
z.array(ToolReadChunkSchema),
|
||||
]);
|
||||
|
||||
const ToolReadPayloadSchema = z.union([
|
||||
z
|
||||
@@ -216,7 +227,7 @@ const ToolReadPayloadSchema = z.union([
|
||||
]);
|
||||
|
||||
function flattenReadContent(
|
||||
value: z.infer<typeof ToolReadContentSchema> | undefined
|
||||
value: z.infer<typeof ToolReadContentSchema> | undefined,
|
||||
): string | undefined {
|
||||
return flattenToolReadContent(value);
|
||||
}
|
||||
@@ -326,11 +337,8 @@ type ToolReadOutputValue = {
|
||||
export const ToolReadOutputSchema: z.ZodType<ToolReadOutputValue, z.ZodTypeDef, unknown> =
|
||||
ToolReadOutputContentSchema;
|
||||
|
||||
export const ToolReadOutputWithPathSchema: z.ZodType<
|
||||
ToolReadOutputValue,
|
||||
z.ZodTypeDef,
|
||||
unknown
|
||||
> = z.union([ToolReadOutputContentSchema, ToolReadOutputPathSchema]);
|
||||
export const ToolReadOutputWithPathSchema: z.ZodType<ToolReadOutputValue, z.ZodTypeDef, unknown> =
|
||||
z.union([ToolReadOutputContentSchema, ToolReadOutputPathSchema]);
|
||||
|
||||
export const ToolWriteContentSchema = z
|
||||
.object({
|
||||
@@ -351,15 +359,13 @@ export const ToolWriteInputSchema = z
|
||||
}));
|
||||
|
||||
export const ToolWriteOutputSchema = z.union([
|
||||
z
|
||||
.intersection(ToolPathInputSchema, ToolWriteContentSchema)
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
content:
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.newContent),
|
||||
})),
|
||||
z.intersection(ToolPathInputSchema, ToolWriteContentSchema).transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
content:
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.newContent),
|
||||
})),
|
||||
ToolWriteContentSchema.transform((value) => ({
|
||||
filePath: undefined,
|
||||
content:
|
||||
@@ -406,7 +412,7 @@ export const ToolEditInputSchema = z
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff)
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -426,7 +432,7 @@ const ToolEditOutputFileSchema = z.union([
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff)
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
),
|
||||
})),
|
||||
z
|
||||
@@ -444,7 +450,7 @@ const ToolEditOutputFileSchema = z.union([
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff)
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
),
|
||||
})),
|
||||
z
|
||||
@@ -462,27 +468,25 @@ const ToolEditOutputFileSchema = z.union([
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff)
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
),
|
||||
})),
|
||||
]);
|
||||
|
||||
export const ToolEditOutputSchema = z.union([
|
||||
z
|
||||
.intersection(ToolPathInputSchema, ToolEditTextSchema)
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
newString:
|
||||
nonEmptyString(value.newContent) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.content),
|
||||
unifiedDiff: truncateDiffText(
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff)
|
||||
),
|
||||
})),
|
||||
z.intersection(ToolPathInputSchema, ToolEditTextSchema).transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
newString:
|
||||
nonEmptyString(value.newContent) ??
|
||||
nonEmptyString(value.new_content) ??
|
||||
nonEmptyString(value.content),
|
||||
unifiedDiff: truncateDiffText(
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
),
|
||||
})),
|
||||
z
|
||||
.object({ files: z.array(ToolEditOutputFileSchema).min(1) })
|
||||
.passthrough()
|
||||
@@ -501,15 +505,24 @@ export const ToolEditOutputSchema = z.union([
|
||||
nonEmptyString(value.patch) ??
|
||||
nonEmptyString(value.diff) ??
|
||||
nonEmptyString(value.unified_diff) ??
|
||||
nonEmptyString(value.unifiedDiff)
|
||||
nonEmptyString(value.unifiedDiff),
|
||||
),
|
||||
})),
|
||||
]);
|
||||
|
||||
export const ToolSearchInputSchema = z.union([
|
||||
z.object({ query: z.string() }).passthrough().transform((value) => ({ query: value.query })),
|
||||
z.object({ q: z.string() }).passthrough().transform((value) => ({ query: value.q })),
|
||||
z.object({ pattern: z.string() }).passthrough().transform((value) => ({ query: value.pattern })),
|
||||
z
|
||||
.object({ query: z.string() })
|
||||
.passthrough()
|
||||
.transform((value) => ({ query: value.query })),
|
||||
z
|
||||
.object({ q: z.string() })
|
||||
.passthrough()
|
||||
.transform((value) => ({ query: value.q })),
|
||||
z
|
||||
.object({ pattern: z.string() })
|
||||
.passthrough()
|
||||
.transform((value) => ({ query: value.pattern })),
|
||||
]);
|
||||
|
||||
export const ToolGlobOutputSchema = z
|
||||
@@ -597,7 +610,7 @@ type NormalizePathFn = (filePath: string) => string | undefined;
|
||||
|
||||
function normalizeDetailPath(
|
||||
filePath: string | undefined,
|
||||
normalizePath?: NormalizePathFn
|
||||
normalizePath?: NormalizePathFn,
|
||||
): string | undefined {
|
||||
if (typeof filePath !== "string") {
|
||||
return undefined;
|
||||
@@ -610,26 +623,41 @@ function normalizeDetailPath(
|
||||
}
|
||||
|
||||
function isParsedToolGlobOutput(
|
||||
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
|
||||
output:
|
||||
| ParsedToolGrepOutput
|
||||
| ParsedToolGlobOutput
|
||||
| ParsedToolWebSearchOutput
|
||||
| null
|
||||
| undefined,
|
||||
): output is ParsedToolGlobOutput {
|
||||
return Boolean(output && "truncated" in output);
|
||||
}
|
||||
|
||||
function isParsedToolGrepOutput(
|
||||
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
|
||||
output:
|
||||
| ParsedToolGrepOutput
|
||||
| ParsedToolGlobOutput
|
||||
| ParsedToolWebSearchOutput
|
||||
| null
|
||||
| undefined,
|
||||
): output is ParsedToolGrepOutput {
|
||||
return Boolean(output && "filenames" in output && !("truncated" in output));
|
||||
}
|
||||
|
||||
function isParsedToolWebSearchOutput(
|
||||
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
|
||||
output:
|
||||
| ParsedToolGrepOutput
|
||||
| ParsedToolGlobOutput
|
||||
| ParsedToolWebSearchOutput
|
||||
| null
|
||||
| undefined,
|
||||
): output is ParsedToolWebSearchOutput {
|
||||
return Boolean(output && "results" in output);
|
||||
}
|
||||
|
||||
export function toShellToolDetail(
|
||||
input: ParsedToolShellInput | null,
|
||||
output: ParsedToolShellOutput | null
|
||||
output: ParsedToolShellOutput | null,
|
||||
): ToolCallDetail | undefined {
|
||||
const command = input?.command ?? output?.command;
|
||||
if (!command) {
|
||||
@@ -648,12 +676,9 @@ export function toShellToolDetail(
|
||||
export function toReadToolDetail(
|
||||
input: ParsedToolReadInput | null,
|
||||
output: ParsedToolReadOutput | ParsedToolReadOutputWithPath | null,
|
||||
options?: { normalizePath?: NormalizePathFn }
|
||||
options?: { normalizePath?: NormalizePathFn },
|
||||
): ToolCallDetail | undefined {
|
||||
const filePath = normalizeDetailPath(
|
||||
input?.filePath ?? output?.filePath,
|
||||
options?.normalizePath
|
||||
);
|
||||
const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath);
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -670,12 +695,9 @@ export function toReadToolDetail(
|
||||
export function toWriteToolDetail(
|
||||
input: ParsedToolWriteInput | null,
|
||||
output: ParsedToolWriteOutput | null,
|
||||
options?: { normalizePath?: NormalizePathFn }
|
||||
options?: { normalizePath?: NormalizePathFn },
|
||||
): ToolCallDetail | undefined {
|
||||
const filePath = normalizeDetailPath(
|
||||
input?.filePath ?? output?.filePath,
|
||||
options?.normalizePath
|
||||
);
|
||||
const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath);
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -683,19 +705,20 @@ export function toWriteToolDetail(
|
||||
return {
|
||||
type: "write",
|
||||
filePath,
|
||||
...(input?.content ? { content: input.content } : output?.content ? { content: output.content } : {}),
|
||||
...(input?.content
|
||||
? { content: input.content }
|
||||
: output?.content
|
||||
? { content: output.content }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function toEditToolDetail(
|
||||
input: ParsedToolEditInput | null,
|
||||
output: ParsedToolEditOutput | null,
|
||||
options?: { normalizePath?: NormalizePathFn }
|
||||
options?: { normalizePath?: NormalizePathFn },
|
||||
): ToolCallDetail | undefined {
|
||||
const filePath = normalizeDetailPath(
|
||||
input?.filePath ?? output?.filePath,
|
||||
options?.normalizePath
|
||||
);
|
||||
const filePath = normalizeDetailPath(input?.filePath ?? output?.filePath, options?.normalizePath);
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -704,7 +727,11 @@ export function toEditToolDetail(
|
||||
type: "edit",
|
||||
filePath,
|
||||
...(input?.oldString ? { oldString: input.oldString } : {}),
|
||||
...(input?.newString ? { newString: input.newString } : output?.newString ? { newString: output.newString } : {}),
|
||||
...(input?.newString
|
||||
? { newString: input.newString }
|
||||
: output?.newString
|
||||
? { newString: output.newString }
|
||||
: {}),
|
||||
...(input?.unifiedDiff
|
||||
? { unifiedDiff: input.unifiedDiff }
|
||||
: output?.unifiedDiff
|
||||
@@ -751,9 +778,7 @@ export function toSearchToolDetail(params: {
|
||||
? { numMatches: output.numMatches }
|
||||
: {}),
|
||||
...(isParsedToolGlobOutput(output) ? { durationMs: output.durationMs } : {}),
|
||||
...(isParsedToolWebSearchOutput(output)
|
||||
? { durationSeconds: output.durationSeconds }
|
||||
: {}),
|
||||
...(isParsedToolWebSearchOutput(output) ? { durationSeconds: output.durationSeconds } : {}),
|
||||
...(isParsedToolGlobOutput(output) ? { truncated: output.truncated } : {}),
|
||||
...(isParsedToolGrepOutput(output) && output.mode ? { mode: output.mode } : {}),
|
||||
};
|
||||
@@ -761,7 +786,7 @@ export function toSearchToolDetail(params: {
|
||||
|
||||
export function toFetchToolDetail(
|
||||
input: ParsedToolWebFetchInput | null,
|
||||
output: ParsedToolWebFetchOutput | null
|
||||
output: ParsedToolWebFetchOutput | null,
|
||||
): ToolCallDetail | undefined {
|
||||
const url = input?.url ?? output?.url;
|
||||
if (!url) {
|
||||
@@ -790,8 +815,8 @@ export function toolDetailBranchByName<
|
||||
outputSchema: OutputSchema,
|
||||
mapper: (
|
||||
input: z.infer<InputSchema> | null,
|
||||
output: z.infer<OutputSchema> | null
|
||||
) => ToolCallDetail | undefined
|
||||
output: z.infer<OutputSchema> | null,
|
||||
) => ToolCallDetail | undefined,
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
@@ -818,8 +843,8 @@ export function toolDetailBranchByToolName<
|
||||
outputSchema: OutputSchema,
|
||||
mapper: (
|
||||
input: z.infer<InputSchema> | null,
|
||||
output: z.infer<OutputSchema> | null
|
||||
) => ToolCallDetail | undefined
|
||||
output: z.infer<OutputSchema> | null,
|
||||
) => ToolCallDetail | undefined,
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
@@ -847,8 +872,8 @@ export function toolDetailBranchByNameWithCwd<
|
||||
mapper: (
|
||||
input: z.infer<InputSchema> | null,
|
||||
output: z.infer<OutputSchema> | null,
|
||||
cwd: string | null
|
||||
) => ToolCallDetail | undefined
|
||||
cwd: string | null,
|
||||
) => ToolCallDetail | undefined,
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
|
||||
@@ -83,7 +83,7 @@ export function extractCodexTerminalSessionId(value: string | undefined): string
|
||||
}
|
||||
|
||||
export function flattenReadContent<Chunk extends ReadChunkLike>(
|
||||
value: string | Chunk | Chunk[] | undefined
|
||||
value: string | Chunk | Chunk[] | undefined,
|
||||
): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return nonEmptyString(value);
|
||||
@@ -97,21 +97,19 @@ export function flattenReadContent<Chunk extends ReadChunkLike>(
|
||||
(chunk) =>
|
||||
nonEmptyString(chunk.text) ??
|
||||
nonEmptyString(chunk.content) ??
|
||||
nonEmptyString(chunk.output)
|
||||
nonEmptyString(chunk.output),
|
||||
)
|
||||
.filter((part): part is string => typeof part === "string");
|
||||
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||
}
|
||||
return (
|
||||
nonEmptyString(value.text) ??
|
||||
nonEmptyString(value.content) ??
|
||||
nonEmptyString(value.output)
|
||||
nonEmptyString(value.text) ?? nonEmptyString(value.content) ?? nonEmptyString(value.output)
|
||||
);
|
||||
}
|
||||
|
||||
export function truncateDiffText(
|
||||
text: string | undefined,
|
||||
maxChars: number = 12_000
|
||||
maxChars: number = 12_000,
|
||||
): string | undefined {
|
||||
if (typeof text !== "string") {
|
||||
return undefined;
|
||||
|
||||
@@ -21,4 +21,3 @@ export function resolveRecordingsDebugDir(explicitEnvVarName: string): string |
|
||||
|
||||
return resolve(process.cwd(), ".debug/recordings");
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface DebugAudioMetadata {
|
||||
export async function maybePersistDebugAudio(
|
||||
audio: Buffer,
|
||||
metadata: DebugAudioMetadata,
|
||||
logger: pino.Logger
|
||||
logger: pino.Logger,
|
||||
): Promise<string | null> {
|
||||
if (!debugDir) {
|
||||
return null;
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("STTManager", () => {
|
||||
const manager = new STTManager(
|
||||
"s1",
|
||||
pino({ level: "silent" }),
|
||||
new FakeStt({ text: "um", isLowConfidence: true, avgLogprob: -10 })
|
||||
new FakeStt({ text: "um", isLowConfidence: true, avgLogprob: -10 }),
|
||||
);
|
||||
|
||||
const result = await manager.transcribe(Buffer.alloc(2), "audio/pcm;rate=24000", {
|
||||
@@ -114,7 +114,7 @@ describe("STTManager", () => {
|
||||
const manager = new STTManager(
|
||||
"s1",
|
||||
pino({ level: "silent" }),
|
||||
new FakeStt({ text: "hello world", language: "en", isLowConfidence: false })
|
||||
new FakeStt({ text: "hello world", language: "en", isLowConfidence: false }),
|
||||
);
|
||||
|
||||
const result = await manager.transcribe(Buffer.alloc(4), "audio/pcm;rate=24000");
|
||||
@@ -131,7 +131,7 @@ describe("STTManager", () => {
|
||||
const manager = new STTManager(
|
||||
"s1",
|
||||
pino({ level: "silent" }),
|
||||
new SequencedFakeStt(["alpha", "beta", "gamma"])
|
||||
new SequencedFakeStt(["alpha", "beta", "gamma"]),
|
||||
);
|
||||
|
||||
const threeSecondsPcm = Buffer.alloc(24000 * 2 * 3);
|
||||
|
||||
@@ -45,7 +45,7 @@ export class STTManager {
|
||||
constructor(
|
||||
sessionId: string,
|
||||
logger: pino.Logger,
|
||||
stt: Resolvable<SpeechToTextProvider | null>
|
||||
stt: Resolvable<SpeechToTextProvider | null>,
|
||||
) {
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger.child({ module: "agent", component: "stt-manager", sessionId });
|
||||
@@ -58,7 +58,7 @@ export class STTManager {
|
||||
public async transcribe(
|
||||
audio: Buffer,
|
||||
format: string,
|
||||
metadata?: TranscriptionMetadata
|
||||
metadata?: TranscriptionMetadata,
|
||||
): Promise<SessionTranscriptionResult> {
|
||||
const stt = this.resolveStt();
|
||||
if (!stt) {
|
||||
@@ -67,7 +67,7 @@ export class STTManager {
|
||||
|
||||
this.logger.debug(
|
||||
{ bytes: audio.length, format, label: metadata?.label },
|
||||
"Transcribing audio"
|
||||
"Transcribing audio",
|
||||
);
|
||||
|
||||
let debugRecordingPath: string | null = null;
|
||||
@@ -81,7 +81,7 @@ export class STTManager {
|
||||
label: metadata?.label,
|
||||
format,
|
||||
},
|
||||
this.logger
|
||||
this.logger,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist debug audio");
|
||||
@@ -100,8 +100,7 @@ export class STTManager {
|
||||
pcm16 = parsed.pcm16;
|
||||
} else if (format.toLowerCase().includes("audio/pcm")) {
|
||||
inputRate =
|
||||
parsePcmRateFromFormat(format, session.requiredSampleRate) ??
|
||||
session.requiredSampleRate;
|
||||
parsePcmRateFromFormat(format, session.requiredSampleRate) ?? session.requiredSampleRate;
|
||||
pcm16 = audio;
|
||||
} else {
|
||||
throw new Error(`Unsupported audio format for STT: ${format}`);
|
||||
@@ -193,7 +192,7 @@ export class STTManager {
|
||||
|
||||
const appendChunkBytes = Math.max(
|
||||
1,
|
||||
Math.round(session.requiredSampleRate * 2 * BATCH_APPEND_CHUNK_SECONDS)
|
||||
Math.round(session.requiredSampleRate * 2 * BATCH_APPEND_CHUNK_SECONDS),
|
||||
);
|
||||
const commitEverySeconds = resolveBatchCommitEverySeconds();
|
||||
const commitEveryBytes =
|
||||
@@ -205,7 +204,7 @@ export class STTManager {
|
||||
for (let offset = 0; offset < pcmForModel.length; offset += appendChunkBytes) {
|
||||
const chunk = pcmForModel.subarray(
|
||||
offset,
|
||||
Math.min(pcmForModel.length, offset + appendChunkBytes)
|
||||
Math.min(pcmForModel.length, offset + appendChunkBytes),
|
||||
);
|
||||
if (chunk.length === 0) {
|
||||
continue;
|
||||
@@ -234,7 +233,7 @@ export class STTManager {
|
||||
receivedFinals: finalTranscriptSegmentIds.size,
|
||||
label: metadata?.label,
|
||||
},
|
||||
"Timed out waiting for final STT segments; returning available transcripts"
|
||||
"Timed out waiting for final STT segments; returning available transcripts",
|
||||
);
|
||||
settle?.();
|
||||
}, BATCH_FINAL_TIMEOUT_MS);
|
||||
@@ -259,13 +258,13 @@ export class STTManager {
|
||||
.map((segmentId) => transcriptMetaBySegmentId.get(segmentId))
|
||||
.filter(
|
||||
(
|
||||
meta
|
||||
meta,
|
||||
): meta is {
|
||||
language?: string;
|
||||
logprobs?: TranscriptionResult["logprobs"];
|
||||
avgLogprob?: number;
|
||||
isLowConfidence?: boolean;
|
||||
} => Boolean(meta)
|
||||
} => Boolean(meta),
|
||||
);
|
||||
const language = orderedFinalMeta.find((meta) => meta.language)?.language;
|
||||
const singleSegmentMeta = orderedFinalMeta.length === 1 ? orderedFinalMeta[0] : null;
|
||||
@@ -288,7 +287,7 @@ export class STTManager {
|
||||
if (result.isLowConfidence) {
|
||||
this.logger.debug(
|
||||
{ text: result.text, avgLogprob: result.avgLogprob },
|
||||
"Filtered low-confidence transcription (likely non-speech)"
|
||||
"Filtered low-confidence transcription (likely non-speech)",
|
||||
);
|
||||
|
||||
// Return empty text to ignore this transcription
|
||||
@@ -303,7 +302,7 @@ export class STTManager {
|
||||
|
||||
this.logger.debug(
|
||||
{ text: result.text, avgLogprob: result.avgLogprob },
|
||||
"Transcription complete"
|
||||
"Transcription complete",
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load system prompt from agent-prompt.md in package root
|
||||
export function loadSystemPrompt(): string {
|
||||
const promptPath = path.join(__dirname, '../../../agent-prompt.md');
|
||||
return readFileSync(promptPath, 'utf-8');
|
||||
const promptPath = path.join(__dirname, "../../../agent-prompt.md");
|
||||
return readFileSync(promptPath, "utf-8");
|
||||
}
|
||||
|
||||
// Cache the loaded prompt
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface AppendTimelineItemIfAgentKnownOptions {
|
||||
}
|
||||
|
||||
export async function appendTimelineItemIfAgentKnown(
|
||||
options: AppendTimelineItemIfAgentKnownOptions
|
||||
options: AppendTimelineItemIfAgentKnownOptions,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await options.agentManager.appendTimelineItem(options.agentId, options.item);
|
||||
@@ -23,7 +23,7 @@ export async function appendTimelineItemIfAgentKnown(
|
||||
}
|
||||
|
||||
export async function emitLiveTimelineItemIfAgentKnown(
|
||||
options: AppendTimelineItemIfAgentKnownOptions
|
||||
options: AppendTimelineItemIfAgentKnownOptions,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await options.agentManager.emitLiveTimelineItem(options.agentId, options.item);
|
||||
|
||||
@@ -51,7 +51,7 @@ function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeq
|
||||
|
||||
function mergeSeqRanges(
|
||||
existing: TimelineSeqRange[],
|
||||
incoming: TimelineSeqRange[]
|
||||
incoming: TimelineSeqRange[],
|
||||
): TimelineSeqRange[] {
|
||||
let merged = [...existing];
|
||||
for (const range of incoming) {
|
||||
@@ -74,7 +74,7 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail)
|
||||
|
||||
function mergeToolCallItems(
|
||||
existing: Extract<AgentTimelineItem, { type: "tool_call" }>,
|
||||
incoming: Extract<AgentTimelineItem, { type: "tool_call" }>
|
||||
incoming: Extract<AgentTimelineItem, { type: "tool_call" }>,
|
||||
): Extract<AgentTimelineItem, { type: "tool_call" }> {
|
||||
const mergedDetail = mergeToolCallDetail(existing.detail, incoming.detail);
|
||||
const mergedMetadata =
|
||||
@@ -102,7 +102,7 @@ function mergeToolCallItems(
|
||||
|
||||
function makeCanonicalEntries(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
provider: AgentProvider
|
||||
provider: AgentProvider,
|
||||
): WorkingEntry[] {
|
||||
return rows.map((row) => ({
|
||||
provider,
|
||||
@@ -175,10 +175,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
AgentTimelineItem,
|
||||
{ type: "assistant_message" }
|
||||
>;
|
||||
const entryAssistant = entry.item as Extract<
|
||||
AgentTimelineItem,
|
||||
{ type: "assistant_message" }
|
||||
>;
|
||||
const entryAssistant = entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>;
|
||||
|
||||
const collapsedKinds = new Set<TimelineProjectionKind>([
|
||||
...previous.collapsed,
|
||||
@@ -204,7 +201,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
export function projectTimelineRows(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
provider: AgentProvider,
|
||||
mode: TimelineProjectionMode
|
||||
mode: TimelineProjectionMode,
|
||||
): TimelineProjectionEntry[] {
|
||||
const canonical = makeCanonicalEntries(rows, provider);
|
||||
if (mode === "canonical") {
|
||||
@@ -232,7 +229,7 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
||||
const collapseTools = input.collapseToolLifecycle ?? true;
|
||||
const canonical = makeCanonicalEntries(rows, provider);
|
||||
const projectedAll = mergeAssistantChunks(
|
||||
collapseTools ? collapseToolLifecycle(canonical) : canonical
|
||||
collapseTools ? collapseToolLifecycle(canonical) : canonical,
|
||||
);
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
@@ -283,7 +280,7 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
||||
// seqEnd values, which would otherwise create cursor gaps.
|
||||
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
|
||||
const overlapping = projectedAll.filter(
|
||||
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq
|
||||
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
|
||||
);
|
||||
const nextBounds = computeWindowBounds(overlapping);
|
||||
if (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user