fix: propagate agent run context for subagent announce

This commit is contained in:
Peter Steinberger
2026-01-19 00:45:03 +00:00
parent 953472bf25
commit 989543c9c3
8 changed files with 157 additions and 3 deletions

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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");

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -49,6 +49,8 @@ const BASE_IMAGE_PNG =
function expectChannels(call: Record<string, unknown>, 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();