import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { attachErrorDiagnostic } from "../../infra/error-diagnostics.js"; import { buildAgentRunTerminalOutcome } from "../agent-run-terminal-outcome.js"; import { createCliTimeoutError } from "../cli-runner/no-output-timeout-policy.js"; import { FailoverError } from "../failover-error.js"; import { renderFailoverCodeUserCopy } from "../failover/user-copy.js"; import { createAgentCommandLifecycle } from "./lifecycle.js"; const { emitAgentEvent, lifecycleLog } = vi.hoisted(() => ({ emitAgentEvent: vi.fn(), lifecycleLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); vi.mock("../../infra/agent-events.js", () => ({ emitAgentEvent })); vi.mock("../../logging/subsystem.js", () => ({ createSubsystemLogger: () => lifecycleLog, })); describe("createAgentCommandLifecycle", () => { it("publishes an outer timeout that arrives after a yielded result", () => { emitAgentEvent.mockClear(); const controller = new AbortController(); const lifecycle = createAgentCommandLifecycle({ runId: "yield-then-outer-timeout", lifecycleGeneration: () => "test-generation", startedAt: 100, abortSignal: controller.signal, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); const terminal = { metadata: { yielded: true, aborted: false }, outcome: buildAgentRunTerminalOutcome({ status: "ok", stopReason: "end_turn", livenessState: "paused", }), }; controller.abort(new DOMException("outer deadline", "TimeoutError")); lifecycle.emitEnd(terminal); expect(emitAgentEvent).toHaveBeenCalledWith( expect.objectContaining({ runId: "yield-then-outer-timeout", data: expect.objectContaining({ phase: "end", yielded: true, aborted: true, stopReason: "timeout", executionSettled: true, }), }), ); }); it.each(["basic", "post-turn"] as const)( "turns an embedded-runtime stale install %s error into restart guidance", (source) => { emitAgentEvent.mockClear(); const missingChunk = path.join( process.cwd(), "dist", "session-transcript-reconcile-stale.mjs", ); const importingChunk = path.join(process.cwd(), "dist", "embedded-agent-stale.mjs"); const error = Object.assign( new Error(`Cannot find module '${missingChunk}' imported from ${importingChunk}`), { code: "ERR_MODULE_NOT_FOUND", url: pathToFileURL(missingChunk).href, }, ); const lifecycle = createAgentCommandLifecycle({ runId: "stale-install", lifecycleGeneration: () => "test-generation", startedAt: 100, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); if (source === "basic") { lifecycle.emitBasicError(error); } else { lifecycle.emitPostTurnError(error, { metadata: {}, outcome: buildAgentRunTerminalOutcome({ status: "error", stopReason: "error" }), }); } const event = emitAgentEvent.mock.calls[0]?.[0]; expect(event.data.error).toMatch(/installation may have changed.*gateway restart/i); expect(JSON.stringify(event)).not.toContain(missingChunk); expect(JSON.stringify(event)).not.toContain(importingChunk); }, ); it.each([ { name: "successful stops", status: "ok", stopReason: "stop", level: "info" }, { name: "tool-use stops", status: "ok", stopReason: "toolUse", level: "info" }, { name: "ordinary end turns", status: "ok", stopReason: "end_turn", level: undefined }, { name: "timeouts", status: "timeout", stopReason: "timeout", level: "warn" }, { name: "cancelled runs", status: "error", stopReason: "stop", level: "error" }, { name: "failed runs", status: "error", stopReason: "error", level: "error" }, ] as const)("logs $name at the expected severity", ({ status, stopReason, level }) => { vi.clearAllMocks(); const lifecycle = createAgentCommandLifecycle({ runId: "logged-terminal-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); lifecycle.emitEnd({ metadata: {}, outcome: buildAgentRunTerminalOutcome({ status, stopReason }), }); for (const candidate of ["info", "warn", "error"] as const) { if (candidate === level) { expect(lifecycleLog[candidate]).toHaveBeenCalledOnce(); } else { expect(lifecycleLog[candidate]).not.toHaveBeenCalled(); } } }); it.each([ { name: "finishing", phase: "finishing", lifecycleError: undefined }, { name: "end", phase: "end", lifecycleError: undefined }, { name: "result error", phase: "error", lifecycleError: undefined }, { name: "explicit lifecycle guidance", phase: "error", lifecycleError: "Reconnect the selected provider, then try again.", }, ] as const)("publishes the timeout diagnostic through $name", ({ phase, lifecycleError }) => { emitAgentEvent.mockClear(); const error = "Request timed out before a response was generated. Please try again."; const lifecycle = createAgentCommandLifecycle({ runId: "timeout-diagnostic-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, lifecycleError, }, }); const terminal = { metadata: { aborted: false, replayInvalid: false }, outcome: buildAgentRunTerminalOutcome({ status: "timeout", stopReason: "timeout", timeoutPhase: "provider", providerStarted: true, error, }), }; if (phase === "finishing") { lifecycle.emitFinishing(terminal); } else if (phase === "end") { lifecycle.emitEnd(terminal); } else { lifecycle.emitResultError( { payloads: [ { text: "An earlier tool failed.", isError: true }, { text: error, isError: true }, ], meta: { durationMs: 0, error: { kind: "incomplete_turn", message: error, fallbackSafe: false }, }, }, false, terminal, ); } expect(emitAgentEvent).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ runId: "timeout-diagnostic-owner", stream: "lifecycle", data: expect.objectContaining({ phase, error: lifecycleError ?? error, aborted: false, stopReason: "timeout", timeoutPhase: "provider", providerStarted: true, }), }), ); }); it.each(["finishing", "end", "error"] as const)( "preserves only canonical terminal facts on %s events", (phase) => { emitAgentEvent.mockClear(); const secret = ["sk", "abcdefghijklmnopqrstuv"].join("-"); const metadata = { aborted: true, stopReason: "timeout", timeoutPhase: "provider", providerStarted: true, livenessState: "blocked", yielded: true, replayInvalid: true, error: { message: `Authorization: Bearer ${secret}`, nested: { secret } }, terminalDelivery: { status: "sent", resultCount: 2, errorMessage: secret, target: "private-target", }, unsafeMetadata: { credential: secret }, }; const lifecycle = createAgentCommandLifecycle({ runId: "terminal-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); const terminal = { metadata, outcome: buildAgentRunTerminalOutcome({ status: "timeout", ...metadata }), }; if (phase === "finishing") { lifecycle.emitFinishing(terminal); } else if (phase === "end") { lifecycle.emitEnd(terminal); } else { lifecycle.emitResultError( { payloads: [], meta: { durationMs: 0, error: { kind: "retry_limit", message: "internal provider diagnostic" }, }, }, true, terminal, ); } expect(emitAgentEvent).toHaveBeenCalledWith( expect.objectContaining({ runId: "terminal-owner", stream: "lifecycle", data: expect.objectContaining({ phase, aborted: true, stopReason: "timeout", timeoutPhase: "provider", providerStarted: true, livenessState: "blocked", yielded: true, replayInvalid: true, ...(phase !== "finishing" ? { executionSettled: true } : {}), }), }), ); const event = emitAgentEvent.mock.calls[0]?.[0]; if (phase === "finishing") { expect(event.data).not.toHaveProperty("executionSettled"); } expect(event.data.terminalDelivery).toEqual({ status: "sent", resultCount: 2 }); expect(JSON.stringify(event)).not.toContain(secret); expect(event.data).not.toHaveProperty("unsafeMetadata"); }, ); it.each([ { name: "compaction failure", message: "Context compaction timed out before the pending message could be processed.", lifecycleError: undefined, expected: "Context compaction timed out before the pending message could be processed.", }, { name: "recorded lifecycle guidance", message: "Context compaction failed.", lifecycleError: "Reconnect the selected provider, then try again.", expected: "Reconnect the selected provider, then try again.", }, { name: "empty failure detail", message: " ", lifecycleError: undefined, expected: "Agent run failed", }, ])("publishes $name from a structured failed result", ({ message, lifecycleError, expected }) => { emitAgentEvent.mockClear(); const lifecycle = createAgentCommandLifecycle({ runId: "structured-failure-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, lifecycleError, }, }); lifecycle.emitResultError( { payloads: [{ text: "An earlier tool failed.", isError: true }], meta: { durationMs: 0, error: { kind: "compaction_failure", message } }, }, false, { metadata: {}, outcome: buildAgentRunTerminalOutcome({ status: "error", stopReason: "error" }), }, ); expect(emitAgentEvent).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ runId: "structured-failure-owner", stream: "lifecycle", data: expect.objectContaining({ phase: "error", error: expected, executionSettled: true }), }), ); }); it.each([ "lifecycle callback", "fallback payload", "structured result", "post-turn error", ] as const)("redacts credentials from a %s before publishing the lifecycle event", (source) => { emitAgentEvent.mockClear(); const secret = ["sk", "abcdefghijklmnopqrstuv"].join("-"); const error = `The provider failed. Authorization: Bearer ${secret}`; const state = { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, ...(source === "lifecycle callback" ? { lifecycleError: error } : {}), }; const lifecycle = createAgentCommandLifecycle({ runId: "secret-safe-terminal-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, state, }); const terminal = { metadata: {}, outcome: buildAgentRunTerminalOutcome({ status: "error", stopReason: "error" }), }; if (source === "post-turn error") { lifecycle.emitPostTurnError(new Error(error), terminal); } else { lifecycle.emitResultError( { payloads: source === "fallback payload" ? [{ isError: true, text: error }] : [], meta: { durationMs: 0, ...(source === "structured result" ? { error: { kind: "compaction_failure" as const, message: error } } : {}), }, }, source === "fallback payload", terminal, ); } const event = emitAgentEvent.mock.calls[0]?.[0]; expect(event.data.error).toContain("The provider failed."); expect(event.data.error).toContain("Authorization: Bearer"); expect(JSON.stringify(event)).not.toContain(secret); }); it.each([ ["basic", "plain"], ["post-turn", "plain"], ["post-turn", "timeout"], ["post-turn", "abort"], ] as const)( "displays diagnostics on %s errors while retaining native %s facts", (source, kind) => { emitAgentEvent.mockClear(); const controller = new AbortController(); if (kind === "abort") { controller.abort(); } const lifecycle = createAgentCommandLifecycle({ runId: "diagnostic-terminal-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, abortSignal: controller.signal, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); const error = attachErrorDiagnostic( kind === "timeout" ? createCliTimeoutError( {}, { mode: "overall", timeoutSeconds: 30, observedActivity: false, activeToolCount: 0, backgroundTaskCount: 0, }, ) : new Error("child exited with code 1"), "stderr: an earlier request timed out and was aborted", ); if (source === "basic") { lifecycle.emitBasicError(error); } else { lifecycle.emitPostTurnError(error, { metadata: {}, outcome: buildAgentRunTerminalOutcome({ status: "error", stopReason: "error" }), }); } expect(emitAgentEvent).toHaveBeenCalledOnce(); const event = emitAgentEvent.mock.calls[0]?.[0]; expect(event.data.error).toContain(error.message); expect(event.data.error).toContain("an earlier request timed out and was aborted"); if (kind === "timeout") { expect(event.data).toMatchObject({ stopReason: "timeout", timeoutPhase: "provider" }); expect(event.data).not.toHaveProperty("aborted"); } else if (kind === "abort") { expect(event.data).toMatchObject({ aborted: true, stopReason: "aborted" }); expect(event.data).not.toHaveProperty("timeoutPhase"); } else { for (const field of ["aborted", "stopReason", "timeoutPhase"]) { expect(event.data).not.toHaveProperty(field); } } }, ); it.each(["basic", "post-turn"] as const)( "publishes bounded selected-profile recovery from %s lifecycle errors", (source) => { emitAgentEvent.mockClear(); const profileId = "openai:private-profile"; const rawCause = `Codex app-server auth profile "${profileId}" was not found`; const secret = ["sk", "abcdefghijklmnopqrstuv"].join("-"); const lifecycle = createAgentCommandLifecycle({ runId: "missing-selected-profile", lifecycleGeneration: () => "test-generation", startedAt: 100, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); const error = new FailoverError(rawCause, { reason: "auth", code: "selected_auth_profile_unavailable", profileId, cause: new Error(rawCause), }); attachErrorDiagnostic( error, `stderr: credential staging failed. Authorization: Bearer ${secret}`, ); if (source === "basic") { lifecycle.emitBasicError(error); } else { lifecycle.emitPostTurnError(error, { metadata: {}, outcome: buildAgentRunTerminalOutcome({ status: "error", stopReason: "error" }), }); } const event = emitAgentEvent.mock.calls[0]?.[0]; expect(event.data.error).toContain( renderFailoverCodeUserCopy("selected_auth_profile_unavailable"), ); expect(event.data.error).toContain("stderr: credential staging failed."); expect(event.data.executionSettled).toBe(true); expect(JSON.stringify(event)).not.toContain(profileId); expect(JSON.stringify(event)).not.toContain(rawCause); expect(JSON.stringify(event)).not.toContain(secret); }, ); it("does not let generic abort metadata erase a superseded outcome", () => { emitAgentEvent.mockClear(); const controller = new AbortController(); controller.abort(); const lifecycle = createAgentCommandLifecycle({ runId: "superseded-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, abortSignal: controller.signal, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); lifecycle.emitEnd({ metadata: { aborted: true }, outcome: buildAgentRunTerminalOutcome({ status: "error", stopReason: "superseded" }), }); expect(emitAgentEvent).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ aborted: true, phase: "end", stopReason: "superseded", }), }), ); }); it("keeps post-turn errors narrow while publishing bounded delivery evidence", () => { emitAgentEvent.mockClear(); const secret = ["sk", "abcdefghijklmnopqrstuv"].join("-"); const lifecycle = createAgentCommandLifecycle({ runId: "post-turn-delivery-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); lifecycle.emitPostTurnError(new Error("delivery failed"), { metadata: { terminalDelivery: { status: "failed", resultCount: 0, errorMessage: secret, }, terminalReceipt: { runId: "unrelated-receipt", secret }, terminalReply: { disposition: "visible", text: secret }, unsafeMetadata: { secret }, }, outcome: buildAgentRunTerminalOutcome({ status: "timeout", stopReason: "timeout", livenessState: "blocked", timeoutPhase: "provider", providerStarted: true, }), }); const event = emitAgentEvent.mock.calls[0]?.[0]; expect(event.data).toMatchObject({ phase: "error", error: "delivery failed", terminalDelivery: { status: "failed", resultCount: 0 }, }); expect(JSON.stringify(event)).not.toContain(secret); for (const field of [ "aborted", "stopReason", "livenessState", "timeoutPhase", "providerStarted", "terminalReceipt", "terminalReply", "unsafeMetadata", ]) { expect(event.data).not.toHaveProperty(field); } }); it.each(["finishing", "end", "error"] as const)( "rejects malformed canonical metadata on %s events", (phase) => { emitAgentEvent.mockClear(); const secret = ["sk", "abcdefghijklmnopqrstuv"].join("-"); const malicious = { authorization: `Bearer ${secret}`, nested: { secret } }; const lifecycle = createAgentCommandLifecycle({ runId: "malformed-terminal-owner", lifecycleGeneration: () => "test-generation", startedAt: 100, state: { currentTurnUserMessagePersisted: true, lifecycleFinishing: false, lifecycleEnded: false, }, }); const terminal = { metadata: { aborted: malicious, stopReason: malicious, yielded: malicious, timeoutPhase: malicious, providerStarted: malicious, livenessState: malicious, replayInvalid: malicious, terminalReceipt: malicious, terminalDelivery: malicious, error: malicious, unknownMetadata: malicious, }, outcome: buildAgentRunTerminalOutcome({ status: "error", stopReason: "error" }), }; if (phase === "finishing") { lifecycle.emitFinishing(terminal); } else if (phase === "end") { lifecycle.emitEnd(terminal); } else { lifecycle.emitResultError({ payloads: [], meta: { durationMs: 0 } }, false, terminal); } const event = emitAgentEvent.mock.calls[0]?.[0]; expect(event.data).toMatchObject({ phase, aborted: false, stopReason: "error" }); expect(JSON.stringify(event)).not.toContain(secret); for (const field of [ "yielded", "timeoutPhase", "providerStarted", "livenessState", "replayInvalid", "terminalDelivery", "terminalReceipt", "unknownMetadata", ]) { expect(event.data).not.toHaveProperty(field); } }, ); });