diff --git a/CHANGELOG.md b/CHANGELOG.md index 7633a36e2..d5dc9eae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44. - Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson. - Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson. +- Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson. - Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway. ## 2026.1.10 diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 55f7f1f22..fa3d616a5 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -24,7 +24,6 @@ import { buildConfigSchema } from "../config/schema.js"; import { loadSessionStore, mergeSessionEntry, - resolveAgentMainSessionKey, resolveMainSessionKeyFromConfig, type SessionEntry, saveSessionStore, @@ -36,10 +35,7 @@ import { } from "../infra/voicewake.js"; import { clearCommandLane } from "../process/command-queue.js"; import { normalizeProviderId } from "../providers/plugins/index.js"; -import { - normalizeMainKey, - resolveAgentIdFromSessionKey, -} from "../routing/session-key.js"; +import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { abortChatRunById, @@ -93,6 +89,7 @@ import { readSessionMessages, resolveGatewaySessionStoreTarget, resolveSessionModelRef, + resolveSessionStoreKey, resolveSessionTranscriptCandidates, type SessionsPatchResult, } from "./session-utils.js"; @@ -922,12 +919,10 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { clientRunId, }); - // Normalize short main key alias to canonical form before store write - const agentId = resolveAgentIdFromSessionKey(p.sessionKey); - const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); - const rawMainKey = normalizeMainKey(cfg.session?.mainKey); - const storeKey = - p.sessionKey === rawMainKey ? mainSessionKey : p.sessionKey; + const storeKey = resolveSessionStoreKey({ + cfg, + sessionKey: p.sessionKey, + }); if (store) { store[storeKey] = sessionEntry; if (storePath) { @@ -1044,13 +1039,10 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; const cfg = loadConfig(); const rawMainKey = normalizeMainKey(cfg.session?.mainKey); - const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey; + const sessionKey = + sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey; const { storePath, store, entry } = loadSessionEntry(sessionKey); - // Normalize short main key alias to canonical form before store write - const agentId = resolveAgentIdFromSessionKey(sessionKey); - const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); - const storeKey = - sessionKey === rawMainKey ? mainSessionKey : sessionKey; + const storeKey = resolveSessionStoreKey({ cfg, sessionKey }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); store[storeKey] = { @@ -1128,16 +1120,11 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; const { storePath, store, entry } = loadSessionEntry(sessionKey); - // Normalize short main key alias to canonical form before store write const nodeCfg = loadConfig(); - const nodeAgentId = resolveAgentIdFromSessionKey(sessionKey); - const nodeMainSessionKey = resolveAgentMainSessionKey({ + const nodeStoreKey = resolveSessionStoreKey({ cfg: nodeCfg, - agentId: nodeAgentId, + sessionKey, }); - const nodeRawMainKey = normalizeMainKey(nodeCfg.session?.mainKey); - const nodeStoreKey = - sessionKey === nodeRawMainKey ? nodeMainSessionKey : sessionKey; const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); store[nodeStoreKey] = { diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index ce4e64b5f..ba1e633ae 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -11,7 +11,6 @@ import { import { registerAgentRunContext } from "../../infra/agent-events.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { DEFAULT_CHAT_PROVIDER } from "../../providers/registry.js"; -import { normalizeMainKey } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { @@ -29,7 +28,7 @@ import { validateAgentParams, validateAgentWaitParams, } from "../protocol/index.js"; -import { loadSessionEntry } from "../session-utils.js"; +import { loadSessionEntry, resolveSessionStoreKey } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import { waitForAgentJob } from "./agent-job.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -190,24 +189,21 @@ export const agentHandlers: GatewayRequestHandlers = { return; } resolvedSessionId = sessionId; - const agentId = resolveAgentIdFromSessionKey(requestedSessionKey); - const mainSessionKey = resolveAgentMainSessionKey({ + const canonicalSessionKey = resolveSessionStoreKey({ cfg, - agentId, + sessionKey: requestedSessionKey, }); - const rawMainKey = normalizeMainKey(cfg.session?.mainKey); - // Normalize short main key alias to canonical form before store write - const storeKey = - requestedSessionKey === rawMainKey ? mainSessionKey : requestedSessionKey; + const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey); + const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); if (store) { - store[storeKey] = nextEntry; + store[canonicalSessionKey] = nextEntry; if (storePath) { await saveSessionStore(storePath, store); } } if ( - requestedSessionKey === mainSessionKey || - requestedSessionKey === rawMainKey + canonicalSessionKey === mainSessionKey || + canonicalSessionKey === "global" ) { context.addChatRun(idem, { sessionKey: requestedSessionKey, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 3da182034..5f6ef9f6d 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -3,15 +3,7 @@ import { randomUUID } from "node:crypto"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { agentCommand } from "../../commands/agent.js"; -import { - mergeSessionEntry, - resolveAgentMainSessionKey, - saveSessionStore, -} from "../../config/sessions.js"; -import { - normalizeMainKey, - resolveAgentIdFromSessionKey, -} from "../../routing/session-key.js"; +import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -40,6 +32,7 @@ import { loadSessionEntry, readSessionMessages, resolveSessionModelRef, + resolveSessionStoreKey, } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -314,12 +307,10 @@ export const chatHandlers: GatewayRequestHandlers = { clientRunId, }); - // Normalize short main key alias to canonical form before store write - const agentId = resolveAgentIdFromSessionKey(p.sessionKey); - const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); - const rawMainKey = normalizeMainKey(cfg.session?.mainKey); - const storeKey = - p.sessionKey === rawMainKey ? mainSessionKey : p.sessionKey; + const storeKey = resolveSessionStoreKey({ + cfg, + sessionKey: p.sessionKey, + }); if (store) { store[storeKey] = sessionEntry; if (storePath) { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 9fa2cf36c..ce1afe8a6 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1,9 +1,14 @@ +import os from "node:os"; +import path from "node:path"; import { describe, expect, test } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { capArrayByJsonBytes, classifySessionKey, parseGroupKey, + resolveGatewaySessionStoreTarget, + resolveSessionStoreKey, } from "./session-utils.js"; describe("gateway session utils", () => { @@ -31,4 +36,62 @@ describe("gateway session utils", () => { const entry = { chatType: "group" } as SessionEntry; expect(classifySessionKey("main", entry)).toBe("group"); }); + + test("resolveSessionStoreKey maps main aliases to default agent main", () => { + const cfg = { + session: { mainKey: "work" }, + agents: { list: [{ id: "ops", default: true }] }, + } as ClawdbotConfig; + expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe( + "agent:ops:work", + ); + expect(resolveSessionStoreKey({ cfg, sessionKey: "work" })).toBe( + "agent:ops:work", + ); + }); + + test("resolveSessionStoreKey canonicalizes bare keys to default agent", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { list: [{ id: "ops", default: true }] }, + } as ClawdbotConfig; + expect(resolveSessionStoreKey({ cfg, sessionKey: "group:123" })).toBe( + "agent:ops:group:123", + ); + expect( + resolveSessionStoreKey({ cfg, sessionKey: "agent:alpha:main" }), + ).toBe("agent:alpha:main"); + }); + + test("resolveSessionStoreKey honors global scope", () => { + const cfg = { + session: { scope: "global", mainKey: "work" }, + agents: { list: [{ id: "ops", default: true }] }, + } as ClawdbotConfig; + expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("global"); + const target = resolveGatewaySessionStoreTarget({ cfg, key: "main" }); + expect(target.canonicalKey).toBe("global"); + expect(target.agentId).toBe("ops"); + }); + + test("resolveGatewaySessionStoreTarget uses canonical key for main alias", () => { + const storeTemplate = path.join( + os.tmpdir(), + "clawdbot-session-utils", + "{agentId}", + "sessions.json", + ); + const cfg = { + session: { mainKey: "main", store: storeTemplate }, + agents: { list: [{ id: "ops", default: true }] }, + } as ClawdbotConfig; + const target = resolveGatewaySessionStoreTarget({ cfg, key: "main" }); + expect(target.canonicalKey).toBe("agent:ops:main"); + expect(target.storeKeys).toEqual( + expect.arrayContaining(["agent:ops:main", "main"]), + ); + expect(target.storePath).toBe( + path.resolve(storeTemplate.replace("{agentId}", "ops")), + ); + }); }); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index aacd004f0..960ac9021 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -14,8 +14,7 @@ import { resolveStateDir } from "../config/paths.js"; import { buildGroupDisplayName, loadSessionStore, - resolveAgentIdFromSessionKey, - resolveAgentMainSessionKey, + resolveMainSessionKey, resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, @@ -168,22 +167,18 @@ export function capArrayByJsonBytes( export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const sessionCfg = cfg.session; - const agentId = resolveAgentIdFromSessionKey(sessionKey); + const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey }); + const agentId = resolveSessionStoreAgentId(cfg, canonicalKey); const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const store = loadSessionStore(storePath); - const parsed = parseAgentSessionKey(sessionKey); - const legacyKey = parsed?.rest; - // Also try the canonical key if sessionKey is the short mainKey alias - const rawMainKey = normalizeMainKey(sessionCfg?.mainKey); - const canonicalKey = - sessionKey === rawMainKey - ? resolveAgentMainSessionKey({ cfg, agentId }) - : undefined; + const parsed = parseAgentSessionKey(canonicalKey); + const legacyKey = + parsed?.rest ?? parseAgentSessionKey(sessionKey)?.rest ?? undefined; const entry = + store[canonicalKey] ?? store[sessionKey] ?? - (legacyKey ? store[legacyKey] : undefined) ?? - (canonicalKey ? store[canonicalKey] : undefined); - return { cfg, storePath, store, entry }; + (legacyKey ? store[legacyKey] : undefined); + return { cfg, storePath, store, entry, canonicalKey }; } export function classifySessionKey( @@ -293,6 +288,38 @@ function canonicalizeSessionKeyForAgent(agentId: string, key: string): string { return `agent:${normalizeAgentId(agentId)}:${key}`; } +function resolveDefaultStoreAgentId(cfg: ClawdbotConfig): string { + return normalizeAgentId(resolveDefaultAgentId(cfg)); +} + +export function resolveSessionStoreKey(params: { + cfg: ClawdbotConfig; + sessionKey: string; +}): string { + const raw = params.sessionKey.trim(); + if (!raw) return raw; + if (raw === "global" || raw === "unknown") return raw; + const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey); + if (raw === "main" || raw === rawMainKey) { + return resolveMainSessionKey(params.cfg); + } + if (raw.startsWith("agent:")) return raw; + const agentId = resolveDefaultStoreAgentId(params.cfg); + return canonicalizeSessionKeyForAgent(agentId, raw); +} + +function resolveSessionStoreAgentId( + cfg: ClawdbotConfig, + canonicalKey: string, +): string { + if (canonicalKey === "global" || canonicalKey === "unknown") { + return resolveDefaultStoreAgentId(cfg); + } + const parsed = parseAgentSessionKey(canonicalKey); + if (parsed?.agentId) return normalizeAgentId(parsed.agentId); + return resolveDefaultStoreAgentId(cfg); +} + function canonicalizeSpawnedByForAgent( agentId: string, spawnedBy?: string, @@ -314,40 +341,29 @@ export function resolveGatewaySessionStoreTarget(params: { storeKeys: string[]; } { const key = params.key.trim(); - const agentId = resolveAgentIdFromSessionKey(key); + const canonicalKey = resolveSessionStoreKey({ + cfg: params.cfg, + sessionKey: key, + }); + const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey); const storeConfig = params.cfg.session?.store; const storePath = resolveStorePath(storeConfig, { agentId }); - if (key === "global" || key === "unknown") { - return { agentId, storePath, canonicalKey: key, storeKeys: [key] }; + if (canonicalKey === "global" || canonicalKey === "unknown") { + const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key]; + return { agentId, storePath, canonicalKey, storeKeys }; } - const parsed = parseAgentSessionKey(key); - if (parsed) { - return { - agentId, - storePath, - canonicalKey: key, - storeKeys: [key, parsed.rest], - }; - } - - if (key.startsWith("subagent:")) { - const canonical = canonicalizeSessionKeyForAgent(agentId, key); - return { - agentId, - storePath, - canonicalKey: canonical, - storeKeys: [canonical, key], - }; - } - - const canonical = canonicalizeSessionKeyForAgent(agentId, key); + const parsed = parseAgentSessionKey(canonicalKey); + const storeKeys = new Set(); + storeKeys.add(canonicalKey); + if (parsed?.rest) storeKeys.add(parsed.rest); + if (key && key !== canonicalKey) storeKeys.add(key); return { agentId, storePath, - canonicalKey: canonical, - storeKeys: [canonical, key], + canonicalKey, + storeKeys: Array.from(storeKeys), }; }