diff --git a/CHANGELOG.md b/CHANGELOG.md index 46037f224..0ea5b5cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index e5ce8360c..3f9a9a5c3 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -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); diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 5dbf5893d..061912094 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -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; - 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> }; - 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) => { + const result = (await callGateway({ + method: "sessions.list", + params: listParams, + timeoutMs: 10_000, + })) as { sessions?: Array> }; + 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", diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index dd1c8a2cc..48bfc7cbc 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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); } diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index adf52410e..ce5248966 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -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); } diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index fa759be0a..37b290309 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -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); } diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 3972ef6ac..4e78115ed 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -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;