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
This commit is contained in:
Ross Morsali
2026-01-25 19:56:04 +01:00
committed by Peter Steinberger
parent 68824c8903
commit ffaeee4c39
4 changed files with 174 additions and 0 deletions

View File

@@ -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"`

View File

@@ -28,6 +28,14 @@ const CLAUDE_MODEL_ALIASES: Record<string, string> = {
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",

View File

@@ -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<typeof import("../../config/sessions.js")>(
"../../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<typeof import("../../utils/delivery-context.js")>(
"../../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<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {};
await updater(store);
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
});
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<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {};
await updater(store);
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
});
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();
});
});

View File

@@ -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({