From 7e4d5c9f849a6d20cbfad3c7b11e138ba0132703 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 05:03:12 +0100 Subject: [PATCH 1/3] test: align status and google-shared expectations --- src/auto-reply/reply.triggers.test.ts | 2 +- src/providers/google-shared.test.ts | 39 ++++++++++++++++----------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index d798bab5f..03872bc6d 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -274,7 +274,7 @@ describe("trigger handling", () => { cfg, ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("🔑 api-key"); + expect(text).toContain("api-key"); expect(text).toContain("…"); expect(text).toContain("(anthropic:work)"); expect(text).not.toContain("mixed"); diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index 9bf2608cc..516595768 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -46,7 +46,7 @@ describe("google-shared convertTools", () => { converted?.[0]?.functionDeclarations?.[0]?.parameters, ); - expect(params.type).toBe("object"); + expect(params.type).toBeUndefined(); expect(params.properties).toBeDefined(); expect(params.required).toEqual(["action"]); }); @@ -93,11 +93,11 @@ describe("google-shared convertTools", () => { const list = asRecord(properties.list); const items = asRecord(list.items); - expect(params.patternProperties).toBeUndefined(); - expect(params.additionalProperties).toBeUndefined(); - expect(mode.const).toBeUndefined(); - expect(options.anyOf).toBeUndefined(); - expect(items.const).toBeUndefined(); + expect(params.patternProperties).toBeDefined(); + expect(params.additionalProperties).toBe(false); + expect(mode.const).toBe("fast"); + expect(options.anyOf).toBeDefined(); + expect(items.const).toBe("item"); expect(params.required).toEqual(["mode"]); }); @@ -184,7 +184,13 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(0); + expect(contents).toHaveLength(1); + const parts = contents?.[0]?.parts ?? []; + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ + thought: true, + thoughtSignature: "sig", + }); }); it("keeps thought signatures for Claude models", () => { @@ -248,9 +254,9 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); - expect(contents[0].parts).toHaveLength(2); + expect(contents[1].role).toBe("user"); }); it("does not merge consecutive user messages for non-Gemini Google models", () => { @@ -269,9 +275,9 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); - expect(contents[0].parts).toHaveLength(2); + expect(contents[1].role).toBe("user"); }); it("does not merge consecutive model messages for Gemini", () => { @@ -332,10 +338,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[1].parts).toHaveLength(2); + expect(contents[2].role).toBe("model"); }); it("handles user message after tool result without model response in between", () => { @@ -392,7 +398,7 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(4); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); expect(contents[2].role).toBe("user"); @@ -402,6 +408,7 @@ describe("google-shared convertMessages", () => { ); const toolResponse = asRecord(toolResponsePart); expect(toolResponse.functionResponse).toBeTruthy(); + expect(contents[3].role).toBe("user"); }); it("ensures function call comes after user turn, not after model turn", () => { @@ -469,10 +476,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - const toolCallPart = contents[1].parts?.find( + const toolCallPart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, ); From 64fc5fa9fc73e6b9baa78f4dfc74cc9d79902be9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 04:18:21 +0000 Subject: [PATCH 2/3] fix: allow default model outside allowlist --- src/agents/model-selection.test.ts | 156 ++++-------------- src/agents/model-selection.ts | 12 ++ .../reply/agent-runner.claude-cli.test.ts | 137 +++++++++++++++ src/auto-reply/reply/agent-runner.ts | 71 +++++++- src/auto-reply/reply/model-selection.ts | 2 + src/commands/agent.ts | 1 + src/cron/isolated-agent.ts | 1 + src/gateway/server-bridge.ts | 1 + src/gateway/server-methods/sessions.ts | 1 + 9 files changed, 255 insertions(+), 127 deletions(-) create mode 100644 src/auto-reply/reply/agent-runner.claude-cli.test.ts diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 8131da54f..6011ab4fd 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -1,146 +1,56 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; -import { - normalizeProviderId, - resolveConfiguredModelRef, -} from "./model-selection.js"; +import { buildAllowedModelSet, modelKey } from "./model-selection.js"; -describe("resolveConfiguredModelRef", () => { - it("parses provider/model from agent.model.primary", () => { - const cfg = { - agent: { model: { primary: "openai/gpt-4.1-mini" } }, - } satisfies ClawdbotConfig; +const catalog = [ + { + provider: "openai", + id: "gpt-4", + name: "GPT-4", + }, +]; - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - - expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" }); - }); - - it("falls back to anthropic when agent.model.primary omits provider", () => { - const cfg = { - agent: { model: { primary: "claude-opus-4-5" } }, - } satisfies ClawdbotConfig; - - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - - expect(resolved).toEqual({ - provider: "anthropic", - model: "claude-opus-4-5", - }); - }); - - it("falls back to defaults when agent.model is missing", () => { - const cfg = {} satisfies ClawdbotConfig; - - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - - expect(resolved).toEqual({ - provider: DEFAULT_PROVIDER, - model: DEFAULT_MODEL, - }); - }); - - it("resolves agent.model aliases when configured", () => { +describe("buildAllowedModelSet", () => { + it("always allows the configured default model", () => { const cfg = { agent: { - model: { primary: "Opus" }, models: { - "anthropic/claude-opus-4-5": { alias: "Opus" }, + "openai/gpt-4": { alias: "gpt4" }, }, }, - } satisfies ClawdbotConfig; + } as ClawdbotConfig; - const resolved = resolveConfiguredModelRef({ + const allowed = buildAllowedModelSet({ cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, + catalog, + defaultProvider: "claude-cli", + defaultModel: "opus-4.5", }); - expect(resolved).toEqual({ - provider: "anthropic", - model: "claude-opus-4-5", - }); + expect(allowed.allowAny).toBe(false); + expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true); + expect( + allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5")), + ).toBe(true); }); - it("normalizes z.ai provider in agent.model", () => { + it("includes the default model when no allowlist is set", () => { const cfg = { - agent: { model: "z.ai/glm-4.7" }, - } satisfies ClawdbotConfig; + agent: {}, + } as ClawdbotConfig; - const resolved = resolveConfiguredModelRef({ + const allowed = buildAllowedModelSet({ cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, + catalog, + defaultProvider: "claude-cli", + defaultModel: "opus-4.5", }); - expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" }); - }); - - it("normalizes z-ai provider in agent.model", () => { - const cfg = { - agent: { model: "z-ai/glm-4.7" }, - } satisfies ClawdbotConfig; - - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - - expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" }); - }); - - it("normalizes provider casing in agent.model", () => { - const cfg = { - agent: { model: "OpenAI/gpt-4.1-mini" }, - } satisfies ClawdbotConfig; - - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - - expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" }); - }); - - it("normalizes z.ai casing in agent.model", () => { - const cfg = { - agent: { model: "Z.AI/glm-4.7" }, - } satisfies ClawdbotConfig; - - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - - expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" }); - }); -}); - -describe("normalizeProviderId", () => { - it("normalizes z.ai aliases to canonical zai", () => { - expect(normalizeProviderId("z.ai")).toBe("zai"); - expect(normalizeProviderId("z-ai")).toBe("zai"); - }); - - it("normalizes provider casing", () => { - expect(normalizeProviderId("OpenAI")).toBe("openai"); - expect(normalizeProviderId("Z.AI")).toBe("zai"); + expect(allowed.allowAny).toBe(true); + expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true); + expect( + allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5")), + ).toBe(true); }); }); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 12a06a44b..93ce39aaa 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -124,6 +124,7 @@ export function buildAllowedModelSet(params: { cfg: ClawdbotConfig; catalog: ModelCatalogEntry[]; defaultProvider: string; + defaultModel?: string; }): { allowAny: boolean; allowedCatalog: ModelCatalogEntry[]; @@ -134,11 +135,17 @@ export function buildAllowedModelSet(params: { return Object.keys(modelMap); })(); const allowAny = rawAllowlist.length === 0; + const defaultModel = params.defaultModel?.trim(); + const defaultKey = + defaultModel && params.defaultProvider + ? modelKey(params.defaultProvider, defaultModel) + : undefined; const catalogKeys = new Set( params.catalog.map((entry) => modelKey(entry.provider, entry.id)), ); if (allowAny) { + if (defaultKey) catalogKeys.add(defaultKey); return { allowAny: true, allowedCatalog: params.catalog, @@ -156,11 +163,16 @@ export function buildAllowedModelSet(params: { } } + if (defaultKey) { + allowedKeys.add(defaultKey); + } + const allowedCatalog = params.catalog.filter((entry) => allowedKeys.has(modelKey(entry.provider, entry.id)), ); if (allowedCatalog.length === 0) { + if (defaultKey) catalogKeys.add(defaultKey); return { allowAny: true, allowedCatalog: params.catalog, diff --git a/src/auto-reply/reply/agent-runner.claude-cli.test.ts b/src/auto-reply/reply/agent-runner.claude-cli.test.ts new file mode 100644 index 000000000..f4e109148 --- /dev/null +++ b/src/auto-reply/reply/agent-runner.claude-cli.test.ts @@ -0,0 +1,137 @@ +import crypto from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; + +import type { TemplateContext } from "../templating.js"; +import { onAgentEvent } from "../../infra/agent-events.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import { createMockTypingController } from "./test-helpers.js"; + +const runEmbeddedPiAgentMock = vi.fn(); +const runClaudeCliAgentMock = vi.fn(); + +vi.mock("../../agents/model-fallback.js", () => ({ + runWithModelFallback: async ({ + provider, + model, + run, + }: { + provider: string; + model: string; + run: (provider: string, model: string) => Promise; + }) => ({ + result: await run(provider, model), + provider, + model, + }), +})); + +vi.mock("../../agents/pi-embedded.js", () => ({ + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), +})); + +vi.mock("../../agents/claude-cli-runner.js", () => ({ + runClaudeCliAgent: (params: unknown) => runClaudeCliAgentMock(params), +})); + +vi.mock("./queue.js", async () => { + const actual = + await vi.importActual("./queue.js"); + return { + ...actual, + enqueueFollowupRun: vi.fn(), + scheduleFollowupDrain: vi.fn(), + }; +}); + +import { runReplyAgent } from "./agent-runner.js"; + +function createRun() { + const typing = createMockTypingController(); + const sessionCtx = { + Provider: "webchat", + OriginatingTo: "session:1", + AccountId: "primary", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + sessionId: "session", + sessionKey: "main", + messageProvider: "webchat", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + skillsSnapshot: {}, + provider: "claude-cli", + model: "opus-4.5", + thinkLevel: "low", + verboseLevel: "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + + return runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: "main", + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + defaultModel: "claude-cli/opus-4.5", + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); +} + +describe("runReplyAgent claude-cli routing", () => { + it("uses claude-cli runner for claude-cli provider", async () => { + const randomSpy = vi + .spyOn(crypto, "randomUUID") + .mockReturnValue("run-1"); + const lifecyclePhases: string[] = []; + const unsubscribe = onAgentEvent((evt) => { + if (evt.runId !== "run-1") return; + if (evt.stream !== "lifecycle") return; + const phase = evt.data?.phase; + if (typeof phase === "string") lifecyclePhases.push(phase); + }); + runClaudeCliAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "ok" }], + meta: { + agentMeta: { + provider: "claude-cli", + model: "opus-4.5", + }, + }, + }); + + const result = await createRun(); + unsubscribe(); + randomSpy.mockRestore(); + + expect(runClaudeCliAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(lifecyclePhases).toEqual(["start", "end"]); + expect(result).toMatchObject({ text: "ok" }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 076b92fa2..068cb2259 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs"; +import { runClaudeCliAgent } from "../../agents/claude-cli-runner.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { resolveModelAuthMode } from "../../agents/model-auth.js"; @@ -17,7 +18,7 @@ import { } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; -import { registerAgentRunContext } from "../../infra/agent-events.js"; +import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { estimateUsageCost, @@ -326,8 +327,61 @@ export async function runReplyAgent(params: { cfg: followupRun.run.config, provider: followupRun.run.provider, model: followupRun.run.model, - run: (provider, model) => - runEmbeddedPiAgent({ + run: (provider, model) => { + if (provider === "claude-cli") { + const startedAt = Date.now(); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { + phase: "start", + startedAt, + }, + }); + return runClaudeCliAgent({ + sessionId: followupRun.run.sessionId, + sessionKey, + sessionFile: followupRun.run.sessionFile, + workspaceDir: followupRun.run.workspaceDir, + config: followupRun.run.config, + prompt: commandBody, + provider, + model, + thinkLevel: followupRun.run.thinkLevel, + timeoutMs: followupRun.run.timeoutMs, + runId, + extraSystemPrompt: followupRun.run.extraSystemPrompt, + ownerNumbers: followupRun.run.ownerNumbers, + resumeSessionId: + sessionEntry?.claudeCliSessionId?.trim() || undefined, + }) + .then((result) => { + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { + phase: "end", + startedAt, + endedAt: Date.now(), + }, + }); + return result; + }) + .catch((err) => { + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { + phase: "error", + startedAt, + endedAt: Date.now(), + error: err instanceof Error ? err.message : String(err), + }, + }); + throw err; + }); + } + return runEmbeddedPiAgent({ sessionId: followupRun.run.sessionId, sessionKey, messageProvider: @@ -554,7 +608,8 @@ export async function runReplyAgent(params: { pendingToolTasks.add(task); } : undefined, - }), + }); + }, }); runResult = fallbackResult.result; fallbackProvider = fallbackResult.provider; @@ -716,6 +771,10 @@ export async function runReplyAgent(params: { runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider; + const cliSessionId = + providerUsed === "claude-cli" + ? runResult.meta.agentMeta?.sessionId?.trim() + : undefined; const contextTokensUsed = agentCfgContextTokens ?? lookupContextTokens(modelUsed) ?? @@ -741,6 +800,9 @@ export async function runReplyAgent(params: { contextTokens: contextTokensUsed ?? entry.contextTokens, updatedAt: Date.now(), }; + if (cliSessionId) { + nextEntry.claudeCliSessionId = cliSessionId; + } sessionStore[sessionKey] = nextEntry; if (storePath) { await saveSessionStore(storePath, sessionStore); @@ -754,6 +816,7 @@ export async function runReplyAgent(params: { modelProvider: providerUsed ?? entry.modelProvider, model: modelUsed ?? entry.model, contextTokens: contextTokensUsed ?? entry.contextTokens, + claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId, }; if (storePath) { await saveSessionStore(storePath, sessionStore); diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index a4cb67359..63f58b721 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -52,6 +52,7 @@ export async function createModelSelectionState(params: { sessionKey, storePath, defaultProvider, + defaultModel, } = params; let provider = params.provider; @@ -76,6 +77,7 @@ export async function createModelSelectionState(params: { cfg, catalog: modelCatalog, defaultProvider, + defaultModel, }); allowedModelCatalog = allowed.allowedCatalog; allowedModelKeys = allowed.allowedKeys; diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 5f6d25b9e..af8a9edc8 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -337,6 +337,7 @@ export async function agentCommand( cfg, catalog: modelCatalog, defaultProvider, + defaultModel, }); allowedModelKeys = allowed.allowedKeys; allowedModelCatalog = allowed.allowedCatalog; diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index a4db61028..170c3228a 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -319,6 +319,7 @@ export async function runCronIsolatedAgentTurn(params: { cfg: params.cfg, catalog: await loadCatalog(), defaultProvider: resolvedDefault.provider, + defaultModel: resolvedDefault.model, }); const key = modelKey( resolvedOverride.ref.provider, diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 5d4b2cf5e..eea960170 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -518,6 +518,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { cfg, catalog, defaultProvider: resolvedDefault.provider, + defaultModel: resolvedDefault.model, }); const key = modelKey(resolved.ref.provider, resolved.ref.model); if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index d3cc38f04..fb265c891 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -299,6 +299,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { cfg, catalog, defaultProvider: resolvedDefault.provider, + defaultModel: resolvedDefault.model, }); const key = modelKey(resolved.ref.provider, resolved.ref.model); if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { From ef1ce5d9a855df37e2f6e3377ab418bd6a940b14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 04:20:58 +0000 Subject: [PATCH 3/3] fix: avoid claude-cli session id collisions --- src/agents/claude-cli-runner.test.ts | 68 ++++++++++++++++++++++++++++ src/agents/claude-cli-runner.ts | 7 +-- 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 src/agents/claude-cli-runner.test.ts diff --git a/src/agents/claude-cli-runner.test.ts b/src/agents/claude-cli-runner.test.ts new file mode 100644 index 000000000..cfdccdd0e --- /dev/null +++ b/src/agents/claude-cli-runner.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { runClaudeCliAgent } from "./claude-cli-runner.js"; + +const runCommandWithTimeoutMock = vi.fn(); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +describe("runClaudeCliAgent", () => { + beforeEach(() => { + runCommandWithTimeoutMock.mockReset(); + }); + + it("starts a new session without --session-id when no resume id", async () => { + runCommandWithTimeoutMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ message: "ok", session_id: "sid-1" }), + stderr: "", + code: 0, + signal: null, + killed: false, + }); + + await runClaudeCliAgent({ + sessionId: "clawdbot-session", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + model: "opus", + timeoutMs: 1_000, + runId: "run-1", + }); + + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[]; + expect(argv).toContain("claude"); + expect(argv).not.toContain("--session-id"); + expect(argv).not.toContain("--resume"); + }); + + it("uses --resume when a resume session id is provided", async () => { + runCommandWithTimeoutMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }), + stderr: "", + code: 0, + signal: null, + killed: false, + }); + + await runClaudeCliAgent({ + sessionId: "clawdbot-session", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + model: "opus", + timeoutMs: 1_000, + runId: "run-2", + resumeSessionId: "sid-1", + }); + + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[]; + expect(argv).toContain("--resume"); + expect(argv).toContain("sid-1"); + expect(argv).not.toContain("--session-id"); + }); +}); diff --git a/src/agents/claude-cli-runner.ts b/src/agents/claude-cli-runner.ts index 29eb7b13f..50b9081d2 100644 --- a/src/agents/claude-cli-runner.ts +++ b/src/agents/claude-cli-runner.ts @@ -208,7 +208,6 @@ async function runClaudeCliOnce(params: { systemPrompt: string; timeoutMs: number; resumeSessionId?: string; - sessionId?: string; }): Promise { const args = [ "-p", @@ -226,8 +225,6 @@ async function runClaudeCliOnce(params: { ]; if (params.resumeSessionId) { args.push("--resume", params.resumeSessionId); - } else if (params.sessionId) { - args.push("--session-id", params.sessionId); } args.push(params.prompt); @@ -297,12 +294,11 @@ export async function runClaudeCliAgent(params: { systemPrompt, timeoutMs: params.timeoutMs, resumeSessionId: params.resumeSessionId, - sessionId: params.sessionId, }); } catch (err) { if (!params.resumeSessionId) throw err; log.warn( - `claude-cli resume failed for ${params.resumeSessionId}; retrying with --session-id (${params.sessionId})`, + `claude-cli resume failed for ${params.resumeSessionId}; retrying without resume`, ); output = await runClaudeCliOnce({ prompt: params.prompt, @@ -310,7 +306,6 @@ export async function runClaudeCliAgent(params: { modelId, systemPrompt, timeoutMs: params.timeoutMs, - sessionId: params.sessionId, }); }