import { describe, expect, it, vi } from "vitest"; import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-prompt.js"; import { agentHandlers } from "./agent.js"; import type { GatewayRequestContext } from "./types.js"; const mocks = vi.hoisted(() => ({ loadSessionEntry: vi.fn(), updateSessionStore: vi.fn(), agentCommand: vi.fn(), registerAgentRunContext: vi.fn(), sessionsResetHandler: vi.fn(), loadConfigReturn: {} as Record, })); vi.mock("../session-utils.js", async () => { const actual = await vi.importActual("../session-utils.js"); return { ...actual, loadSessionEntry: mocks.loadSessionEntry, }; }); vi.mock("../../config/sessions.js", async () => { const actual = await vi.importActual( "../../config/sessions.js", ); return { ...actual, updateSessionStore: mocks.updateSessionStore, resolveAgentIdFromSessionKey: () => "main", resolveExplicitAgentSessionKey: () => undefined, resolveAgentMainSessionKey: ({ cfg, agentId, }: { cfg?: { session?: { mainKey?: string } }; agentId: string; }) => `agent:${agentId}:${cfg?.session?.mainKey ?? "main"}`, }; }); vi.mock("../../commands/agent.js", () => ({ agentCommand: mocks.agentCommand, })); vi.mock("../../config/config.js", async () => { const actual = await vi.importActual("../../config/config.js"); return { ...actual, loadConfig: () => mocks.loadConfigReturn, }; }); vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: () => ["main"], })); vi.mock("../../infra/agent-events.js", () => ({ registerAgentRunContext: mocks.registerAgentRunContext, onAgentEvent: vi.fn(), })); vi.mock("./sessions.js", () => ({ sessionsHandlers: { "sessions.reset": (...args: unknown[]) => (mocks.sessionsResetHandler as (...args: unknown[]) => unknown)(...args), }, })); vi.mock("../../sessions/send-policy.js", () => ({ resolveSendPolicy: () => "allow", })); vi.mock("../../utils/delivery-context.js", async () => { const actual = await vi.importActual( "../../utils/delivery-context.js", ); return { ...actual, normalizeSessionDeliveryFields: () => ({}), }; }); const makeContext = (): GatewayRequestContext => ({ dedupe: new Map(), addChatRun: vi.fn(), logGateway: { info: vi.fn(), error: vi.fn() }, }) as unknown as GatewayRequestContext; type AgentHandlerArgs = Parameters[0]; type AgentParams = AgentHandlerArgs["params"]; type AgentIdentityGetHandlerArgs = Parameters<(typeof agentHandlers)["agent.identity.get"]>[0]; type AgentIdentityGetParams = AgentIdentityGetHandlerArgs["params"]; function mockMainSessionEntry(entry: Record, cfg: Record = {}) { mocks.loadSessionEntry.mockReturnValue({ cfg, storePath: "/tmp/sessions.json", entry: { sessionId: "existing-session-id", updatedAt: Date.now(), ...entry, }, canonicalKey: "agent:main:main", }); } function captureUpdatedMainEntry() { let capturedEntry: Record | undefined; mocks.updateSessionStore.mockImplementation(async (_path, updater) => { const store: Record = {}; await updater(store); capturedEntry = store["agent:main:main"] as Record; }); return () => capturedEntry; } function buildExistingMainStoreEntry(overrides: Record = {}) { return { sessionId: "existing-session-id", updatedAt: Date.now(), ...overrides, }; } async function runMainAgentAndCaptureEntry(idempotencyKey: string) { const getCapturedEntry = captureUpdatedMainEntry(); mocks.agentCommand.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 100 }, }); await runMainAgent("test", idempotencyKey); expect(mocks.updateSessionStore).toHaveBeenCalled(); return getCapturedEntry(); } function setupNewYorkTimeConfig(isoDate: string) { vi.useFakeTimers(); vi.setSystemTime(new Date(isoDate)); // Wed Jan 28, 8:30 PM EST mocks.agentCommand.mockClear(); mocks.loadConfigReturn = { agents: { defaults: { userTimezone: "America/New_York", }, }, }; } function resetTimeConfig() { mocks.loadConfigReturn = {}; vi.useRealTimers(); } async function expectResetCall(expectedMessage: string) { await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); expect(mocks.sessionsResetHandler).toHaveBeenCalledTimes(1); const call = readLastAgentCommandCall(); expect(call?.message).toBe(expectedMessage); return call; } 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( { message, agentId: "main", sessionKey: "agent:main:main", idempotencyKey, }, { respond, reqId: idempotencyKey }, ); return respond; } function readLastAgentCommandCall(): | { message?: string; sessionId?: string; } | undefined { return mocks.agentCommand.mock.calls.at(-1)?.[0] as | { message?: string; sessionId?: string } | undefined; } function mockSessionResetSuccess(params: { reason: "new" | "reset"; key?: string; sessionId?: string; }) { const key = params.key ?? "agent:main:main"; const sessionId = params.sessionId ?? "reset-session-id"; mocks.sessionsResetHandler.mockImplementation( async (opts: { params: { key: string; reason: string }; respond: (ok: boolean, payload?: unknown) => void; }) => { expect(opts.params.key).toBe(key); expect(opts.params.reason).toBe(params.reason); opts.respond(true, { ok: true, key, entry: { sessionId }, }); }, ); } async function invokeAgent( params: AgentParams, options?: { respond?: ReturnType; reqId?: string; context?: GatewayRequestContext; client?: AgentHandlerArgs["client"]; isWebchatConnect?: AgentHandlerArgs["isWebchatConnect"]; }, ) { const respond = options?.respond ?? vi.fn(); await agentHandlers.agent({ params, respond: respond as never, context: options?.context ?? makeContext(), req: { type: "req", id: options?.reqId ?? "agent-test-req", method: "agent" }, client: options?.client ?? null, isWebchatConnect: options?.isWebchatConnect ?? (() => false), }); return respond; } async function invokeAgentIdentityGet( params: AgentIdentityGetParams, options?: { respond?: ReturnType; reqId?: string; context?: GatewayRequestContext; }, ) { const respond = options?.respond ?? vi.fn(); await agentHandlers["agent.identity.get"]({ params, respond: respond as never, context: options?.context ?? makeContext(), req: { type: "req", id: options?.reqId ?? "agent-identity-test-req", method: "agent.identity.get", }, client: null, isWebchatConnect: () => false, }); return respond; } describe("gateway agent handler", () => { it("preserves ACP metadata from the current stored session entry", async () => { const existingAcpMeta = { backend: "acpx", agent: "codex", runtimeSessionName: "runtime-1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }; mockMainSessionEntry({ acp: existingAcpMeta, }); let capturedEntry: Record | undefined; mocks.updateSessionStore.mockImplementation(async (_path, updater) => { const store: Record = { "agent:main:main": buildExistingMainStoreEntry({ acp: existingAcpMeta }), }; const result = await updater(store); capturedEntry = store["agent:main:main"] as Record; return result; }); mocks.agentCommand.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 100 }, }); await runMainAgent("test", "test-idem-acp-meta"); expect(mocks.updateSessionStore).toHaveBeenCalled(); expect(capturedEntry).toBeDefined(); expect(capturedEntry?.acp).toEqual(existingAcpMeta); }); it("preserves cliSessionIds from existing session entry", async () => { const existingCliSessionIds = { "claude-cli": "abc-123-def" }; const existingClaudeCliSessionId = "abc-123-def"; mockMainSessionEntry({ cliSessionIds: existingCliSessionIds, claudeCliSessionId: existingClaudeCliSessionId, }); const capturedEntry = await runMainAgentAndCaptureEntry("test-idem"); expect(capturedEntry).toBeDefined(); expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds); expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId); }); it("injects a timestamp into the message passed to agentCommand", async () => { setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z"); primeMainAgentRun({ cfg: mocks.loadConfigReturn }); await invokeAgent( { message: "Is it the weekend?", agentId: "main", sessionKey: "agent:main:main", idempotencyKey: "test-timestamp-inject", }, { reqId: "ts-1" }, ); // Wait for the async agentCommand call await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); const callArgs = mocks.agentCommand.mock.calls[0][0]; expect(callArgs.message).toBe("[Wed 2026-01-28 20:30 EST] Is it the weekend?"); resetTimeConfig(); }); it.each([ { name: "passes senderIsOwner=false for write-scoped gateway callers", scopes: ["operator.write"], idempotencyKey: "test-sender-owner-write", senderIsOwner: false, }, { name: "passes senderIsOwner=true for admin-scoped gateway callers", scopes: ["operator.admin"], idempotencyKey: "test-sender-owner-admin", senderIsOwner: true, }, ])("$name", async ({ scopes, idempotencyKey, senderIsOwner }) => { primeMainAgentRun(); await invokeAgent( { message: "owner-tools check", sessionKey: "agent:main:main", idempotencyKey, }, { client: { connect: { role: "operator", scopes, client: { id: "test-client", mode: "gateway" }, }, } as unknown as AgentHandlerArgs["client"], }, ); await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as | { senderIsOwner?: boolean } | undefined; expect(callArgs?.senderIsOwner).toBe(senderIsOwner); }); it("respects explicit bestEffortDeliver=false for main session runs", async () => { mocks.agentCommand.mockClear(); primeMainAgentRun(); await invokeAgent( { message: "strict delivery", agentId: "main", sessionKey: "agent:main:main", deliver: true, replyChannel: "telegram", to: "123", bestEffortDeliver: false, idempotencyKey: "test-strict-delivery", }, { reqId: "strict-1" }, ); await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as Record; expect(callArgs.bestEffortDeliver).toBe(false); }); it("keeps origin messageChannel as webchat while delivery channel uses last session channel", async () => { mockMainSessionEntry({ sessionId: "existing-session-id", lastChannel: "telegram", lastTo: "12345", }); mocks.updateSessionStore.mockImplementation(async (_path, updater) => { const store: Record = { "agent:main:main": buildExistingMainStoreEntry({ lastChannel: "telegram", lastTo: "12345", }), }; return await updater(store); }); mocks.agentCommand.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 100 }, }); await invokeAgent( { message: "webchat turn", sessionKey: "agent:main:main", idempotencyKey: "test-webchat-origin-channel", }, { reqId: "webchat-origin-1", client: { connect: { client: { id: "webchat-ui", mode: "webchat" }, }, } as AgentHandlerArgs["client"], isWebchatConnect: () => true, }, ); await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as { channel?: string; messageChannel?: string; runContext?: { messageChannel?: string }; }; expect(callArgs.channel).toBe("telegram"); expect(callArgs.messageChannel).toBe("webchat"); expect(callArgs.runContext?.messageChannel).toBe("webchat"); }); it("handles missing cliSessionIds gracefully", async () => { mockMainSessionEntry({}); const capturedEntry = await runMainAgentAndCaptureEntry("test-idem-2"); expect(capturedEntry).toBeDefined(); // Should be undefined, not cause an error expect(capturedEntry?.cliSessionIds).toBeUndefined(); expect(capturedEntry?.claudeCliSessionId).toBeUndefined(); }); it("prunes legacy main alias keys when writing a canonical session entry", async () => { mocks.loadSessionEntry.mockReturnValue({ cfg: { session: { mainKey: "work" }, agents: { list: [{ id: "main", default: true }] }, }, storePath: "/tmp/sessions.json", entry: { sessionId: "existing-session-id", updatedAt: Date.now(), }, canonicalKey: "agent:main:work", }); let capturedStore: Record | undefined; mocks.updateSessionStore.mockImplementation(async (_path, updater) => { const store: Record = { "agent:main:work": { sessionId: "existing-session-id", updatedAt: 10 }, "agent:main:MAIN": { sessionId: "legacy-session-id", updatedAt: 5 }, }; await updater(store); capturedStore = store; }); mocks.agentCommand.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 100 }, }); await invokeAgent( { message: "test", agentId: "main", sessionKey: "main", idempotencyKey: "test-idem-alias-prune", }, { reqId: "3" }, ); expect(mocks.updateSessionStore).toHaveBeenCalled(); expect(capturedStore).toBeDefined(); expect(capturedStore?.["agent:main:work"]).toBeDefined(); expect(capturedStore?.["agent:main:MAIN"]).toBeUndefined(); }); it("handles bare /new by resetting the same session and sending reset greeting prompt", async () => { mockSessionResetSuccess({ reason: "new" }); primeMainAgentRun({ sessionId: "reset-session-id" }); await invokeAgent( { message: "/new", sessionKey: "agent:main:main", idempotencyKey: "test-idem-new", }, { reqId: "4" }, ); const call = await expectResetCall(BARE_SESSION_RESET_PROMPT); expect(call?.message).toContain("Execute your Session Startup sequence now"); expect(call?.sessionId).toBe("reset-session-id"); }); it("uses /reset suffix as the post-reset message and still injects timestamp", async () => { setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z"); mockSessionResetSuccess({ reason: "reset" }); 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" }, ); const call = await expectResetCall("[Wed 2026-01-28 20:30 EST] check status"); expect(call?.sessionId).toBe("reset-session-id"); resetTimeConfig(); }); it("rejects malformed agent session keys early in agent handler", async () => { mocks.agentCommand.mockClear(); const respond = await invokeAgent( { message: "test", sessionKey: "agent:main", idempotencyKey: "test-malformed-session-key", }, { reqId: "4" }, ); expect(mocks.agentCommand).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("malformed session key"), }), ); }); it("rejects malformed session keys in agent.identity.get", async () => { const respond = await invokeAgentIdentityGet( { sessionKey: "agent:main", }, { reqId: "5" }, ); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("malformed session key"), }), ); }); });