Skip to content

Commit 7e0128a

Browse files
fix(agents): preserve literal current session resolution (#93138)
* fix(agents): resolve "current" session alias locally without gateway round-trip The system prompt tells agents to use sessionKey="current" to refer to their own session. Previously, resolveSessionReference sent the literal string "current" to the gateway sessions.resolve action, which rejected it with INVALID_REQUEST and logged a noisy error line on every tool call. The wrapper fell back to requesterInternalKey and succeeded, so the tool worked — but the gateway error was spurious. Add "current" to the well-known client alias check in resolveCurrentSessionClientAlias so it is resolved locally to the requester's session key, matching how TUI/CLI/WebChat client labels are handled. This eliminates the unnecessary gateway round-trip and the error log line. Fixes #78424 * test: update session_status tests for local current-key resolution * test: update session_status tests for local current-key resolution * Revert "test: update session_status tests for local current-key resolution" This reverts commit d9f6c8b5248921c99f43dc222667ffa429b34401. * Revert "test: update session_status tests for local current-key resolution" This reverts commit 40bf77d06711833c1beaeedf562b60a765a559d6. * Revert "fix(agents): resolve "current" session alias locally without gateway round-trip" This reverts commit d92bc9b91e0840ea5823cd44223c139e434c5ec4. * fix(agents): preserve literal current session resolution --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 1db8ab3 commit 7e0128a

11 files changed

Lines changed: 186 additions & 31 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1756,6 +1756,7 @@ public struct SessionsResolveParams: Codable, Sendable {
17561756
public let spawnedby: String?
17571757
public let includeglobal: Bool?
17581758
public let includeunknown: Bool?
1759+
public let allowmissing: Bool?
17591760

17601761
public init(
17611762
key: String?,
@@ -1764,7 +1765,8 @@ public struct SessionsResolveParams: Codable, Sendable {
17641765
agentid: String? = nil,
17651766
spawnedby: String?,
17661767
includeglobal: Bool?,
1767-
includeunknown: Bool?)
1768+
includeunknown: Bool?,
1769+
allowmissing: Bool? = nil)
17681770
{
17691771
self.key = key
17701772
self.sessionid = sessionid
@@ -1773,6 +1775,7 @@ public struct SessionsResolveParams: Codable, Sendable {
17731775
self.spawnedby = spawnedby
17741776
self.includeglobal = includeglobal
17751777
self.includeunknown = includeunknown
1778+
self.allowmissing = allowmissing
17761779
}
17771780

17781781
private enum CodingKeys: String, CodingKey {
@@ -1783,6 +1786,7 @@ public struct SessionsResolveParams: Codable, Sendable {
17831786
case spawnedby = "spawnedBy"
17841787
case includeglobal = "includeGlobal"
17851788
case includeunknown = "includeUnknown"
1789+
case allowmissing = "allowMissing"
17861790
}
17871791
}
17881792

packages/gateway-protocol/src/schema/sessions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ export const SessionsResolveParamsSchema = Type.Object(
232232
spawnedBy: Type.Optional(NonEmptyString),
233233
includeGlobal: Type.Optional(Type.Boolean()),
234234
includeUnknown: Type.Optional(Type.Boolean()),
235+
/** Return a successful `{ ok: false }` response when the selector does not match a session. */
236+
allowMissing: Type.Optional(Type.Boolean()),
235237
},
236238
{ additionalProperties: false },
237239
);

scripts/protocol-gen-swift.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
5656
["SessionsResetParams", ["agentId"]],
5757
["SessionsDeleteParams", ["agentId"]],
5858
["SessionsCompactParams", ["agentId"]],
59+
["SessionsResolveParams", ["allowMissing"]],
5960
["SessionsUsageParams", ["agentId", "agentScope"]],
6061
["ChatHistoryParams", ["agentId"]],
6162
["ChatSendParams", ["agentId"]],

src/agents/tools/embedded-gateway-stub.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ async function handleSessionsResolve(params: Record<string, unknown>) {
118118
if (!resolved.ok) {
119119
throw new Error(resolved.error.message);
120120
}
121+
if ("missing" in resolved) {
122+
return { ok: false };
123+
}
121124
return { ok: true, key: resolved.key };
122125
}
123126

src/agents/tools/sessions-resolution.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// verification, and requester-spawned access checks.
33
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
44
import type { OpenClawConfig } from "../../config/config.js";
5+
import { GatewayClientRequestError } from "../../gateway/client.js";
56
const callGatewayMock = vi.fn();
67
vi.mock("../../gateway/call.js", () => ({
78
callGateway: (opts: unknown) => callGatewayMock(opts),
@@ -270,6 +271,7 @@ describe("resolveSessionReference", () => {
270271
params: {
271272
key: "current",
272273
spawnedBy: undefined,
274+
allowMissing: true,
273275
},
274276
});
275277
});
@@ -295,6 +297,7 @@ describe("resolveSessionReference", () => {
295297
params: {
296298
key: "current",
297299
spawnedBy: undefined,
300+
allowMissing: true,
298301
},
299302
});
300303
expect(callGatewayMock).toHaveBeenNthCalledWith(2, {
@@ -304,10 +307,94 @@ describe("resolveSessionReference", () => {
304307
spawnedBy: undefined,
305308
includeGlobal: true,
306309
includeUnknown: true,
310+
allowMissing: true,
307311
},
308312
});
309313
});
310314

315+
it("retries literal current probes without allowMissing for older gateways", async () => {
316+
const unsupportedAllowMissing = () =>
317+
new GatewayClientRequestError({
318+
code: "INVALID_REQUEST",
319+
message: "invalid sessions.resolve params: at root: unexpected property 'allowMissing'",
320+
});
321+
callGatewayMock
322+
.mockRejectedValueOnce(unsupportedAllowMissing())
323+
.mockRejectedValueOnce(
324+
new GatewayClientRequestError({
325+
code: "INVALID_REQUEST",
326+
message: "No session found: current",
327+
}),
328+
)
329+
.mockRejectedValueOnce(unsupportedAllowMissing())
330+
.mockResolvedValueOnce({ key: "agent:ops:main" });
331+
332+
const result = await resolveSessionReference({
333+
sessionKey: "current",
334+
alias: "main",
335+
mainKey: "main",
336+
requesterInternalKey: "agent:main:subagent:child",
337+
restrictToSpawned: false,
338+
});
339+
expectResolvedSessionReference(result, {
340+
key: "agent:ops:main",
341+
displayKey: "agent:ops:main",
342+
resolvedViaSessionId: true,
343+
});
344+
expect(callGatewayMock).toHaveBeenNthCalledWith(1, {
345+
method: "sessions.resolve",
346+
params: {
347+
key: "current",
348+
spawnedBy: undefined,
349+
allowMissing: true,
350+
},
351+
});
352+
expect(callGatewayMock).toHaveBeenNthCalledWith(2, {
353+
method: "sessions.resolve",
354+
params: {
355+
key: "current",
356+
spawnedBy: undefined,
357+
},
358+
});
359+
expect(callGatewayMock).toHaveBeenNthCalledWith(3, {
360+
method: "sessions.resolve",
361+
params: {
362+
sessionId: "current",
363+
spawnedBy: undefined,
364+
includeGlobal: true,
365+
includeUnknown: true,
366+
allowMissing: true,
367+
},
368+
});
369+
expect(callGatewayMock).toHaveBeenNthCalledWith(4, {
370+
method: "sessions.resolve",
371+
params: {
372+
sessionId: "current",
373+
spawnedBy: undefined,
374+
includeGlobal: true,
375+
includeUnknown: true,
376+
},
377+
});
378+
});
379+
380+
it("does not compatibility-retry unrelated gateway failures", async () => {
381+
callGatewayMock.mockRejectedValueOnce(new Error("gateway timeout")).mockResolvedValueOnce({});
382+
383+
const result = await resolveSessionReference({
384+
sessionKey: "current",
385+
alias: "main",
386+
mainKey: "main",
387+
requesterInternalKey: "agent:main:subagent:child",
388+
restrictToSpawned: false,
389+
});
390+
expectResolvedSessionReference(result, {
391+
key: "agent:main:subagent:child",
392+
displayKey: "agent:main:subagent:child",
393+
resolvedViaSessionId: false,
394+
});
395+
expect(callGatewayMock).toHaveBeenCalledTimes(2);
396+
});
397+
311398
it("skips literal current key lookup when spawned visibility is restricted", async () => {
312399
const result = await resolveSessionReference({
313400
sessionKey: "current",
@@ -328,6 +415,7 @@ describe("resolveSessionReference", () => {
328415
spawnedBy: "agent:main:subagent:child",
329416
includeGlobal: false,
330417
includeUnknown: false,
418+
allowMissing: true,
331419
},
332420
});
333421
expect(callGatewayMock).toHaveBeenCalledTimes(1);

src/agents/tools/sessions-resolution.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "../../../packages/gateway-protocol/src/client-info.js";
1111
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1212
import { callGateway } from "../../gateway/call.js";
13+
import { GatewayClientRequestError } from "../../gateway/client.js";
1314
import { formatErrorMessage } from "../../infra/errors.js";
1415
import {
1516
listSpawnedSessionKeys,
@@ -233,24 +234,53 @@ function buildSessionIdResolveParams(params: {
233234
sessionId: string;
234235
requesterInternalKey?: string;
235236
restrictToSpawned: boolean;
237+
allowMissing?: boolean;
236238
}) {
237239
return {
238240
sessionId: params.sessionId,
239241
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
240242
includeGlobal: !params.restrictToSpawned,
241243
includeUnknown: !params.restrictToSpawned,
244+
...(params.allowMissing ? { allowMissing: true } : {}),
242245
};
243246
}
244247

248+
async function callGatewayResolveSession(
249+
params: Record<string, unknown> & { allowMissing?: boolean },
250+
) {
251+
try {
252+
return await sessionsResolutionDeps.callGateway({
253+
method: "sessions.resolve",
254+
params,
255+
});
256+
} catch (error) {
257+
const olderGatewayRejectedProbe =
258+
params.allowMissing === true &&
259+
error instanceof GatewayClientRequestError &&
260+
error.gatewayCode === "INVALID_REQUEST" &&
261+
error.message.includes("invalid sessions.resolve params") &&
262+
error.message.includes("unexpected property 'allowMissing'");
263+
if (!olderGatewayRejectedProbe) {
264+
throw error;
265+
}
266+
// Protocol v4 gateways predating allowMissing reject the additive field.
267+
// Retry without it for mixed-version correctness; remove at the next protocol break.
268+
const legacyParams: Record<string, unknown> = { ...params };
269+
delete legacyParams.allowMissing;
270+
return await sessionsResolutionDeps.callGateway({
271+
method: "sessions.resolve",
272+
params: legacyParams,
273+
});
274+
}
275+
}
276+
245277
async function callGatewayResolveSessionId(params: {
246278
sessionId: string;
247279
requesterInternalKey?: string;
248280
restrictToSpawned: boolean;
281+
allowMissing?: boolean;
249282
}): Promise<string> {
250-
const result = await sessionsResolutionDeps.callGateway({
251-
method: "sessions.resolve",
252-
params: buildSessionIdResolveParams(params),
253-
});
283+
const result = await callGatewayResolveSession(buildSessionIdResolveParams(params));
254284
const key = normalizeOptionalString(result?.key) ?? "";
255285
if (!key) {
256286
throw new Error(
@@ -266,6 +296,7 @@ async function resolveSessionKeyFromSessionId(params: {
266296
mainKey: string;
267297
requesterInternalKey?: string;
268298
restrictToSpawned: boolean;
299+
allowMissing?: boolean;
269300
}): Promise<SessionReferenceResolution> {
270301
try {
271302
// Resolve via gateway so we respect store routing and visibility rules.
@@ -301,15 +332,14 @@ async function resolveSessionKeyFromKey(params: {
301332
mainKey: string;
302333
requesterInternalKey?: string;
303334
restrictToSpawned: boolean;
335+
allowMissing?: boolean;
304336
}): Promise<SessionReferenceResolution | null> {
305337
try {
306338
// Try key-based resolution first so non-standard keys keep working.
307-
const result = await sessionsResolutionDeps.callGateway({
308-
method: "sessions.resolve",
309-
params: {
310-
key: params.key,
311-
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
312-
},
339+
const result = await callGatewayResolveSession({
340+
key: params.key,
341+
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
342+
...(params.allowMissing ? { allowMissing: true } : {}),
313343
});
314344
const key = normalizeOptionalString(result?.key) ?? "";
315345
if (!key) {
@@ -332,6 +362,7 @@ async function tryResolveSessionKeyFromSessionId(params: {
332362
mainKey: string;
333363
requesterInternalKey?: string;
334364
restrictToSpawned: boolean;
365+
allowMissing?: boolean;
335366
}): Promise<Extract<SessionReferenceResolution, { ok: true }> | null> {
336367
try {
337368
const key = await callGatewayResolveSessionId(params);
@@ -353,6 +384,7 @@ async function resolveSessionReferenceByKeyOrSessionId(params: {
353384
requesterInternalKey?: string;
354385
restrictToSpawned: boolean;
355386
allowUnresolvedSessionId: boolean;
387+
allowMissing?: boolean;
356388
skipKeyLookup?: boolean;
357389
forceSessionIdLookup?: boolean;
358390
}): Promise<SessionReferenceResolution | null> {
@@ -364,6 +396,7 @@ async function resolveSessionReferenceByKeyOrSessionId(params: {
364396
mainKey: params.mainKey,
365397
requesterInternalKey: params.requesterInternalKey,
366398
restrictToSpawned: params.restrictToSpawned,
399+
allowMissing: params.allowMissing,
367400
});
368401
if (resolvedByKey) {
369402
return resolvedByKey;
@@ -379,6 +412,7 @@ async function resolveSessionReferenceByKeyOrSessionId(params: {
379412
mainKey: params.mainKey,
380413
requesterInternalKey: params.requesterInternalKey,
381414
restrictToSpawned: params.restrictToSpawned,
415+
allowMissing: params.allowMissing,
382416
});
383417
}
384418
return await resolveSessionKeyFromSessionId({
@@ -387,6 +421,7 @@ async function resolveSessionReferenceByKeyOrSessionId(params: {
387421
mainKey: params.mainKey,
388422
requesterInternalKey: params.requesterInternalKey,
389423
restrictToSpawned: params.restrictToSpawned,
424+
allowMissing: params.allowMissing,
390425
});
391426
}
392427

@@ -410,6 +445,7 @@ export async function resolveSessionReference(params: {
410445
requesterInternalKey: params.requesterInternalKey,
411446
restrictToSpawned: params.restrictToSpawned,
412447
allowUnresolvedSessionId: true,
448+
allowMissing: true,
413449
skipKeyLookup: params.restrictToSpawned,
414450
forceSessionIdLookup: true,
415451
});

src/gateway/server-methods/sessions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
12591259
respond(false, undefined, resolved.error);
12601260
return;
12611261
}
1262+
if ("missing" in resolved) {
1263+
respond(true, { ok: false }, undefined);
1264+
return;
1265+
}
12621266
respond(true, { ok: true, key: resolved.key }, undefined);
12631267
},
12641268
"sessions.compaction.list": ({ params, respond, context }) => {

src/gateway/server-methods/talk-session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ export const talkSessionHandlers: GatewayRequestHandlers = {
257257
respond(false, undefined, resolvedSession.error);
258258
return;
259259
}
260+
if ("missing" in resolvedSession) {
261+
respondInvalidRequest(respond, `No session found: ${params.sessionKey}`);
262+
return;
263+
}
260264
const handoff = createTalkHandoff({
261265
sessionKey: resolvedSession.key,
262266
provider: normalizeOptionalString(params.provider),

src/gateway/server.sessions.preview-resolve.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,19 @@ test("sessions.resolve by sessionId ignores fuzzy-search list limits and returns
218218
expect(resolved.payload?.key).toBe("agent:main:subagent:target");
219219
});
220220

221+
test("sessions.resolve can probe a missing selector without returning an RPC error", async () => {
222+
await createSessionStoreDir();
223+
const { ws } = await openClient();
224+
225+
const resolved = await rpcReq<{ ok: false }>(ws, "sessions.resolve", {
226+
key: "agent:main:missing",
227+
allowMissing: true,
228+
});
229+
230+
expect(resolved.ok).toBe(true);
231+
expect(resolved.payload).toEqual({ ok: false });
232+
});
233+
221234
test("sessions.resolve by key respects spawnedBy visibility filters", async () => {
222235
await createSessionStoreDir();
223236
const now = Date.now();

src/gateway/sessions-resolve.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe("resolveSessionKeyFromResolveParams", () => {
146146
expect(typeof updateSessionStoreCall?.[1]).toBe("function");
147147
});
148148

149-
it("rejects sessions belonging to a deleted agent (key-based lookup)", async () => {
149+
it("does not let allowMissing mask a deleted-agent error", async () => {
150150
const deletedAgentKey = "agent:deleted-agent:main";
151151
targetStore = {
152152
[deletedAgentKey]: { sessionId: "sess-orphan", updatedAt: 1 },
@@ -162,7 +162,7 @@ describe("resolveSessionKeyFromResolveParams", () => {
162162

163163
const result = await resolveSessionKeyFromResolveParams({
164164
cfg: {},
165-
p: { key: deletedAgentKey },
165+
p: { key: deletedAgentKey, allowMissing: true },
166166
});
167167

168168
expect(result).toEqual({

0 commit comments

Comments
 (0)