fix: sessions label lookup and persistence (#570) (thanks @azade-c)

This commit is contained in:
Peter Steinberger
2026-01-09 14:01:49 +01:00
parent e24e0cf364
commit 56e77f6843
7 changed files with 123 additions and 69 deletions

View File

@@ -44,6 +44,7 @@
- Control UI: logs tab opens at the newest entries (bottom).
- Control UI: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).

View File

@@ -84,7 +84,7 @@ function ensureListener() {
? (evt.data.endedAt as number)
: Date.now();
entry.endedAt = endedAt;
if (!beginSubagentAnnounce(evt.runId)) {
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);

View File

@@ -29,12 +29,24 @@ import {
resolvePingPongTurns,
} from "./sessions-send-helpers.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
label: Type.Optional(Type.String()),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
});
const SessionsSendToolSchema = Type.Union([
Type.Object(
{
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
Type.Object(
{
label: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
]);
export function createSessionsSendTool(opts?: {
agentSessionKey?: string;
@@ -49,36 +61,8 @@ export function createSessionsSendTool(opts?: {
parameters: SessionsSendToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
let sessionKey = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label");
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
// Lookup by label if sessionKey not provided
if (!sessionKey && labelParam) {
const listResult = (await callGateway({
method: "sessions.list",
params: { activeMinutes: 1440 }, // Last 24h
timeoutMs: 10_000,
})) as { sessions?: Array<{ key: string; label?: string }> };
const match = listResult.sessions?.find(
(s) => s.label === labelParam,
);
if (!match) {
return jsonResult({
status: "error",
error: `No session found with label: ${labelParam}`,
});
}
sessionKey = match.key;
}
if (!sessionKey) {
return jsonResult({
status: "error",
error: "Either sessionKey or label is required",
});
}
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility =
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
@@ -90,42 +74,111 @@ export function createSessionsSendTool(opts?: {
mainKey,
})
: undefined;
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) {
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
const sessionKeyParam = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label");
if (sessionKeyParam && labelParam) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: "Provide either sessionKey or label (not both).",
});
}
const listSessions = async (listParams: Record<string, unknown>) => {
const result = (await callGateway({
method: "sessions.list",
params: listParams,
timeoutMs: 10_000,
})) as { sessions?: Array<Record<string, unknown>> };
return Array.isArray(result?.sessions) ? result.sessions : [];
};
const activeMinutes = 24 * 60;
const visibleSessions = restrictToSpawned
? await listSessions({
activeMinutes,
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
})
: undefined;
let sessionKey = sessionKeyParam;
if (!sessionKey && labelParam) {
const sessions =
visibleSessions ??
(await listSessions({
activeMinutes,
includeGlobal: false,
includeUnknown: false,
limit: 500,
}));
const matches = sessions.filter((entry) => {
const label =
typeof entry?.label === "string" ? entry.label : undefined;
return label === labelParam;
});
if (matches.length === 0) {
if (restrictToSpawned) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
});
}
} catch {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: `No session found with label: ${labelParam}`,
});
}
if (matches.length > 1) {
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) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: "Either sessionKey or label is required",
});
}
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
if (restrictToSpawned) {
const sessions = visibleSessions ?? [];
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",

View File

@@ -271,7 +271,7 @@ export async function runReplyAgent(params: {
if (steered && !shouldFollowup) {
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
@@ -285,7 +285,7 @@ export async function runReplyAgent(params: {
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
@@ -674,7 +674,7 @@ export async function runReplyAgent(params: {
) {
sessionEntry.groupActivationNeedsSystemIntro = false;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}

View File

@@ -880,7 +880,7 @@ export async function handleDirectiveOnly(params: {
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
@@ -1099,7 +1099,7 @@ export async function persistInlineDirectives(params: {
}
if (updated) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}

View File

@@ -95,7 +95,7 @@ export async function createModelSelectionState(params: {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
@@ -129,7 +129,7 @@ export async function createModelSelectionState(params: {
if (!profile || profile.provider !== provider) {
delete sessionEntry.authProfileOverride;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}

View File

@@ -173,7 +173,7 @@ describe("sessions_send label lookup", () => {
};
expect(details.status).toBe("ok");
expect(details.reply).toBe("labeled response");
expect(details.sessionKey).toBe("test-labeled-session");
expect(details.sessionKey).toBe("agent:main:test-labeled-session");
} finally {
if (prevPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;