diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 95293724af..eead292da0 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -113,6 +113,18 @@ function captureUpdatedMainEntry() { return () => capturedEntry; } +function primeMainAgentRun(params?: { sessionId?: string; cfg?: Record }) { + mockMainSessionEntry( + { sessionId: params?.sessionId ?? "existing-session-id" }, + params?.cfg ?? {}, + ); + mocks.updateSessionStore.mockResolvedValue(undefined); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); +} + async function runMainAgent(message: string, idempotencyKey: string) { const respond = vi.fn(); await invokeAgent( @@ -210,20 +222,7 @@ describe("gateway agent handler", () => { }, }; - mocks.loadSessionEntry.mockReturnValue({ - cfg: mocks.loadConfigReturn, - storePath: "/tmp/sessions.json", - entry: { - sessionId: "existing-session-id", - updatedAt: Date.now(), - }, - canonicalKey: "agent:main:main", - }); - mocks.updateSessionStore.mockResolvedValue(undefined); - mocks.agentCommand.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { durationMs: 100 }, - }); + primeMainAgentRun({ cfg: mocks.loadConfigReturn }); await invokeAgent( { @@ -326,20 +325,7 @@ describe("gateway agent handler", () => { }, ); - mocks.loadSessionEntry.mockReturnValue({ - cfg: {}, - storePath: "/tmp/sessions.json", - entry: { - sessionId: "reset-session-id", - updatedAt: Date.now(), - }, - canonicalKey: "agent:main:main", - }); - mocks.updateSessionStore.mockResolvedValue(undefined); - mocks.agentCommand.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { durationMs: 100 }, - }); + primeMainAgentRun({ sessionId: "reset-session-id" }); await invokeAgent( { @@ -359,6 +345,58 @@ describe("gateway agent handler", () => { expect(call?.sessionId).toBe("reset-session-id"); }); + it("uses /reset suffix as the post-reset message and still injects timestamp", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); // Wed Jan 28, 8:30 PM EST + mocks.agentCommand.mockReset(); + mocks.loadConfigReturn = { + agents: { + defaults: { + userTimezone: "America/New_York", + }, + }, + }; + mocks.sessionsResetHandler.mockImplementation( + async (opts: { + params: { key: string; reason: string }; + respond: (ok: boolean, payload?: unknown) => void; + }) => { + expect(opts.params.key).toBe("agent:main:main"); + expect(opts.params.reason).toBe("reset"); + opts.respond(true, { + ok: true, + key: "agent:main:main", + entry: { sessionId: "reset-session-id" }, + }); + }, + ); + mocks.sessionsResetHandler.mockClear(); + primeMainAgentRun({ + sessionId: "reset-session-id", + cfg: mocks.loadConfigReturn, + }); + + await invokeAgent( + { + message: "/reset check status", + sessionKey: "agent:main:main", + idempotencyKey: "test-idem-reset-suffix", + }, + { reqId: "4b" }, + ); + + await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); + expect(mocks.sessionsResetHandler).toHaveBeenCalledTimes(1); + const call = mocks.agentCommand.mock.calls.at(-1)?.[0] as + | { message?: string; sessionId?: string } + | undefined; + expect(call?.message).toBe("[Wed 2026-01-28 20:30 EST] check status"); + expect(call?.sessionId).toBe("reset-session-id"); + + mocks.loadConfigReturn = {}; + vi.useRealTimers(); + }); + it("rejects malformed agent session keys early in agent handler", async () => { mocks.agentCommand.mockClear(); const respond = await invokeAgent( diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 4e59b524e4..54c285203f 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -156,6 +156,15 @@ async function listAgentFileNames(agentId = "main") { return files.map((file) => file.name); } +function expectNotFoundResponseAndNoWrite(respond: ReturnType) { + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("not found") }), + ); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); +} + beforeEach(() => { mocks.fsReadFile.mockImplementation(async () => { throw createEnoentError(); @@ -319,12 +328,7 @@ describe("agents.update", () => { }); await promise; - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: expect.stringContaining("not found") }), - ); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expectNotFoundResponseAndNoWrite(respond); }); it("ensures workspace when workspace changes", async () => { @@ -408,12 +412,7 @@ describe("agents.delete", () => { }); await promise; - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: expect.stringContaining("not found") }), - ); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expectNotFoundResponseAndNoWrite(respond); }); it("rejects invalid params (missing agentId)", async () => { diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 1643ae54ce..18c9754f79 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -42,7 +42,7 @@ import { } from "../session-utils.js"; import { applySessionsPatchToStore } from "../sessions-patch.js"; import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; -import type { GatewayRequestHandlers, RespondFn } from "./types.js"; +import type { GatewayClient, GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; function requireSessionKey(key: unknown, respond: RespondFn): string | null { @@ -68,6 +68,26 @@ function resolveGatewaySessionTargetFromKey(key: string) { return { cfg, target, storePath: target.storePath }; } +function rejectWebchatSessionMutation(params: { + action: "patch" | "delete"; + client: GatewayClient | null; + isWebchatConnect: (params: GatewayClient["connect"] | null | undefined) => boolean; + respond: RespondFn; +}): boolean { + if (!params.client?.connect || !params.isWebchatConnect(params.client.connect)) { + return false; + } + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `webchat clients cannot ${params.action} sessions; use chat.send for session-scoped updates`, + ), + ); + return true; +} + function migrateAndPruneSessionStoreKey(params: { cfg: ReturnType; key: string; @@ -240,15 +260,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (!key) { return; } - if (client?.connect && isWebchatConnect(client.connect)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "webchat clients cannot patch sessions; use chat.send for session-scoped updates", - ), - ); + if (rejectWebchatSessionMutation({ action: "patch", client, isWebchatConnect, respond })) { return; } @@ -366,15 +378,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (!key) { return; } - if (client?.connect && isWebchatConnect(client.connect)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "webchat clients cannot delete sessions; use chat.send for session-scoped updates", - ), - ); + if (rejectWebchatSessionMutation({ action: "delete", client, isWebchatConnect, respond })) { return; }