diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index 9d30fe8a9fc..1d7af0a1da8 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -25,6 +25,23 @@ type HookEventRecord = Record & { messages?: Array<{ role?: string; content?: unknown }>; }; +type CommandNewHookEvent = { + type: string; + action: string; + sessionKey?: string; + context?: { + commandSource?: string; + previousSessionEntry?: { sessionId?: string }; + }; +}; + +type SessionEntryWithCliBindings = { + sessionId?: string; + claudeCliSessionId?: string; + cliSessionBindings?: unknown; + cliSessionIds?: unknown; +}; + function firstHookCall(mock: { mock: { calls: unknown[][] } }): [HookEventRecord, HookEventRecord] { const call = mock.mock.calls.at(0); if (!call) { @@ -101,6 +118,27 @@ async function configureGlobalAgentSessionStore(dir: string) { }; } +async function withGlobalAgentSessionStore( + dir: string, + run: (globalConfig: Awaited>) => Promise, +) { + const globalConfig = await configureGlobalAgentSessionStore(dir); + try { + return await run(globalConfig); + } finally { + await globalConfig.cleanup(); + } +} + +async function writeGlobalSessionFile(storePath: string, sessionId: string) { + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify({ global: sessionStoreEntry(sessionId) }, null, 2), + "utf-8", + ); +} + async function writeMessageTranscript(params: { dir: string; sessionId: string; @@ -151,14 +189,27 @@ async function writeMainSessionEntry( } async function resetMainSession() { + return resetSession("main"); +} + +async function resetSession(key: string) { const reset = await directSessionReq<{ ok: true; key: string }>("sessions.reset", { - key: "main", + key, reason: "new", }); expect(reset.ok).toBe(true); return reset; } +async function createFromMainSession(params: { emitCommandHooks?: boolean } = {}) { + const result = await directSessionReq<{ ok: boolean; key: string }>("sessions.create", { + parentSessionKey: "main", + ...params, + }); + expect(result.ok).toBe(true); + return result; +} + async function performSessionReset(params: { key: string; agentId?: string; @@ -180,16 +231,31 @@ function expectResetErrorMessage( expect(reset.error.message).toBe(message); } +function isCommandNewHookEvent(event: unknown): event is CommandNewHookEvent { + return ( + Boolean(event) && + typeof event === "object" && + (event as { type?: unknown }).type === "command" && + (event as { action?: unknown }).action === "new" + ); +} + function commandNewHookEvents() { return (sessionHookMocks.triggerInternalHook.mock.calls as unknown as Array<[unknown]>) .map((call) => call[0]) - .filter( - (event): event is { type: string; action: string; context?: { commandSource?: string } } => - Boolean(event) && - typeof event === "object" && - (event as { type?: unknown }).type === "command" && - (event as { action?: unknown }).action === "new", - ); + .filter(isCommandNewHookEvent); +} + +function expectSingleCommandNewHookEvent() { + const events = commandNewHookEvents(); + expect(events).toHaveLength(1); + const event = events[0]; + if (!event) { + throw new Error("expected session hook event"); + } + expect(event.type).toBe("command"); + expect(event.action).toBe("new"); + return event; } function claudeCliBindings(sessionId: string) { @@ -202,20 +268,71 @@ function claudeCliBindings(sessionId: string) { }; } -async function loadGatewaySessionStoreForKey(key: string) { - const [{ getRuntimeConfig }, { resolveGatewaySessionStoreTarget }, { loadSessionStore }] = - await Promise.all([ - import("../config/config.js"), - import("./session-utils.js"), - import("../config/sessions.js"), - ]); - const gatewayStorePath = resolveGatewaySessionStoreTarget({ +function cliBoundSessionEntry( + sessionId: string, + sessionFile: string, + cliSessionId: string, + overrides: Parameters[1] = {}, +) { + return sessionStoreEntry(sessionId, { + sessionFile, + ...overrides, + ...claudeCliBindings(cliSessionId), + }); +} + +async function resolveGatewaySessionStorePathForKey(key: string) { + const [{ getRuntimeConfig }, { resolveGatewaySessionStoreTarget }] = await Promise.all([ + import("../config/config.js"), + import("./session-utils.js"), + ]); + return resolveGatewaySessionStoreTarget({ cfg: getRuntimeConfig(), key, }).storePath; +} + +async function loadGatewaySessionStoreForKey(key: string) { + const [{ loadSessionStore }, gatewayStorePath] = await Promise.all([ + import("../config/sessions.js"), + resolveGatewaySessionStorePathForKey(key), + ]); return loadSessionStore(gatewayStorePath, { skipCache: true }); } +async function updateGatewaySessionStoreForKey( + key: string, + update: Parameters<(typeof import("../config/sessions.js"))["updateSessionStore"]>[1], +) { + const [{ updateSessionStore }, gatewayStorePath] = await Promise.all([ + import("../config/sessions.js"), + resolveGatewaySessionStorePathForKey(key), + ]); + await updateSessionStore(gatewayStorePath, update); +} + +function expectCliBindingsCleared( + nextEntry: SessionEntryWithCliBindings | undefined, + previousSessionId: string, +) { + expect(nextEntry).toBeDefined(); + expect(nextEntry?.sessionId).not.toBe(previousSessionId); + expect(nextEntry?.claudeCliSessionId).toBeUndefined(); + expect(nextEntry?.cliSessionBindings).toBeUndefined(); + expect(nextEntry?.cliSessionIds).toBeUndefined(); +} + +function expectClaudeCliBinding( + nextEntry: SessionEntryWithCliBindings | undefined, + cliSessionId: string, +) { + expect(nextEntry?.claudeCliSessionId).toBe(cliSessionId); + expect(nextEntry?.cliSessionBindings).toEqual({ + "claude-cli": { sessionId: cliSessionId }, + }); + expect(nextEntry?.cliSessionIds).toEqual({ "claude-cli": cliSessionId }); +} + test("sessions.reset emits internal command hook with reason", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); @@ -223,34 +340,7 @@ test("sessions.reset emits internal command hook with reason", async () => { await writeMainSessionEntry("sess-main"); await resetMainSession(); - const resetHookEvents = ( - sessionHookMocks.triggerInternalHook.mock.calls as unknown as Array<[unknown]> - ) - .map((call) => call[0]) - .filter( - ( - event, - ): event is { - type: string; - action: string; - sessionKey?: string; - context?: { - commandSource?: string; - previousSessionEntry?: { sessionId?: string }; - }; - } => - Boolean(event) && - typeof event === "object" && - (event as { type?: unknown }).type === "command" && - (event as { action?: unknown }).action === "new", - ); - expect(resetHookEvents).toHaveLength(1); - const event = resetHookEvents[0]; - if (!event) { - throw new Error("expected session hook event"); - } - expect(event.type).toBe("command"); - expect(event.action).toBe("new"); + const event = expectSingleCommandNewHookEvent(); expect(event.sessionKey).toBe("agent:main:main"); expect(event.context?.commandSource).toBe("gateway:sessions.reset"); expect(event.context?.previousSessionEntry?.sessionId).toBe("sess-main"); @@ -266,11 +356,7 @@ test("sessions.reset emits before_reset hook with transcript context", async () beforeResetHookState.hasBeforeResetHook = true; - const reset = await directSessionReq<{ ok: true; key: string }>("sessions.reset", { - key: "main", - reason: "new", - }); - expect(reset.ok).toBe(true); + await resetMainSession(); expect(beforeResetHookMocks.runBeforeReset).toHaveBeenCalledTimes(1); const [event, context] = firstHookCall(beforeResetHookMocks.runBeforeReset); expectTranscriptResetEvent({ @@ -283,27 +369,15 @@ test("sessions.reset emits before_reset hook with transcript context", async () test("sessions.reset infers selected global agent from agent-prefixed aliases", async () => { const { dir } = await createSessionStoreDir(); - const globalConfig = await configureGlobalAgentSessionStore(dir); - await writeSessionStore({ - entries: {}, - storePath: path.join(dir, "prime-sessions.json"), - }); - await fs.mkdir(path.dirname(globalConfig.mainStorePath), { recursive: true }); - await fs.mkdir(path.dirname(globalConfig.workStorePath), { recursive: true }); - await fs.writeFile( - globalConfig.mainStorePath, - JSON.stringify({ global: sessionStoreEntry("sess-main-global") }, null, 2), - "utf-8", - ); - await fs.writeFile( - globalConfig.workStorePath, - JSON.stringify({ global: sessionStoreEntry("sess-work-global") }, null, 2), - "utf-8", - ); - const { getRuntimeConfig } = await import("../config/config.js"); - const { resolveGatewaySessionStoreTarget } = await import("./session-utils.js"); - - try { + await withGlobalAgentSessionStore(dir, async (globalConfig) => { + await writeSessionStore({ + entries: {}, + storePath: path.join(dir, "prime-sessions.json"), + }); + await writeGlobalSessionFile(globalConfig.mainStorePath, "sess-main-global"); + await writeGlobalSessionFile(globalConfig.workStorePath, "sess-work-global"); + const { getRuntimeConfig } = await import("../config/config.js"); + const { resolveGatewaySessionStoreTarget } = await import("./session-utils.js"); const { performGatewaySessionReset } = await import("./session-reset-service.js"); const reset = await performGatewaySessionReset({ key: "agent:work:main", @@ -331,16 +405,12 @@ test("sessions.reset infers selected global agent from agent-prefixed aliases", expect(mainStore.global?.sessionId).toBe("sess-main-global"); expect(workStore.global?.sessionId).toBe(reset.entry.sessionId); expect(workStore.global?.sessionId).not.toBe("sess-work-global"); - } finally { - await globalConfig.cleanup(); - } + }); }); test("sessions.reset rejects selected global agentId conflicts", async () => { const { dir } = await createSessionStoreDir(); - const globalConfig = await configureGlobalAgentSessionStore(dir); - - try { + await withGlobalAgentSessionStore(dir, async () => { const reset = await performSessionReset({ key: "agent:main:main", agentId: "work", @@ -349,16 +419,12 @@ test("sessions.reset rejects selected global agentId conflicts", async () => { }); expectResetErrorMessage(reset, "session key agent does not match agentId"); - } finally { - await globalConfig.cleanup(); - } + }); }); test("sessions.reset rejects unknown selected global agents", async () => { const { dir } = await createSessionStoreDir(); - const globalConfig = await configureGlobalAgentSessionStore(dir); - - try { + await withGlobalAgentSessionStore(dir, async () => { const reset = await performSessionReset({ key: "agent:typo:main", reason: "reset", @@ -366,22 +432,13 @@ test("sessions.reset rejects unknown selected global agents", async () => { }); expectResetErrorMessage(reset, "Unknown agent id: typo"); - } finally { - await globalConfig.cleanup(); - } + }); }); test("sessions.reset emits inferred selected global agent scope", async () => { const { dir } = await createSessionStoreDir(); - const globalConfig = await configureGlobalAgentSessionStore(dir); - await fs.mkdir(path.dirname(globalConfig.workStorePath), { recursive: true }); - await fs.writeFile( - globalConfig.workStorePath, - JSON.stringify({ global: sessionStoreEntry("sess-work-global") }, null, 2), - "utf-8", - ); - - try { + await withGlobalAgentSessionStore(dir, async (globalConfig) => { + await writeGlobalSessionFile(globalConfig.workStorePath, "sess-work-global"); const broadcast = vi.fn(); const reset = await directSessionReq<{ ok: true; key: string }>( "sessions.reset", @@ -404,9 +461,7 @@ test("sessions.reset emits inferred selected global agent scope", async () => { }), ); expect(broadcast.mock.calls[0]?.[2]).toEqual(new Set(["conn-work"])); - } finally { - await globalConfig.cleanup(); - } + }); }); test("sessions.reset emits enriched session_end and session_start hooks", async () => { @@ -417,11 +472,7 @@ test("sessions.reset emits enriched session_end and session_start hooks", async content: "hello from transcript", }); - const reset = await directSessionReq<{ ok: true; key: string }>("sessions.reset", { - key: "main", - reason: "new", - }); - expect(reset.ok).toBe(true); + await resetMainSession(); expect(sessionLifecycleHookMocks.runSessionEnd).toHaveBeenCalledTimes(1); expect(sessionLifecycleHookMocks.runSessionStart).toHaveBeenCalledTimes(1); @@ -507,25 +558,13 @@ test("sessions.reset emits before_reset for the entry actually reset in the writ }); beforeResetHookState.hasBeforeResetHook = true; - const [{ getRuntimeConfig }, { resolveGatewaySessionStoreTarget }, { updateSessionStore }] = - await Promise.all([ - import("../config/config.js"), - import("./session-utils.js"), - import("../config/sessions.js"), - ]); - const gatewayStorePath = resolveGatewaySessionStoreTarget({ - cfg: getRuntimeConfig(), - key: "main", - }).storePath; - - const { performGatewaySessionReset } = await import("./session-reset-service.js"); - await updateSessionStore(gatewayStorePath, (store) => { + await updateGatewaySessionStoreForKey("main", (store) => { store["agent:main:main"] = sessionStoreEntry("sess-new", { sessionFile: newTranscriptPath, }); }); - const reset = await performGatewaySessionReset({ + const reset = await performSessionReset({ key: "main", reason: "new", commandSource: "gateway:sessions.reset", @@ -545,23 +584,11 @@ test("sessions.create with emitCommandHooks=true fires command:new hook against const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-parent", "hello from parent"); - await writeSessionStore({ - entries: { - main: sessionStoreEntry("sess-parent"), - }, - }); + await writeMainSessionEntry("sess-parent"); - const result = await directSessionReq<{ ok: boolean; key: string }>("sessions.create", { - parentSessionKey: "main", - emitCommandHooks: true, - }); - expect(result.ok).toBe(true); + await createFromMainSession({ emitCommandHooks: true }); - const commandNewEvents = commandNewHookEvents(); - expect(commandNewEvents).toHaveLength(1); - expect(commandNewEvents[0]?.type).toBe("command"); - expect(commandNewEvents[0]?.action).toBe("new"); - expect(commandNewEvents[0]?.context?.commandSource).toBe("webchat"); + expect(expectSingleCommandNewHookEvent().context?.commandSource).toBe("webchat"); }); test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks against parent (#76957)", async () => { @@ -574,11 +601,7 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga beforeResetHookState.hasBeforeResetHook = true; - const result = await directSessionReq<{ ok: boolean; key: string }>("sessions.create", { - parentSessionKey: "main", - emitCommandHooks: true, - }); - expect(result.ok).toBe(true); + await createFromMainSession({ emitCommandHooks: true }); expect(beforeResetHookMocks.runBeforeReset).toHaveBeenCalledTimes(1); const [beforeResetEvent, beforeResetContext] = firstHookCall(beforeResetHookMocks.runBeforeReset); @@ -657,16 +680,9 @@ test("sessions.create without emitCommandHooks does not fire command:new hook (# const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-parent2", "hello from parent 2"); - await writeSessionStore({ - entries: { - main: sessionStoreEntry("sess-parent2"), - }, - }); + await writeMainSessionEntry("sess-parent2"); - const result = await directSessionReq<{ ok: boolean; key: string }>("sessions.create", { - parentSessionKey: "main", - }); - expect(result.ok).toBe(true); + await createFromMainSession(); expect(commandNewHookEvents()).toHaveLength(0); expect(beforeResetHookMocks.runBeforeReset).not.toHaveBeenCalled(); @@ -683,12 +699,7 @@ test("sessions.reset drops cli session bindings so the next turn does not --resu await resetMainSession(); const store = await loadGatewaySessionStoreForKey("main"); - const nextEntry = store["agent:main:main"]; - expect(nextEntry).toBeDefined(); - expect(nextEntry?.sessionId).not.toBe("sess-with-binding"); - expect(nextEntry?.claudeCliSessionId).toBeUndefined(); - expect(nextEntry?.cliSessionBindings).toBeUndefined(); - expect(nextEntry?.cliSessionIds).toBeUndefined(); + expectCliBindingsCleared(store["agent:main:main"], "sess-with-binding"); }); test("sessions.reset clears cli session bindings for parent-linked non-subagent sessions (e.g. dashboard children)", async () => { @@ -702,31 +713,25 @@ test("sessions.reset clears cli session bindings for parent-linked non-subagent await writeSessionStore({ entries: { - "dashboard:child:42": sessionStoreEntry("sess-dashboard-child", { - sessionFile: dashboardTranscript, - // parentSessionKey is set but the session key carries no `:subagent:` - // marker, so this is a user-facing parent-linked session, not a - // spawned subagent. The tighter predicate should still clear the - // CLI binding here so /reset matches user intuition. - parentSessionKey: "agent:main:main", - ...claudeCliBindings("claude-cli-dashboard-session"), - }), + "dashboard:child:42": cliBoundSessionEntry( + "sess-dashboard-child", + dashboardTranscript, + "claude-cli-dashboard-session", + { + // parentSessionKey is set but the session key carries no `:subagent:` + // marker, so this is a user-facing parent-linked session, not a + // spawned subagent. The tighter predicate should still clear the + // CLI binding here so /reset matches user intuition. + parentSessionKey: "agent:main:main", + }, + ), }, }); - const reset = await directSessionReq<{ ok: true; key: string }>("sessions.reset", { - key: "dashboard:child:42", - reason: "new", - }); - expect(reset.ok).toBe(true); + await resetSession("dashboard:child:42"); const store = await loadGatewaySessionStoreForKey("dashboard:child:42"); - const nextEntry = store["agent:main:dashboard:child:42"]; - expect(nextEntry).toBeDefined(); - expect(nextEntry?.sessionId).not.toBe("sess-dashboard-child"); - expect(nextEntry?.claudeCliSessionId).toBeUndefined(); - expect(nextEntry?.cliSessionBindings).toBeUndefined(); - expect(nextEntry?.cliSessionIds).toBeUndefined(); + expectCliBindingsCleared(store["agent:main:dashboard:child:42"], "sess-dashboard-child"); }); test("sessions.reset preserves cli session bindings for spawned subagents (Tak Hoffman's fa56682b3ced contract)", async () => { @@ -740,29 +745,24 @@ test("sessions.reset preserves cli session bindings for spawned subagents (Tak H await writeSessionStore({ entries: { - "subagent:child": sessionStoreEntry("sess-spawned-child", { - sessionFile: childTranscript, - parentSessionKey: "agent:main:main", - spawnedBy: "agent:main:main", - subagentRole: "orchestrator", - ...claudeCliBindings("claude-cli-child-session"), - }), + "subagent:child": cliBoundSessionEntry( + "sess-spawned-child", + childTranscript, + "claude-cli-child-session", + { + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + subagentRole: "orchestrator", + }, + ), }, }); - const reset = await directSessionReq<{ ok: true; key: string }>("sessions.reset", { - key: "subagent:child", - reason: "new", - }); - expect(reset.ok).toBe(true); + await resetSession("subagent:child"); const store = await loadGatewaySessionStoreForKey("subagent:child"); const nextEntry = store["agent:main:subagent:child"]; expect(nextEntry).toBeDefined(); expect(nextEntry?.sessionId).not.toBe("sess-spawned-child"); - expect(nextEntry?.claudeCliSessionId).toBe("claude-cli-child-session"); - expect(nextEntry?.cliSessionBindings).toEqual({ - "claude-cli": { sessionId: "claude-cli-child-session" }, - }); - expect(nextEntry?.cliSessionIds).toEqual({ "claude-cli": "claude-cli-child-session" }); + expectClaudeCliBinding(nextEntry, "claude-cli-child-session"); });