fix(agents): backfill missing sessionKey in embedded PI runner — prevents undefined key in model selection and live-switch (#60555)

Merged via squash.

Prepared head SHA: 8081345f1c
Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
EVA
2026-04-06 23:51:05 +07:00
committed by GitHub
parent b4e1747391
commit 594ea6e1b9
6 changed files with 337 additions and 16 deletions

View File

@@ -57,7 +57,7 @@ Docs: https://docs.openclaw.ai
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
- Agents/session keys: backfill `sessionKey` from `sessionId` in the embedded PI runner when callers omit it, so hooks, LCM, and compaction receive a valid key; also normalize whitespace-only session keys to `undefined` before downstream consumers see them. (#60555) Thanks @100yenadmin.
## 2026.4.5
### Breaking

View File

@@ -25,7 +25,8 @@ vi.mock("../agent-scope.js", () => ({
listAgentIds: () => hoisted.listAgentIdsMock(),
}));
const { resolveSessionKeyForRequest } = await import("./session.js");
const { resolveSessionKeyForRequest, resolveStoredSessionKeyForSessionId } =
await import("./session.js");
describe("resolveSessionKeyForRequest", () => {
beforeEach(() => {
@@ -95,4 +96,32 @@ describe("resolveSessionKeyForRequest", () => {
expect(result.sessionStore).toBe(otherStore);
expect(result.storePath).toBe("/stores/other.json");
});
it("scopes stored session-key lookup to the requested agent store", () => {
const embeddedAgentStore = {
"agent:embedded-agent:main": { sessionId: "other-session", updatedAt: 2 },
"agent:embedded-agent:work": { sessionId: "resume-agent-1", updatedAt: 1 },
} satisfies Record<string, SessionEntry>;
hoisted.loadSessionStoreMock.mockImplementation((storePath) => {
if (storePath === "/stores/embedded-agent.json") {
return embeddedAgentStore;
}
return {};
});
const result = resolveStoredSessionKeyForSessionId({
cfg: {
session: {
store: "/stores/{agentId}.json",
},
} satisfies OpenClawConfig,
sessionId: "resume-agent-1",
agentId: "embedded-agent",
});
expect(result.sessionKey).toBe("agent:embedded-agent:work");
expect(result.sessionStore).toBe(embeddedAgentStore);
expect(result.storePath).toBe("/stores/embedded-agent.json");
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -95,6 +95,37 @@ function collectSessionIdMatchesForRequest(opts: {
return { matches, primaryStoreMatches, storeByKey };
}
/**
* Resolve an existing stored session key for a session id from a specific agent store.
* This scopes the lookup to the target store without implicitly converting `agentId`
* into that agent's main session key.
*/
export function resolveStoredSessionKeyForSessionId(opts: {
cfg: OpenClawConfig;
sessionId: string;
agentId?: string;
}): SessionKeyResolution {
const sessionId = opts.sessionId.trim();
const storeAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined;
const storePath = resolveStorePath(opts.cfg.session?.store, {
agentId: storeAgentId,
});
const sessionStore = loadSessionStore(storePath);
if (!sessionId) {
return { sessionKey: undefined, sessionStore, storePath };
}
const selection = resolveSessionIdMatchSelection(
Object.entries(sessionStore).filter(([, entry]) => entry?.sessionId === sessionId),
sessionId,
);
return {
sessionKey: selection.kind === "selected" ? selection.sessionKey : undefined,
sessionStore,
storePath,
};
}
export function resolveSessionKeyForRequest(opts: {
cfg: OpenClawConfig;
to?: string;
@@ -121,9 +152,10 @@ export function resolveSessionKeyForRequest(opts: {
let sessionKey: string | undefined =
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
// If a session id was provided, prefer to re-use its entry (by id) even when no key was derived.
// When duplicates exist across agent stores, pick the same deterministic best match used by the
// shared gateway/session resolver helpers instead of whichever store happens to be scanned first.
// If a session id was provided, prefer to re-use its existing entry (by id) even when no key was
// derived. When duplicates exist across agent stores, pick the same deterministic best match used
// by the shared gateway/session resolver helpers instead of whichever store happens to be scanned
// first.
if (
opts.sessionId &&
!explicitSessionKey &&

View File

@@ -18,6 +18,9 @@ const runEmbeddedAttemptMock = vi.fn();
const disposeSessionMcpRuntimeMock = vi.fn<(sessionId: string) => Promise<void>>(async () => {
return undefined;
});
const resolveSessionKeyForRequestMock = vi.fn();
const resolveStoredSessionKeyForSessionIdMock = vi.fn();
const loggerWarnMock = vi.fn();
let refreshRuntimeAuthOnFirstPromptError = false;
vi.mock("@mariozechner/pi-ai", async () => {
@@ -95,6 +98,28 @@ const installRunEmbeddedMocks = () => {
vi.doMock("./runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: vi.fn(),
}));
vi.doMock("./command/session.js", async () => {
const actual =
await vi.importActual<typeof import("./command/session.js")>("./command/session.js");
return {
...actual,
resolveSessionKeyForRequest: (opts: unknown) => resolveSessionKeyForRequestMock(opts),
resolveStoredSessionKeyForSessionId: (opts: unknown) =>
resolveStoredSessionKeyForSessionIdMock(opts),
};
});
vi.doMock("./pi-embedded-runner/logger.js", async () => {
const actual = await vi.importActual<typeof import("./pi-embedded-runner/logger.js")>(
"./pi-embedded-runner/logger.js",
);
return {
...actual,
log: {
...actual.log,
warn: (...args: unknown[]) => loggerWarnMock(...args),
},
};
});
vi.doMock("./pi-embedded-runner/run/attempt.js", () => ({
runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params),
}));
@@ -166,6 +191,9 @@ beforeEach(() => {
vi.useRealTimers();
runEmbeddedAttemptMock.mockReset();
disposeSessionMcpRuntimeMock.mockReset();
resolveSessionKeyForRequestMock.mockReset();
resolveStoredSessionKeyForSessionIdMock.mockReset();
loggerWarnMock.mockReset();
refreshRuntimeAuthOnFirstPromptError = false;
runEmbeddedAttemptMock.mockImplementation(async () => {
throw new Error("unexpected extra runEmbeddedAttempt call");
@@ -268,6 +296,165 @@ const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string, sessi
};
describe("runEmbeddedPiAgent", () => {
it("backfills a trimmed session key from sessionId when the embedded run omits it", async () => {
const sessionFile = nextSessionFile();
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
resolveSessionKeyForRequestMock.mockReturnValue({
sessionKey: "agent:test:resolved",
sessionStore: {},
storePath: "/tmp/session-store.json",
});
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeEmbeddedRunnerAttempt({
assistantTexts: ["ok"],
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "resume-123",
sessionKey: " ",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
runId: nextRunId("backfill"),
enqueue: immediateEnqueue,
});
expect(resolveSessionKeyForRequestMock).toHaveBeenCalledWith({
cfg,
sessionId: "resume-123",
agentId: undefined,
});
const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as { sessionKey?: string };
expect(firstCall.sessionKey).toBe("agent:test:resolved");
});
it("drops whitespace-only session keys when backfill cannot resolve a session key", async () => {
const sessionFile = nextSessionFile();
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
resolveSessionKeyForRequestMock.mockReturnValue({
sessionKey: undefined,
sessionStore: {},
storePath: "/tmp/session-store.json",
});
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeEmbeddedRunnerAttempt({
assistantTexts: ["ok"],
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "resume-124",
sessionKey: " ",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
runId: nextRunId("backfill-empty"),
enqueue: immediateEnqueue,
});
expect(resolveSessionKeyForRequestMock).toHaveBeenCalledWith({
cfg,
sessionId: "resume-124",
agentId: undefined,
});
const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as { sessionKey?: string };
expect(firstCall.sessionKey).toBeUndefined();
});
it("logs when embedded session-key backfill resolution fails", async () => {
const sessionFile = nextSessionFile();
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
resolveSessionKeyForRequestMock.mockImplementation(() => {
throw new Error("resolver exploded");
});
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeEmbeddedRunnerAttempt({
assistantTexts: ["ok"],
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "resume-456",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
runId: nextRunId("backfill-warn"),
enqueue: immediateEnqueue,
});
expect(
loggerWarnMock.mock.calls.some(([message]) =>
String(message ?? "").includes("[backfillSessionKey] Failed to resolve sessionKey"),
),
).toBe(true);
});
it("passes the current agentId when backfilling a session key", async () => {
const sessionFile = nextSessionFile();
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
resolveStoredSessionKeyForSessionIdMock.mockReturnValue({
sessionKey: "agent:test:resolved",
sessionStore: {},
storePath: "/tmp/session-store.json",
});
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeEmbeddedRunnerAttempt({
assistantTexts: ["ok"],
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "resume-agent-1",
sessionKey: undefined,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
agentId: "embedded-agent",
runId: nextRunId("backfill-agent-scope"),
enqueue: immediateEnqueue,
});
expect(resolveStoredSessionKeyForSessionIdMock).toHaveBeenCalledWith({
cfg,
sessionId: "resume-agent-1",
agentId: "embedded-agent",
});
expect(resolveSessionKeyForRequestMock).not.toHaveBeenCalled();
});
it("disposes bundle MCP once when a one-shot local run completes", async () => {
const sessionFile = nextSessionFile();
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
@@ -353,7 +540,7 @@ describe("runEmbeddedPiAgent", () => {
runEmbeddedAttemptMock
.mockImplementationOnce(async (params: unknown) => {
expect((params as { prompt?: string }).prompt).toBe("ship it");
expect((params as { prompt?: string }).prompt).toMatch(/^ship it(?:\n\n|$)/);
return makeEmbeddedRunnerAttempt({
assistantTexts: ["I'll inspect the files, make the change, and run the checks."],
lastAssistant: buildEmbeddedRunnerAssistant({

View File

@@ -19,6 +19,10 @@ import {
markAuthProfileGood,
markAuthProfileUsed,
} from "../auth-profiles.js";
import {
resolveSessionKeyForRequest,
resolveStoredSessionKeyForSessionId,
} from "../command/session.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import {
coerceToFailoverError,
@@ -101,9 +105,60 @@ import { describeUnknownError } from "./utils.js";
type ApiKeyInfo = ResolvedProviderAuth;
/**
* Best-effort backfill of sessionKey from sessionId when not explicitly provided.
* The return value is normalized: whitespace-only inputs collapse to undefined, and
* successful resolution returns a trimmed session key. This is a read-only lookup
* with no side effects.
* See: https://github.com/openclaw/openclaw/issues/60552
*/
function backfillSessionKey(params: {
config: RunEmbeddedPiAgentParams["config"];
sessionId: string;
sessionKey?: string;
agentId?: string;
}): string | undefined {
const trimmed = params.sessionKey?.trim() || undefined;
if (trimmed) {
return trimmed;
}
if (!params.config || !params.sessionId) {
return undefined;
}
try {
const resolved = params.agentId?.trim()
? resolveStoredSessionKeyForSessionId({
cfg: params.config,
sessionId: params.sessionId,
agentId: params.agentId,
})
: resolveSessionKeyForRequest({
cfg: params.config,
sessionId: params.sessionId,
});
return resolved.sessionKey?.trim() || undefined;
} catch (err) {
log.warn(
`[backfillSessionKey] Failed to resolve sessionKey for sessionId=${redactRunIdentifier(sanitizeForLog(params.sessionId))}: ${describeUnknownError(err)}`,
);
return undefined;
}
}
export async function runEmbeddedPiAgent(
params: RunEmbeddedPiAgentParams,
): Promise<EmbeddedPiRunResult> {
// Resolve sessionKey early so all downstream consumers (hooks, LCM, compaction)
// receive a non-null key even when callers omit it. See #60552.
const effectiveSessionKey = backfillSessionKey({
config: params.config,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
});
if (effectiveSessionKey !== params.sessionKey) {
params = { ...params, sessionKey: effectiveSessionKey };
}
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
const globalLane = resolveGlobalLane(params.lane);
const enqueueGlobal =
@@ -167,17 +222,19 @@ export async function runEmbeddedPiAgent(
let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
let modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const normalizedSessionKey = params.sessionKey?.trim();
const fallbackConfigured = hasConfiguredModelFallbacks({
cfg: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionKey: normalizedSessionKey,
});
await ensureOpenClawModelsJson(params.config, agentDir);
const resolvedSessionKey = normalizedSessionKey;
const hookRunner = getGlobalHookRunner();
const hookCtx = {
runId: params.runId,
agentId: workspaceResolution.agentId,
sessionKey: params.sessionKey,
sessionKey: resolvedSessionKey,
sessionId: params.sessionId,
workspaceDir: resolvedWorkspace,
modelProviderId: provider,
@@ -526,7 +583,7 @@ export async function runEmbeddedPiAgent(
const attempt = await runEmbeddedAttempt({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionKey: resolvedSessionKey,
trigger: params.trigger,
memoryFlushWritePath: params.memoryFlushWritePath,
messageChannel: params.messageChannel,
@@ -649,7 +706,7 @@ export async function runEmbeddedPiAgent(
const formattedAssistantErrorText = lastAssistant
? formatAssistantErrorText(lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: resolvedSessionKey ?? params.sessionId,
provider: activeErrorContext.provider,
model: activeErrorContext.model,
})
@@ -673,7 +730,7 @@ export async function runEmbeddedPiAgent(
}
const requestedSelection = shouldSwitchToLiveModel({
cfg: params.config,
sessionKey: params.sessionKey,
sessionKey: resolvedSessionKey,
agentId: params.agentId,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
@@ -685,7 +742,7 @@ export async function runEmbeddedPiAgent(
if (requestedSelection && canRestartForLiveSwitch) {
await clearLiveModelSwitchPending({
cfg: params.config,
sessionKey: params.sessionKey,
sessionKey: resolvedSessionKey,
agentId: params.agentId,
});
log.info(

View File

@@ -368,6 +368,13 @@ describe("session history HTTP endpoints", () => {
.messages?.[0]?.content?.[0]?.text,
).toBe("second message");
const suppressed = await appendAssistantMessageToSessionTranscript({
sessionKey: "agent:main:main",
text: "NO_REPLY",
storePath,
});
expect(suppressed.ok).toBe(true);
const appended = await appendAssistantMessageToSessionTranscript({
sessionKey: "agent:main:main",
text: "third message",
@@ -375,12 +382,21 @@ describe("session history HTTP endpoints", () => {
});
expect(appended.ok).toBe(true);
const suppressedEvent = await readSseEvent(reader!, streamState);
expect(suppressedEvent.event).toBe("history");
const suppressedData = suppressedEvent.data as {
messages?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
};
expect(suppressedData.messages?.[0]?.content?.[0]?.text).toBe("NO_REPLY");
expect(suppressedData.messages?.[0]?.__openclaw?.seq).toBe(3);
const nextEvent = await readSseEvent(reader!, streamState);
expect(nextEvent.event).toBe("history");
expect(
(nextEvent.data as { messages?: Array<{ content?: Array<{ text?: string }> }> })
.messages?.[0]?.content?.[0]?.text,
).toBe("third message");
const nextData = nextEvent.data as {
messages?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
};
expect(nextData.messages?.[0]?.content?.[0]?.text).toBe("third message");
expect(nextData.messages?.[0]?.__openclaw?.seq).toBe(4);
await reader?.cancel();
});