From ffaeee4c39cbb56be473e719ded16e6b6b8d8986 Mon Sep 17 00:00:00 2001 From: Ross Morsali Date: Sun, 25 Jan 2026 19:56:04 +0100 Subject: [PATCH] fix: preserve CLI session IDs for session resume - Add resumeArgs to DEFAULT_CLAUDE_BACKEND for proper --resume flag usage - Fix gateway not preserving cliSessionIds/claudeCliSessionId in nextEntry - Add test for CLI session ID preservation in gateway agent handler - Update docs with new resumeArgs default --- docs/gateway/cli-backends.md | 1 + src/agents/cli-backends.ts | 8 ++ src/gateway/server-methods/agent.test.ts | 163 +++++++++++++++++++++++ src/gateway/server-methods/agent.ts | 2 + 4 files changed, 174 insertions(+) create mode 100644 src/gateway/server-methods/agent.test.ts diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 917145cc2..092533c2e 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -182,6 +182,7 @@ Clawdbot ships a default for `claude-cli`: - `command: "claude"` - `args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"]` +- `resumeArgs: ["-p", "--output-format", "json", "--dangerously-skip-permissions", "--resume", "{sessionId}"]` - `modelArg: "--model"` - `systemPromptArg: "--append-system-prompt"` - `sessionArg: "--session-id"` diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index a2fcaa8a5..f21c04f52 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -28,6 +28,14 @@ const CLAUDE_MODEL_ALIASES: Record = { const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = { command: "claude", args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"], + resumeArgs: [ + "-p", + "--output-format", + "json", + "--dangerously-skip-permissions", + "--resume", + "{sessionId}", + ], output: "json", input: "arg", modelArg: "--model", diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts new file mode 100644 index 000000000..149ab4a67 --- /dev/null +++ b/src/gateway/server-methods/agent.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { GatewayRequestContext } from "./types.js"; +import { agentHandlers } from "./agent.js"; + +const mocks = vi.hoisted(() => ({ + loadSessionEntry: vi.fn(), + updateSessionStore: vi.fn(), + agentCommand: vi.fn(), + registerAgentRunContext: vi.fn(), +})); + +vi.mock("../session-utils.js", () => ({ + 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: () => "agent:main:main", + }; +}); + +vi.mock("../../commands/agent.js", () => ({ + agentCommand: mocks.agentCommand, +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + listAgentIds: () => ["main"], +})); + +vi.mock("../../infra/agent-events.js", () => ({ + registerAgentRunContext: mocks.registerAgentRunContext, + onAgentEvent: vi.fn(), +})); + +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; + +describe("gateway agent handler", () => { + it("preserves cliSessionIds from existing session entry", async () => { + const existingCliSessionIds = { "claude-cli": "abc-123-def" }; + const existingClaudeCliSessionId = "abc-123-def"; + + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "existing-session-id", + updatedAt: Date.now(), + cliSessionIds: existingCliSessionIds, + claudeCliSessionId: existingClaudeCliSessionId, + }, + canonicalKey: "agent:main:main", + }); + + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = {}; + await updater(store); + capturedEntry = store["agent:main:main"] as Record; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + const respond = vi.fn(); + await agentHandlers.agent({ + params: { + message: "test", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-idem", + }, + respond, + context: makeContext(), + req: { type: "req", id: "1", method: "agent" }, + client: null, + isWebchatConnect: () => false, + }); + + expect(mocks.updateSessionStore).toHaveBeenCalled(); + expect(capturedEntry).toBeDefined(); + expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds); + expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId); + }); + + it("handles missing cliSessionIds gracefully", async () => { + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "existing-session-id", + updatedAt: Date.now(), + // No cliSessionIds or claudeCliSessionId + }, + canonicalKey: "agent:main:main", + }); + + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = {}; + await updater(store); + capturedEntry = store["agent:main:main"] as Record; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + const respond = vi.fn(); + await agentHandlers.agent({ + params: { + message: "test", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-idem-2", + }, + respond, + context: makeContext(), + req: { type: "req", id: "2", method: "agent" }, + client: null, + isWebchatConnect: () => false, + }); + + expect(mocks.updateSessionStore).toHaveBeenCalled(); + expect(capturedEntry).toBeDefined(); + // Should be undefined, not cause an error + expect(capturedEntry?.cliSessionIds).toBeUndefined(); + expect(capturedEntry?.claudeCliSessionId).toBeUndefined(); + }); +}); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 8c5782e00..d159d1f78 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -251,6 +251,8 @@ export const agentHandlers: GatewayRequestHandlers = { groupId: resolvedGroupId ?? entry?.groupId, groupChannel: resolvedGroupChannel ?? entry?.groupChannel, space: resolvedGroupSpace ?? entry?.space, + cliSessionIds: entry?.cliSessionIds, + claudeCliSessionId: entry?.claudeCliSessionId, }; sessionEntry = nextEntry; const sendPolicy = resolveSendPolicy({