diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e665c895..da414ef0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370. - Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298. - Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent. +- Sessions: forward explicit sessionKey through gateway/chat/node bridge to avoid sub-agent sessionId mixups. - Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior. - Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327. - Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300. diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index b25a95304..8383190f5 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -168,6 +168,45 @@ describe("agentCommand", () => { }); }); + it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + fs.mkdirSync(path.dirname(store), { recursive: true }); + fs.writeFileSync( + store, + JSON.stringify( + { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + ); + mockConfig(home, store); + + await agentCommand( + { + message: "hi", + sessionId: "sess-main", + sessionKey: "agent:main:subagent:abc", + }, + runtime, + ); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.sessionKey).toBe("agent:main:subagent:abc"); + + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { sessionId?: string } + >; + expect(saved["agent:main:subagent:abc"]?.sessionId).toBe("sess-main"); + }); + }); + it("defaults thinking to low for reasoning-capable models", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 86af31262..c425e09e5 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -34,6 +34,7 @@ import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, + resolveAgentIdFromSessionKey, resolveSessionKey, resolveSessionTranscriptPath, resolveStorePath, @@ -61,6 +62,7 @@ type AgentCommandOpts = { message: string; to?: string; sessionId?: string; + sessionKey?: string; thinking?: string; thinkingOnce?: string; verbose?: string; @@ -92,6 +94,7 @@ function resolveSession(opts: { cfg: ClawdbotConfig; to?: string; sessionId?: string; + sessionKey?: string; }): SessionResolution { const sessionCfg = opts.cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; @@ -101,20 +104,25 @@ function resolveSession(opts: { 1, ); const idleMs = idleMinutes * 60_000; - const storePath = resolveStorePath(sessionCfg?.store); + const explicitSessionKey = opts.sessionKey?.trim(); + const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey); + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: storeAgentId, + }); const sessionStore = loadSessionStore(storePath); const now = Date.now(); const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined; - let sessionKey: string | undefined = ctx - ? resolveSessionKey(scope, ctx, mainKey) - : undefined; + let sessionKey: string | undefined = + explicitSessionKey ?? + (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined); let sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined; // If a session id was provided, prefer to re-use its entry (by id) even when no key was derived. if ( + !explicitSessionKey && opts.sessionId && (!sessionEntry || sessionEntry.sessionId !== opts.sessionId) ) { @@ -162,7 +170,7 @@ export async function agentCommand( ) { const body = (opts.message ?? "").trim(); if (!body) throw new Error("Message (--message) is required"); - if (!opts.to && !opts.sessionId) { + if (!opts.to && !opts.sessionId && !opts.sessionKey) { throw new Error("Pass --to or --session-id to choose a session"); } @@ -216,6 +224,7 @@ export async function agentCommand( cfg, to: opts.to, sessionId: opts.sessionId, + sessionKey: opts.sessionKey, }); const { diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index cc18f8e3e..2fc6f49af 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -1053,6 +1053,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { { message: messageWithAttachments, sessionId, + sessionKey: p.sessionKey, runId: clientRunId, thinking: p.thinking, deliver: p.deliver, @@ -1169,6 +1170,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { { message: text, sessionId, + sessionKey, thinking: "low", deliver: false, messageProvider: "node", @@ -1245,6 +1247,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { { message, sessionId, + sessionKey, thinking: link?.thinking ?? undefined, deliver, to, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index cab2009e0..0419e9696 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -244,6 +244,7 @@ export const agentHandlers: GatewayRequestHandlers = { message, to: sanitizedTo, sessionId: resolvedSessionId, + sessionKey: requestedSessionKey, thinking: request.thinking, deliver, provider: resolvedProvider, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 9bef65084..36b412442 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -259,6 +259,7 @@ export const chatHandlers: GatewayRequestHandlers = { { message: messageWithAttachments, sessionId, + sessionKey: p.sessionKey, runId: clientRunId, thinking: p.thinking, deliver: p.deliver, diff --git a/src/gateway/server.agent.test.ts b/src/gateway/server.agent.test.ts index a13df9206..3aff8b125 100644 --- a/src/gateway/server.agent.test.ts +++ b/src/gateway/server.agent.test.ts @@ -66,6 +66,43 @@ describe("gateway server agent", () => { testState.allowFrom = undefined; }); + test("agent forwards sessionKey to agentCommand", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + JSON.stringify( + { + "agent:main:subagent:abc": { + sessionId: "sess-sub", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq(ws, "agent", { + message: "hi", + sessionKey: "agent:main:subagent:abc", + idempotencyKey: "idem-agent-subkey", + }); + expect(res.ok).toBe(true); + + const spy = vi.mocked(agentCommand); + const call = spy.mock.calls.at(-1)?.[0] as Record; + expect(call.sessionKey).toBe("agent:main:subagent:abc"); + expect(call.sessionId).toBe("sess-sub"); + + ws.close(); + await server.close(); + }); + test("agent routes main last-channel whatsapp", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index 748203ee0..2c4a72af2 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -61,6 +61,26 @@ describe("gateway server chat", () => { await server.close(); }); + test("chat.send forwards sessionKey to agentCommand", async () => { + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq(ws, "chat.send", { + sessionKey: "agent:main:subagent:abc", + message: "hello", + idempotencyKey: "idem-session-key-1", + }); + expect(res.ok).toBe(true); + + const call = vi.mocked(agentCommand).mock.calls.at(-1)?.[0] as + | { sessionKey?: string } + | undefined; + expect(call?.sessionKey).toBe("agent:main:subagent:abc"); + + ws.close(); + await server.close(); + }); + test("chat.send blocked by send policy", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); diff --git a/src/gateway/server.node-bridge.test.ts b/src/gateway/server.node-bridge.test.ts index 3b25d4c8d..a885014df 100644 --- a/src/gateway/server.node-bridge.test.ts +++ b/src/gateway/server.node-bridge.test.ts @@ -758,6 +758,7 @@ describe("gateway server node/bridge", () => { expect(spy.mock.calls.length).toBe(beforeCalls + 1); const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.sessionId).toBe("sess-main"); + expect(call.sessionKey).toBe("main"); expect(call.deliver).toBe(false); expect(call.messageProvider).toBe("node");