diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 9528dbf72..1abf1cdb1 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -671,6 +671,7 @@ public struct SessionsListParams: Codable, Sendable { public let activeminutes: Int? public let includeglobal: Bool? public let includeunknown: Bool? + public let label: String? public let spawnedby: String? public let agentid: String? @@ -679,6 +680,7 @@ public struct SessionsListParams: Codable, Sendable { activeminutes: Int?, includeglobal: Bool?, includeunknown: Bool?, + label: String?, spawnedby: String?, agentid: String? ) { @@ -686,6 +688,7 @@ public struct SessionsListParams: Codable, Sendable { self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown + self.label = label self.spawnedby = spawnedby self.agentid = agentid } @@ -694,6 +697,7 @@ public struct SessionsListParams: Codable, Sendable { case activeminutes = "activeMinutes" case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" + case label case spawnedby = "spawnedBy" case agentid = "agentId" } diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 061912094..b95dd61e0 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -40,7 +40,7 @@ const SessionsSendToolSchema = Type.Union([ ), Type.Object( { - label: Type.String(), + label: Type.String({ minLength: 1, maxLength: 64 }), message: Type.String(), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), }, @@ -81,7 +81,7 @@ export function createSessionsSendTool(opts?: { !isSubagentSessionKey(requesterInternalKey); const sessionKeyParam = readStringParam(params, "sessionKey"); - const labelParam = readStringParam(params, "label"); + const labelParam = readStringParam(params, "label")?.trim() || undefined; if (sessionKeyParam && labelParam) { return jsonResult({ runId: crypto.randomUUID(), @@ -99,32 +99,21 @@ export function createSessionsSendTool(opts?: { 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; - }); + const agentIdForLookup = requesterInternalKey + ? normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ) + : undefined; + const listParams: Record = { + includeGlobal: false, + includeUnknown: false, + label: labelParam, + }; + if (restrictToSpawned) listParams.spawnedBy = requesterInternalKey; + if (agentIdForLookup) listParams.agentId = agentIdForLookup; + const matches = await listSessions(listParams); if (matches.length === 0) { if (restrictToSpawned) { return jsonResult({ @@ -176,7 +165,18 @@ export function createSessionsSendTool(opts?: { }); if (restrictToSpawned) { - const sessions = visibleSessions ?? []; + const agentIdForLookup = requesterInternalKey + ? normalizeAgentId( + parseAgentSessionKey(requesterInternalKey)?.agentId, + ) + : undefined; + const sessions = await listSessions({ + includeGlobal: false, + includeUnknown: false, + limit: 500, + spawnedBy: requesterInternalKey, + ...(agentIdForLookup ? { agentId: agentIdForLookup } : {}), + }); const ok = sessions.some((entry) => entry?.key === resolvedKey); if (!ok) { return jsonResult({ diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index e5f5a8d2f..58329c109 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -1,6 +1,7 @@ import { type Static, type TSchema, Type } from "@sinclair/typebox"; const NonEmptyString = Type.String({ minLength: 1 }); +const SessionLabelString = Type.String({ minLength: 1, maxLength: 64 }); export const PresenceEntrySchema = Type.Object( { @@ -225,7 +226,7 @@ export const AgentParamsSchema = Type.Object( lane: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, - label: Type.Optional(Type.String()), + label: Type.Optional(SessionLabelString), spawnedBy: Type.Optional(Type.String()), }, { additionalProperties: false }, @@ -315,6 +316,7 @@ export const SessionsListParamsSchema = Type.Object( activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), includeGlobal: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()), + label: Type.Optional(SessionLabelString), spawnedBy: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString), }, @@ -324,7 +326,7 @@ export const SessionsListParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object( { key: NonEmptyString, - label: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + label: Type.Optional(Type.Union([SessionLabelString, 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 8a34b8088..5311ad606 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -1,15 +1,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; -import { - buildAllowedModelSet, - buildModelAliasIndex, - modelKey, - resolveConfiguredModelRef, - resolveModelRefFromString, - resolveThinkingDefault, -} from "../agents/model-selection.js"; +import { resolveThinkingDefault } from "../agents/model-selection.js"; import { abortEmbeddedPiRun, isEmbeddedPiRunActive, @@ -17,13 +9,6 @@ import { waitForEmbeddedPiRunEnd, } from "../agents/pi-embedded.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; -import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; -import { - normalizeElevatedLevel, - normalizeReasoningLevel, - normalizeThinkLevel, - normalizeVerboseLevel, -} from "../auto-reply/thinking.js"; import type { CliDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import type { HealthSummary } from "../commands/health.js"; @@ -49,9 +34,7 @@ import { setVoiceWakeTriggers, } from "../infra/voicewake.js"; import { clearCommandLane } from "../process/command-queue.js"; -import { isSubagentSessionKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; -import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; import { ErrorCodes, @@ -93,6 +76,7 @@ import { resolveSessionTranscriptCandidates, type SessionsPatchResult, } from "./session-utils.js"; +import { applySessionsPatchToStore } from "./sessions-patch.js"; import { formatForLog } from "./ws-log.js"; export type BridgeHandlersContext = { @@ -341,272 +325,29 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { const cfg = loadConfig(); const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); - const now = Date.now(); - - const existing = store[key]; - const next: SessionEntry = existing - ? { - ...existing, - updatedAt: Math.max(existing.updatedAt ?? 0, now), - } - : { sessionId: randomUUID(), updatedAt: now }; - - if ("spawnedBy" in p) { - const raw = p.spawnedBy; - if (raw === null) { - if (existing?.spawnedBy) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "spawnedBy cannot be cleared once set", - }, - }; - } - } else if (raw !== undefined) { - const trimmed = String(raw).trim(); - if (!trimmed) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "invalid spawnedBy: empty", - }, - }; - } - if (!isSubagentSessionKey(key)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: - "spawnedBy is only supported for subagent:* sessions", - }, - }; - } - if (existing?.spawnedBy && existing.spawnedBy !== trimmed) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "spawnedBy cannot be changed once set", - }, - }; - } - next.spawnedBy = trimmed; - } + const applied = await applySessionsPatchToStore({ + cfg, + store, + storeKey: key, + patch: p, + loadGatewayModelCatalog: ctx.loadGatewayModelCatalog, + }); + if (!applied.ok) { + return { + ok: false, + error: { + code: applied.error.code, + message: applied.error.message, + details: applied.error.details, + }, + }; } - - 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) { - delete next.thinkingLevel; - } else if (raw !== undefined) { - const normalized = normalizeThinkLevel(String(raw)); - if (!normalized) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid thinkingLevel: ${String(raw)}`, - }, - }; - } - next.thinkingLevel = normalized; - } - } - - if ("verboseLevel" in p) { - const raw = p.verboseLevel; - if (raw === null) { - delete next.verboseLevel; - } else if (raw !== undefined) { - const normalized = normalizeVerboseLevel(String(raw)); - if (!normalized) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid verboseLevel: ${String(raw)}`, - }, - }; - } - next.verboseLevel = normalized; - } - } - - if ("reasoningLevel" in p) { - const raw = p.reasoningLevel; - if (raw === null) { - delete next.reasoningLevel; - } else if (raw !== undefined) { - const normalized = normalizeReasoningLevel(String(raw)); - if (!normalized) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid reasoningLevel: ${String(raw)} (use on|off|stream)`, - }, - }; - } - if (normalized === "off") delete next.reasoningLevel; - else next.reasoningLevel = normalized; - } - } - - if ("elevatedLevel" in p) { - const raw = p.elevatedLevel; - if (raw === null) { - delete next.elevatedLevel; - } else if (raw !== undefined) { - const normalized = normalizeElevatedLevel(String(raw)); - if (!normalized) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid elevatedLevel: ${String(raw)}`, - }, - }; - } - next.elevatedLevel = normalized; - } - } - - if ("model" in p) { - const raw = p.model; - if (raw === null) { - delete next.providerOverride; - delete next.modelOverride; - } else if (raw !== undefined) { - const trimmed = String(raw).trim(); - if (!trimmed) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "invalid model: empty", - }, - }; - } - const resolvedDefault = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: resolvedDefault.provider, - }); - const resolved = resolveModelRefFromString({ - raw: trimmed, - defaultProvider: resolvedDefault.provider, - aliasIndex, - }); - if (!resolved) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid model: ${trimmed}`, - }, - }; - } - const catalog = await ctx.loadGatewayModelCatalog(); - const allowed = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: resolvedDefault.provider, - defaultModel: resolvedDefault.model, - }); - const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `model not allowed: ${key}`, - }, - }; - } - if ( - resolved.ref.provider === resolvedDefault.provider && - resolved.ref.model === resolvedDefault.model - ) { - delete next.providerOverride; - delete next.modelOverride; - } else { - next.providerOverride = resolved.ref.provider; - next.modelOverride = resolved.ref.model; - } - } - } - - if ("sendPolicy" in p) { - const raw = p.sendPolicy; - if (raw === null) { - delete next.sendPolicy; - } else if (raw !== undefined) { - const normalized = normalizeSendPolicy(String(raw)); - if (!normalized) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: 'invalid sendPolicy (use "allow"|"deny")', - }, - }; - } - next.sendPolicy = normalized; - } - } - - if ("groupActivation" in p) { - const raw = p.groupActivation; - if (raw === null) { - delete next.groupActivation; - } else if (raw !== undefined) { - const normalized = normalizeGroupActivation(String(raw)); - if (!normalized) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid groupActivation: ${String(raw)}`, - }, - }; - } - next.groupActivation = normalized; - } - } - - store[key] = next; await saveSessionStore(storePath, store); const payload: SessionsPatchResult = { ok: true, path: storePath, key, - entry: next, + entry: applied.entry, }; return { ok: true, payloadJSON: JSON.stringify(payload) }; } diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 137fb3bed..3ddd02717 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -1,27 +1,12 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; -import { - buildAllowedModelSet, - buildModelAliasIndex, - modelKey, - resolveConfiguredModelRef, - resolveModelRefFromString, -} from "../../agents/model-selection.js"; import { abortEmbeddedPiRun, isEmbeddedPiRunActive, resolveEmbeddedSessionLane, waitForEmbeddedPiRunEnd, } from "../../agents/pi-embedded.js"; -import { normalizeGroupActivation } from "../../auto-reply/group-activation.js"; -import { - normalizeReasoningLevel, - normalizeThinkLevel, - normalizeUsageDisplay, - normalizeVerboseLevel, -} from "../../auto-reply/thinking.js"; import { loadConfig } from "../../config/config.js"; import { loadSessionStore, @@ -30,8 +15,6 @@ import { saveSessionStore, } from "../../config/sessions.js"; import { clearCommandLane } from "../../process/command-queue.js"; -import { isSubagentSessionKey } from "../../routing/session-key.js"; -import { normalizeSendPolicy } from "../../sessions/send-policy.js"; import { ErrorCodes, errorShape, @@ -50,6 +33,7 @@ import { resolveSessionTranscriptCandidates, type SessionsPatchResult, } from "../session-utils.js"; +import { applySessionsPatchToStore } from "../sessions-patch.js"; import type { GatewayRequestHandlers } from "./types.js"; export const sessionsHandlers: GatewayRequestHandlers = { @@ -103,7 +87,6 @@ export const sessionsHandlers: GatewayRequestHandlers = { const target = resolveGatewaySessionStoreTarget({ cfg, key }); const storePath = target.storePath; const store = loadSessionStore(storePath); - const now = Date.now(); const primaryKey = target.storeKeys[0] ?? key; const existingKey = target.storeKeys.find((candidate) => store[candidate]); @@ -111,285 +94,23 @@ export const sessionsHandlers: GatewayRequestHandlers = { store[primaryKey] = store[existingKey]; delete store[existingKey]; } - const existing = store[primaryKey]; - const next: SessionEntry = existing - ? { - ...existing, - updatedAt: Math.max(existing.updatedAt ?? 0, now), - } - : { sessionId: randomUUID(), updatedAt: now }; - - if ("spawnedBy" in p) { - const raw = p.spawnedBy; - if (raw === null) { - if (existing?.spawnedBy) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "spawnedBy cannot be cleared once set", - ), - ); - return; - } - } else if (raw !== undefined) { - const trimmed = String(raw).trim(); - if (!trimmed) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "invalid spawnedBy: empty"), - ); - return; - } - if (!isSubagentSessionKey(primaryKey)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "spawnedBy is only supported for subagent:* sessions", - ), - ); - return; - } - if (existing?.spawnedBy && existing.spawnedBy !== trimmed) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "spawnedBy cannot be changed once set", - ), - ); - return; - } - next.spawnedBy = trimmed; - } + const applied = await applySessionsPatchToStore({ + cfg, + store, + storeKey: primaryKey, + patch: p, + loadGatewayModelCatalog: context.loadGatewayModelCatalog, + }); + if (!applied.ok) { + respond(false, undefined, applied.error); + return; } - - 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) { - delete next.thinkingLevel; - } else if (raw !== undefined) { - const normalized = normalizeThinkLevel(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "invalid thinkingLevel (use off|minimal|low|medium|high)", - ), - ); - return; - } - if (normalized === "off") delete next.thinkingLevel; - else next.thinkingLevel = normalized; - } - } - - if ("verboseLevel" in p) { - const raw = p.verboseLevel; - if (raw === null) { - delete next.verboseLevel; - } else if (raw !== undefined) { - const normalized = normalizeVerboseLevel(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid verboseLevel (use "on"|"off")', - ), - ); - return; - } - if (normalized === "off") delete next.verboseLevel; - else next.verboseLevel = normalized; - } - } - - if ("reasoningLevel" in p) { - const raw = p.reasoningLevel; - if (raw === null) { - delete next.reasoningLevel; - } else if (raw !== undefined) { - const normalized = normalizeReasoningLevel(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid reasoningLevel (use "on"|"off"|"stream")', - ), - ); - return; - } - if (normalized === "off") delete next.reasoningLevel; - else next.reasoningLevel = normalized; - } - } - - if ("responseUsage" in p) { - const raw = p.responseUsage; - if (raw === null) { - delete next.responseUsage; - } else if (raw !== undefined) { - const normalized = normalizeUsageDisplay(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid responseUsage (use "on"|"off")', - ), - ); - return; - } - if (normalized === "off") delete next.responseUsage; - else next.responseUsage = normalized; - } - } - - if ("model" in p) { - const raw = p.model; - if (raw === null) { - delete next.providerOverride; - delete next.modelOverride; - } else if (raw !== undefined) { - const trimmed = String(raw).trim(); - if (!trimmed) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "invalid model: empty"), - ); - return; - } - const resolvedDefault = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: resolvedDefault.provider, - }); - const resolved = resolveModelRefFromString({ - raw: trimmed, - defaultProvider: resolvedDefault.provider, - aliasIndex, - }); - if (!resolved) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `invalid model: ${trimmed}`), - ); - return; - } - const catalog = await context.loadGatewayModelCatalog(); - const allowed = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: resolvedDefault.provider, - defaultModel: resolvedDefault.model, - }); - const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `model not allowed: ${key}`), - ); - return; - } - if ( - resolved.ref.provider === resolvedDefault.provider && - resolved.ref.model === resolvedDefault.model - ) { - delete next.providerOverride; - delete next.modelOverride; - } else { - next.providerOverride = resolved.ref.provider; - next.modelOverride = resolved.ref.model; - } - } - } - - if ("sendPolicy" in p) { - const raw = p.sendPolicy; - if (raw === null) { - delete next.sendPolicy; - } else if (raw !== undefined) { - const normalized = normalizeSendPolicy(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid sendPolicy (use "allow"|"deny")', - ), - ); - return; - } - next.sendPolicy = normalized; - } - } - - if ("groupActivation" in p) { - const raw = p.groupActivation; - if (raw === null) { - delete next.groupActivation; - } else if (raw !== undefined) { - const normalized = normalizeGroupActivation(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid groupActivation (use "mention"|"always")', - ), - ); - return; - } - next.groupActivation = normalized; - } - } - - store[primaryKey] = next; await saveSessionStore(storePath, store); const result: SessionsPatchResult = { ok: true, path: storePath, key: target.canonicalKey, - entry: next, + entry: applied.entry, }; respond(true, result, undefined); }, diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 07ebe3a5f..7e48edaa2 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -158,6 +158,12 @@ describe("gateway server sessions", () => { expect(labelPatched.ok).toBe(true); expect(labelPatched.payload?.entry.label).toBe("Briefing"); + const labelPatchedDuplicate = await rpcReq(ws, "sessions.patch", { + key: "agent:main:discord:group:dev", + label: "Briefing", + }); + expect(labelPatchedDuplicate.ok).toBe(false); + const list2 = await rpcReq<{ sessions: Array<{ key: string; @@ -179,6 +185,18 @@ describe("gateway server sessions", () => { ); expect(subagent?.label).toBe("Briefing"); + const listByLabel = await rpcReq<{ + sessions: Array<{ key: string }>; + }>(ws, "sessions.list", { + includeGlobal: false, + includeUnknown: false, + label: "Briefing", + }); + expect(listByLabel.ok).toBe(true); + expect(listByLabel.payload?.sessions.map((s) => s.key)).toEqual([ + "agent:main:subagent:one", + ]); + const spawnedOnly = await rpcReq<{ sessions: Array<{ key: string }>; }>(ws, "sessions.list", { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index ff8c4fbf9..644fc2f93 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -435,6 +435,7 @@ export function listSessionsFromStore(params: { const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; + const label = typeof opts.label === "string" ? opts.label.trim() : ""; const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : ""; const activeMinutes = @@ -460,6 +461,10 @@ export function listSessionsFromStore(params: { if (key === "unknown" || key === "global") return false; return entry?.spawnedBy === spawnedBy; }) + .filter(([, entry]) => { + if (!label) return true; + return entry?.label === label; + }) .map(([key, entry]) => { const updatedAt = entry?.updatedAt ?? null; const input = entry?.inputTokens ?? 0; diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts new file mode 100644 index 000000000..5ad040291 --- /dev/null +++ b/src/gateway/sessions-patch.ts @@ -0,0 +1,254 @@ +import { randomUUID } from "node:crypto"; + +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import { + buildAllowedModelSet, + buildModelAliasIndex, + modelKey, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../agents/model-selection.js"; +import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; +import { + normalizeElevatedLevel, + normalizeReasoningLevel, + normalizeThinkLevel, + normalizeUsageDisplay, + normalizeVerboseLevel, +} from "../auto-reply/thinking.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { isSubagentSessionKey } from "../routing/session-key.js"; +import { normalizeSendPolicy } from "../sessions/send-policy.js"; +import { + ErrorCodes, + type ErrorShape, + errorShape, + type SessionsPatchParams, +} from "./protocol/index.js"; + +export const SESSION_LABEL_MAX_LENGTH = 64; + +function invalid(message: string): { ok: false; error: ErrorShape } { + return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) }; +} + +function normalizeLabel( + raw: unknown, +): { ok: true; label: string } | ReturnType { + const trimmed = 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: { + cfg: ClawdbotConfig; + store: Record; + storeKey: string; + patch: SessionsPatchParams; + loadGatewayModelCatalog?: () => Promise; +}): Promise< + { ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape } +> { + const { cfg, store, storeKey, patch } = params; + const now = Date.now(); + + const existing = store[storeKey]; + const next: SessionEntry = existing + ? { + ...existing, + updatedAt: Math.max(existing.updatedAt ?? 0, now), + } + : { sessionId: randomUUID(), updatedAt: now }; + + if ("spawnedBy" in patch) { + const raw = patch.spawnedBy; + if (raw === null) { + if (existing?.spawnedBy) + return invalid("spawnedBy cannot be cleared once set"); + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) return invalid("invalid spawnedBy: empty"); + if (!isSubagentSessionKey(storeKey)) { + return invalid("spawnedBy is only supported for subagent:* sessions"); + } + if (existing?.spawnedBy && existing.spawnedBy !== trimmed) { + return invalid("spawnedBy cannot be changed once set"); + } + next.spawnedBy = trimmed; + } + } + + if ("label" in patch) { + const raw = patch.label; + if (raw === null) { + delete next.label; + } else if (raw !== undefined) { + const normalized = normalizeLabel(raw); + if (!normalized.ok) return normalized; + for (const [key, entry] of Object.entries(store)) { + if (key === storeKey) continue; + if (entry?.label === normalized.label) { + return invalid(`label already in use: ${normalized.label}`); + } + } + next.label = normalized.label; + } + } + + if ("thinkingLevel" in patch) { + const raw = patch.thinkingLevel; + if (raw === null) { + delete next.thinkingLevel; + } else if (raw !== undefined) { + const normalized = normalizeThinkLevel(String(raw)); + if (!normalized) { + return invalid( + "invalid thinkingLevel (use off|minimal|low|medium|high)", + ); + } + if (normalized === "off") delete next.thinkingLevel; + else next.thinkingLevel = normalized; + } + } + + if ("verboseLevel" in patch) { + const raw = patch.verboseLevel; + if (raw === null) { + delete next.verboseLevel; + } else if (raw !== undefined) { + const normalized = normalizeVerboseLevel(String(raw)); + if (!normalized) return invalid('invalid verboseLevel (use "on"|"off")'); + if (normalized === "off") delete next.verboseLevel; + else next.verboseLevel = normalized; + } + } + + if ("reasoningLevel" in patch) { + const raw = patch.reasoningLevel; + if (raw === null) { + delete next.reasoningLevel; + } else if (raw !== undefined) { + const normalized = normalizeReasoningLevel(String(raw)); + if (!normalized) { + return invalid('invalid reasoningLevel (use "on"|"off"|"stream")'); + } + if (normalized === "off") delete next.reasoningLevel; + else next.reasoningLevel = normalized; + } + } + + if ("responseUsage" in patch) { + const raw = patch.responseUsage; + if (raw === null) { + delete next.responseUsage; + } else if (raw !== undefined) { + const normalized = normalizeUsageDisplay(String(raw)); + if (!normalized) return invalid('invalid responseUsage (use "on"|"off")'); + if (normalized === "off") delete next.responseUsage; + else next.responseUsage = normalized; + } + } + + if ("elevatedLevel" in patch) { + const raw = patch.elevatedLevel; + if (raw === null) { + delete next.elevatedLevel; + } else if (raw !== undefined) { + const normalized = normalizeElevatedLevel(String(raw)); + if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off")'); + if (normalized === "off") delete next.elevatedLevel; + else next.elevatedLevel = normalized; + } + } + + if ("model" in patch) { + const raw = patch.model; + if (raw === null) { + delete next.providerOverride; + delete next.modelOverride; + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) return invalid("invalid model: empty"); + + const resolvedDefault = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: resolvedDefault.provider, + }); + const resolved = resolveModelRefFromString({ + raw: trimmed, + defaultProvider: resolvedDefault.provider, + aliasIndex, + }); + if (!resolved) return invalid(`invalid model: ${trimmed}`); + + if (!params.loadGatewayModelCatalog) { + return { + ok: false, + error: errorShape( + ErrorCodes.UNAVAILABLE, + "model catalog unavailable", + ), + }; + } + const catalog = await params.loadGatewayModelCatalog(); + const allowed = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: resolvedDefault.provider, + defaultModel: resolvedDefault.model, + }); + const key = modelKey(resolved.ref.provider, resolved.ref.model); + if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { + return invalid(`model not allowed: ${key}`); + } + if ( + resolved.ref.provider === resolvedDefault.provider && + resolved.ref.model === resolvedDefault.model + ) { + delete next.providerOverride; + delete next.modelOverride; + } else { + next.providerOverride = resolved.ref.provider; + next.modelOverride = resolved.ref.model; + } + } + } + + if ("sendPolicy" in patch) { + const raw = patch.sendPolicy; + if (raw === null) { + delete next.sendPolicy; + } else if (raw !== undefined) { + const normalized = normalizeSendPolicy(String(raw)); + if (!normalized) + return invalid('invalid sendPolicy (use "allow"|"deny")'); + next.sendPolicy = normalized; + } + } + + if ("groupActivation" in patch) { + const raw = patch.groupActivation; + if (raw === null) { + delete next.groupActivation; + } else if (raw !== undefined) { + const normalized = normalizeGroupActivation(String(raw)); + if (!normalized) { + return invalid('invalid groupActivation (use "mention"|"always")'); + } + next.groupActivation = normalized; + } + } + + store[storeKey] = next; + return { ok: true, entry: next }; +}