fix: resume explicit session-id agent runs

This commit is contained in:
Peter Steinberger 2026-04-04 17:45:29 +09:00
parent 87f512f80d
commit cd36ff7483
No known key found for this signature in database
4 changed files with 124 additions and 1 deletions

View File

@ -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.

View File

@ -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<string, SessionKeyResolution>;
};
function buildExplicitSessionIdSessionKey(params: { sessionId: string; agentId?: string }): string {
return `agent:${normalizeAgentId(params.agentId)}:explicit:${params.sessionId.trim()}`;
}
function collectSessionIdMatchesForRequest(opts: {
cfg: OpenClawConfig;
sessionStore: Record<string, SessionEntry>;
@ -146,6 +150,13 @@ export function resolveSessionKeyForRequest(opts: {
}
}
if (opts.sessionId && !sessionKey) {
sessionKey = buildExplicitSessionIdSessionKey({
sessionId: opts.sessionId,
agentId: opts.agentId,
});
}
return { sessionKey, sessionStore, storePath };
}

View File

@ -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 });

View File

@ -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",
});
});
});