diff --git a/CHANGELOG.md b/CHANGELOG.md index a20382166..d66e0d3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Docs: https://docs.clawd.bot ### Fixes - Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context. -- Web: trim HTML error bodies in web_fetch failures. (#1193) — thanks @sebslight. +- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058) ## 2026.1.18-5 @@ -18,7 +18,6 @@ Docs: https://docs.clawd.bot - Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. - TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07. - Docs: clarify allowlist input types and onboarding behavior for messaging channels. -- Exec: add `tools.exec.pathPrepend` for prepending PATH entries on exec runs. ### Fixes - Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x. diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-back-requester-group-channel.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-back-requester-group-channel.test.ts index 008025fde..6a81c9d07 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-back-requester-group-channel.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-back-requester-group-channel.test.ts @@ -157,4 +157,79 @@ describe("clawdbot-tools: subagents", () => { // Session should be deleted since cleanup=delete expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); + + it("sessions_spawn announces with requester accountId", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + let agentCallCount = 0; + let childRunId: string | undefined; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { lane?: string; sessionKey?: string } | undefined; + if (params?.lane === "subagent") { + childRunId = runId; + } + return { + runId, + status: "accepted", + acceptedAt: 4000 + agentCallCount, + }; + } + if (request.method === "agent.wait") { + const params = request.params as { runId?: string; timeoutMs?: number } | undefined; + return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 }; + } + if (request.method === "sessions.delete" || request.method === "sessions.patch") { + return { ok: true }; + } + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "main", + agentChannel: "whatsapp", + agentAccountId: "kev", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call2", { + task: "do thing", + runTimeoutSeconds: 1, + cleanup: "keep", + }); + expect(result.details).toMatchObject({ + status: "accepted", + runId: "run-1", + }); + + if (!childRunId) throw new Error("missing child runId"); + emitAgentEvent({ + runId: childRunId, + stream: "lifecycle", + data: { + phase: "end", + startedAt: 1000, + endedAt: 2000, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const agentCalls = calls.filter((call) => call.method === "agent"); + expect(agentCalls).toHaveLength(2); + const announceParams = agentCalls[1]?.params as + | { accountId?: string; channel?: string; deliver?: boolean } + | undefined; + expect(announceParams?.deliver).toBe(true); + expect(announceParams?.channel).toBe("whatsapp"); + expect(announceParams?.accountId).toBe("kev"); + }); }); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 2ddb2f5c5..1cb41838e 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -335,6 +335,42 @@ describe("agentCommand", () => { }); }); + it("prefers runContext for embedded routing", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + await agentCommand( + { + message: "hi", + to: "+1555", + channel: "whatsapp", + runContext: { messageChannel: "slack", accountId: "acct-2" }, + }, + runtime, + ); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.messageChannel).toBe("slack"); + expect(callArgs?.agentAccountId).toBe("acct-2"); + }); + }); + + it("forwards accountId to embedded runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + await agentCommand( + { message: "hi", to: "+1555", accountId: "kev" }, + runtime, + ); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.agentAccountId).toBe("kev"); + }); + }); + it("logs output when delivery is disabled", 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 802250634..4cef10216 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -51,6 +51,7 @@ import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; import { resolveMessageChannel } from "../utils/message-channel.js"; import { deliverAgentCommandResult } from "./agent/delivery.js"; +import { resolveAgentRunContext } from "./agent/run-context.js"; import { resolveSession } from "./agent/session.js"; import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js"; import type { AgentCommandOpts } from "./agent/types.js"; @@ -367,8 +368,9 @@ export async function agentCommand( let fallbackProvider = provider; let fallbackModel = model; try { + const runContext = resolveAgentRunContext(opts); const messageChannel = resolveMessageChannel( - opts.messageChannel, + runContext.messageChannel, opts.replyChannel ?? opts.channel, ); const fallbackResult = await runWithModelFallback({ @@ -402,6 +404,11 @@ export async function agentCommand( sessionId, sessionKey, messageChannel, + agentAccountId: runContext.accountId, + currentChannelId: runContext.currentChannelId, + currentThreadTs: runContext.currentThreadTs, + replyToMode: runContext.replyToMode, + hasRepliedRef: runContext.hasRepliedRef, sessionFile, workspaceDir, config: cfg, diff --git a/src/commands/agent/run-context.ts b/src/commands/agent/run-context.ts new file mode 100644 index 000000000..2386db568 --- /dev/null +++ b/src/commands/agent/run-context.ts @@ -0,0 +1,18 @@ +import { normalizeAccountId } from "../../utils/account-id.js"; +import { resolveMessageChannel } from "../../utils/message-channel.js"; +import type { AgentCommandOpts, AgentRunContext } from "./types.js"; + +export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext { + const merged: AgentRunContext = opts.runContext ? { ...opts.runContext } : {}; + + const normalizedChannel = resolveMessageChannel( + merged.messageChannel ?? opts.messageChannel, + opts.replyChannel ?? opts.channel, + ); + if (normalizedChannel) merged.messageChannel = normalizedChannel; + + const normalizedAccountId = normalizeAccountId(merged.accountId ?? opts.accountId); + if (normalizedAccountId) merged.accountId = normalizedAccountId; + + return merged; +} diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index 2480380a3..f02282a4d 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -7,6 +7,15 @@ export type ImageContent = { mimeType: string; }; +export type AgentRunContext = { + messageChannel?: string; + accountId?: string; + currentChannelId?: string; + currentThreadTs?: string; + replyToMode?: "off" | "first" | "all"; + hasRepliedRef?: { value: boolean }; +}; + export type AgentCommandOpts = { message: string; /** Optional image attachments for multimodal messages. */ @@ -33,6 +42,8 @@ export type AgentCommandOpts = { channel?: string; // delivery channel (whatsapp|telegram|...) /** Account ID for multi-account channel routing (e.g., WhatsApp account). */ accountId?: string; + /** Context for embedded run routing (channel/account/thread). */ + runContext?: AgentRunContext; deliveryTargetMode?: ChannelOutboundTargetMode; bestEffortDeliver?: boolean; abortSignal?: AbortSignal; diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 9b22c613e..d19e025ce 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -310,6 +310,10 @@ export const agentHandlers: GatewayRequestHandlers = { deliveryTargetMode, channel: resolvedChannel, accountId: resolvedAccountId, + runContext: { + messageChannel: resolvedChannel, + accountId: resolvedAccountId, + }, timeout: request.timeout?.toString(), bestEffortDeliver, messageChannel: resolvedChannel, diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index d9111058e..439b6475f 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -49,6 +49,8 @@ const BASE_IMAGE_PNG = function expectChannels(call: Record, channel: string) { expect(call.channel).toBe(channel); expect(call.messageChannel).toBe(channel); + const runContext = call.runContext as { messageChannel?: string } | undefined; + expect(runContext?.messageChannel).toBe(channel); } const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ @@ -325,6 +327,8 @@ describe("gateway server agent", () => { expectChannels(call, "whatsapp"); expect(call.to).toBe("+1555"); expect(call.accountId).toBe("kev"); + const runContext = call.runContext as { accountId?: string } | undefined; + expect(runContext?.accountId).toBe("kev"); ws.close(); await server.close();