From cd36ff748369eefa700f7b67485056296788cc5f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 17:45:29 +0900 Subject: [PATCH] fix: resume explicit session-id agent runs --- CHANGELOG.md | 1 + src/agents/command/session.ts | 13 ++++- src/commands/agent.test.ts | 47 +++++++++++++++++ src/commands/agent/session-store.test.ts | 64 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb3df26e105..8fd57da3a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) - Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) - MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) +- Agents/Claude CLI: persist explicit `openclaw agent --session-id` runs under a stable session key so follow-ups can reuse the stored CLI binding and resume the same underlying Claude session. - Auth/failover: persist selected fallback overrides before retrying, shorten `auth_permanent` lockouts, and refresh websocket/shared-auth sessions only when real auth changes occur so retries and secret rotations behave predictably. (#60404, #60323, #60387) - Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth. - Plugins/media understanding: enable bundled Groq and Deepgram providers by default so configured transcription models work without extra plugin activation config. (#59982) Thanks @yxjsxy. diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index 763995bf1b2..f46cd53e8bc 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -19,7 +19,7 @@ import { resolveStorePath, type SessionEntry, } from "../../config/sessions.js"; -import { normalizeMainKey } from "../../routing/session-key.js"; +import { normalizeAgentId, normalizeMainKey } from "../../routing/session-key.js"; import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js"; import { listAgentIds } from "../agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js"; @@ -47,6 +47,10 @@ type SessionIdMatchSet = { storeByKey: Map; }; +function buildExplicitSessionIdSessionKey(params: { sessionId: string; agentId?: string }): string { + return `agent:${normalizeAgentId(params.agentId)}:explicit:${params.sessionId.trim()}`; +} + function collectSessionIdMatchesForRequest(opts: { cfg: OpenClawConfig; sessionStore: Record; @@ -146,6 +150,13 @@ export function resolveSessionKeyForRequest(opts: { } } + if (opts.sessionId && !sessionKey) { + sessionKey = buildExplicitSessionIdSessionKey({ + sessionId: opts.sessionId, + agentId: opts.agentId, + }); + } + return { sessionKey, sessionStore, storePath }; } diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index daba257bd41..085eb20d6af 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -7,6 +7,7 @@ import { __testing as acpManagerTesting } from "../acp/control-plane/manager.js" import { resolveAgentDir, resolveSessionAgentId } from "../agents/agent-scope.js"; import * as authProfilesModule from "../agents/auth-profiles.js"; import * as cliRunnerModule from "../agents/cli-runner.js"; +import * as sessionStoreModule from "../agents/command/session-store.js"; import { resolveSession } from "../agents/command/session.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; @@ -555,6 +556,52 @@ describe("agentCommand", () => { }); }); + it("creates a stable session key for explicit session-id-only runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + const cfg = mockConfig(home, store); + + const resolution = resolveSession({ cfg, sessionId: "explicit-session-123" }); + + expect(resolution.sessionKey).toBe("agent:main:explicit:explicit-session-123"); + expect(resolution.sessionId).toBe("explicit-session-123"); + }); + }); + + it("persists explicit session-id-only CLI runs with the synthetic session key", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, { + model: { primary: "claude-cli/claude-sonnet-4-6" }, + models: { "claude-cli/claude-sonnet-4-6": {} }, + }); + vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => true); + runCliAgentSpy.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { + sessionId: "claude-cli-session-1", + provider: "claude-cli", + model: "claude-sonnet-4-6", + cliSessionBinding: { + sessionId: "claude-cli-session-1", + }, + }, + }, + } as never); + + await agentCommand({ message: "resume me", sessionId: "explicit-session-123" }, runtime); + + expect(vi.mocked(sessionStoreModule.updateSessionStoreAfterAgentRun)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "explicit-session-123", + sessionKey: "agent:main:explicit:explicit-session-123", + }), + ); + }); + }); + it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => { await withCrossAgentResumeFixture(async ({ sessionId, sessionKey, cfg }) => { const resolution = resolveSession({ cfg, sessionId }); diff --git a/src/commands/agent/session-store.test.ts b/src/commands/agent/session-store.test.ts index 480a0f097fa..1cdaf1a2cbc 100644 --- a/src/commands/agent/session-store.test.ts +++ b/src/commands/agent/session-store.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolveSession } from "../../agents/command/session.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionStore } from "../../config/sessions.js"; import { updateSessionStoreAfterAgentRun } from "./session-store.js"; @@ -124,4 +125,67 @@ describe("updateSessionStoreAfterAgentRun", () => { "once", ); }); + + it("stores and reloads CLI bindings for explicit session-id-only runs", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")); + const storePath = path.join(dir, "sessions.json"); + const cfg = { + session: { + store: storePath, + mainKey: "main", + }, + agents: { + defaults: { + cliBackends: { + "claude-cli": {}, + }, + }, + }, + } as never; + + const first = resolveSession({ + cfg, + sessionId: "explicit-session-123", + }); + + expect(first.sessionKey).toBe("agent:main:explicit:explicit-session-123"); + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId: first.sessionId, + sessionKey: first.sessionKey!, + storePath: first.storePath, + sessionStore: first.sessionStore!, + defaultProvider: "claude-cli", + defaultModel: "claude-sonnet-4-6", + result: { + payloads: [], + meta: { + agentMeta: { + provider: "claude-cli", + model: "claude-sonnet-4-6", + sessionId: "claude-cli-session-1", + cliSessionBinding: { + sessionId: "claude-cli-session-1", + }, + }, + }, + } as never, + }); + + const second = resolveSession({ + cfg, + sessionId: "explicit-session-123", + }); + + expect(second.sessionKey).toBe(first.sessionKey); + expect(second.sessionEntry?.cliSessionBindings?.["claude-cli"]).toEqual({ + sessionId: "claude-cli-session-1", + }); + + const persisted = loadSessionStore(storePath, { skipCache: true })[first.sessionKey!]; + expect(persisted?.cliSessionBindings?.["claude-cli"]).toEqual({ + sessionId: "claude-cli-session-1", + }); + }); });