diff --git a/src/cli/one-shot-exit.process.test.ts b/src/cli/one-shot-exit.process.test.ts new file mode 100644 index 00000000000..e6fcfc31de5 --- /dev/null +++ b/src/cli/one-shot-exit.process.test.ts @@ -0,0 +1,178 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +function cliChildEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.VITEST; + delete env.VITEST_POOL_ID; + delete env.VITEST_WORKER_ID; + return env; +} + +describe("one-shot CLI process exit", () => { + it.skipIf(Boolean(process.versions.bun))( + "drains background work before disposing the Node isolate", + () => { + const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href; + const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; + // Match nodejs/node#66171: immediate exit can join a V8 worker waiting + // for main-thread GC while libuv waits for an in-flight crypto job. + const script = ` + import { pbkdf2, pbkdf2Sync } from "node:crypto"; + import { writeSync } from "node:fs"; + import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)}; + import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; + const forceExit = process.exit; + process.exit = function (...args) { + writeSync(2, "FORCED_EXIT\\n"); + return Reflect.apply(forceExit, this, args); + }; + const keep = Array.from({ length: 1e5 }, (_, i) => ({ i })); + const start = performance.now(); + pbkdf2Sync("a", "b", 200000, 64, "sha512"); + const iterations = Math.ceil(200000 * 1000 / (performance.now() - start)); + setTimeout(async () => { + await runCliWithExitFinalization({ + run: async () => { + pbkdf2("a", "b", iterations, 64, "sha512", () => { + process.stdout.write("ASYNC_FINISHED\\n"); + }); + for (let i = 0; i < 40; i++) new Array(1e5).fill(i); + requestExitAfterOneShotOutput(defaultRuntime, 7); + }, + onError(error) { throw error; }, + }); + }, 300); + `; + const result = spawnSync( + process.execPath, + [ + "--stress-concurrent-allocation", + "--import", + "tsx", + "--input-type=module", + "--eval", + script, + ], + { encoding: "utf8", env: cliChildEnvironment(), timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(7); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("ASYNC_FINISHED\n"); + }, + ); + + it("drains large piped stdout before a requested nonzero exit", () => { + const env = cliChildEnvironment(); + const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href; + const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; + const payloadBytes = 1024 * 1024; + const script = ` + import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)}; + import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; + await runCliWithExitFinalization({ + run: async () => { + process.stdout.write("x".repeat(${payloadBytes})); + requestExitAfterOneShotOutput(defaultRuntime, 7); + }, + onError: (error) => { throw error; }, + }); + `; + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { + encoding: "utf8", + env, + maxBuffer: 2 * payloadBytes, + timeout: 30_000, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(7); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout).toHaveLength(payloadBytes); + }); + + it.each([ + { name: "deferred hooks success", exitCode: 0, explicitRequest: true }, + { name: "deferred hooks failure", exitCode: 1, explicitRequest: true }, + { name: "automatic macOS system-CA success", exitCode: 0, explicitRequest: false }, + ])("keeps real dual-TTY JSON clean for $name", ({ exitCode, explicitRequest }) => { + const env = cliChildEnvironment(); + const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href; + const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; + const loggingStateUrl = new URL("../logging/state.ts", import.meta.url).href; + const script = ` + import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)}; + import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; + import { loggingState } from ${JSON.stringify(loggingStateUrl)}; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + loggingState.forceConsoleToStderr = true; + await runCliWithExitFinalization({ + run: async () => { + defaultRuntime.writeStdout(JSON.stringify({ ok: ${exitCode === 0} })); + ${explicitRequest ? `requestExitAfterOneShotOutput(defaultRuntime, ${exitCode});` : ""} + }, + onError: (error) => { throw error; }, + ${explicitRequest ? "" : 'env: { NODE_USE_SYSTEM_CA: "1" }, execArgv: [], platform: "darwin", markers: {},'} + }); + `; + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", env, timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(exitCode); + expect(result.signal).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual({ ok: exitCode === 0 }); + expect(result.stderr).toContain("\x1b[?25h"); + }); + + it.each([ + { name: "fatal unhandled rejection", errorCode: "ERR_OUT_OF_MEMORY", exitCode: 1 }, + { name: "invalid configuration rejection", errorCode: "INVALID_CONFIG", exitCode: 78 }, + ])("keeps real dual-TTY JSON clean after $name", ({ errorCode, exitCode }) => { + const env = cliChildEnvironment(); + const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; + const loggingStateUrl = new URL("../logging/state.ts", import.meta.url).href; + const unhandledRejectionsUrl = new URL("../infra/unhandled-rejections.ts", import.meta.url) + .href; + const script = ` + import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; + import { loggingState } from ${JSON.stringify(loggingStateUrl)}; + import { installUnhandledRejectionHandler } from ${JSON.stringify(unhandledRejectionsUrl)}; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + loggingState.forceConsoleToStderr = true; + installUnhandledRejectionHandler(); + defaultRuntime.writeJson({ ok: false }); + const error = Object.assign(new Error("expected fatal test"), { + code: ${JSON.stringify(errorCode)}, + }); + process.emit("unhandledRejection", error, Promise.resolve()); + `; + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", env, timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(exitCode); + expect(result.signal).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual({ ok: false }); + expect(result.stderr).toContain("\x1b[?25h"); + }); +}); diff --git a/src/cli/one-shot-exit.test.ts b/src/cli/one-shot-exit.test.ts index 26706e21614..2974e0e91bb 100644 --- a/src/cli/one-shot-exit.test.ts +++ b/src/cli/one-shot-exit.test.ts @@ -1,5 +1,5 @@ -import { spawnSync } from "node:child_process"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as runtimeModule from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from "./one-shot-exit.js"; @@ -7,8 +7,21 @@ const successfulRun = async () => {}; const ignoreError = () => {}; describe("one-shot CLI exit", () => { + let previousExitCode: typeof process.exitCode; + + beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = undefined; + vi.useFakeTimers(); + vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); + vi.spyOn(runtimeModule, "restoreRuntimeTerminalState").mockImplementation(() => undefined); + }); + afterEach(() => { + vi.clearAllTimers(); + process.exitCode = previousExitCode; vi.restoreAllMocks(); + vi.useRealTimers(); }); it.each([ @@ -18,33 +31,31 @@ describe("one-shot CLI exit", () => { ["NODE_OPTIONS", { NODE_OPTIONS: "'--use-system-ca'" }, []], ["underscored NODE_OPTIONS", { NODE_OPTIONS: "--use_system_ca" }, []], ] as const)( - "exits after macOS system CA command completion from %s", + "allows natural macOS system CA command completion from %s", async (_label, env, execArgv) => { - const previousExitCode = process.exitCode; - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); - try { - process.exitCode = 3; - await runCliWithExitFinalization({ - run: successfulRun, - onError: ignoreError, - env: env as NodeJS.ProcessEnv, - execArgv, - platform: "darwin", - markers: {}, - }); - await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(3)); - } finally { - process.exitCode = previousExitCode; - } + process.exitCode = 3; + await runCliWithExitFinalization({ + run: successfulRun, + onError: ignoreError, + env: env as NodeJS.ProcessEnv, + execArgv, + platform: "darwin", + markers: {}, + }); + + expect(process.exitCode).toBe(3); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + expect(runtimeModule.restoreRuntimeTerminalState).toHaveBeenCalledExactlyOnceWith( + "runtime exit", + { resumeStdinIfPaused: false }, + ); }, ); it.each([ ["non-macOS", "linux" as const, { NODE_USE_SYSTEM_CA: "1" }, []], ["system CA disabled", "darwin" as const, { NODE_USE_SYSTEM_CA: "0" }, []], - ])("does not exit after completion when %s", async (_label, platform, env, execArgv) => { - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); - + ])("does not finalize after completion when %s", async (_label, platform, env, execArgv) => { await runCliWithExitFinalization({ run: successfulRun, onError: ignoreError, @@ -53,15 +64,14 @@ describe("one-shot CLI exit", () => { platform, markers: {}, }); - await new Promise((resolve) => { - setImmediate(resolve); - }); - expect(exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + expect(runtimeModule.restoreRuntimeTerminalState).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); }); it("does not finalize a long-lived command until its run promise settles", async () => { - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); let finishRun: (() => void) | undefined; const runPromise = runCliWithExitFinalization({ run: async () => @@ -75,73 +85,61 @@ describe("one-shot CLI exit", () => { markers: {}, }); - await new Promise((resolve) => { - setImmediate(resolve); - }); - expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5_000); + expect(process.exitCode).toBeUndefined(); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + expect(runtimeModule.restoreRuntimeTerminalState).not.toHaveBeenCalled(); finishRun?.(); await runPromise; - await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0)); + expect(process.exitCode).toBe(0); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + expect(runtimeModule.restoreRuntimeTerminalState).toHaveBeenCalledOnce(); }); - it("reports failures and replaces a pending successful exit before draining", async () => { - const previousExitCode = process.exitCode; + it("reports failures and replaces a pending successful exit before finalizing", async () => { const order: string[] = []; - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation((code) => { - order.push(`exit:${String(code)}`); + vi.mocked(runtimeModule.restoreRuntimeTerminalState).mockImplementation(() => { + order.push(`finalized:${String(process.exitCode)}`); }); + requestExitAfterOneShotOutput(defaultRuntime, 0); - try { - process.exitCode = undefined; - requestExitAfterOneShotOutput(defaultRuntime, 0); - await runCliWithExitFinalization({ - run: async () => { - throw new Error("command failed"); - }, - onError: async () => { - await new Promise((resolve) => { - setImmediate(resolve); - }); - order.push("reported"); - process.exitCode = 6; - }, - env: { NODE_USE_SYSTEM_CA: "1" }, - execArgv: [], - platform: "darwin", - markers: {}, - }); + await runCliWithExitFinalization({ + run: async () => { + throw new Error("command failed"); + }, + onError: async () => { + await Promise.resolve(); + order.push("reported"); + process.exitCode = 6; + }, + env: { NODE_USE_SYSTEM_CA: "1" }, + execArgv: [], + platform: "darwin", + markers: {}, + }); - await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(6)); - expect(order).toEqual(["reported", "exit:6"]); - } finally { - process.exitCode = previousExitCode; - } + expect(process.exitCode).toBe(6); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + expect(order).toEqual(["reported", "finalized:6"]); }); it("normalizes a Node integer-string process exit code", async () => { - const previousExitCode = process.exitCode; - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); + process.exitCode = "9"; + await runCliWithExitFinalization({ + run: successfulRun, + onError: ignoreError, + env: { NODE_USE_SYSTEM_CA: "1" }, + execArgv: [], + platform: "darwin", + markers: {}, + }); - try { - process.exitCode = "9"; - await runCliWithExitFinalization({ - run: successfulRun, - onError: ignoreError, - env: { NODE_USE_SYSTEM_CA: "1" }, - execArgv: [], - platform: "darwin", - markers: {}, - }); - await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(9)); - } finally { - process.exitCode = previousExitCode; - } + expect(process.exitCode).toBe(9); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); }); it("preserves a command-specific exit code when system CA completion also requests exit", async () => { - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); - requestExitAfterOneShotOutput(defaultRuntime, 7); await runCliWithExitFinalization({ run: successfulRun, @@ -152,17 +150,16 @@ describe("one-shot CLI exit", () => { markers: {}, }); - await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(7)); + expect(process.exitCode).toBe(7); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); }); it("defers a requested exit until the outer finalizer", async () => { - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); - expect(requestExitAfterOneShotOutput(defaultRuntime, 2)).toBe(true); - await new Promise((resolve) => { - setImmediate(resolve); - }); - expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5_000); + expect(process.exitCode).toBeUndefined(); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + expect(runtimeModule.restoreRuntimeTerminalState).not.toHaveBeenCalled(); await runCliWithExitFinalization({ run: successfulRun, @@ -172,12 +169,12 @@ describe("one-shot CLI exit", () => { platform: "linux", markers: {}, }); - await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(2)); + expect(process.exitCode).toBe(2); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); }); it("does not request exits for embedded custom runtimes", async () => { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - expect(requestExitAfterOneShotOutput(runtime)).toBe(false); await runCliWithExitFinalization({ run: successfulRun, @@ -188,18 +185,16 @@ describe("one-shot CLI exit", () => { platform: "darwin", markers: {}, }); - await new Promise((resolve) => { - setImmediate(resolve); - }); + expect(process.exitCode).toBeUndefined(); expect(runtime.exit).not.toHaveBeenCalled(); + expect(runtimeModule.restoreRuntimeTerminalState).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); }); - it("suppresses exits inside Vitest workers but not spawned CLI children", async () => { - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); + it("suppresses finalization inside Vitest workers but not spawned CLI children", async () => { const inheritedTestEnv = { VITEST: "1", VITEST_WORKER_ID: "1" } as NodeJS.ProcessEnv; - - requestExitAfterOneShotOutput(defaultRuntime); + requestExitAfterOneShotOutput(defaultRuntime, 7); await runCliWithExitFinalization({ run: successfulRun, onError: ignoreError, @@ -208,9 +203,12 @@ describe("one-shot CLI exit", () => { platform: "linux", markers: { tinypoolState: {} }, }); - expect(exit).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + expect(runtimeModule.restoreRuntimeTerminalState).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); - requestExitAfterOneShotOutput(defaultRuntime); + requestExitAfterOneShotOutput(defaultRuntime, 7); await runCliWithExitFinalization({ run: successfulRun, onError: ignoreError, @@ -219,26 +217,13 @@ describe("one-shot CLI exit", () => { platform: "linux", markers: {}, }); - await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0)); + expect(process.exitCode).toBe(7); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + expect(runtimeModule.restoreRuntimeTerminalState).toHaveBeenCalledOnce(); }); - it("waits for stream callbacks even when writableLength is zero", async () => { - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); - vi.spyOn(process.stdout, "writableLength", "get").mockReturnValue(0); - vi.spyOn(process.stderr, "writableLength", "get").mockReturnValue(0); - - let flushStdout: (() => void) | undefined; - let flushStderr: (() => void) | undefined; - vi.spyOn(process.stdout, "write").mockImplementation(((...args: unknown[]) => { - flushStdout = args.find((arg): arg is () => void => typeof arg === "function"); - return true; - }) as typeof process.stdout.write); - vi.spyOn(process.stderr, "write").mockImplementation(((...args: unknown[]) => { - flushStderr = args.find((arg): arg is () => void => typeof arg === "function"); - return true; - }) as typeof process.stderr.write); - - requestExitAfterOneShotOutput(defaultRuntime); + it("forces the current exit code only when a completed command remains alive for five seconds", async () => { + requestExitAfterOneShotOutput(defaultRuntime, 5); await runCliWithExitFinalization({ run: successfulRun, onError: ignoreError, @@ -248,193 +233,11 @@ describe("one-shot CLI exit", () => { markers: {}, }); - expect(exit).not.toHaveBeenCalled(); - flushStdout?.(); - expect(exit).not.toHaveBeenCalled(); - flushStderr?.(); - expect(exit).not.toHaveBeenCalled(); - await new Promise((resolve) => { - setImmediate(resolve); - }); - expect(exit).toHaveBeenCalledWith(0); - }); - - it.each(["stdout", "stderr", "both"] as const)( - "finishes a completed command when %s drain callbacks never settle", - async (missing) => { - vi.useFakeTimers(); - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); - for (const name of ["stdout", "stderr"] as const) { - vi.spyOn(process[name], "write").mockImplementation(((...args: unknown[]) => { - if (missing !== "both" && missing !== name) { - args.find((arg): arg is () => void => typeof arg === "function")?.(); - } - return true; - }) as typeof process.stdout.write); - } - try { - requestExitAfterOneShotOutput(defaultRuntime, 5); - await runCliWithExitFinalization({ - run: successfulRun, - onError: ignoreError, - env: {}, - execArgv: [], - platform: "linux", - markers: {}, - }); - await vi.advanceTimersByTimeAsync(4_999); - expect(exit).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - expect(exit).toHaveBeenCalledExactlyOnceWith(5); - } finally { - vi.useRealTimers(); - } - }, - ); - - it("cancels the drain deadline after both stream callbacks settle", async () => { - vi.useFakeTimers(); - const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); - for (const stream of [process.stdout, process.stderr]) { - vi.spyOn(stream, "write").mockImplementation(((...args: unknown[]) => { - args.find((arg): arg is () => void => typeof arg === "function")?.(); - return true; - }) as typeof process.stdout.write); - } - try { - requestExitAfterOneShotOutput(defaultRuntime, 7); - await runCliWithExitFinalization({ - run: successfulRun, - onError: ignoreError, - env: {}, - execArgv: [], - platform: "linux", - markers: {}, - }); - await vi.runAllTimersAsync(); - expect(exit).toHaveBeenCalledExactlyOnceWith(7); - } finally { - vi.useRealTimers(); - } - }); - - it("drains large piped stdout before a requested nonzero exit", () => { - const env = { ...process.env }; - delete env.VITEST; - delete env.VITEST_POOL_ID; - delete env.VITEST_WORKER_ID; - const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href; - const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; - const payloadBytes = 1024 * 1024; - const script = ` - import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)}; - import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; - await runCliWithExitFinalization({ - run: async () => { - process.stdout.write("x".repeat(${payloadBytes})); - requestExitAfterOneShotOutput(defaultRuntime, 7); - }, - onError: (error) => { throw error; }, - }); - `; - - const result = spawnSync( - process.execPath, - ["--import", "tsx", "--input-type=module", "--eval", script], - { - encoding: "utf8", - env, - maxBuffer: 2 * payloadBytes, - timeout: 30_000, - }, - ); - - expect(result.error).toBeUndefined(); - expect(result.status).toBe(7); - expect(result.signal).toBeNull(); - expect(result.stderr).toBe(""); - expect(result.stdout).toHaveLength(payloadBytes); - }); - - it.each([ - { name: "deferred hooks success", exitCode: 0, explicitRequest: true }, - { name: "deferred hooks failure", exitCode: 1, explicitRequest: true }, - { name: "automatic macOS system-CA success", exitCode: 0, explicitRequest: false }, - ])("keeps real dual-TTY JSON clean for $name", ({ exitCode, explicitRequest }) => { - const env = { ...process.env }; - delete env.VITEST; - delete env.VITEST_POOL_ID; - delete env.VITEST_WORKER_ID; - const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href; - const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; - const loggingStateUrl = new URL("../logging/state.ts", import.meta.url).href; - const script = ` - import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)}; - import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; - import { loggingState } from ${JSON.stringify(loggingStateUrl)}; - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); - loggingState.forceConsoleToStderr = true; - await runCliWithExitFinalization({ - run: async () => { - defaultRuntime.writeStdout(JSON.stringify({ ok: ${exitCode === 0} })); - ${explicitRequest ? `requestExitAfterOneShotOutput(defaultRuntime, ${exitCode});` : ""} - }, - onError: (error) => { throw error; }, - ${explicitRequest ? "" : 'env: { NODE_USE_SYSTEM_CA: "1" }, execArgv: [], platform: "darwin", markers: {},'} - }); - `; - - const result = spawnSync( - process.execPath, - ["--import", "tsx", "--input-type=module", "--eval", script], - { encoding: "utf8", env, timeout: 30_000 }, - ); - - expect(result.error).toBeUndefined(); - expect(result.status).toBe(exitCode); - expect(result.signal).toBeNull(); - expect(JSON.parse(result.stdout)).toEqual({ ok: exitCode === 0 }); - expect(result.stderr).toContain("\x1b[?25h"); - }); - - it.each([ - { name: "fatal unhandled rejection", errorCode: "ERR_OUT_OF_MEMORY", exitCode: 1 }, - { name: "invalid configuration rejection", errorCode: "INVALID_CONFIG", exitCode: 78 }, - ])("keeps real dual-TTY JSON clean after $name", ({ errorCode, exitCode }) => { - const env = { ...process.env }; - delete env.VITEST; - delete env.VITEST_POOL_ID; - delete env.VITEST_WORKER_ID; - const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; - const loggingStateUrl = new URL("../logging/state.ts", import.meta.url).href; - const unhandledRejectionsUrl = new URL("../infra/unhandled-rejections.ts", import.meta.url) - .href; - const script = ` - import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; - import { loggingState } from ${JSON.stringify(loggingStateUrl)}; - import { installUnhandledRejectionHandler } from ${JSON.stringify(unhandledRejectionsUrl)}; - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); - loggingState.forceConsoleToStderr = true; - installUnhandledRejectionHandler(); - defaultRuntime.writeJson({ ok: false }); - const error = Object.assign(new Error("expected fatal test"), { - code: ${JSON.stringify(errorCode)}, - }); - process.emit("unhandledRejection", error, Promise.resolve()); - `; - - const result = spawnSync( - process.execPath, - ["--import", "tsx", "--input-type=module", "--eval", script], - { encoding: "utf8", env, timeout: 30_000 }, - ); - - expect(result.error).toBeUndefined(); - expect(result.status).toBe(exitCode); - expect(result.signal).toBeNull(); - expect(JSON.parse(result.stdout)).toEqual({ ok: false }); - expect(result.stderr).toContain("\x1b[?25h"); + expect(process.exitCode).toBe(5); + await vi.advanceTimersByTimeAsync(4_999); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + process.exitCode = 6; + await vi.advanceTimersByTimeAsync(1); + expect(defaultRuntime.exit).toHaveBeenCalledExactlyOnceWith(6); }); }); diff --git a/src/cli/one-shot-exit.ts b/src/cli/one-shot-exit.ts index 23c9ff924bf..e2cfaa7650b 100644 --- a/src/cli/one-shot-exit.ts +++ b/src/cli/one-shot-exit.ts @@ -1,5 +1,5 @@ import type { RuntimeEnv } from "../runtime.js"; -import { defaultRuntime } from "../runtime.js"; +import { defaultRuntime, restoreRuntimeTerminalState } from "../runtime.js"; type VitestWorkerMarkers = { tinypoolState?: unknown; @@ -7,7 +7,7 @@ type VitestWorkerMarkers = { }; const SYSTEM_CA_FLAG = "--use-system-ca"; -const ONE_SHOT_EXIT_DRAIN_TIMEOUT_MS = 5_000; +const ONE_SHOT_EXIT_TIMEOUT_MS = 5_000; let requestedExitCode: number | "process" | undefined; @@ -132,24 +132,11 @@ function flushExitAfterOneShotOutput( return; } - const exit = () => - runtime.exit(requestedCode === "process" ? resolveProcessExitCode() : requestedCode); - let pendingStreams = 2; + process.exitCode = requestedCode === "process" ? resolveProcessExitCode() : requestedCode; + restoreRuntimeTerminalState("runtime exit", { resumeStdinIfPaused: false }); - // A missing pipe callback must not leave a completed one-shot command alive forever. - const fallback = setTimeout(exit, ONE_SHOT_EXIT_DRAIN_TIMEOUT_MS); - fallback.unref(); - - const drain = (stream: NodeJS.WriteStream) => { - stream.write("", () => { - pendingStreams -= 1; - if (pendingStreams === 0) { - clearTimeout(fallback); - setImmediate(exit); - } - }); - }; - - drain(process.stdout); - drain(process.stderr); + // Output callbacks do not mean native work has drained. Natural exit lets Node + // dispose it safely; forcing early can deadlock V8 worker shutdown (nodejs/node#64274). + // Retained handles still get the existing deadline; later failures may update the code. + setTimeout(() => runtime.exit(resolveProcessExitCode()), ONE_SHOT_EXIT_TIMEOUT_MS).unref(); } diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index c71b35d841b..9e9e35a1ffe 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../config/config.js"; import { hashConfigIncludeRaw } from "../config/includes.js"; import { @@ -44,6 +45,7 @@ import { } from "./plugins-cli-test-helpers.js"; const CLI_STATE_ROOT = "/tmp/openclaw-state"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); const ORIGINAL_OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR; const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE; const ORIGINAL_STDIN_TTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); @@ -2071,7 +2073,12 @@ describe("plugins cli install", () => { it("uses bundled OpenClaw package specs instead of pinning stale managed npm overrides", async () => { primeSuccessfulPluginPersistence("discord"); - const bundledPath = "/app/dist/extensions/discord"; + const bundledPath = path.join( + tempDirs.make("openclaw-bundled-package-"), + "dist", + "extensions", + "discord", + ); findBundledPluginSourceMock.mockImplementation((params: unknown) => { const { lookup } = params as { lookup: { kind: "pluginId" | "npmSpec"; value: string }; diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 2d6476d6495..1928dfd15c2 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -1366,67 +1366,70 @@ describe("chat directive tag stripping for non-streaming final payloads", () => } }); - it("rejects a copied exact leaf from the session before a branch switch", async () => { - await createGatewayUserTurnSqliteFixture("openclaw-chat-send-rotated-exact-leaf-"); - await appendTranscriptMessage(transcriptScope(), { - eventId: "branch-root", - message: { role: "user", content: "root" }, - now: 1, - parentId: null, - }); - await appendTranscriptMessage(transcriptScope(), { - eventId: "copied-leaf", - message: { role: "assistant", content: "selected branch" }, - now: 2, - parentId: "branch-root", - }); - await appendTranscriptMessage(transcriptScope(), { - eventId: "active-sibling", - message: { role: "assistant", content: "active branch" }, - now: 3, - parentId: "branch-root", - }); - await waitForSessionTranscriptIndexReconcile({ - agentId: "main", - env: suiteFixtureEnv, - path: suiteDatabasePath, - }); - const staleSessionId = mockState.sessionId; - const switched = await switchSessionBranch({ - agentId: "main", - env: suiteFixtureEnv, - leafEntryId: "copied-leaf", - sessionKey: "agent:main:main", - storePath: suiteDatabasePath, - }); - expect(switched.status).toBe("created"); - if (switched.status !== "created") { - throw new Error("expected branch switch test invariant"); - } - expect(switched.entry.sessionId).not.toBe(staleSessionId); - mockState.sessionId = switched.entry.sessionId; - const before = loadTranscriptEventsSync(transcriptScope()); - expect(resolveSessionTranscriptActiveLeafEntryId(before)).toBe("copied-leaf"); - const { context, respond, send } = createChatRequestFixture(); + it.each(["copied-leaf", null])( + "rejects stale cursor %s from before a branch switch", + async (expectedLeafEntryId) => { + await createGatewayUserTurnSqliteFixture("openclaw-chat-send-rotated-exact-leaf-"); + await appendTranscriptMessage(transcriptScope(), { + eventId: "branch-root", + message: { role: "user", content: "root" }, + now: 1, + parentId: null, + }); + await appendTranscriptMessage(transcriptScope(), { + eventId: "copied-leaf", + message: { role: "assistant", content: "selected branch" }, + now: 2, + parentId: "branch-root", + }); + await appendTranscriptMessage(transcriptScope(), { + eventId: "active-sibling", + message: { role: "assistant", content: "active branch" }, + now: 3, + parentId: "branch-root", + }); + await waitForSessionTranscriptIndexReconcile({ + agentId: "main", + env: suiteFixtureEnv, + path: suiteDatabasePath, + }); + const staleSessionId = mockState.sessionId; + const switched = await switchSessionBranch({ + agentId: "main", + env: suiteFixtureEnv, + leafEntryId: "copied-leaf", + sessionKey: "agent:main:main", + storePath: suiteDatabasePath, + }); + expect(switched.status).toBe("created"); + if (switched.status !== "created") { + throw new Error("expected branch switch test invariant"); + } + expect(switched.entry.sessionId).not.toBe(staleSessionId); + mockState.sessionId = switched.entry.sessionId; + const before = loadTranscriptEventsSync(transcriptScope()); + expect(resolveSessionTranscriptActiveLeafEntryId(before)).toBe("copied-leaf"); + const { context, respond, send } = createChatRequestFixture(); - await send({ - idempotencyKey: "idem-rotated-exact-leaf", - requestParams: { - expectedLeafEntryId: "copied-leaf", - sessionId: staleSessionId, - }, - waitFor: "none", - }); + await send({ + idempotencyKey: "idem-rotated-exact-leaf", + requestParams: { + expectedLeafEntryId, + sessionId: staleSessionId, + }, + waitFor: "none", + }); - expect(lastRespondCall(respond)).toEqual([ - false, - undefined, - expect.objectContaining({ details: { reason: "active-leaf-changed" } }), - ]); - expect(context.addChatRun).not.toHaveBeenCalled(); - expect(mockState.lastDispatchCtx).toBeUndefined(); - expect(loadTranscriptEventsSync(transcriptScope())).toEqual(before); - }); + expect(lastRespondCall(respond)).toEqual([ + false, + undefined, + expect.objectContaining({ details: { reason: "active-leaf-changed" } }), + ]); + expect(context.addChatRun).not.toHaveBeenCalled(); + expect(mockState.lastDispatchCtx).toBeUndefined(); + expect(loadTranscriptEventsSync(transcriptScope())).toEqual(before); + }, + ); it("rejects an expected sibling that is off the active path", async () => { await createGatewayUserTurnSqliteFixture("openclaw-chat-send-off-path-sibling-"); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index f762ad6118c..f93c26965c7 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -2724,12 +2724,15 @@ describe("scripts/test-projects changed-target routing", () => { ]); }); - it("routes CLI process tests through their isolated project", () => { - expectSingleVitestRunPlan(buildVitestRunPlans(["src/cli/help-exit.process.test.ts"]), { - config: "test/vitest/vitest.cli-process.config.ts", - includePatterns: ["src/cli/help-exit.process.test.ts"], - }); - }); + it.each(["src/cli/help-exit.process.test.ts", "src/cli/one-shot-exit.process.test.ts"])( + "routes %s through the isolated CLI process project", + (file) => { + expectSingleVitestRunPlan(buildVitestRunPlans([file]), { + config: "test/vitest/vitest.cli-process.config.ts", + includePatterns: [file], + }); + }, + ); it("adds the CLI process project for broad CLI targets", () => { const plans = buildVitestRunPlans(["src/cli"]); diff --git a/test/vitest/vitest.cli-process-paths.mjs b/test/vitest/vitest.cli-process-paths.mjs index d186ae44c8e..bf155b2ee6e 100644 --- a/test/vitest/vitest.cli-process-paths.mjs +++ b/test/vitest/vitest.cli-process-paths.mjs @@ -5,6 +5,7 @@ export const cliProcessTestFiles = [ "src/cli/gateway-backed-exit.process.test.ts", "src/cli/help-exit.process.test.ts", "src/cli/hooks-cli.process.test.ts", + "src/cli/one-shot-exit.process.test.ts", ]; const cliProcessTestFileSet = new Set(cliProcessTestFiles); diff --git a/ui/src/e2e/chat-history-send-sync.e2e.test.ts b/ui/src/e2e/chat-history-send-sync.e2e.test.ts new file mode 100644 index 00000000000..0e26ca854e3 --- /dev/null +++ b/ui/src/e2e/chat-history-send-sync.e2e.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from "vitest"; +import { + createChatFlowE2eSuite, + installMockGateway, + requireRecord, + requireString, + waitForRequests, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("recovers a follow-up after the first response history read fails", async () => { + const context = await suite.newBrowserContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const sessionId = "control-ui-e2e-session"; + const gateway = await installMockGateway(page, { + historyMessages: [], + sessionInfo: { key: "main", sessionId, activeLeafEntryId: null }, + }); + try { + await page.goto(`${suite.server.baseUrl}chat`); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.fill("first request"); + await page.getByRole("button", { name: "Send message" }).click(); + const first = requireRecord((await gateway.waitForRequest("chat.send")).params); + expect(first.expectedLeafEntryId).toBeNull(); + const runId = requireString(first.idempotencyKey, "first run"); + const previousReads = (await gateway.getRequests("chat.history")).length; + await gateway.deferNext("chat.history"); + await gateway.emitGatewayEvent("session.message", { + key: "main", + sessionKey: "main", + clientRunId: runId, + hasActiveRun: false, + session: { + key: "main", + kind: "direct", + status: "done", + hasActiveRun: false, + activeRunIds: [], + }, + }); + await gateway.emitChatFinal({ runId, text: "first response" }); + await waitForRequests(gateway, "chat.history", previousReads + 1); + await gateway.rejectDeferred("chat.history", { + code: "INVALID_REQUEST", + message: "history temporarily unavailable", + }); + await page + .getByText(/history temporarily unavailable/) + .first() + .waitFor(); + + await gateway.setMethodResponse("chat.history", { + messages: [{ role: "assistant", content: [{ type: "text", text: "first response" }] }], + sessionId, + sessionInfo: { key: "main", sessionId, activeLeafEntryId: "first-response" }, + }); + await composer.fill("next request"); + await page.getByRole("button", { name: "Send message" }).click(); + const requests = await waitForRequests(gateway, "chat.send", 2); + expect(requireRecord(requests[1]?.params)).toMatchObject({ + message: "next request", + expectedLeafEntryId: "first-response", + sessionId, + }); + expect((await gateway.getRequests("chat.history")).length).toBeGreaterThan(previousReads + 1); + expect( + await page + .getByText("The thread switched branches — review and resend.", { exact: true }) + .count(), + ).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/pages/chat/chat-history.test.ts b/ui/src/pages/chat/chat-history.test.ts index fa6b5b82301..41fd75e002f 100644 --- a/ui/src/pages/chat/chat-history.test.ts +++ b/ui/src/pages/chat/chat-history.test.ts @@ -8,6 +8,7 @@ import { } from "./chat-history.test-support.ts"; import { loadChatHistory, + needsChatHistoryRefresh, rewindChatHistory, syncSelectedSessionMessageSubscription, switchChatHistoryBranch, @@ -45,6 +46,50 @@ function activeHistory( } satisfies ChatHistoryResult; } +describe("failed chat history reconciliation", () => { + it("keeps failure until an authoritative read succeeds without changing the displayed cursor", async () => { + const state = createState({ messages: [] }); + state.chatDisplayedLeafEntryId = null; + const request = vi.spyOn(state.client!, "request"); + request.mockRejectedValueOnce(new Error("history unavailable")); + + await loadChatHistory(state, { deferBranches: true }); + expect(state.chatLoading).toBe(false); + expect(state.chatDisplayedLeafEntryId).toBeNull(); + expect(needsChatHistoryRefresh(state)).toBe(true); + + await loadChatHistory(state, { deferBranches: true }); + expect(needsChatHistoryRefresh(state)).toBe(false); + }); + + it.each(["pane", "session", "connection", "agent"])( + "does not carry a failure into another %s", + async (scope) => { + const state = createState({ messages: [] }); + state.sessionKey = "global"; + state.assistantAgentId = "main"; + vi.spyOn(state.client!, "request").mockRejectedValueOnce(new Error("history unavailable")); + await loadChatHistory(state, { deferBranches: true }); + expect(needsChatHistoryRefresh(state)).toBe(true); + + if (scope === "pane") { + expect(needsChatHistoryRefresh({ ...state })).toBe(false); + return; + } + if (scope === "session") { + state.sessionKey = "agent:main:other"; + } + if (scope === "connection") { + state.connectionEpoch += 1; + } + if (scope === "agent") { + state.assistantAgentId = "other"; + } + expect(needsChatHistoryRefresh(state)).toBe(false); + }, + ); +}); + describe("syncSelectedSessionMessageSubscription", () => { it("requests and adopts approval replay when the Gateway advertises session approvals", async () => { const replay = { diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index bc5312152fd..87e25175cdb 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -108,6 +108,7 @@ type ChatHistoryPaneRequests = { subscriptionGeneration: number; pendingSubscriptionReleases: Set; inFlightHistory?: InFlightChatHistoryRequest; + failedHistory?: ChatHistoryRequestOwnership; }; const chatHistoryPaneRequests = new WeakMap(); @@ -178,6 +179,7 @@ function resetChatHistoryProjection(state: ChatState, agentId?: string): void { // snapshot owner and its coalesced request before creating the next epoch. requests.historyVersion += 1; requests.inFlightHistory = undefined; + requests.failedHistory = undefined; state.chatLoading = false; const scope = readChatSessionProjectionScope(state, { agentId }); // Destructive operations keep the public session key, so only an explicit @@ -1493,6 +1495,23 @@ export async function loadChatHistory( return promise; } +/** Failed reconciliation must be recovered before a foreground send uses its old cursor. */ +export function needsChatHistoryRefresh(state: ChatState): boolean { + const failed = getChatHistoryPaneRequests(state).failedHistory; + return ( + state.chatLoading || + Boolean( + failed && + state.connected && + state.client === failed.client && + state.connectionEpoch === failed.connectionEpoch && + state.sessionKey === failed.sessionKey && + (!isUiSelectedGlobalSessionKey(state, failed.sessionKey) || + resolveUiSelectedSessionAgentId(state) === failed.agentId), + ) + ); +} + /** Initial hydration commits before any cached or fetched search hit is revealed. */ export async function waitForPendingChatHistory(state: ChatState): Promise { await getChatHistoryPaneRequests(state).inFlightHistory?.promise; @@ -1908,6 +1927,7 @@ async function loadChatHistoryUncached( visibleMessageCount: visibleMessages.length, resetStream, }); + getChatHistoryPaneRequests(state).failedHistory = undefined; return res; } catch (err) { if (!shouldApplyChatHistoryResult(state, ownership)) { @@ -1933,6 +1953,9 @@ async function loadChatHistoryUncached( state.chatVerboseLevel = null; setChatError(state, formatMissingOperatorReadScopeMessage("existing chat history")); } else { + // Loading ending does not make the retained transcript authoritative. + // Keep the failed read scoped to this pane, connection, and selected thread. + getChatHistoryPaneRequests(state).failedHistory = ownership; setChatError(state, String(err)); } } finally { diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index d4fb4b93400..74470e6ff44 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -19,7 +19,7 @@ import { requireChatSessionAction, shouldQueueLocalSlashCommand, } from "./chat-commands.ts"; -import { loadChatHistory, type ChatState } from "./chat-history.ts"; +import { loadChatHistory, needsChatHistoryRefresh, type ChatState } from "./chat-history.ts"; import { admitQueuedMessageForSession, enqueueChatMessage, @@ -211,6 +211,8 @@ export async function handleSendChat( const message = (messageOverride ?? host.chatMessage).trim(); const submittedAtMs = controlUiNowMs(); const submittedSessionKey = host.sessionKey; + const submittedAgentId = resolveStoredChatOutboxScope(host, submittedSessionKey).agentId; + const submittedSessionId = (host as unknown as ChatState).currentSessionId; const actionScope = opts?.actionScope; const actionConnectionIsCurrent = captureChatConnectionOwner(host); let expectedLeafEntryId = actionScope @@ -433,12 +435,27 @@ export async function handleSendChat( opts?.runId ?? chatSubmitKey(host, "message", effectiveMessage, attachmentsToSend, skillWorkshopRevision); await withChatSubmitGuard(host, submitKey, async () => { - if (host.chatLoading) { - // A terminal event can render before its authoritative leaf arrives. - // Reuse the in-flight history request before fencing the follow-up send. + if (needsChatHistoryRefresh(host as unknown as ChatState)) { + // Terminal delivery can precede its history read, which can also fail. + // Reuse or recover that read before fencing the follow-up send. if (!(await loadChatHistory(host as unknown as ChatState))) { return; } + if ( + !actionConnectionIsCurrent() || + !visibleSessionMatches(host, submittedSessionKey, submittedAgentId) + ) { + return; + } + // A branch can rotate transcript identity while keeping the same route. + // Never move the submitted draft into that replacement transcript. + if ( + submittedSessionId && + submittedSessionId !== (host as unknown as ChatState).currentSessionId + ) { + setChatError(host, t("chat.sendErrors.activeLeafChanged")); + return; + } if (!actionScope) { expectedLeafEntryId = resolveDisplayedLeafEntryId(host as unknown as ChatState); } diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 2a04004c134..f734c26e2f4 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -7621,42 +7621,136 @@ describe("handleSendChat", () => { ).toHaveProperty("expectedLeafEntryId", null); }); - it("waits for terminal history reconciliation before sending a follow-up", async () => { - const history = createDeferred(); + it.each([false, true])( + "waits for terminal history reconciliation after a failed read: %s", + async (failedRead) => { + const history = createDeferred(); + let failHistory = failedRead; + const host = makeHost({ + requestHandlers: { + "chat.history": () => { + if (failHistory) { + throw new Error("history temporarily unavailable"); + } + return history.promise; + }, + "chat.send": (params: unknown) => { + const payload = requireRecord(params, "reconciled follow-up payload"); + return { runId: payload.idempotencyKey, status: "started" }; + }, + }, + chatDisplayedLeafEntryId: failedRead ? null : "leaf-before-terminal", + chatMessage: "immediate follow-up", + }); + if (failedRead) { + await loadChatHistory(host as unknown as Parameters[0]); + expect(host.chatLoading).toBe(false); + failHistory = false; + } + const historyLoad = failedRead + ? undefined + : loadChatHistory(host as unknown as Parameters[0]); + + const sending = handleSendChat(host); + await Promise.resolve(); + const sendsBeforeHistory = host.request.mock.calls.filter( + ([method]) => method === "chat.send", + ).length; + history.resolve({ + messages: [], + sessionInfo: { + activeLeafEntryId: "leaf-after-terminal", + key: "agent:main", + sessionId: "session-main", + }, + }); + await Promise.all([historyLoad, sending]); + + expect(sendsBeforeHistory).toBe(0); + expect( + findRequestPayload( + host.request as unknown as MockCallSource, + "chat.send", + "follow-up send", + ), + ).toMatchObject({ expectedLeafEntryId: "leaf-after-terminal" }); + }, + ); + + it("preserves the composer when a failed history refresh still cannot recover", async () => { + const attachment = { + id: "draft-image", + mimeType: "image/png", + dataUrl: "data:image/png;base64,cG5n", + }; const host = makeHost({ requestHandlers: { - "chat.history": () => history.promise, - "chat.send": (params: unknown) => { - const payload = requireRecord(params, "reconciled follow-up payload"); - return { runId: payload.idempotencyKey, status: "started" }; + "chat.history": () => { + throw new Error("history temporarily unavailable"); }, }, - chatDisplayedLeafEntryId: "leaf-before-terminal", - chatMessage: "immediate follow-up", + chatDisplayedLeafEntryId: null, + chatMessage: "keep this draft", + chatAttachments: [attachment], }); - const historyLoad = loadChatHistory(host as unknown as Parameters[0]); + await loadChatHistory(host as unknown as Parameters[0]); - const sending = handleSendChat(host); - await Promise.resolve(); - const sendsBeforeHistory = host.request.mock.calls.filter( - ([method]) => method === "chat.send", - ).length; - history.resolve({ - messages: [], - sessionInfo: { - activeLeafEntryId: "leaf-after-terminal", - key: "agent:main", - sessionId: "session-main", - }, - }); - await Promise.all([historyLoad, sending]); + await handleSendChat(host); - expect(sendsBeforeHistory).toBe(0); - expect( - findRequestPayload(host.request as unknown as MockCallSource, "chat.send", "follow-up send"), - ).toMatchObject({ expectedLeafEntryId: "leaf-after-terminal" }); + expect(host.request.mock.calls.filter(([method]) => method === "chat.history")).toHaveLength(2); + expect(host.request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(0); + expect(host.chatMessage).toBe("keep this draft"); + expect(host.chatAttachments).toEqual([attachment]); + expect(host.chatQueue).toEqual([]); + expect(host.chatError).toContain("history temporarily unavailable"); }); + it.each(["session", "connection", "agent", "branch"])( + "does not send across a %s change while reconciling history", + async (change) => { + const history = createDeferred(); + const host = makeHost({ + requestHandlers: { "chat.history": () => history.promise }, + sessionKey: "global", + assistantAgentId: "main", + chatMessage: "stay with this conversation", + chatDisplayedLeafEntryId: "old-leaf", + connectionEpoch: 0, + }); + const state = host as unknown as Parameters[0]; + state.currentSessionId = "original-session"; + const loading = loadChatHistory(state); + const sending = handleSendChat(host); + await Promise.resolve(); + + if (change === "session") { + host.sessionKey = "agent:main:other"; + } + if (change === "connection") { + host.connectionEpoch = 1; + } + if (change === "agent") { + host.assistantAgentId = "other"; + } + history.resolve({ + messages: [], + sessionInfo: { + key: "global", + sessionId: change === "branch" ? "replacement-session" : "original-session", + activeLeafEntryId: "new-leaf", + }, + }); + await Promise.all([loading, sending]); + + expect(host.request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(0); + expect(host.chatMessage).toBe("stay with this conversation"); + expect(host.chatQueue).toEqual([]); + if (change === "branch") { + expect(host.chatError).toBe("The thread switched branches — review and resend."); + } + }, + ); + it("omits the active leaf when draining a restored outbox", async () => { const host = makeHost({ client: null,