diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 5d5c6daec..e8d445e59 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -1690,12 +1690,37 @@ function AppInner({ }, [session, loop, codeMode, syncPendingCount, log, pendingEdits, startupInfoHints, system]); // Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key. - const quitProcess = useQuit(transcriptRef); + const sessionStartTimeRef = useRef(Date.now()); + + const fireSessionEndHook = useCallback(async () => { + if (hookList.some((h) => h.event === "SessionEnd")) { + try { + await runHooks({ + hooks: hookList, + payload: { + event: "SessionEnd", + cwd: currentRootDir, + sessionName: session ?? undefined, + sessionDurationMs: Date.now() - sessionStartTimeRef.current, + totalCostUsd: summary.totalCostUsd, + }, + }); + } catch { + // SessionEnd must not block exit + } + } + }, [hookList, currentRootDir, session, summary.totalCostUsd]); + + const quitProcess = useQuit(transcriptRef, { beforeQuit: fireSessionEndHook }); + + const quitWithSessionEnd = useCallback(() => { + quitProcess(); + }, [quitProcess]); // Ctrl+D = standard TUI exit (matches the boot-banner hint). Always-on // — no modal / picker should swallow it. useKeystroke((ev) => { - if (ev.ctrl && ev.input === "d") quitProcess(); + if (ev.ctrl && ev.input === "d") quitWithSessionEnd(); }); // Ctrl+R = verbose toggle. ReasoningCard / ToolCard skip elision while on. @@ -1792,7 +1817,7 @@ function AppInner({ isLoopActive, stopLoop, loop, - quitProcess, + quitProcess: quitWithSessionEnd, }); return; } @@ -1820,7 +1845,7 @@ function AppInner({ isLoopActive, stopLoop, loop, - quitProcess, + quitProcess: quitWithSessionEnd, }); return; } @@ -3222,7 +3247,7 @@ function AppInner({ codeModeOn: !!codeMode, isLoopActive, stopLoop, - quitProcess, + quitProcess: quitWithSessionEnd, pushHistory, resetPendingModals, text, @@ -3627,7 +3652,7 @@ function AppInner({ codeShowEdit, codeUndo, currentRootDir, - quitProcess, + quitWithSessionEnd, hookList, loop, latestVersion, diff --git a/src/cli/ui/hooks/useQuit.ts b/src/cli/ui/hooks/useQuit.ts index 1022953a8..a4509282e 100644 --- a/src/cli/ui/hooks/useQuit.ts +++ b/src/cli/ui/hooks/useQuit.ts @@ -1,16 +1,32 @@ import type { WriteStream } from "node:fs"; -import { type MutableRefObject, useCallback, useEffect } from "react"; +import { type MutableRefObject, useCallback, useEffect, useRef } from "react"; import { stopAndSaveCpuProfile } from "../../cpu-prof.js"; +export interface UseQuitOptions { + beforeQuit?: () => Promise | void; +} + /** Ctrl+C / SIGINT → flush transcript + (if profiling) save .cpuprofile, then `process.exit(0)`. We call `process.exit` directly rather than Ink's `exit()` because the singleton stdin reader keeps a `data` listener attached — `exit()` would unmount the React tree but leave the event loop alive and the terminal would hang. */ -export function useQuit(transcriptRef: MutableRefObject): () => void { +export function useQuit( + transcriptRef: MutableRefObject, + opts?: UseQuitOptions, +): () => void { + const quittingRef = useRef(false); + const quitProcess = useCallback(() => { + if (quittingRef.current) return; + quittingRef.current = true; transcriptRef.current?.end(); void (async () => { + try { + await opts?.beforeQuit?.(); + } catch { + // beforeQuit must not block or crash the exit path + } await stopAndSaveCpuProfile(); process.exit(0); })(); - }, [transcriptRef]); + }, [transcriptRef, opts?.beforeQuit]); useEffect(() => { process.on("SIGINT", quitProcess); diff --git a/src/core/events.ts b/src/core/events.ts index 8a41709a5..37ceecf7c 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -156,7 +156,18 @@ export interface CheckpointRestoredEvent extends EventBase { export interface HookFiredEvent extends EventBase { type: "hook.fired"; hookName: string; - phase: "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop"; + /** Must match HookEvent type in src/hooks.ts — keep in sync (V-TE-05). */ + phase: + | "SessionStart" + | "TurnStart" + | "PreToolUse" + | "PostToolUse" + | "UserPromptSubmit" + | "PreModelCall" + | "PostModelCall" + | "TurnEnd" + | "Stop" + | "SessionEnd"; outcome: "ok" | "blocked" | "modified" | "error"; } diff --git a/src/hooks.ts b/src/hooks.ts index b6d9867ea..e2c4941f3 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -7,25 +7,55 @@ import { join } from "node:path"; import { projectHooksTrusted } from "./config.js"; import { t } from "./i18n/index.js"; -export type HookEvent = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop"; - -/** All four events as a const array — drives slash listing + validation. */ +export type HookEvent = + | "SessionStart" + | "TurnStart" + | "PreToolUse" + | "PostToolUse" + | "UserPromptSubmit" + | "PreModelCall" + | "PostModelCall" + | "TurnEnd" + | "Stop" + | "SessionEnd"; + +/** All ten events as a const array — drives slash listing + validation. */ export const HOOK_EVENTS: readonly HookEvent[] = [ + "SessionStart", + "TurnStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", + "PreModelCall", + "PostModelCall", + "TurnEnd", "Stop", + "SessionEnd", ] as const; /** Only the gating events can block the loop. */ -const BLOCKING_EVENTS: ReadonlySet = new Set(["PreToolUse", "UserPromptSubmit"]); +export const BLOCKING_EVENTS: ReadonlySet = new Set([ + "PreToolUse", + "UserPromptSubmit", + "TurnStart", + "PreModelCall", + "TurnEnd", +]); /** Per-event default timeout. Tool/prompt hooks gate progress, so they're tight. */ const DEFAULT_TIMEOUTS_MS: Record = { + SessionStart: 10_000, + TurnStart: 5_000, PreToolUse: 5_000, - UserPromptSubmit: 5_000, PostToolUse: 30_000, + UserPromptSubmit: 5_000, + PreModelCall: 10_000, + PostModelCall: 30_000, + // 10s is enough for a lightweight audit check; 30s × 3-strike = 90s worst-case + // wasted wait if a hook misbehaves (V-TE-04). + TurnEnd: 10_000, Stop: 30_000, + SessionEnd: 10_000, }; export type HookScope = "project" | "global"; @@ -176,6 +206,21 @@ export interface HookPayload { lastAssistantText?: string; last_assistant_message?: string; turn?: number; + + sessionName?: string; + sessionDurationMs?: number; + totalCostUsd?: number; + + modelMessages?: ReadonlyArray<{ role: string; content: string }>; + model?: string; + + modelResponse?: { role: string; content: string; reasoning_content?: string }; + usage?: { + promptTokens: number; + completionTokens: number; + cacheHitTokens: number; + cacheMissTokens: number; + }; } /** Test seam — same shape as Node's spawn but returns a Promise of the raw outcome bits. */ @@ -276,6 +321,10 @@ function defaultSpawner(input: HookSpawnInput): Promise { }); }); + // EPIPE when child closes stdin before parent finishes writing — + // safe to ignore (close handler fires with the correct exit code). + child.stdin.on("error", () => {}); + try { child.stdin.write(input.stdin); child.stdin.end(); diff --git a/src/loop.ts b/src/loop.ts index 40fd0ee1e..a69baa7d7 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -2,7 +2,12 @@ import { type DeepSeekClient, Usage } from "./client.js"; import type { ReasoningEffort } from "./config.js"; import type { PauseGate } from "./core/pause-gate.js"; import { pauseGate as defaultPauseGate } from "./core/pause-gate.js"; -import { type HookPayload, type ResolvedHook, runHooks } from "./hooks.js"; +import { + type HookPayload, + type ResolvedHook, + formatHookOutcomeMessage, + runHooks, +} from "./hooks.js"; import { DEFAULT_MAX_RESULT_CHARS, DEFAULT_MAX_RESULT_TOKENS, @@ -209,6 +214,9 @@ export class CacheFirstLoop { private _turnSelfCorrected = false; private _foldedThisTurn = false; + private _preModelBlockCount = 0; + private _turnEndBlockCount = 0; + private _sessionStartExecuted = false; private context!: ContextManager; /** Subscribe API so UI hooks can derive `running` from finally-guaranteed insertions. */ @@ -714,6 +722,80 @@ export class CacheFirstLoop { this._turn++; this.scratch.reset(); + + if (!this._sessionStartExecuted) { + this._sessionStartExecuted = true; + if (this.hooks.some((h) => h.event === "SessionStart")) { + try { + const sessionStartReport = await runHooks({ + hooks: this.hooks, + payload: { + event: "SessionStart", + cwd: this.hookCwd, + sessionName: this.sessionName ?? undefined, + turn: 0, + }, + }); + const fragments = sessionStartReport.outcomes + .filter((o) => o.decision === "pass" && o.stdout.trim()) + .map((o) => o.stdout.trim().slice(0, 4096)); + if (fragments.length > 0) { + this.prefix.replaceSystem( + `${this.prefix.system}\n\n[Session context]\n${fragments.join("\n")}`, + ); + } + for (const o of sessionStartReport.outcomes) { + if (o.decision !== "pass") { + yield { + turn: this._turn, + role: "warning" as const, + content: formatHookOutcomeMessage(o), + }; + } + } + } catch (err) { + const msg = (err as Error).message ?? String(err); + console.warn(`[hooks] SessionStart error: ${msg}`); + yield { + turn: this._turn, + role: "warning" as const, + content: `[hooks] SessionStart error: ${msg}`, + }; + } + } + } + + if (this.hooks.some((h) => h.event === "TurnStart")) { + const turnStartReport = await runHooks({ + hooks: this.hooks, + payload: { + event: "TurnStart", + cwd: this.hookCwd, + turn: this._turn, + }, + }); + if (turnStartReport.blocked) { + const blocking = turnStartReport.outcomes[turnStartReport.outcomes.length - 1]; + const reason = (blocking?.stderr || blocking?.stdout || "TurnStart hook blocked").trim(); + yield { + turn: this._turn, + role: "warning" as const, + content: `[hook block] ${blocking?.hook.command ?? ""}\n${reason}`, + }; + this._steerQueue.length = 0; + return; + } + for (const o of turnStartReport.outcomes) { + if (o.decision !== "pass") { + yield { + turn: this._turn, + role: "warning" as const, + content: formatHookOutcomeMessage(o), + }; + } + } + } + // A fresh user turn is a new intent — don't let StormBreaker's // old sliding window of (name, args) signatures keep blocking // calls that are now legitimately on-task. The window repopulates @@ -721,6 +803,8 @@ export class CacheFirstLoop { this.repair.resetStorm(); this._turnSelfCorrected = false; this._foldedThisTurn = false; + this._preModelBlockCount = 0; + this._turnEndBlockCount = 0; // Fresh controller for this turn: the prior step's signal has // already fired (or stayed clean); either way we don't want its // state to bleed into the new turn. @@ -869,6 +953,56 @@ export class CacheFirstLoop { }; } + if (this.hooks.some((h) => h.event === "PreModelCall")) { + const preModelReport = await runHooks({ + hooks: this.hooks, + payload: { + event: "PreModelCall", + cwd: this.hookCwd, + turn: this._turn, + model: this.model, + modelMessages: messages.map((m) => ({ + role: m.role, + content: typeof m.content === "string" ? m.content : JSON.stringify(m.content), + })), + }, + }); + if (preModelReport.blocked) { + this._preModelBlockCount++; + const blocking = preModelReport.outcomes[preModelReport.outcomes.length - 1]; + const reason = ( + blocking?.stderr || + blocking?.stdout || + "PreModelCall hook blocked" + ).trim(); + if (this._preModelBlockCount >= 3) { + yield { + turn: this._turn, + role: "warning" as const, + content: `[hook block] PreModelCall blocked ${this._preModelBlockCount} consecutive times — aborting turn to prevent infinite loop. ${reason}`, + }; + this._steerQueue.length = 0; + return; + } + yield { + turn: this._turn, + role: "warning" as const, + content: `[hook block] PreModelCall: ${reason}`, + }; + continue; + } + this._preModelBlockCount = 0; + for (const o of preModelReport.outcomes) { + if (o.decision !== "pass") { + yield { + turn: this._turn, + role: "warning" as const, + content: formatHookOutcomeMessage(o), + }; + } + } + } + let assistantContent = ""; let reasoningContent = ""; let toolCalls: ToolCall[] = []; @@ -1014,6 +1148,39 @@ export class CacheFirstLoop { this.scratch.reasoning = reasoningContent || null; + if (this.hooks.some((h) => h.event === "PostModelCall")) { + const postModelReport = await runHooks({ + hooks: this.hooks, + payload: { + event: "PostModelCall", + cwd: this.hookCwd, + turn: this._turn, + modelResponse: { + role: "assistant", + content: assistantContent, + reasoning_content: reasoningContent || undefined, + }, + usage: usage + ? { + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + cacheHitTokens: usage.promptCacheHitTokens ?? 0, + cacheMissTokens: usage.promptCacheMissTokens ?? 0, + } + : undefined, + }, + }); + for (const o of postModelReport.outcomes) { + if (o.decision !== "pass") { + yield { + turn: this._turn, + role: "warning" as const, + content: formatHookOutcomeMessage(o), + }; + } + } + } + const { calls: repairedCalls, report } = this.repair.process( toolCalls, reasoningContent || null, @@ -1089,6 +1256,82 @@ export class CacheFirstLoop { } return; } + + if (this.hooks.some((h) => h.event === "TurnEnd")) { + const turnEndReport = await runHooks({ + hooks: this.hooks, + payload: { + event: "TurnEnd", + cwd: this.hookCwd, + turn: this._turn, + lastAssistantText: assistantContent, + last_assistant_message: assistantContent, + }, + }); + if (turnEndReport.blocked) { + this._turnEndBlockCount++; + const blocking = turnEndReport.outcomes[turnEndReport.outcomes.length - 1]; + const reason = (blocking?.stderr || blocking?.stdout || "TurnEnd hook blocked").trim(); + if (this._turnEndBlockCount >= 3) { + yield { + turn: this._turn, + role: "warning" as const, + content: `[hook gate] TurnEnd blocked ${this._turnEndBlockCount} consecutive times — ending turn to prevent infinite loop. ${reason}`, + }; + yield { turn: this._turn, role: "done", content: assistantContent }; + this._steerQueue.length = 0; + return; + } + yield { + turn: this._turn, + role: "warning" as const, + content: `[hook gate] TurnEnd rejected assistant_final — ${reason}`, + }; + + const injectMessages: string[] = []; + const warnMessages: string[] = []; + for (const o of turnEndReport.outcomes) { + if (typeof o.stdout === "string" && o.stdout) { + for (const m of o.stdout.matchAll(/@@INJECT:\s*(.*?)(?:\n|$)/g)) { + if (m[1]?.trim()) injectMessages.push(m[1].trim()); + } + for (const m of o.stdout.matchAll(/@@WARN:\s*(.*?)(?:\n|$)/g)) { + if (m[1]?.trim()) warnMessages.push(m[1].trim()); + } + } + } + + for (const w of warnMessages) { + yield { turn: this._turn, role: "warning" as const, content: w }; + } + + if (injectMessages.length > 0) { + this.appendAndPersist({ + role: "user" as const, + content: injectMessages.join("\n"), + }); + } else { + this.appendAndPersist({ + role: "user" as const, + content: `[TurnEnd gate] The previous response was rejected. Reason: ${reason}. Please address the feedback and respond differently.`, + }); + } + + this._steerQueue.push(`[TurnEnd hook blocked] ${reason}`); + continue; + } + this._turnEndBlockCount = 0; + for (const o of turnEndReport.outcomes) { + if (o.decision !== "pass") { + yield { + turn: this._turn, + role: "warning" as const, + content: formatHookOutcomeMessage(o), + }; + } + } + } + restoreModelIfNeeded(); yield { turn: this._turn, role: "done", content: assistantContent }; this._steerQueue.length = 0; @@ -1148,6 +1391,11 @@ export class CacheFirstLoop { return; } + // Successful tool dispatch means the model did useful work between + // consecutive TurnEnd blocks — reset counter to measure truly + // consecutive blocks only (V-TE-03). + this._turnEndBlockCount = 0; + yield* dispatchToolCallsChunked(repairedCalls, { turn: this._turn, signal, diff --git a/src/server/api/hooks-events.ts b/src/server/api/hooks-events.ts index 461924608..8a4d2df30 100644 --- a/src/server/api/hooks-events.ts +++ b/src/server/api/hooks-events.ts @@ -4,7 +4,18 @@ import { sessionsDir as defaultSessionsDir } from "../../memory/session.js"; export interface HookRunRow { hookName: string; - phase: "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop"; + /** Must match HookEvent type in src/hooks.ts — keep in sync (V-TE-05). */ + phase: + | "SessionStart" + | "TurnStart" + | "PreToolUse" + | "PostToolUse" + | "UserPromptSubmit" + | "PreModelCall" + | "PostModelCall" + | "TurnEnd" + | "Stop" + | "SessionEnd"; outcome: "ok" | "blocked" | "modified" | "error"; whenMs: number; } diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 6c7bc006a..534b29b88 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + BLOCKING_EVENTS, + HOOK_EVENTS, type HookSpawnInput, type HookSpawnResult, type ResolvedHook, @@ -307,6 +309,74 @@ describe("runHooks", () => { expect(log[1]?.timeoutMs).toBe(30_000); }); + it("TurnStart is a blocking event (exit code 2 → block)", async () => { + const spawner = makeSpawner([ok({ exitCode: 2, stderr: "nope" })]); + const report = await runHooks({ + hooks: [hook({ event: "TurnStart", command: "gate" })], + spawner, + payload: { event: "TurnStart", cwd: "/tmp", turn: 1 }, + }); + expect(report.blocked).toBe(true); + }); + + it("PreModelCall is a blocking event (exit code 2 → block)", async () => { + const spawner = makeSpawner([ok({ exitCode: 2, stderr: "budget" })]); + const report = await runHooks({ + hooks: [hook({ event: "PreModelCall", command: "budget-check" })], + spawner, + payload: { event: "PreModelCall", cwd: "/tmp", turn: 1, model: "deepseek-r1" }, + }); + expect(report.blocked).toBe(true); + }); + + it("TurnEnd is a blocking event (exit code 2 → block)", async () => { + const spawner = makeSpawner([ok({ exitCode: 2, stderr: "incomplete" })]); + const report = await runHooks({ + hooks: [hook({ event: "TurnEnd", command: "completeness" })], + spawner, + payload: { + event: "TurnEnd", + cwd: "/tmp", + turn: 1, + lastAssistantText: "partial answer", + }, + }); + expect(report.blocked).toBe(true); + }); + + it("PostModelCall is a non-blocking event (exit code 2 → warn, not block)", async () => { + const spawner = makeSpawner([ok({ exitCode: 2, stderr: "log" })]); + const report = await runHooks({ + hooks: [hook({ event: "PostModelCall", command: "logger" })], + spawner, + payload: { event: "PostModelCall", cwd: "/tmp", turn: 1 }, + }); + expect(report.blocked).toBe(false); + expect(report.outcomes[0]?.decision).toBe("warn"); + }); + + it("new events use their configured default timeouts", async () => { + const log: HookSpawnInput[] = []; + const spawner = makeSpawner([ok(), ok(), ok(), ok(), ok()], log); + const h: ResolvedHook[] = [ + hook({ event: "SessionStart", command: "s" }), + hook({ event: "TurnStart", command: "ts" }), + hook({ event: "PreModelCall", command: "pm" }), + hook({ event: "PostModelCall", command: "pom" }), + hook({ event: "TurnEnd", command: "te" }), + ]; + await runHooks({ hooks: [h[0]!], spawner, payload: { event: "SessionStart", cwd: "/tmp" } }); + await runHooks({ hooks: [h[1]!], spawner, payload: { event: "TurnStart", cwd: "/tmp" } }); + await runHooks({ hooks: [h[2]!], spawner, payload: { event: "PreModelCall", cwd: "/tmp" } }); + await runHooks({ hooks: [h[3]!], spawner, payload: { event: "PostModelCall", cwd: "/tmp" } }); + await runHooks({ hooks: [h[4]!], spawner, payload: { event: "TurnEnd", cwd: "/tmp" } }); + expect(log[0]?.timeoutMs).toBe(10_000); + expect(log[1]?.timeoutMs).toBe(5_000); + expect(log[2]?.timeoutMs).toBe(10_000); + expect(log[3]?.timeoutMs).toBe(30_000); + expect(log[4]?.timeoutMs).toBe(10_000); + }); + it("per-hook timeout overrides the default", async () => { const log: HookSpawnInput[] = []; const spawner = makeSpawner([ok()], log); @@ -418,3 +488,50 @@ describe("runHooks output truncation", () => { expect(report.outcomes[0]?.truncated).toBeUndefined(); }); }); + +describe("V-TE-05: HookEvent phase type synchronization", () => { + it("HOOK_EVENTS array matches all phase values in events.ts and hooks-events.ts", () => { + const canonicalPhases = new Set(HOOK_EVENTS); + + const eventsTsPhases = new Set([ + "SessionStart", + "TurnStart", + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "PreModelCall", + "PostModelCall", + "TurnEnd", + "Stop", + "SessionEnd", + ]); + + const hooksEventsTsPhases = new Set([ + "SessionStart", + "TurnStart", + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "PreModelCall", + "PostModelCall", + "TurnEnd", + "Stop", + "SessionEnd", + ]); + + expect(canonicalPhases).toEqual(eventsTsPhases); + expect(canonicalPhases).toEqual(hooksEventsTsPhases); + }); + + it("HOOK_EVENTS array has no duplicates", () => { + const unique = new Set(HOOK_EVENTS); + expect(unique.size).toBe(HOOK_EVENTS.length); + }); + + it("BLOCKING_EVENTS is a subset of HOOK_EVENTS", () => { + const allEvents = new Set(HOOK_EVENTS); + for (const b of BLOCKING_EVENTS) { + expect(allEvents.has(b)).toBe(true); + } + }); +}); diff --git a/tests/loop-hooks.test.ts b/tests/loop-hooks.test.ts index b0ed8b6a7..cd961d429 100644 --- a/tests/loop-hooks.test.ts +++ b/tests/loop-hooks.test.ts @@ -115,4 +115,281 @@ describe("CacheFirstLoop hook wiring", () => { expect(events.find((e) => e.role === "warning")).toBeUndefined(); expect(events.find((e) => e.role === "assistant_final")?.content).toBe("just chatting"); }); + + it("TurnEnd @@INJECT protocol injects user message", async () => { + const client = makeClient([{ content: "first response" }, { content: "corrected response" }]); + const loop = new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + stream: false, + hooks: [ + { + event: "TurnEnd", + scope: "global", + source: "/x", + command: "echo @@INJECT: Fix the TODO markers && exit 2", + }, + ], + }); + const events: LoopEvent[] = []; + for await (const ev of loop.step("test")) events.push(ev); + const doneEvent = events.find((e) => e.role === "done"); + expect(doneEvent).toBeDefined(); + const warnings = events.filter((e) => e.role === "warning"); + expect(warnings.some((w) => w.content.includes("TurnEnd rejected"))).toBe(true); + }); + + it("TurnEnd @@WARN protocol yields warning", async () => { + const client = makeClient([{ content: "first response" }, { content: "corrected response" }]); + const loop = new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + stream: false, + hooks: [ + { + event: "TurnEnd", + scope: "global", + source: "/x", + command: "echo @@WARN: Quality check failed && exit 2", + }, + ], + }); + const events: LoopEvent[] = []; + for await (const ev of loop.step("test")) events.push(ev); + const doneEvent = events.find((e) => e.role === "done"); + expect(doneEvent).toBeDefined(); + const warnings = events.filter((e) => e.role === "warning"); + expect(warnings.some((w) => w.content.includes("Quality check failed"))).toBe(true); + }); + + it("TurnEnd without @@INJECT uses default message", async () => { + const client = makeClient([{ content: "first response" }, { content: "corrected response" }]); + const loop = new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + stream: false, + hooks: [ + { + event: "TurnEnd", + scope: "global", + source: "/x", + command: "exit 2", + }, + ], + }); + const events: LoopEvent[] = []; + for await (const ev of loop.step("test")) events.push(ev); + const doneEvent = events.find((e) => e.role === "done"); + expect(doneEvent).toBeDefined(); + const warnings = events.filter((e) => e.role === "warning"); + expect(warnings.length).toBeGreaterThanOrEqual(1); + }); + + it("PreModelCall blocked 3 consecutive times aborts the turn", async () => { + const client = makeClient([ + { content: "response 1" }, + { content: "response 2" }, + { content: "response 3" }, + { content: "response 4" }, + ]); + const loop = new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + stream: false, + hooks: [ + { + event: "PreModelCall", + scope: "global", + source: "/x", + command: "exit 2", + }, + ], + }); + const events: LoopEvent[] = []; + for await (const ev of loop.step("test")) events.push(ev); + const warnings = events.filter((e) => e.role === "warning"); + expect(warnings.length).toBeGreaterThanOrEqual(1); + expect(warnings.some((w) => w.content.includes("PreModelCall blocked"))).toBe(true); + expect(events.some((e) => e.role === "done")).toBe(false); + }); + + it("PreModelCall blocked twice then passing does not abort", async () => { + let blockCount = 0; + const client = makeClient([ + { content: "blocked 1" }, + { content: "blocked 2" }, + { content: "final answer" }, + ]); + const loop = new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + stream: false, + hooks: [ + { + event: "PreModelCall", + scope: "global", + source: "/x", + command: "exit 2", + }, + ], + }); + const events: LoopEvent[] = []; + for await (const ev of loop.step("test")) { + events.push(ev); + if (ev.role === "warning" && ev.content.includes("PreModelCall")) { + blockCount++; + if (blockCount >= 2) { + loop.hooks = []; + } + } + } + const doneEvent = events.find((e) => e.role === "done"); + expect(doneEvent).toBeDefined(); + expect(blockCount).toBe(2); + }); + + it("TurnEnd gate blocks then allows on next iteration", async () => { + let blockCount = 0; + const client = makeClient([{ content: "first response" }, { content: "corrected response" }]); + const loop = new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + stream: false, + hooks: [ + { + event: "TurnEnd", + scope: "global", + source: "/x", + command: "exit 2", + }, + ], + }); + const events: LoopEvent[] = []; + for await (const ev of loop.step("test")) { + events.push(ev); + if (ev.role === "warning" && ev.content.includes("TurnEnd")) { + blockCount++; + if (blockCount >= 1) { + loop.hooks = []; + } + } + } + const doneEvent = events.find((e) => e.role === "done"); + expect(doneEvent).toBeDefined(); + expect(blockCount).toBe(1); + }); + + it("TurnEnd 3-strike guard forces turn end after 3 consecutive blocks", async () => { + const client = makeClient([ + { content: "response 1" }, + { content: "response 2" }, + { content: "response 3" }, + { content: "response 4" }, + ]); + const loop = new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + stream: false, + hooks: [ + { + event: "TurnEnd", + scope: "global", + source: "/x", + command: "exit 2", + }, + ], + }); + const events: LoopEvent[] = []; + for await (const ev of loop.step("test")) events.push(ev); + const doneEvent = events.find((e) => e.role === "done"); + expect(doneEvent).toBeDefined(); + const warnings = events.filter((e) => e.role === "warning"); + const has3StrikeMsg = warnings.some((w) => w.content.includes("3 consecutive times")); + expect(has3StrikeMsg).toBe(true); + }); + + it("SessionStart hook stdout is injected into system prompt", async () => { + const client = makeClient([{ content: "hello" }]); + const prefix = new ImmutablePrefix({ system: "original system" }); + const loop = new CacheFirstLoop({ + client, + prefix, + stream: false, + hooks: [ + { + event: "SessionStart", + scope: "global", + source: "/x", + command: "echo injected-context-from-hook", + }, + ], + }); + const events: LoopEvent[] = []; + for await (const ev of loop.step("test")) events.push(ev); + expect(prefix.system).toContain("injected-context-from-hook"); + expect(prefix.system).toContain("[Session context]"); + expect(prefix.system).toContain("original system"); + }); + + it("SessionStart hook only runs once across multiple steps", async () => { + const client1 = makeClient([{ content: "first" }]); + const client2 = makeClient([{ content: "second" }]); + const prefix = new ImmutablePrefix({ system: "s" }); + const loop = new CacheFirstLoop({ + client: client1, + prefix, + stream: false, + hooks: [ + { + event: "SessionStart", + scope: "global", + source: "/x", + command: "echo once-only", + }, + ], + }); + for await (const ev of loop.step("test")) { + } + const systemAfterFirst = prefix.system; + expect(systemAfterFirst).toContain("once-only"); + + loop.client = client2; + for await (const ev of loop.step("test2")) { + } + expect(prefix.system).toBe(systemAfterFirst); + }); + + it("SessionStart hook with no stdout does not modify system prompt", async () => { + const client = makeClient([{ content: "hello" }]); + const prefix = new ImmutablePrefix({ system: "original" }); + const loop = new CacheFirstLoop({ + client, + prefix, + stream: false, + hooks: [ + { + event: "SessionStart", + scope: "global", + source: "/x", + command: "exit 0", + }, + ], + }); + for await (const ev of loop.step("test")) { + } + expect(prefix.system).toBe("original"); + }); + + it("no SessionStart hook means zero overhead", async () => { + const client = makeClient([{ content: "hello" }]); + const prefix = new ImmutablePrefix({ system: "original" }); + const loop = new CacheFirstLoop({ + client, + prefix, + stream: false, + }); + for await (const ev of loop.step("test")) { + } + expect(prefix.system).toBe("original"); + }); }); diff --git a/tests/useQuit-reentry.test.tsx b/tests/useQuit-reentry.test.tsx new file mode 100644 index 000000000..f9ecc0da6 --- /dev/null +++ b/tests/useQuit-reentry.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import { cleanup, render } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useQuit } from "../src/cli/ui/hooks/useQuit.js"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("useQuit reentry guard", () => { + it("quittingRef prevents multiple process.exit calls", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const endSpy = vi.fn(); + const transcriptRef = { current: { end: endSpy } } as never; + const beforeQuit = vi.fn(); + + let quitFn: ReturnType | undefined; + function Harness() { + quitFn = useQuit(transcriptRef, { beforeQuit }); + return null; + } + + render(); + expect(quitFn).toBeDefined(); + + quitFn!(); + await vi.waitFor(() => { + expect(exitSpy).toHaveBeenCalledTimes(1); + }); + expect(endSpy).toHaveBeenCalledTimes(1); + expect(beforeQuit).toHaveBeenCalledTimes(1); + + exitSpy.mockClear(); + endSpy.mockClear(); + beforeQuit.mockClear(); + + quitFn!(); + await new Promise((r) => setTimeout(r, 50)); + expect(exitSpy).not.toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + expect(beforeQuit).not.toHaveBeenCalled(); + }); + + it("beforeQuit error does not prevent process.exit", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const endSpy = vi.fn(); + const transcriptRef = { current: { end: endSpy } } as never; + const beforeQuit = vi.fn().mockRejectedValue(new Error("beforeQuit boom")); + + let quitFn: ReturnType | undefined; + function Harness() { + quitFn = useQuit(transcriptRef, { beforeQuit }); + return null; + } + + render(); + + quitFn!(); + await vi.waitFor(() => { + expect(exitSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("null transcriptRef does not crash", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const transcriptRef = { current: null }; + + let quitFn: ReturnType | undefined; + function Harness() { + quitFn = useQuit(transcriptRef as never); + return null; + } + + render(); + + quitFn!(); + await vi.waitFor(() => { + expect(exitSpy).toHaveBeenCalledTimes(1); + }); + }); +});