refactor(sessions): add sessions.resolve + label helper (#570)

This commit is contained in:
Peter Steinberger
2026-01-09 16:59:54 +01:00
parent d099dabf37
commit c892fd174e
10 changed files with 446 additions and 83 deletions

View File

@@ -703,6 +703,39 @@ public struct SessionsListParams: Codable, Sendable {
} }
} }
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
public let includeglobal: Bool?
public let includeunknown: Bool?
public init(
key: String?,
label: String?,
agentid: String?,
spawnedby: String?,
includeglobal: Bool?,
includeunknown: Bool?
) {
self.key = key
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
self.includeglobal = includeglobal
self.includeunknown = includeunknown
}
private enum CodingKeys: String, CodingKey {
case key
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
}
}
public struct SessionsPatchParams: Codable, Sendable { public struct SessionsPatchParams: Codable, Sendable {
public let key: String public let key: String
public let label: AnyCodable? public let label: AnyCodable?

View File

@@ -9,6 +9,7 @@ import {
normalizeAgentId, normalizeAgentId,
parseAgentSessionKey, parseAgentSessionKey,
} from "../../routing/session-key.js"; } from "../../routing/session-key.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js"; import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js"; import { jsonResult, readStringParam } from "./common.js";
@@ -40,7 +41,8 @@ const SessionsSendToolSchema = Type.Union([
), ),
Type.Object( Type.Object(
{ {
label: Type.String({ minLength: 1, maxLength: 64 }), label: Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH }),
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
message: Type.String(), message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
}, },
@@ -80,8 +82,28 @@ export function createSessionsSendTool(opts?: {
requesterInternalKey && requesterInternalKey &&
!isSubagentSessionKey(requesterInternalKey); !isSubagentSessionKey(requesterInternalKey);
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const sessionKeyParam = readStringParam(params, "sessionKey"); const sessionKeyParam = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label")?.trim() || undefined; const labelParam = readStringParam(params, "label")?.trim() || undefined;
const labelAgentIdParam =
readStringParam(params, "agentId")?.trim() || undefined;
if (sessionKeyParam && labelParam) { if (sessionKeyParam && labelParam) {
return jsonResult({ return jsonResult({
runId: crypto.randomUUID(), runId: crypto.randomUUID(),
@@ -101,20 +123,86 @@ export function createSessionsSendTool(opts?: {
let sessionKey = sessionKeyParam; let sessionKey = sessionKeyParam;
if (!sessionKey && labelParam) { if (!sessionKey && labelParam) {
const agentIdForLookup = requesterInternalKey const requesterAgentId = requesterInternalKey
? normalizeAgentId( ? normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId, parseAgentSessionKey(requesterInternalKey)?.agentId,
) )
: undefined; : undefined;
const listParams: Record<string, unknown> = { const requestedAgentId = labelAgentIdParam
includeGlobal: false, ? normalizeAgentId(labelAgentIdParam)
includeUnknown: false, : undefined;
if (
restrictToSpawned &&
requestedAgentId &&
requesterAgentId &&
requestedAgentId !== requesterAgentId
) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Sandboxed sessions_send label lookup is limited to this agent",
});
}
if (
requesterAgentId &&
requestedAgentId &&
requestedAgentId !== requesterAgentId
) {
if (!a2aEnabled) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
});
}
if (
!matchesAllow(requesterAgentId) ||
!matchesAllow(requestedAgentId)
) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
});
}
}
const resolveParams: Record<string, unknown> = {
label: labelParam, label: labelParam,
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}),
}; };
if (restrictToSpawned) listParams.spawnedBy = requesterInternalKey; let resolvedKey = "";
if (agentIdForLookup) listParams.agentId = agentIdForLookup; try {
const matches = await listSessions(listParams); const resolved = (await callGateway({
if (matches.length === 0) { method: "sessions.resolve",
params: resolveParams,
timeoutMs: 10_000,
})) as { key?: unknown };
resolvedKey =
typeof resolved?.key === "string" ? resolved.key.trim() : "";
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (restrictToSpawned) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
});
}
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: msg || `No session found with label: ${labelParam}`,
});
}
if (!resolvedKey) {
if (restrictToSpawned) { if (restrictToSpawned) {
return jsonResult({ return jsonResult({
runId: crypto.randomUUID(), runId: crypto.randomUUID(),
@@ -128,26 +216,7 @@ export function createSessionsSendTool(opts?: {
error: `No session found with label: ${labelParam}`, error: `No session found with label: ${labelParam}`,
}); });
} }
if (matches.length > 1) { sessionKey = resolvedKey;
const keys = matches
.map((entry) => (typeof entry?.key === "string" ? entry.key : ""))
.filter(Boolean)
.join(", ");
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: `Multiple sessions found with label: ${labelParam}${keys ? ` (${keys})` : ""}`,
});
}
const key = matches[0]?.key;
if (typeof key !== "string" || !key.trim()) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: `Invalid session entry for label: ${labelParam}`,
});
}
sessionKey = key;
} }
if (!sessionKey) { if (!sessionKey) {
@@ -165,17 +234,11 @@ export function createSessionsSendTool(opts?: {
}); });
if (restrictToSpawned) { if (restrictToSpawned) {
const agentIdForLookup = requesterInternalKey
? normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
)
: undefined;
const sessions = await listSessions({ const sessions = await listSessions({
includeGlobal: false, includeGlobal: false,
includeUnknown: false, includeUnknown: false,
limit: 500, limit: 500,
spawnedBy: requesterInternalKey, spawnedBy: requesterInternalKey,
...(agentIdForLookup ? { agentId: agentIdForLookup } : {}),
}); });
const ok = sessions.some((entry) => entry?.key === resolvedKey); const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) { if (!ok) {
@@ -205,24 +268,6 @@ export function createSessionsSendTool(opts?: {
alias, alias,
mainKey, mainKey,
}); });
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId( const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId, parseAgentSessionKey(requesterInternalKey)?.agentId,
); );

View File

@@ -103,6 +103,8 @@ import {
SessionsPatchParamsSchema, SessionsPatchParamsSchema,
type SessionsResetParams, type SessionsResetParams,
SessionsResetParamsSchema, SessionsResetParamsSchema,
type SessionsResolveParams,
SessionsResolveParamsSchema,
type ShutdownEvent, type ShutdownEvent,
ShutdownEventSchema, ShutdownEventSchema,
type SkillsInstallParams, type SkillsInstallParams,
@@ -201,6 +203,9 @@ export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(
export const validateSessionsListParams = ajv.compile<SessionsListParams>( export const validateSessionsListParams = ajv.compile<SessionsListParams>(
SessionsListParamsSchema, SessionsListParamsSchema,
); );
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
SessionsResolveParamsSchema,
);
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>( export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
SessionsPatchParamsSchema, SessionsPatchParamsSchema,
); );
@@ -417,6 +422,7 @@ export type {
NodeListParams, NodeListParams,
NodeInvokeParams, NodeInvokeParams,
SessionsListParams, SessionsListParams,
SessionsResolveParams,
SessionsPatchParams, SessionsPatchParams,
SessionsResetParams, SessionsResetParams,
SessionsDeleteParams, SessionsDeleteParams,

View File

@@ -1,7 +1,11 @@
import { type Static, type TSchema, Type } from "@sinclair/typebox"; import { type Static, type TSchema, Type } from "@sinclair/typebox";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
const NonEmptyString = Type.String({ minLength: 1 }); const NonEmptyString = Type.String({ minLength: 1 });
const SessionLabelString = Type.String({ minLength: 1, maxLength: 64 }); const SessionLabelString = Type.String({
minLength: 1,
maxLength: SESSION_LABEL_MAX_LENGTH,
});
export const PresenceEntrySchema = Type.Object( export const PresenceEntrySchema = Type.Object(
{ {
@@ -323,6 +327,18 @@ export const SessionsListParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsResolveParamsSchema = Type.Object(
{
key: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString),
agentId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString),
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const SessionsPatchParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
@@ -938,6 +954,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
NodeDescribeParams: NodeDescribeParamsSchema, NodeDescribeParams: NodeDescribeParamsSchema,
NodeInvokeParams: NodeInvokeParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema,
SessionsListParams: SessionsListParamsSchema, SessionsListParams: SessionsListParamsSchema,
SessionsResolveParams: SessionsResolveParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema,
SessionsResetParams: SessionsResetParamsSchema, SessionsResetParams: SessionsResetParamsSchema,
SessionsDeleteParams: SessionsDeleteParamsSchema, SessionsDeleteParams: SessionsDeleteParamsSchema,
@@ -1014,6 +1031,7 @@ export type NodeListParams = Static<typeof NodeListParamsSchema>;
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>; export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>; export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>; export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>; export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>; export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>; export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;

View File

@@ -24,7 +24,6 @@ import { buildConfigSchema } from "../config/schema.js";
import { import {
loadSessionStore, loadSessionStore,
resolveMainSessionKey, resolveMainSessionKey,
resolveStorePath,
type SessionEntry, type SessionEntry,
saveSessionStore, saveSessionStore,
} from "../config/sessions.js"; } from "../config/sessions.js";
@@ -45,6 +44,7 @@ import {
type SessionsListParams, type SessionsListParams,
type SessionsPatchParams, type SessionsPatchParams,
type SessionsResetParams, type SessionsResetParams,
type SessionsResolveParams,
validateChatAbortParams, validateChatAbortParams,
validateChatHistoryParams, validateChatHistoryParams,
validateChatSendParams, validateChatSendParams,
@@ -57,6 +57,7 @@ import {
validateSessionsListParams, validateSessionsListParams,
validateSessionsPatchParams, validateSessionsPatchParams,
validateSessionsResetParams, validateSessionsResetParams,
validateSessionsResolveParams,
validateTalkModeParams, validateTalkModeParams,
} from "./protocol/index.js"; } from "./protocol/index.js";
import type { ChatRunEntry } from "./server-chat.js"; import type { ChatRunEntry } from "./server-chat.js";
@@ -70,8 +71,10 @@ import {
archiveFileOnDisk, archiveFileOnDisk,
capArrayByJsonBytes, capArrayByJsonBytes,
listSessionsFromStore, listSessionsFromStore,
loadCombinedSessionStoreForGateway,
loadSessionEntry, loadSessionEntry,
readSessionMessages, readSessionMessages,
resolveGatewaySessionStoreTarget,
resolveSessionModelRef, resolveSessionModelRef,
resolveSessionTranscriptCandidates, resolveSessionTranscriptCandidates,
type SessionsPatchResult, type SessionsPatchResult,
@@ -288,8 +291,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
} }
const p = params as SessionsListParams; const p = params as SessionsListParams;
const cfg = loadConfig(); const cfg = loadConfig();
const storePath = resolveStorePath(cfg.session?.store); const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const store = loadSessionStore(storePath);
const result = listSessionsFromStore({ const result = listSessionsFromStore({
cfg, cfg,
storePath, storePath,
@@ -298,6 +300,109 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
}); });
return { ok: true, payloadJSON: JSON.stringify(result) }; return { ok: true, payloadJSON: JSON.stringify(result) };
} }
case "sessions.resolve": {
const params = parseParams();
if (!validateSessionsResolveParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
},
};
}
const p = params as SessionsResolveParams;
const cfg = loadConfig();
const key = typeof p.key === "string" ? p.key.trim() : "";
const label = typeof p.label === "string" ? p.label.trim() : "";
const hasKey = key.length > 0;
const hasLabel = label.length > 0;
if (hasKey && hasLabel) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "Provide either key or label (not both)",
},
};
}
if (!hasKey && !hasLabel) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "Either key or label is required",
},
};
}
if (hasKey) {
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const store = loadSessionStore(target.storePath);
const existingKey = target.storeKeys.find(
(candidate) => store[candidate],
);
if (!existingKey) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `No session found: ${key}`,
},
};
}
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key: target.canonicalKey,
}),
};
}
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const list = listSessionsFromStore({
cfg,
storePath,
store,
opts: {
includeGlobal: p.includeGlobal === true,
includeUnknown: p.includeUnknown === true,
label,
agentId: p.agentId,
spawnedBy: p.spawnedBy,
limit: 2,
},
});
if (list.sessions.length === 0) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `No session found with label: ${label}`,
},
};
}
if (list.sessions.length > 1) {
const keys = list.sessions.map((s) => s.key).join(", ");
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `Multiple sessions found with label: ${label} (${keys})`,
},
};
}
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key: list.sessions[0]?.key,
}),
};
}
case "sessions.patch": { case "sessions.patch": {
const params = parseParams(); const params = parseParams();
if (!validateSessionsPatchParams(params)) { if (!validateSessionsPatchParams(params)) {
@@ -323,12 +428,21 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
} }
const cfg = loadConfig(); const cfg = loadConfig();
const storePath = resolveStorePath(cfg.session?.store); const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find(
(candidate) => store[candidate],
);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const applied = await applySessionsPatchToStore({ const applied = await applySessionsPatchToStore({
cfg, cfg,
store, store,
storeKey: key, storeKey: primaryKey,
patch: p, patch: p,
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog, loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
}); });
@@ -346,7 +460,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const payload: SessionsPatchResult = { const payload: SessionsPatchResult = {
ok: true, ok: true,
path: storePath, path: storePath,
key, key: target.canonicalKey,
entry: applied.entry, entry: applied.entry,
}; };
return { ok: true, payloadJSON: JSON.stringify(payload) }; return { ok: true, payloadJSON: JSON.stringify(payload) };

View File

@@ -24,6 +24,7 @@ import {
validateSessionsListParams, validateSessionsListParams,
validateSessionsPatchParams, validateSessionsPatchParams,
validateSessionsResetParams, validateSessionsResetParams,
validateSessionsResolveParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import { import {
archiveFileOnDisk, archiveFileOnDisk,
@@ -60,6 +61,122 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}); });
respond(true, result, undefined); respond(true, result, undefined);
}, },
"sessions.resolve": ({ params, respond }) => {
if (!validateSessionsResolveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
),
);
return;
}
const p = params as import("../protocol/index.js").SessionsResolveParams;
const cfg = loadConfig();
const key = typeof p.key === "string" ? p.key.trim() : "";
const label = typeof p.label === "string" ? p.label.trim() : "";
const hasKey = key.length > 0;
const hasLabel = label.length > 0;
if (hasKey && hasLabel) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"Provide either key or label (not both)",
),
);
return;
}
if (!hasKey && !hasLabel) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"Either key or label is required",
),
);
return;
}
if (hasKey) {
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
return;
}
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const store = loadSessionStore(target.storePath);
const existingKey = target.storeKeys.find(
(candidate) => store[candidate],
);
if (!existingKey) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`),
);
return;
}
respond(true, { ok: true, key: target.canonicalKey }, undefined);
return;
}
if (!label) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "label required"),
);
return;
}
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const list = listSessionsFromStore({
cfg,
storePath,
store,
opts: {
includeGlobal: p.includeGlobal === true,
includeUnknown: p.includeUnknown === true,
label,
agentId: p.agentId,
spawnedBy: p.spawnedBy,
limit: 2,
},
});
if (list.sessions.length === 0) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`No session found with label: ${label}`,
),
);
return;
}
if (list.sessions.length > 1) {
const keys = list.sessions.map((s) => s.key).join(", ");
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`Multiple sessions found with label: ${label} (${keys})`,
),
);
return;
}
respond(true, { ok: true, key: list.sessions[0]?.key }, undefined);
},
"sessions.patch": async ({ params, respond, context }) => { "sessions.patch": async ({ params, respond, context }) => {
if (!validateSessionsPatchParams(params)) { if (!validateSessionsPatchParams(params)) {
respond( respond(

View File

@@ -642,6 +642,17 @@ describe("gateway server node/bridge", () => {
expect(typeof payload.count).toBe("number"); expect(typeof payload.count).toBe("number");
expect(typeof payload.path).toBe("string"); expect(typeof payload.path).toBe("string");
const resolveRes = await bridgeCall?.onRequest?.("ios-node", {
id: "r2",
method: "sessions.resolve",
paramsJSON: JSON.stringify({ key: "main" }),
});
expect(resolveRes?.ok).toBe(true);
const resolvedPayload = JSON.parse(
String((resolveRes as { payloadJSON?: string }).payloadJSON ?? "{}"),
) as { key?: string };
expect(resolvedPayload.key).toBe("agent:main:main");
await server.close(); await server.close();
}); });

View File

@@ -87,6 +87,14 @@ describe("gateway server sessions", () => {
]), ]),
); );
const resolvedByKey = await rpcReq<{ ok: true; key: string }>(
ws,
"sessions.resolve",
{ key: "main" },
);
expect(resolvedByKey.ok).toBe(true);
expect(resolvedByKey.payload?.key).toBe("agent:main:main");
const list1 = await rpcReq<{ const list1 = await rpcReq<{
path: string; path: string;
sessions: Array<{ sessions: Array<{
@@ -197,6 +205,14 @@ describe("gateway server sessions", () => {
"agent:main:subagent:one", "agent:main:subagent:one",
]); ]);
const resolvedByLabel = await rpcReq<{ ok: true; key: string }>(
ws,
"sessions.resolve",
{ label: "Briefing", agentId: "main" },
);
expect(resolvedByLabel.ok).toBe(true);
expect(resolvedByLabel.payload?.key).toBe("agent:main:subagent:one");
const spawnedOnly = await rpcReq<{ const spawnedOnly = await rpcReq<{
sessions: Array<{ key: string }>; sessions: Array<{ key: string }>;
}>(ws, "sessions.list", { }>(ws, "sessions.list", {

View File

@@ -21,6 +21,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js";
import { isSubagentSessionKey } from "../routing/session-key.js"; import { isSubagentSessionKey } from "../routing/session-key.js";
import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js";
import { parseSessionLabel } from "../sessions/session-label.js";
import { import {
ErrorCodes, ErrorCodes,
type ErrorShape, type ErrorShape,
@@ -28,28 +29,10 @@ import {
type SessionsPatchParams, type SessionsPatchParams,
} from "./protocol/index.js"; } from "./protocol/index.js";
export const SESSION_LABEL_MAX_LENGTH = 64;
function invalid(message: string): { ok: false; error: ErrorShape } { function invalid(message: string): { ok: false; error: ErrorShape } {
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) }; return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
} }
function normalizeLabel(
raw: unknown,
): { ok: true; label: string } | ReturnType<typeof invalid> {
const trimmed =
typeof raw === "string"
? raw.trim()
: typeof raw === "number" || typeof raw === "boolean"
? String(raw).trim()
: "";
if (!trimmed) return invalid("invalid label: empty");
if (trimmed.length > SESSION_LABEL_MAX_LENGTH) {
return invalid(`invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`);
}
return { ok: true, label: trimmed };
}
export async function applySessionsPatchToStore(params: { export async function applySessionsPatchToStore(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
store: Record<string, SessionEntry>; store: Record<string, SessionEntry>;
@@ -93,15 +76,15 @@ export async function applySessionsPatchToStore(params: {
if (raw === null) { if (raw === null) {
delete next.label; delete next.label;
} else if (raw !== undefined) { } else if (raw !== undefined) {
const normalized = normalizeLabel(raw); const parsed = parseSessionLabel(raw);
if (!normalized.ok) return normalized; if (!parsed.ok) return invalid(parsed.error);
for (const [key, entry] of Object.entries(store)) { for (const [key, entry] of Object.entries(store)) {
if (key === storeKey) continue; if (key === storeKey) continue;
if (entry?.label === normalized.label) { if (entry?.label === parsed.label) {
return invalid(`label already in use: ${normalized.label}`); return invalid(`label already in use: ${parsed.label}`);
} }
} }
next.label = normalized.label; next.label = parsed.label;
} }
} }

View File

@@ -0,0 +1,20 @@
export const SESSION_LABEL_MAX_LENGTH = 64;
export type ParsedSessionLabel =
| { ok: true; label: string }
| { ok: false; error: string };
export function parseSessionLabel(raw: unknown): ParsedSessionLabel {
if (typeof raw !== "string") {
return { ok: false, error: "invalid label: must be a string" };
}
const trimmed = raw.trim();
if (!trimmed) return { ok: false, error: "invalid label: empty" };
if (trimmed.length > SESSION_LABEL_MAX_LENGTH) {
return {
ok: false,
error: `invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`,
};
}
return { ok: true, label: trimmed };
}