diff --git a/CHANGELOG.md b/CHANGELOG.md index 22cb342a3..7cc07644a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,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/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 713239414..9528dbf72 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -426,6 +426,8 @@ public struct AgentParams: Codable, Sendable { public let lane: String? public let extrasystemprompt: String? public let idempotencykey: String + public let label: String? + public let spawnedby: String? public init( message: String, @@ -438,7 +440,9 @@ public struct AgentParams: Codable, Sendable { timeout: Int?, lane: String?, extrasystemprompt: String?, - idempotencykey: String + idempotencykey: String, + label: String?, + spawnedby: String? ) { self.message = message self.to = to @@ -451,6 +455,8 @@ public struct AgentParams: Codable, Sendable { self.lane = lane self.extrasystemprompt = extrasystemprompt self.idempotencykey = idempotencykey + self.label = label + self.spawnedby = spawnedby } private enum CodingKeys: String, CodingKey { case message @@ -464,6 +470,8 @@ public struct AgentParams: Codable, Sendable { case lane case extrasystemprompt = "extraSystemPrompt" case idempotencykey = "idempotencyKey" + case label + case spawnedby = "spawnedBy" } } @@ -693,6 +701,7 @@ public struct SessionsListParams: Codable, Sendable { public struct SessionsPatchParams: Codable, Sendable { public let key: String + public let label: AnyCodable? public let thinkinglevel: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? @@ -705,6 +714,7 @@ public struct SessionsPatchParams: Codable, Sendable { public init( key: String, + label: AnyCodable?, thinkinglevel: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, @@ -716,6 +726,7 @@ public struct SessionsPatchParams: Codable, Sendable { groupactivation: AnyCodable? ) { self.key = key + self.label = label self.thinkinglevel = thinkinglevel self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel @@ -728,6 +739,7 @@ public struct SessionsPatchParams: Codable, Sendable { } private enum CodingKeys: String, CodingKey { case key + case label case thinkinglevel = "thinkingLevel" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 3240f339c..cc31af1fd 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -196,6 +196,7 @@ export async function runSubagentAnnounceFlow(params: { waitForCompletion?: boolean; startedAt?: number; endedAt?: number; + label?: string; }) { try { let reply = params.roundOneReply; @@ -273,6 +274,18 @@ export async function runSubagentAnnounceFlow(params: { } catch { // Best-effort follow-ups; ignore failures to avoid breaking the caller response. } finally { + // Patch label after all writes complete + if (params.label) { + try { + await callGateway({ + method: "sessions.patch", + params: { key: params.childSessionKey, label: params.label }, + timeoutMs: 10_000, + }); + } catch { + // Best-effort + } + } if (params.cleanup === "delete") { try { await callGateway({ diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index cfd022145..3f9a9a5c3 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -11,6 +11,7 @@ export type SubagentRunRecord = { requesterDisplayKey: string; task: string; cleanup: "delete" | "keep"; + label?: string; createdAt: number; startedAt?: number; endedAt?: number; @@ -83,6 +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); @@ -101,6 +103,7 @@ function ensureListener() { waitForCompletion: false, startedAt: entry.startedAt, endedAt: entry.endedAt, + label: entry.label, }); if (entry.cleanup === "delete") { subagentRuns.delete(evt.runId); @@ -124,6 +127,7 @@ export function registerSubagentRun(params: { requesterDisplayKey: string; task: string; cleanup: "delete" | "keep"; + label?: string; }) { const now = Date.now(); const archiveAfterMs = resolveArchiveAfterMs(); @@ -136,6 +140,7 @@ export function registerSubagentRun(params: { requesterDisplayKey: params.requesterDisplayKey, task: params.task, cleanup: params.cleanup, + label: params.label, createdAt: now, startedAt: now, archiveAtMs, @@ -175,6 +180,7 @@ async function probeImmediateCompletion(runId: string) { waitForCompletion: false, startedAt: entry.startedAt, endedAt: entry.endedAt, + label: entry.label, }); if (entry.cleanup === "delete") { subagentRuns.delete(runId); diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 4afc708a5..87108fad7 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -25,6 +25,7 @@ type SessionListRow = { key: string; kind: SessionKind; provider: string; + label?: string; displayName?: string; updatedAt?: number | null; sessionId?: string; @@ -205,6 +206,7 @@ export function createSessionsListTool(opts?: { key: displayKey, kind, provider: derivedProvider, + label: typeof entry.label === "string" ? entry.label : undefined, displayName: typeof entry.displayName === "string" ? entry.displayName diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index b3711ffef..061912094 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -29,11 +29,24 @@ import { resolvePingPongTurns, } from "./sessions-send-helpers.js"; -const SessionsSendToolSchema = Type.Object({ - sessionKey: 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; @@ -43,13 +56,11 @@ export function createSessionsSendTool(opts?: { return { label: "Session Send", name: "sessions_send", - description: "Send a message into another session.", + description: + "Send a message into another session. Use sessionKey or label to identify the target.", parameters: SessionsSendToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const sessionKey = readStringParam(params, "sessionKey", { - required: true, - }); const message = readStringParam(params, "message", { required: true }); const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); @@ -63,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/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 2379bfafd..e6260a38a 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -126,17 +126,7 @@ export function createSessionsSpawnTool(opts?: { } } const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; - if (opts?.sandboxed === true) { - try { - await callGateway({ - method: "sessions.patch", - params: { key: childSessionKey, spawnedBy: requesterInternalKey }, - timeoutMs: 10_000, - }); - } catch { - // best-effort; scoping relies on this metadata but spawning still works without it - } - } + const shouldPatchSpawnedBy = opts?.sandboxed === true; if (model) { try { await callGateway({ @@ -185,6 +175,8 @@ export function createSessionsSpawnTool(opts?: { lane: "subagent", extraSystemPrompt: childSystemPrompt, timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined, + label: label || undefined, + spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined, }, timeoutMs: 10_000, })) as { runId?: string }; @@ -214,6 +206,7 @@ export function createSessionsSpawnTool(opts?: { requesterDisplayKey, task, cleanup, + label: label || undefined, }); return jsonResult({ diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index d62107a1f..b7e4eaaf8 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -1010,6 +1010,10 @@ export async function persistInlineDirectives(params: { agentCfg, } = params; let { provider, model } = params; + const activeAgentId = sessionKey + ? resolveAgentIdFromSessionKey(sessionKey) + : resolveDefaultAgentId(cfg); + const agentDir = resolveAgentDir(cfg, activeAgentId); if (sessionEntry && sessionStore && sessionKey) { let updated = false; diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index a09d441c6..b437fb132 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -91,7 +91,7 @@ export async function ensureSkillSnapshot(params: { systemSent: true, skillsSnapshot: skillSnapshot, }; - sessionStore[sessionKey] = nextEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } @@ -123,7 +123,7 @@ export async function ensureSkillSnapshot(params: { updatedAt: Date.now(), skillsSnapshot, }; - sessionStore[sessionKey] = nextEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; if (storePath) { await saveSessionStore(storePath, sessionStore); } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 5cf3bd8cc..199afe7f6 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -264,7 +264,7 @@ export async function initSessionState(params: { ctx.MessageThreadId, ); } - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; await saveSessionStore(storePath, sessionStore); const sessionCtx: TemplateContext = { diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 11ecd42b9..f478b3293 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -180,7 +180,10 @@ async function ensureDevWorkspace(dir: string) { path.join(resolvedDir, "TOOLS.md"), DEV_TOOLS_TEMPLATE, ); - await writeFileIfMissing(path.join(resolvedDir, "USER.md"), DEV_USER_TEMPLATE); + await writeFileIfMissing( + path.join(resolvedDir, "USER.md"), + DEV_USER_TEMPLATE, + ); await writeFileIfMissing( path.join(resolvedDir, "HEARTBEAT.md"), DEV_HEARTBEAT_TEMPLATE, diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index daf51071d..e67657713 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -23,12 +23,7 @@ describe("parseCliProfileArgs", () => { }); it("still accepts global --dev before subcommand", () => { - const res = parseCliProfileArgs([ - "node", - "clawdbot", - "--dev", - "gateway", - ]); + const res = parseCliProfileArgs(["node", "clawdbot", "--dev", "gateway"]); if (!res.ok) throw new Error(res.error); expect(res.profile).toBe("dev"); expect(res.argv).toEqual(["node", "clawdbot", "gateway"]); diff --git a/src/commands/configure.ts b/src/commands/configure.ts index f915f439e..d75a1527e 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -80,6 +80,7 @@ import { DEFAULT_WORKSPACE, ensureWorkspaceAndSessions, guardCancel, + openUrl, printWizardHeader, probeGatewayReachable, randomToken, diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 72b1eae5d..29670cb95 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -113,6 +113,7 @@ export type SessionEntry = { contextTokens?: number; compactionCount?: number; claudeCliSessionId?: string; + label?: string; displayName?: string; provider?: string; subject?: string; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 4e2e98700..e5f5a8d2f 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -225,6 +225,8 @@ export const AgentParamsSchema = Type.Object( lane: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, + label: Type.Optional(Type.String()), + spawnedBy: Type.Optional(Type.String()), }, { additionalProperties: false }, ); @@ -322,6 +324,7 @@ export const SessionsListParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object( { key: NonEmptyString, + label: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 44cbde1e8..8a34b8088 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -397,6 +397,25 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } } + if ("label" in p) { + const raw = p.label; + if (raw === null) { + delete next.label; + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid label: empty", + }, + }; + } + next.label = trimmed; + } + } + if ("thinkingLevel" in p) { const raw = p.thinkingLevel; if (raw === null) { @@ -628,6 +647,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, + label: entry?.label, displayName: entry?.displayName, chatType: entry?.chatType, provider: entry?.provider, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 790929fcc..a96e67b3a 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -52,6 +52,8 @@ export const agentHandlers: GatewayRequestHandlers = { extraSystemPrompt?: string; idempotencyKey: string; timeout?: number; + label?: string; + spawnedBy?: string; }; const idem = request.idempotencyKey; const cached = context.dedupe.get(`agent:${idem}`); @@ -78,6 +80,8 @@ export const agentHandlers: GatewayRequestHandlers = { cfgForAgent = cfg; const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); + const labelValue = request.label?.trim() || entry?.label; + const spawnedByValue = request.spawnedBy?.trim() || entry?.spawnedBy; const nextEntry: SessionEntry = { sessionId, updatedAt: now, @@ -91,6 +95,8 @@ export const agentHandlers: GatewayRequestHandlers = { lastTo: entry?.lastTo, modelOverride: entry?.modelOverride, providerOverride: entry?.providerOverride, + label: labelValue, + spawnedBy: spawnedByValue, }; sessionEntry = nextEntry; const sendPolicy = resolveSendPolicy({ diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index fb265c891..137fb3bed 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -169,6 +169,24 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } + if ("label" in p) { + const raw = p.label; + if (raw === null) { + delete next.label; + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid label: empty"), + ); + return; + } + next.label = trimmed; + } + } + if ("thinkingLevel" in p) { const raw = p.thinkingLevel; if (raw === null) { @@ -422,6 +440,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, + label: entry?.label, lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 73ea13d8b..acfbbf38a 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -101,3 +101,157 @@ describe("sessions_send gateway loopback", () => { } }); }); + +describe("sessions_send label lookup", () => { + it( + "finds session by label and sends message", + { timeout: 15_000 }, + async () => { + const port = await getFreePort(); + const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(port); + + const server = await startGatewayServer(port); + const spy = vi.mocked(agentCommand); + spy.mockImplementation(async (opts) => { + const params = opts as { + sessionId?: string; + runId?: string; + extraSystemPrompt?: string; + }; + const sessionId = params.sessionId ?? "test-labeled"; + const runId = params.runId ?? sessionId; + const sessionFile = resolveSessionTranscriptPath(sessionId); + await fs.mkdir(path.dirname(sessionFile), { recursive: true }); + + const startedAt = Date.now(); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "start", startedAt }, + }); + + const text = "labeled response"; + const message = { + role: "assistant", + content: [{ type: "text", text }], + }; + await fs.appendFile( + sessionFile, + `${JSON.stringify({ message })}\n`, + "utf8", + ); + + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", startedAt, endedAt: Date.now() }, + }); + }); + + try { + // First, create a session with a label via sessions.patch + const { callGateway } = await import("./call.js"); + await callGateway({ + method: "sessions.patch", + params: { key: "test-labeled-session", label: "my-test-worker" }, + timeoutMs: 5000, + }); + + const tool = createClawdbotTools().find( + (candidate) => candidate.name === "sessions_send", + ); + if (!tool) throw new Error("missing sessions_send tool"); + + // Send using label instead of sessionKey + const result = await tool.execute("call-by-label", { + label: "my-test-worker", + message: "hello labeled session", + timeoutSeconds: 5, + }); + const details = result.details as { + status?: string; + reply?: string; + sessionKey?: string; + }; + expect(details.status).toBe("ok"); + expect(details.reply).toBe("labeled response"); + expect(details.sessionKey).toBe("agent:main:test-labeled-session"); + } finally { + if (prevPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + } + await server.close(); + } + }, + ); + + it("returns error when label not found", { timeout: 15_000 }, async () => { + const port = await getFreePort(); + const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(port); + + const server = await startGatewayServer(port); + + try { + const tool = createClawdbotTools().find( + (candidate) => candidate.name === "sessions_send", + ); + if (!tool) throw new Error("missing sessions_send tool"); + + const result = await tool.execute("call-missing-label", { + label: "nonexistent-label", + message: "hello", + timeoutSeconds: 5, + }); + const details = result.details as { status?: string; error?: string }; + expect(details.status).toBe("error"); + expect(details.error).toContain("No session found with label"); + } finally { + if (prevPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + } + await server.close(); + } + }); + + it( + "returns error when neither sessionKey nor label provided", + { timeout: 15_000 }, + async () => { + const port = await getFreePort(); + const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(port); + + const server = await startGatewayServer(port); + + try { + const tool = createClawdbotTools().find( + (candidate) => candidate.name === "sessions_send", + ); + if (!tool) throw new Error("missing sessions_send tool"); + + const result = await tool.execute("call-no-key", { + message: "hello", + timeoutSeconds: 5, + }); + const details = result.details as { status?: string; error?: string }; + expect(details.status).toBe("error"); + expect(details.error).toContain( + "Either sessionKey or label is required", + ); + } finally { + if (prevPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevPort; + } + await server.close(); + } + }, + ); +}); diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 675f5213a..07ebe3a5f 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -148,12 +148,23 @@ describe("gateway server sessions", () => { expect(sendPolicyPatched.ok).toBe(true); expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny"); + const labelPatched = await rpcReq<{ + ok: true; + entry: { label?: string }; + }>(ws, "sessions.patch", { + key: "agent:main:subagent:one", + label: "Briefing", + }); + expect(labelPatched.ok).toBe(true); + expect(labelPatched.payload?.entry.label).toBe("Briefing"); + const list2 = await rpcReq<{ sessions: Array<{ key: string; thinkingLevel?: string; verboseLevel?: string; sendPolicy?: string; + label?: string; }>; }>(ws, "sessions.list", {}); expect(list2.ok).toBe(true); @@ -163,6 +174,10 @@ describe("gateway server sessions", () => { expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.verboseLevel).toBeUndefined(); expect(main2?.sendPolicy).toBe("deny"); + const subagent = list2.payload?.sessions.find( + (s) => s.key === "agent:main:subagent:one", + ); + expect(subagent?.label).toBe("Briefing"); const spawnedOnly = await rpcReq<{ sessions: Array<{ key: string }>; diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 9042f763d..ff8c4fbf9 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -34,6 +34,7 @@ export type GatewaySessionsDefaults = { export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; + label?: string; displayName?: string; provider?: string; subject?: string; @@ -485,6 +486,7 @@ export function listSessionsFromStore(params: { return { key, kind: classifySessionKey(key, entry), + label: entry?.label, displayName, provider, subject, diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index bd8afd21c..5b52568bd 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -49,6 +49,7 @@ export type GatewaySessionList = { totalTokens?: number | null; responseUsage?: "on" | "off"; modelProvider?: string; + label?: string; displayName?: string; provider?: string; room?: string; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 96d1c30a6..c65fc92c1 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -215,6 +215,7 @@ export type GatewaySessionsDefaults = { export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; + label?: string; displayName?: string; surface?: string; subject?: string; diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 008285ab9..1d655b1a2 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -117,6 +117,7 @@ export function renderSessions(props: SessionsProps) {
Key
+
Label
Kind
Updated
Tokens
@@ -132,7 +133,11 @@ export function renderSessions(props: SessionsProps) { `; } -function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsProps["onPatch"]) { +function renderRow( + row: GatewaySessionRow, + basePath: string, + onPatch: SessionsProps["onPatch"], +) { const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a"; const thinking = row.thinkingLevel ?? ""; const verbose = row.verboseLevel ?? ""; @@ -148,6 +153,7 @@ function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsPr
${canLink ? html`${displayName}` : displayName}
+
${row.label ?? ""}
${row.kind}
${updated}
${formatSessionTokens(row)}