fix: propagate agent run context for subagent announce
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
18
src/commands/agent/run-context.ts
Normal file
18
src/commands/agent/run-context.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user