mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
Channels: add thread-aware model overrides
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.
|
||||
- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.
|
||||
- Discord/Voice: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.
|
||||
- Channels: allow per-channel model overrides via `channels.modelByChannel` and note them in /status. Thanks @thewilloftheshadow.
|
||||
- Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.
|
||||
- Docs/Discord: document forum channel thread creation flows and component limits. Thanks @thewilloftheshadow.
|
||||
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -7,7 +7,6 @@ ENV PATH="/root/.bun/bin:${PATH}"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
RUN chown node:node /app
|
||||
|
||||
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
|
||||
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
@@ -17,19 +16,17 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY --chown=node:node ui/package.json ./ui/package.json
|
||||
COPY --chown=node:node patches ./patches
|
||||
COPY --chown=node:node scripts ./scripts
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts ./scripts
|
||||
|
||||
USER node
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
|
||||
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
|
||||
# Must run after pnpm install so playwright-core is available in node_modules.
|
||||
USER root
|
||||
ARG OPENCLAW_INSTALL_BROWSER=""
|
||||
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
apt-get update && \
|
||||
@@ -39,8 +36,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
USER node
|
||||
COPY --chown=node:node . .
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
@@ -48,6 +44,9 @@ RUN pnpm ui:build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Allow non-root user to write temp files during runtime/tests.
|
||||
RUN chown -R node:node /app
|
||||
|
||||
# Security hardening: Run as non-root user
|
||||
# The node:22-bookworm image includes a 'node' user (uid 1000)
|
||||
# This reduces the attack surface by preventing container escape via root privileges
|
||||
|
||||
@@ -614,7 +614,7 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
|
||||
- parent thread metadata can be used for parent-session linkage
|
||||
- thread config inherits parent channel config unless a thread-specific entry exists
|
||||
|
||||
Channel topics are injected as untrusted context and also included in trusted inbound metadata on new sessions.
|
||||
Channel topics are injected as **untrusted** context (not as system prompt).
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -166,12 +166,12 @@ Behavior:
|
||||
|
||||
- Starts a new `agent:<agentId>:subagent:<uuid>` session with `deliver: false`.
|
||||
- Sub-agents default to the full tool set **minus session tools** (configurable via `tools.subagents.tools`).
|
||||
- Depth policy is enforced for nested spawns. With the default `maxSpawnDepth = 2`, depth-1 sub-agents can call `sessions_spawn`, depth-2 sub-agents cannot.
|
||||
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
||||
- Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
||||
- After completion, OpenClaw builds a sub-agent announce system message from the child session's latest assistant reply and injects it to the requester session.
|
||||
- Delivery stays internal (`deliver=false`) when the requester is a sub-agent, and is user-facing (`deliver=true`) when the requester is main.
|
||||
- Recipient agents can return the internal silent token to suppress duplicate outward delivery in the same turn.
|
||||
- Announce replies are normalized to runtime-derived status plus result context.
|
||||
- After completion, OpenClaw runs a sub-agent **announce step** and posts the result to the requester chat channel.
|
||||
- If the assistant final reply is empty, the latest `toolResult` from sub-agent history is included as `Result`.
|
||||
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
||||
- Announce replies are normalized to `Status`/`Result`/`Notes`; `Status` comes from runtime outcome (not model text).
|
||||
- Sub-agent sessions are auto-archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60).
|
||||
- Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost).
|
||||
|
||||
|
||||
@@ -38,6 +38,29 @@ Pairing codes expire after 1 hour. Pending DM pairing requests are capped at **3
|
||||
Slack/Discord have a special fallback: if their provider section is missing entirely, runtime group policy can resolve to `open` (with a startup warning).
|
||||
</Note>
|
||||
|
||||
### Channel model overrides
|
||||
|
||||
Use `channels.modelByChannel` to pin specific channel IDs to a model. Values accept `provider/model` or configured model aliases. The channel mapping applies when a session does not already have a model override (for example, set via `/model`).
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
discord: {
|
||||
"123456789012345678": "anthropic/claude-opus-4-6",
|
||||
},
|
||||
slack: {
|
||||
C1234567890: "openai/gpt-4.1",
|
||||
},
|
||||
telegram: {
|
||||
"-1001234567890": "openai/gpt-4.1-mini",
|
||||
"-1001234567890:topic:99": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### WhatsApp
|
||||
|
||||
WhatsApp runs through the gateway's web channel (Baileys Web). It starts automatically when a linked session exists.
|
||||
|
||||
@@ -35,7 +35,7 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session*
|
||||
- If direct delivery fails, it falls back to queue routing.
|
||||
- If queue routing is still not available, the announce is retried with a short exponential backoff before final give-up.
|
||||
- The completion message is a system message and includes:
|
||||
- `Result` (latest assistant reply text from the child session, after a short settle retry)
|
||||
- `Result` (`assistant` reply text, or latest `toolResult` if the assistant reply is empty)
|
||||
- `Status` (`completed successfully` / `failed` / `timed out`)
|
||||
- compact runtime/token stats
|
||||
- `--model` and `--thinking` override defaults for that specific run.
|
||||
@@ -90,7 +90,7 @@ Auto-archive:
|
||||
|
||||
## Nested Sub-Agents
|
||||
|
||||
By default, sub-agents can spawn one additional level (`maxSpawnDepth: 2`), enabling the **orchestrator pattern**: main → orchestrator sub-agent → worker sub-sub-agents. Set `maxSpawnDepth: 1` to disable nested spawning.
|
||||
By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). You can enable one level of nesting by setting `maxSpawnDepth: 2`, which allows the **orchestrator pattern**: main → orchestrator sub-agent → worker sub-sub-agents.
|
||||
|
||||
### How to enable
|
||||
|
||||
@@ -99,7 +99,7 @@ By default, sub-agents can spawn one additional level (`maxSpawnDepth: 2`), enab
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 2)
|
||||
maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1)
|
||||
maxChildrenPerAgent: 5, // max active children per agent session (default: 5)
|
||||
maxConcurrent: 8, // global concurrency lane cap (default: 8)
|
||||
},
|
||||
@@ -110,11 +110,11 @@ By default, sub-agents can spawn one additional level (`maxSpawnDepth: 2`), enab
|
||||
|
||||
### Depth levels
|
||||
|
||||
| Depth | Session key shape | Role | Can spawn? |
|
||||
| ----- | -------------------------------------------- | ----------------------------------- | ------------------------------ |
|
||||
| 0 | `agent:<id>:main` | Main agent | Always |
|
||||
| 1 | `agent:<id>:subagent:<uuid>` | Sub-agent (orchestrator by default) | Yes, when `maxSpawnDepth >= 2` |
|
||||
| 2 | `agent:<id>:subagent:<uuid>:subagent:<uuid>` | Sub-sub-agent (leaf worker) | No, when `maxSpawnDepth = 2` |
|
||||
| Depth | Session key shape | Role | Can spawn? |
|
||||
| ----- | -------------------------------------------- | --------------------------------------------- | ---------------------------- |
|
||||
| 0 | `agent:<id>:main` | Main agent | Always |
|
||||
| 1 | `agent:<id>:subagent:<uuid>` | Sub-agent (orchestrator when depth 2 allowed) | Only if `maxSpawnDepth >= 2` |
|
||||
| 2 | `agent:<id>:subagent:<uuid>:subagent:<uuid>` | Sub-sub-agent (leaf worker) | Never |
|
||||
|
||||
### Announce chain
|
||||
|
||||
@@ -128,9 +128,9 @@ Each level only sees announces from its direct children.
|
||||
|
||||
### Tool policy by depth
|
||||
|
||||
- **Depth 1 (orchestrator, default with `maxSpawnDepth = 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied.
|
||||
- **Depth 1 (leaf, when `maxSpawnDepth = 1`)**: No session tools.
|
||||
- **Depth 2 (leaf worker, default `maxSpawnDepth = 2`)**: No session tools, `sessions_spawn` is denied at depth 2, cannot spawn further children.
|
||||
- **Depth 1 (orchestrator, when `maxSpawnDepth >= 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied.
|
||||
- **Depth 1 (leaf, when `maxSpawnDepth == 1`)**: No session tools (current default behavior).
|
||||
- **Depth 2 (leaf worker)**: No session tools — `sessions_spawn` is always denied at depth 2. Cannot spawn further children.
|
||||
|
||||
### Per-agent spawn limit
|
||||
|
||||
@@ -156,16 +156,17 @@ Note: the merge is additive, so main profiles are always available as fallbacks.
|
||||
|
||||
## Announce
|
||||
|
||||
Sub-agents report back via an announce injection step:
|
||||
Sub-agents report back via an announce step:
|
||||
|
||||
- OpenClaw reads the child session's latest assistant reply after completion, with a short settle retry.
|
||||
- It builds a system message with `Status`, `Result`, compact stats, and reply guidance.
|
||||
- The message is injected with a follow-up `agent` call:
|
||||
- `deliver=false` when the requester is another sub-agent, this keeps orchestration internal.
|
||||
- `deliver=true` when the requester is main, this produces the user-facing update.
|
||||
- Delivery context prefers captured requester origin, but non-deliverable channels (for example `webchat`) are ignored in favor of persisted deliverable routes.
|
||||
- Recipient agents can return the internal silent token to suppress duplicate outward delivery in the same turn.
|
||||
- `Status` is derived from runtime outcome signals, not inferred from model output.
|
||||
- The announce step runs inside the sub-agent session (not the requester session).
|
||||
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
|
||||
- Otherwise the announce reply is posted to the requester chat channel via a follow-up `agent` call (`deliver=true`).
|
||||
- Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads).
|
||||
- Announce messages are normalized to a stable template:
|
||||
- `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`).
|
||||
- `Result:` the summary content from the announce step (or `(not available)` if missing).
|
||||
- `Notes:` error details and other useful context.
|
||||
- `Status` is not inferred from model output; it comes from runtime outcome signals.
|
||||
|
||||
Announce payloads include a stats line at the end (even when wrapped):
|
||||
|
||||
@@ -183,7 +184,7 @@ By default, sub-agents get **all tools except session tools** and system tools:
|
||||
- `sessions_send`
|
||||
- `sessions_spawn`
|
||||
|
||||
With the default `maxSpawnDepth = 2`, depth-1 orchestrator sub-agents receive `sessions_spawn`, `subagents`, `sessions_list`, and `sessions_history` so they can manage their children. If you set `maxSpawnDepth = 1`, those session tools stay denied.
|
||||
When `maxSpawnDepth >= 2`, depth-1 orchestrator sub-agents additionally receive `sessions_spawn`, `subagents`, `sessions_list`, and `sessions_history` so they can manage their children.
|
||||
|
||||
Override via config:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk";
|
||||
@@ -178,12 +178,8 @@ describe("msteams messenger", () => {
|
||||
});
|
||||
|
||||
it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const tmpStateDir = await mkdtemp(path.join(os.tmpdir(), "msteams-mention-state-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tmpStateDir;
|
||||
const workspaceDir = path.join(tmpStateDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
const localFile = path.join(workspaceDir, "note.txt");
|
||||
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "msteams-mention-"));
|
||||
const localFile = path.join(tmpDir, "note.txt");
|
||||
await writeFile(localFile, "hello");
|
||||
|
||||
try {
|
||||
@@ -236,12 +232,7 @@ describe("msteams messenger", () => {
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
await rm(tmpStateDir, { recursive: true, force: true });
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -94,14 +94,13 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows depth-1 callers by default (maxSpawnDepth defaults to 2)", async () => {
|
||||
it("rejects spawning when caller depth reaches maxSpawnDepth", async () => {
|
||||
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
|
||||
const result = await tool.execute("call-depth-reject", { task: "hello" });
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
childSessionKey: expect.stringMatching(/^agent:main:subagent:/),
|
||||
runId: "run-depth",
|
||||
status: "forbidden",
|
||||
error: "sessions_spawn is not allowed at this depth (current depth: 1, max: 1)",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -133,6 +133,35 @@ const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => {
|
||||
);
|
||||
};
|
||||
|
||||
function expectSingleCompletionSend(
|
||||
calls: GatewayRequest[],
|
||||
expected: { sessionKey: string; channel: string; to: string; message: string },
|
||||
) {
|
||||
const sendCalls = calls.filter((call) => call.method === "send");
|
||||
expect(sendCalls).toHaveLength(1);
|
||||
const send = sendCalls[0]?.params as
|
||||
| { sessionKey?: string; channel?: string; to?: string; message?: string }
|
||||
| undefined;
|
||||
expect(send?.sessionKey).toBe(expected.sessionKey);
|
||||
expect(send?.channel).toBe(expected.channel);
|
||||
expect(send?.to).toBe(expected.to);
|
||||
expect(send?.message).toBe(expected.message);
|
||||
}
|
||||
|
||||
function createDeleteCleanupHooks(setDeletedKey: (key: string | undefined) => void) {
|
||||
return {
|
||||
onAgentSubagentSpawn: (params: unknown) => {
|
||||
const rec = params as { channel?: string; timeout?: number } | undefined;
|
||||
expect(rec?.channel).toBe("discord");
|
||||
expect(rec?.timeout).toBe(1);
|
||||
},
|
||||
onSessionsDelete: (params: unknown) => {
|
||||
const rec = params as { key?: string } | undefined;
|
||||
setDeletedKey(rec?.key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
beforeEach(() => {
|
||||
resetSessionsSpawnConfigOverride();
|
||||
@@ -155,6 +184,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
agentTo: "+123",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call2", {
|
||||
@@ -183,7 +213,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
|
||||
await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId));
|
||||
await waitFor(() => patchCalls.some((call) => call.label === "my-task"));
|
||||
await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2);
|
||||
await waitFor(() => ctx.calls.filter((c) => c.method === "send").length >= 1);
|
||||
|
||||
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
|
||||
expect(childWait?.timeoutMs).toBe(1000);
|
||||
@@ -192,22 +222,21 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
expect(labelPatch?.key).toBe(child.sessionKey);
|
||||
expect(labelPatch?.label).toBe("my-task");
|
||||
|
||||
// Two agent calls: subagent spawn + main agent trigger
|
||||
// Subagent spawn call plus direct outbound completion send.
|
||||
const agentCalls = ctx.calls.filter((c) => c.method === "agent");
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
expect(agentCalls).toHaveLength(1);
|
||||
|
||||
// First call: subagent spawn
|
||||
const first = agentCalls[0]?.params as { lane?: string } | undefined;
|
||||
expect(first?.lane).toBe("subagent");
|
||||
|
||||
// Second call: main agent trigger (not "Sub-agent announce step." anymore)
|
||||
const second = agentCalls[1]?.params as { sessionKey?: string; message?: string } | undefined;
|
||||
expect(second?.sessionKey).toBe("main");
|
||||
expect(second?.message).toContain("subagent task");
|
||||
|
||||
// No direct send to external channel (main agent handles delivery)
|
||||
const sendCalls = ctx.calls.filter((c) => c.method === "send");
|
||||
expect(sendCalls.length).toBe(0);
|
||||
// Direct send should route completion to the requester channel/session.
|
||||
expectSingleCompletionSend(ctx.calls, {
|
||||
sessionKey: "agent:main:main",
|
||||
channel: "whatsapp",
|
||||
to: "+123",
|
||||
message: "✅ Subagent main finished\n\ndone",
|
||||
});
|
||||
expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -216,20 +245,15 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
callGatewayMock.mockReset();
|
||||
let deletedKey: string | undefined;
|
||||
const ctx = setupSessionsSpawnGatewayMock({
|
||||
onAgentSubagentSpawn: (params) => {
|
||||
const rec = params as { channel?: string; timeout?: number } | undefined;
|
||||
expect(rec?.channel).toBe("discord");
|
||||
expect(rec?.timeout).toBe(1);
|
||||
},
|
||||
onSessionsDelete: (params) => {
|
||||
const rec = params as { key?: string } | undefined;
|
||||
deletedKey = rec?.key;
|
||||
},
|
||||
...createDeleteCleanupHooks((key) => {
|
||||
deletedKey = key;
|
||||
}),
|
||||
});
|
||||
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
agentTo: "discord:dm:u123",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
@@ -267,7 +291,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
expect(childWait?.timeoutMs).toBe(1000);
|
||||
|
||||
const agentCalls = ctx.calls.filter((call) => call.method === "agent");
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
expect(agentCalls).toHaveLength(1);
|
||||
|
||||
const first = agentCalls[0]?.params as
|
||||
| {
|
||||
@@ -283,19 +307,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
|
||||
const second = agentCalls[1]?.params as
|
||||
| {
|
||||
sessionKey?: string;
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
expect(second?.sessionKey).toBe("discord:group:req");
|
||||
expect(second?.deliver).toBe(true);
|
||||
expect(second?.message).toContain("subagent task");
|
||||
|
||||
const sendCalls = ctx.calls.filter((c) => c.method === "send");
|
||||
expect(sendCalls.length).toBe(0);
|
||||
expectSingleCompletionSend(ctx.calls, {
|
||||
sessionKey: "agent:main:discord:group:req",
|
||||
channel: "discord",
|
||||
to: "discord:dm:u123",
|
||||
message: "✅ Subagent main finished",
|
||||
});
|
||||
|
||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
@@ -306,21 +323,16 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
let deletedKey: string | undefined;
|
||||
const ctx = setupSessionsSpawnGatewayMock({
|
||||
includeChatHistory: true,
|
||||
onAgentSubagentSpawn: (params) => {
|
||||
const rec = params as { channel?: string; timeout?: number } | undefined;
|
||||
expect(rec?.channel).toBe("discord");
|
||||
expect(rec?.timeout).toBe(1);
|
||||
},
|
||||
onSessionsDelete: (params) => {
|
||||
const rec = params as { key?: string } | undefined;
|
||||
deletedKey = rec?.key;
|
||||
},
|
||||
...createDeleteCleanupHooks((key) => {
|
||||
deletedKey = key;
|
||||
}),
|
||||
agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 },
|
||||
});
|
||||
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
agentTo: "discord:dm:u123",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1b", {
|
||||
@@ -338,29 +350,27 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
throw new Error("missing child runId");
|
||||
}
|
||||
await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId));
|
||||
await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2);
|
||||
await waitFor(() => ctx.calls.filter((call) => call.method === "send").length >= 1);
|
||||
await waitFor(() => Boolean(deletedKey));
|
||||
|
||||
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
|
||||
expect(childWait?.timeoutMs).toBe(1000);
|
||||
expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
|
||||
// Two agent calls: subagent spawn + main agent trigger
|
||||
// One agent call for spawn, then direct completion send.
|
||||
const agentCalls = ctx.calls.filter((call) => call.method === "agent");
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
expect(agentCalls).toHaveLength(1);
|
||||
|
||||
// First call: subagent spawn
|
||||
const first = agentCalls[0]?.params as { lane?: string } | undefined;
|
||||
expect(first?.lane).toBe("subagent");
|
||||
|
||||
// Second call: main agent trigger
|
||||
const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined;
|
||||
expect(second?.sessionKey).toBe("discord:group:req");
|
||||
expect(second?.deliver).toBe(true);
|
||||
|
||||
// No direct send to external channel (main agent handles delivery)
|
||||
const sendCalls = ctx.calls.filter((c) => c.method === "send");
|
||||
expect(sendCalls.length).toBe(0);
|
||||
expectSingleCompletionSend(ctx.calls, {
|
||||
sessionKey: "agent:main:discord:group:req",
|
||||
channel: "discord",
|
||||
to: "discord:dm:u123",
|
||||
message: "✅ Subagent main finished\n\ndone",
|
||||
});
|
||||
|
||||
// Session should be deleted
|
||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getChannelDock } from "../channels/dock.js";
|
||||
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
|
||||
import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js";
|
||||
@@ -84,8 +83,7 @@ function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[]
|
||||
|
||||
export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy {
|
||||
const configured = cfg?.tools?.subagents?.tools;
|
||||
const maxSpawnDepth =
|
||||
cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
|
||||
const maxSpawnDepth = cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? 1;
|
||||
const effectiveDepth = typeof depth === "number" && depth >= 0 ? depth : 1;
|
||||
const baseDeny = resolveSubagentDenyList(effectiveDepth, maxSpawnDepth);
|
||||
const deny = [...baseDeny, ...(Array.isArray(configured?.deny) ? configured.deny : [])];
|
||||
|
||||
@@ -7,13 +7,8 @@ type RequesterResolution = {
|
||||
requesterOrigin?: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
type DescendantRun = {
|
||||
runId: string;
|
||||
requesterSessionKey: string;
|
||||
childSessionKey: string;
|
||||
};
|
||||
|
||||
const agentSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" }));
|
||||
const sendSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" }));
|
||||
const sessionsDeleteSpy = vi.fn((_req: AgentCallRequest) => undefined);
|
||||
const readLatestAssistantReplyMock = vi.fn(
|
||||
async (_sessionKey?: string): Promise<string | undefined> => "raw subagent reply",
|
||||
@@ -27,9 +22,11 @@ const embeddedRunMock = {
|
||||
const subagentRegistryMock = {
|
||||
isSubagentSessionRunActive: vi.fn(() => true),
|
||||
countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0),
|
||||
listDescendantRunsForRequester: vi.fn((_sessionKey: string): DescendantRun[] => []),
|
||||
resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null),
|
||||
};
|
||||
const chatHistoryMock = vi.fn(async (_sessionKey?: string) => ({
|
||||
messages: [] as Array<unknown>,
|
||||
}));
|
||||
let sessionStore: Record<string, Record<string, unknown>> = {};
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: {
|
||||
@@ -70,9 +67,15 @@ vi.mock("../gateway/call.js", () => ({
|
||||
if (typed.method === "agent") {
|
||||
return await agentSpy(typed);
|
||||
}
|
||||
if (typed.method === "send") {
|
||||
return await sendSpy(typed);
|
||||
}
|
||||
if (typed.method === "agent.wait") {
|
||||
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
|
||||
}
|
||||
if (typed.method === "chat.history") {
|
||||
return await chatHistoryMock(typed.params?.sessionKey);
|
||||
}
|
||||
if (typed.method === "sessions.patch") {
|
||||
return {};
|
||||
}
|
||||
@@ -112,6 +115,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
describe("subagent announce formatting", () => {
|
||||
beforeEach(() => {
|
||||
agentSpy.mockClear();
|
||||
sendSpy.mockClear();
|
||||
sessionsDeleteSpy.mockClear();
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReset().mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false);
|
||||
@@ -119,9 +123,9 @@ describe("subagent announce formatting", () => {
|
||||
embeddedRunMock.waitForEmbeddedPiRunEnd.mockReset().mockResolvedValue(true);
|
||||
subagentRegistryMock.isSubagentSessionRunActive.mockReset().mockReturnValue(true);
|
||||
subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0);
|
||||
subagentRegistryMock.listDescendantRunsForRequester.mockReset().mockReturnValue([]);
|
||||
subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null);
|
||||
readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply");
|
||||
chatHistoryMock.mockReset().mockResolvedValue({ messages: [] });
|
||||
sessionStore = {};
|
||||
configOverride = {
|
||||
session: {
|
||||
@@ -205,6 +209,72 @@ describe("subagent announce formatting", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ role: "toolResult", toolOutput: "tool output line 1", childRunId: "run-tool-fallback-1" },
|
||||
{ role: "tool", toolOutput: "tool output line 2", childRunId: "run-tool-fallback-2" },
|
||||
] as const)(
|
||||
"falls back to latest $role output when assistant reply is empty",
|
||||
async (testCase) => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
chatHistoryMock.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
},
|
||||
{
|
||||
role: testCase.role,
|
||||
content: [{ type: "text", text: testCase.toolOutput }],
|
||||
},
|
||||
],
|
||||
});
|
||||
readLatestAssistantReplyMock.mockResolvedValue("");
|
||||
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: testCase.childRunId,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
waitForCompletion: false,
|
||||
});
|
||||
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const msg = call?.params?.message as string;
|
||||
expect(msg).toContain(testCase.toolOutput);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses latest assistant text when it appears after a tool output", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
chatHistoryMock.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
role: "tool",
|
||||
content: [{ type: "text", text: "tool output line" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant final line" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
readLatestAssistantReplyMock.mockResolvedValue("");
|
||||
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-latest-assistant",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
waitForCompletion: false,
|
||||
});
|
||||
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const msg = call?.params?.message as string;
|
||||
expect(msg).toContain("assistant final line");
|
||||
});
|
||||
|
||||
it("keeps full findings and includes compact stats", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
sessionStore = {
|
||||
@@ -242,6 +312,121 @@ describe("subagent announce formatting", () => {
|
||||
expect(msg).toContain("step-139");
|
||||
});
|
||||
|
||||
it("sends deterministic completion message directly for manual spawn completion", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
sessionStore = {
|
||||
"agent:main:subagent:test": {
|
||||
sessionId: "child-session-direct",
|
||||
inputTokens: 12,
|
||||
outputTokens: 34,
|
||||
totalTokens: 46,
|
||||
},
|
||||
"agent:main:main": {
|
||||
sessionId: "requester-session",
|
||||
},
|
||||
};
|
||||
chatHistoryMock.mockResolvedValueOnce({
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: 2" }] }],
|
||||
});
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-completion",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
const call = sendSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
const rawMessage = call?.params?.message;
|
||||
const msg = typeof rawMessage === "string" ? rawMessage : "";
|
||||
expect(call?.params?.channel).toBe("discord");
|
||||
expect(call?.params?.to).toBe("channel:12345");
|
||||
expect(call?.params?.sessionKey).toBe("agent:main:main");
|
||||
expect(msg).toContain("✅ Subagent main finished");
|
||||
expect(msg).toContain("final answer: 2");
|
||||
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
|
||||
});
|
||||
|
||||
it("ignores stale session thread hints for manual completion direct-send", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
sessionStore = {
|
||||
"agent:main:subagent:test": {
|
||||
sessionId: "child-session-direct-thread",
|
||||
},
|
||||
"agent:main:main": {
|
||||
sessionId: "requester-session-thread",
|
||||
lastChannel: "discord",
|
||||
lastTo: "channel:stale",
|
||||
lastThreadId: 42,
|
||||
},
|
||||
};
|
||||
chatHistoryMock.mockResolvedValueOnce({
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }],
|
||||
});
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-stale-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
const call = sendSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
expect(call?.params?.channel).toBe("discord");
|
||||
expect(call?.params?.to).toBe("channel:12345");
|
||||
expect(call?.params?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes requesterOrigin.threadId for manual completion direct-send", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
sessionStore = {
|
||||
"agent:main:subagent:test": {
|
||||
sessionId: "child-session-direct-thread-pass",
|
||||
},
|
||||
"agent:main:main": {
|
||||
sessionId: "requester-session-thread-pass",
|
||||
},
|
||||
};
|
||||
chatHistoryMock.mockResolvedValueOnce({
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }],
|
||||
});
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-thread-pass",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
to: "channel:12345",
|
||||
accountId: "acct-1",
|
||||
threadId: 99,
|
||||
},
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
const call = sendSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
expect(call?.params?.channel).toBe("discord");
|
||||
expect(call?.params?.to).toBe("channel:12345");
|
||||
expect(call?.params?.threadId).toBe("99");
|
||||
});
|
||||
|
||||
it("steers announcements into an active run when queue mode is steer", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
@@ -356,6 +541,139 @@ describe("subagent announce formatting", () => {
|
||||
expect(new Set(idempotencyKeys).size).toBe(2);
|
||||
});
|
||||
|
||||
it("prefers direct delivery first for completion-mode and then queues on direct failure", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-collect",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
queueMode: "collect",
|
||||
queueDebounceMs: 0,
|
||||
},
|
||||
};
|
||||
sendSpy.mockRejectedValueOnce(new Error("direct delivery unavailable"));
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-completion-direct-fallback",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
expectsCompletionMessage: true,
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
await expect.poll(() => sendSpy.mock.calls.length).toBe(1);
|
||||
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
|
||||
expect(sendSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||
method: "send",
|
||||
params: { sessionKey: "agent:main:main" },
|
||||
});
|
||||
expect(agentSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||
method: "agent",
|
||||
params: { sessionKey: "agent:main:main" },
|
||||
});
|
||||
expect(agentSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||
method: "agent",
|
||||
params: { channel: "whatsapp", to: "+1555", deliver: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failure for completion-mode when direct delivery fails and queue fallback is unavailable", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-direct-only",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
};
|
||||
sendSpy.mockRejectedValueOnce(new Error("direct delivery unavailable"));
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-completion-direct-fail",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
expectsCompletionMessage: true,
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(false);
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("uses assistant output for completion-mode when latest assistant text exists", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
chatHistoryMock.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
role: "toolResult",
|
||||
content: [{ type: "text", text: "old tool output" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant completion text" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
readLatestAssistantReplyMock.mockResolvedValue("assistant ignored fallback");
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-completion-assistant-output",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
expectsCompletionMessage: true,
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
await expect.poll(() => sendSpy.mock.calls.length).toBe(1);
|
||||
const call = sendSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const msg = call?.params?.message as string;
|
||||
expect(msg).toContain("assistant completion text");
|
||||
expect(msg).not.toContain("old tool output");
|
||||
});
|
||||
|
||||
it("falls back to latest tool output for completion-mode when assistant output is empty", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
chatHistoryMock.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
content: [{ type: "text", text: "tool output only" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
readLatestAssistantReplyMock.mockResolvedValue("");
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-completion-tool-output",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
expectsCompletionMessage: true,
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
await expect.poll(() => sendSpy.mock.calls.length).toBe(1);
|
||||
const call = sendSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const msg = call?.params?.message as string;
|
||||
expect(msg).toContain("tool output only");
|
||||
});
|
||||
|
||||
it("queues announce delivery back into requester subagent session", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
@@ -388,7 +706,24 @@ describe("subagent announce formatting", () => {
|
||||
expect(call?.params?.to).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes threadId when origin has an active topic/thread", async () => {
|
||||
it.each([
|
||||
{
|
||||
testName: "includes threadId when origin has an active topic/thread",
|
||||
childRunId: "run-thread",
|
||||
expectedThreadId: "42",
|
||||
requesterOrigin: undefined,
|
||||
},
|
||||
{
|
||||
testName: "prefers requesterOrigin.threadId over session entry threadId",
|
||||
childRunId: "run-thread-override",
|
||||
expectedThreadId: "99",
|
||||
requesterOrigin: {
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
threadId: 99,
|
||||
},
|
||||
},
|
||||
] as const)("$testName", async (testCase) => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
@@ -405,9 +740,10 @@ describe("subagent announce formatting", () => {
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-thread",
|
||||
childRunId: testCase.childRunId,
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
...(testCase.requesterOrigin ? { requesterOrigin: testCase.requesterOrigin } : {}),
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
@@ -415,42 +751,7 @@ describe("subagent announce formatting", () => {
|
||||
const params = await getSingleAgentCallParams();
|
||||
expect(params.channel).toBe("telegram");
|
||||
expect(params.to).toBe("telegram:123");
|
||||
expect(params.threadId).toBe("42");
|
||||
});
|
||||
|
||||
it("prefers requesterOrigin.threadId over session entry threadId", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-thread-override",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "telegram:123",
|
||||
lastThreadId: 42,
|
||||
queueMode: "collect",
|
||||
queueDebounceMs: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-thread-override",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: {
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
threadId: 99,
|
||||
},
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
|
||||
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
expect(call?.params?.threadId).toBe("99");
|
||||
expect(params.threadId).toBe(testCase.expectedThreadId);
|
||||
});
|
||||
|
||||
it("splits collect-mode queues when accountId differs", async () => {
|
||||
@@ -494,16 +795,31 @@ describe("subagent announce formatting", () => {
|
||||
expect(accountIds).toEqual(expect.arrayContaining(["acct-a", "acct-b"]));
|
||||
});
|
||||
|
||||
it("uses requester origin for direct announce when not queued", async () => {
|
||||
it.each([
|
||||
{
|
||||
testName: "uses requester origin for direct announce when not queued",
|
||||
childRunId: "run-direct",
|
||||
requesterOrigin: { channel: "whatsapp", accountId: "acct-123" },
|
||||
expectedChannel: "whatsapp",
|
||||
expectedAccountId: "acct-123",
|
||||
},
|
||||
{
|
||||
testName: "normalizes requesterOrigin for direct announce delivery",
|
||||
childRunId: "run-direct-origin",
|
||||
requesterOrigin: { channel: " whatsapp ", accountId: " acct-987 " },
|
||||
expectedChannel: "whatsapp",
|
||||
expectedAccountId: "acct-987",
|
||||
},
|
||||
] as const)("$testName", async (testCase) => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct",
|
||||
childRunId: testCase.childRunId,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: { channel: "whatsapp", accountId: "acct-123" },
|
||||
requesterOrigin: testCase.requesterOrigin,
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
@@ -513,8 +829,8 @@ describe("subagent announce formatting", () => {
|
||||
params?: Record<string, unknown>;
|
||||
expectFinal?: boolean;
|
||||
};
|
||||
expect(call?.params?.channel).toBe("whatsapp");
|
||||
expect(call?.params?.accountId).toBe("acct-123");
|
||||
expect(call?.params?.channel).toBe(testCase.expectedChannel);
|
||||
expect(call?.params?.accountId).toBe(testCase.expectedAccountId);
|
||||
expect(call?.expectFinal).toBe(true);
|
||||
});
|
||||
|
||||
@@ -617,93 +933,6 @@ describe("subagent announce formatting", () => {
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for follow-up reply when descendant runs exist and child reply is still waiting", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
const waitingReply = "Spawned the nested subagent. Waiting for its auto-announced result.";
|
||||
const finalReply = "Nested subagent finished and I synthesized the final result.";
|
||||
|
||||
subagentRegistryMock.listDescendantRunsForRequester.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent"
|
||||
? [
|
||||
{
|
||||
runId: "run-leaf",
|
||||
requesterSessionKey: sessionKey,
|
||||
childSessionKey: "agent:main:subagent:parent:subagent:leaf",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
readLatestAssistantReplyMock
|
||||
.mockResolvedValueOnce(waitingReply)
|
||||
.mockResolvedValueOnce(waitingReply)
|
||||
.mockResolvedValueOnce(finalReply);
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const announcePromise = runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent",
|
||||
childRunId: "run-parent",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
const didAnnounce = await announcePromise;
|
||||
expect(didAnnounce).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const msg = call?.params?.message as string;
|
||||
expect(msg).toContain(finalReply);
|
||||
expect(msg).not.toContain("Waiting for its auto-announced result.");
|
||||
});
|
||||
|
||||
it("defers announce when descendant follow-up reply has not arrived yet", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
const waitingReply = "Spawned the nested subagent. Waiting for its auto-announced result.";
|
||||
|
||||
subagentRegistryMock.listDescendantRunsForRequester.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent"
|
||||
? [
|
||||
{
|
||||
runId: "run-leaf",
|
||||
requesterSessionKey: sessionKey,
|
||||
childSessionKey: "agent:main:subagent:parent:subagent:leaf",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
readLatestAssistantReplyMock.mockResolvedValue(waitingReply);
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const announcePromise = runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent",
|
||||
childRunId: "run-parent-still-waiting",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "nested test",
|
||||
timeoutMs: 700,
|
||||
cleanup: "keep",
|
||||
waitForCompletion: false,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
const didAnnounce = await announcePromise;
|
||||
expect(didAnnounce).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bubbles child announce to parent requester when requester subagent already ended", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false);
|
||||
@@ -784,26 +1013,6 @@ describe("subagent announce formatting", () => {
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes requesterOrigin for direct announce delivery", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-origin",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: { channel: " whatsapp ", accountId: " acct-987 " },
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
expect(call?.params?.channel).toBe("whatsapp");
|
||||
expect(call?.params?.accountId).toBe("acct-987");
|
||||
});
|
||||
|
||||
it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
@@ -836,35 +1045,6 @@ describe("subagent announce formatting", () => {
|
||||
expect(call?.params?.to).toBe("bluebubbles:chat_guid:123");
|
||||
});
|
||||
|
||||
it("falls back to persisted deliverable route when requesterOrigin channel is non-deliverable", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-webchat-origin",
|
||||
lastChannel: "discord",
|
||||
lastTo: "discord:channel:123",
|
||||
lastAccountId: "acct-store",
|
||||
},
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-webchat-origin",
|
||||
requesterSessionKey: "main",
|
||||
requesterOrigin: { channel: "webchat", to: "ignored", accountId: "acct-live" },
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
expect(call?.params?.channel).toBe("discord");
|
||||
expect(call?.params?.to).toBe("discord:channel:123");
|
||||
expect(call?.params?.accountId).toBe("acct-live");
|
||||
});
|
||||
|
||||
it("routes to parent subagent when parent run ended but session still exists (#18037)", async () => {
|
||||
// Scenario: Newton (depth-1) spawns Birdie (depth-2). Newton's agent turn ends
|
||||
// after spawning but Newton's SESSION still exists (waiting for Birdie's result).
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveQueueSettings } from "../auto-reply/reply/queue.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
@@ -11,13 +10,14 @@ import {
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { extractTextFromChatContent } from "../shared/chat-content.js";
|
||||
import {
|
||||
type DeliveryContext,
|
||||
deliveryContextFromSession,
|
||||
mergeDeliveryContext,
|
||||
normalizeDeliveryContext,
|
||||
} from "../utils/delivery-context.js";
|
||||
import { isInternalMessageChannel } from "../utils/message-channel.js";
|
||||
import { isDeliverableMessageChannel } from "../utils/message-channel.js";
|
||||
import {
|
||||
buildAnnounceIdFromChildRun,
|
||||
buildAnnounceIdempotencyKey,
|
||||
@@ -30,7 +30,170 @@ import {
|
||||
} from "./pi-embedded.js";
|
||||
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
|
||||
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
|
||||
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
||||
import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js";
|
||||
|
||||
type ToolResultMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none";
|
||||
|
||||
type SubagentAnnounceDeliveryResult = {
|
||||
delivered: boolean;
|
||||
path: SubagentDeliveryPath;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function buildCompletionDeliveryMessage(params: {
|
||||
findings: string;
|
||||
subagentName: string;
|
||||
}): string {
|
||||
const findingsText = params.findings.trim();
|
||||
const hasFindings = findingsText.length > 0 && findingsText !== "(no output)";
|
||||
const header = `✅ Subagent ${params.subagentName} finished`;
|
||||
if (!hasFindings) {
|
||||
return header;
|
||||
}
|
||||
return `${header}\n\n${findingsText}`;
|
||||
}
|
||||
|
||||
function summarizeDeliveryError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message || "error";
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (error === undefined || error === null) {
|
||||
return "unknown error";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return sanitizeTextContent(content);
|
||||
}
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
const obj = content as {
|
||||
text?: unknown;
|
||||
output?: unknown;
|
||||
content?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
summary?: unknown;
|
||||
};
|
||||
if (typeof obj.text === "string") {
|
||||
return sanitizeTextContent(obj.text);
|
||||
}
|
||||
if (typeof obj.output === "string") {
|
||||
return sanitizeTextContent(obj.output);
|
||||
}
|
||||
if (typeof obj.content === "string") {
|
||||
return sanitizeTextContent(obj.content);
|
||||
}
|
||||
if (typeof obj.result === "string") {
|
||||
return sanitizeTextContent(obj.result);
|
||||
}
|
||||
if (typeof obj.error === "string") {
|
||||
return sanitizeTextContent(obj.error);
|
||||
}
|
||||
if (typeof obj.summary === "string") {
|
||||
return sanitizeTextContent(obj.summary);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const joined = extractTextFromChatContent(content, {
|
||||
sanitizeText: sanitizeTextContent,
|
||||
normalizeText: (text) => text,
|
||||
joinWith: "\n",
|
||||
});
|
||||
return joined?.trim() ?? "";
|
||||
}
|
||||
|
||||
function extractInlineTextContent(content: unknown): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
extractTextFromChatContent(content, {
|
||||
sanitizeText: sanitizeTextContent,
|
||||
normalizeText: (text) => text.trim(),
|
||||
joinWith: "",
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function extractSubagentOutputText(message: unknown): string {
|
||||
if (!message || typeof message !== "object") {
|
||||
return "";
|
||||
}
|
||||
const role = (message as { role?: unknown }).role;
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (role === "assistant") {
|
||||
const assistantText = extractAssistantText(message);
|
||||
if (assistantText) {
|
||||
return assistantText;
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return sanitizeTextContent(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return extractInlineTextContent(content);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (role === "toolResult" || role === "tool") {
|
||||
return extractToolResultText((message as ToolResultMessage).content);
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return sanitizeTextContent(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return extractInlineTextContent(content);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function readLatestSubagentOutput(sessionKey: string): Promise<string | undefined> {
|
||||
const history = await callGateway<{ messages?: Array<unknown> }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey, limit: 50 },
|
||||
});
|
||||
const messages = Array.isArray(history?.messages) ? history.messages : [];
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const msg = messages[i];
|
||||
const text = extractSubagentOutputText(msg);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readLatestSubagentOutputWithRetry(params: {
|
||||
sessionKey: string;
|
||||
maxWaitMs: number;
|
||||
}): Promise<string | undefined> {
|
||||
const RETRY_INTERVAL_MS = 100;
|
||||
const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000));
|
||||
let result: string | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
result = await readLatestSubagentOutput(params.sessionKey);
|
||||
if (result?.trim()) {
|
||||
return result;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatDurationShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
@@ -110,8 +273,8 @@ function resolveAnnounceOrigin(
|
||||
): DeliveryContext | undefined {
|
||||
const normalizedRequester = normalizeDeliveryContext(requesterOrigin);
|
||||
const normalizedEntry = deliveryContextFromSession(entry);
|
||||
if (normalizedRequester?.channel && isInternalMessageChannel(normalizedRequester.channel)) {
|
||||
// Ignore internal channel hints, for example webchat,
|
||||
if (normalizedRequester?.channel && !isDeliverableMessageChannel(normalizedRequester.channel)) {
|
||||
// Ignore internal/non-deliverable channel hints (for example webchat)
|
||||
// so a valid persisted route can still be used for outbound delivery.
|
||||
return mergeDeliveryContext(
|
||||
{
|
||||
@@ -121,7 +284,7 @@ function resolveAnnounceOrigin(
|
||||
normalizedEntry,
|
||||
);
|
||||
}
|
||||
// requesterOrigin, captured at spawn time, reflects the channel the user is
|
||||
// requesterOrigin (captured at spawn time) reflects the channel the user is
|
||||
// actually on and must take priority over the session entry, which may carry
|
||||
// stale lastChannel / lastTo values from a previous channel interaction.
|
||||
return mergeDeliveryContext(normalizedRequester, normalizedEntry);
|
||||
@@ -245,6 +408,182 @@ async function maybeQueueSubagentAnnounce(params: {
|
||||
return "none";
|
||||
}
|
||||
|
||||
function queueOutcomeToDeliveryResult(
|
||||
outcome: "steered" | "queued" | "none",
|
||||
): SubagentAnnounceDeliveryResult {
|
||||
if (outcome === "steered") {
|
||||
return {
|
||||
delivered: true,
|
||||
path: "steered",
|
||||
};
|
||||
}
|
||||
if (outcome === "queued") {
|
||||
return {
|
||||
delivered: true,
|
||||
path: "queued",
|
||||
};
|
||||
}
|
||||
return {
|
||||
delivered: false,
|
||||
path: "none",
|
||||
};
|
||||
}
|
||||
|
||||
async function sendSubagentAnnounceDirectly(params: {
|
||||
targetRequesterSessionKey: string;
|
||||
triggerMessage: string;
|
||||
completionMessage?: string;
|
||||
expectsCompletionMessage: boolean;
|
||||
directIdempotencyKey: string;
|
||||
completionDirectOrigin?: DeliveryContext;
|
||||
directOrigin?: DeliveryContext;
|
||||
requesterIsSubagent: boolean;
|
||||
}): Promise<SubagentAnnounceDeliveryResult> {
|
||||
const cfg = loadConfig();
|
||||
const canonicalRequesterSessionKey = resolveRequesterStoreKey(
|
||||
cfg,
|
||||
params.targetRequesterSessionKey,
|
||||
);
|
||||
try {
|
||||
const completionDirectOrigin = normalizeDeliveryContext(params.completionDirectOrigin);
|
||||
const completionChannelRaw =
|
||||
typeof completionDirectOrigin?.channel === "string"
|
||||
? completionDirectOrigin.channel.trim()
|
||||
: "";
|
||||
const completionChannel =
|
||||
completionChannelRaw && isDeliverableMessageChannel(completionChannelRaw)
|
||||
? completionChannelRaw
|
||||
: "";
|
||||
const completionTo =
|
||||
typeof completionDirectOrigin?.to === "string" ? completionDirectOrigin.to.trim() : "";
|
||||
const hasCompletionDirectTarget =
|
||||
!params.requesterIsSubagent && Boolean(completionChannel) && Boolean(completionTo);
|
||||
|
||||
if (
|
||||
params.expectsCompletionMessage &&
|
||||
hasCompletionDirectTarget &&
|
||||
params.completionMessage?.trim()
|
||||
) {
|
||||
const completionThreadId =
|
||||
completionDirectOrigin?.threadId != null && completionDirectOrigin.threadId !== ""
|
||||
? String(completionDirectOrigin.threadId)
|
||||
: undefined;
|
||||
await callGateway({
|
||||
method: "send",
|
||||
params: {
|
||||
channel: completionChannel,
|
||||
to: completionTo,
|
||||
accountId: completionDirectOrigin?.accountId,
|
||||
threadId: completionThreadId,
|
||||
sessionKey: canonicalRequesterSessionKey,
|
||||
message: params.completionMessage,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
|
||||
return {
|
||||
delivered: true,
|
||||
path: "direct",
|
||||
};
|
||||
}
|
||||
|
||||
const directOrigin = normalizeDeliveryContext(params.directOrigin);
|
||||
const threadId =
|
||||
directOrigin?.threadId != null && directOrigin.threadId !== ""
|
||||
? String(directOrigin.threadId)
|
||||
: undefined;
|
||||
await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
sessionKey: canonicalRequesterSessionKey,
|
||||
message: params.triggerMessage,
|
||||
deliver: !params.requesterIsSubagent,
|
||||
channel: params.requesterIsSubagent ? undefined : directOrigin?.channel,
|
||||
accountId: params.requesterIsSubagent ? undefined : directOrigin?.accountId,
|
||||
to: params.requesterIsSubagent ? undefined : directOrigin?.to,
|
||||
threadId: params.requesterIsSubagent ? undefined : threadId,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
|
||||
return {
|
||||
delivered: true,
|
||||
path: "direct",
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
error: summarizeDeliveryError(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverSubagentAnnouncement(params: {
|
||||
requesterSessionKey: string;
|
||||
announceId?: string;
|
||||
triggerMessage: string;
|
||||
completionMessage?: string;
|
||||
summaryLine?: string;
|
||||
requesterOrigin?: DeliveryContext;
|
||||
completionDirectOrigin?: DeliveryContext;
|
||||
directOrigin?: DeliveryContext;
|
||||
targetRequesterSessionKey: string;
|
||||
requesterIsSubagent: boolean;
|
||||
expectsCompletionMessage: boolean;
|
||||
directIdempotencyKey: string;
|
||||
}): Promise<SubagentAnnounceDeliveryResult> {
|
||||
// Non-completion mode mirrors historical behavior: try queued/steered delivery first,
|
||||
// then (only if not queued) attempt direct delivery.
|
||||
if (!params.expectsCompletionMessage) {
|
||||
const queueOutcome = await maybeQueueSubagentAnnounce({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
announceId: params.announceId,
|
||||
triggerMessage: params.triggerMessage,
|
||||
summaryLine: params.summaryLine,
|
||||
requesterOrigin: params.requesterOrigin,
|
||||
});
|
||||
const queued = queueOutcomeToDeliveryResult(queueOutcome);
|
||||
if (queued.delivered) {
|
||||
return queued;
|
||||
}
|
||||
}
|
||||
|
||||
// Completion-mode uses direct send first so manual spawns can return immediately
|
||||
// in the common ready-to-deliver case.
|
||||
const direct = await sendSubagentAnnounceDirectly({
|
||||
targetRequesterSessionKey: params.targetRequesterSessionKey,
|
||||
triggerMessage: params.triggerMessage,
|
||||
completionMessage: params.completionMessage,
|
||||
directIdempotencyKey: params.directIdempotencyKey,
|
||||
completionDirectOrigin: params.completionDirectOrigin,
|
||||
directOrigin: params.directOrigin,
|
||||
requesterIsSubagent: params.requesterIsSubagent,
|
||||
expectsCompletionMessage: params.expectsCompletionMessage,
|
||||
});
|
||||
if (direct.delivered || !params.expectsCompletionMessage) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
// If completion path failed direct delivery, try queueing as a fallback so the
|
||||
// report can still be delivered once the requester session is idle.
|
||||
const queueOutcome = await maybeQueueSubagentAnnounce({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
announceId: params.announceId,
|
||||
triggerMessage: params.triggerMessage,
|
||||
summaryLine: params.summaryLine,
|
||||
requesterOrigin: params.requesterOrigin,
|
||||
});
|
||||
if (queueOutcome === "steered" || queueOutcome === "queued") {
|
||||
return queueOutcomeToDeliveryResult(queueOutcome);
|
||||
}
|
||||
|
||||
return direct;
|
||||
}
|
||||
|
||||
function loadSessionEntryByKey(sessionKey: string) {
|
||||
const cfg = loadConfig();
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
@@ -253,65 +592,6 @@ function loadSessionEntryByKey(sessionKey: string) {
|
||||
return store[sessionKey];
|
||||
}
|
||||
|
||||
async function readLatestAssistantReplyWithRetry(params: {
|
||||
sessionKey: string;
|
||||
initialReply?: string;
|
||||
maxWaitMs: number;
|
||||
}): Promise<string | undefined> {
|
||||
const RETRY_INTERVAL_MS = 100;
|
||||
let reply = params.initialReply?.trim() ? params.initialReply : undefined;
|
||||
if (reply) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000));
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
|
||||
const latest = await readLatestAssistantReply({ sessionKey: params.sessionKey });
|
||||
if (latest?.trim()) {
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
function isLikelyWaitingForDescendantResult(reply?: string): boolean {
|
||||
const text = reply?.trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const normalized = text.toLowerCase();
|
||||
if (!normalized.includes("waiting")) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized.includes("subagent") ||
|
||||
normalized.includes("child") ||
|
||||
normalized.includes("auto-announce") ||
|
||||
normalized.includes("auto announced") ||
|
||||
normalized.includes("result")
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForAssistantReplyChange(params: {
|
||||
sessionKey: string;
|
||||
previousReply?: string;
|
||||
maxWaitMs: number;
|
||||
}): Promise<string | undefined> {
|
||||
const RETRY_INTERVAL_MS = 200;
|
||||
const previous = params.previousReply?.trim() ?? "";
|
||||
const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 30_000));
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
|
||||
const latest = await readLatestAssistantReply({ sessionKey: params.sessionKey });
|
||||
const normalizedLatest = latest?.trim() ?? "";
|
||||
if (normalizedLatest && normalizedLatest !== previous) {
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildSubagentSystemPrompt(params: {
|
||||
requesterSessionKey?: string;
|
||||
requesterOrigin?: DeliveryContext;
|
||||
@@ -328,10 +608,7 @@ export function buildSubagentSystemPrompt(params: {
|
||||
? params.task.replace(/\s+/g, " ").trim()
|
||||
: "{{TASK_DESCRIPTION}}";
|
||||
const childDepth = typeof params.childDepth === "number" ? params.childDepth : 1;
|
||||
const maxSpawnDepth =
|
||||
typeof params.maxSpawnDepth === "number"
|
||||
? params.maxSpawnDepth
|
||||
: DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
|
||||
const maxSpawnDepth = typeof params.maxSpawnDepth === "number" ? params.maxSpawnDepth : 1;
|
||||
const canSpawn = childDepth < maxSpawnDepth;
|
||||
const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent";
|
||||
|
||||
@@ -415,7 +692,11 @@ function buildAnnounceReplyInstruction(params: {
|
||||
remainingActiveSubagentRuns: number;
|
||||
requesterIsSubagent: boolean;
|
||||
announceType: SubagentAnnounceType;
|
||||
expectsCompletionMessage?: boolean;
|
||||
}): string {
|
||||
if (params.expectsCompletionMessage) {
|
||||
return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type).`;
|
||||
}
|
||||
if (params.remainingActiveSubagentRuns > 0) {
|
||||
const activeRunsLabel = params.remainingActiveSubagentRuns === 1 ? "run" : "runs";
|
||||
return `There are still ${params.remainingActiveSubagentRuns} active subagent ${activeRunsLabel} for this session. If they are part of the same workflow, wait for the remaining results before sending a user update. If they are unrelated, respond normally using only the result above.`;
|
||||
@@ -442,8 +723,10 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
label?: string;
|
||||
outcome?: SubagentRunOutcome;
|
||||
announceType?: SubagentAnnounceType;
|
||||
expectsCompletionMessage?: boolean;
|
||||
}): Promise<boolean> {
|
||||
let didAnnounce = false;
|
||||
const expectsCompletionMessage = params.expectsCompletionMessage === true;
|
||||
let shouldDeleteChildSession = params.cleanup === "delete";
|
||||
try {
|
||||
let targetRequesterSessionKey = params.requesterSessionKey;
|
||||
@@ -459,7 +742,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
let outcome: SubagentRunOutcome | undefined = params.outcome;
|
||||
// Lifecycle "end" can arrive before auto-compaction retries finish. If the
|
||||
// subagent is still active, wait for the embedded run to fully settle.
|
||||
if (childSessionId && isEmbeddedPiRunActive(childSessionId)) {
|
||||
if (!expectsCompletionMessage && childSessionId && isEmbeddedPiRunActive(childSessionId)) {
|
||||
const settled = await waitForEmbeddedPiRunEnd(childSessionId, settleTimeoutMs);
|
||||
if (!settled && isEmbeddedPiRunActive(childSessionId)) {
|
||||
// The child run is still active (e.g., compaction retry still in progress).
|
||||
@@ -504,22 +787,26 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
outcome = { status: "timeout" };
|
||||
}
|
||||
}
|
||||
reply = await readLatestAssistantReply({ sessionKey: params.childSessionKey });
|
||||
reply = await readLatestSubagentOutput(params.childSessionKey);
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
reply = await readLatestAssistantReply({ sessionKey: params.childSessionKey });
|
||||
reply = await readLatestSubagentOutput(params.childSessionKey);
|
||||
}
|
||||
|
||||
if (!reply?.trim()) {
|
||||
reply = await readLatestAssistantReplyWithRetry({
|
||||
reply = await readLatestSubagentOutputWithRetry({
|
||||
sessionKey: params.childSessionKey,
|
||||
initialReply: reply,
|
||||
maxWaitMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (!reply?.trim() && childSessionId && isEmbeddedPiRunActive(childSessionId)) {
|
||||
if (
|
||||
!expectsCompletionMessage &&
|
||||
!reply?.trim() &&
|
||||
childSessionId &&
|
||||
isEmbeddedPiRunActive(childSessionId)
|
||||
) {
|
||||
// Avoid announcing "(no output)" while the child run is still producing output.
|
||||
shouldDeleteChildSession = false;
|
||||
return false;
|
||||
@@ -536,46 +823,12 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
} catch {
|
||||
// Best-effort only; fall back to direct announce behavior when unavailable.
|
||||
}
|
||||
if (activeChildDescendantRuns > 0) {
|
||||
if (!expectsCompletionMessage && activeChildDescendantRuns > 0) {
|
||||
// The finished run still has active descendant subagents. Defer announcing
|
||||
// this run until descendants settle so we avoid posting in-progress updates.
|
||||
shouldDeleteChildSession = false;
|
||||
return false;
|
||||
}
|
||||
// If the subagent reply is still a "waiting for nested result" placeholder,
|
||||
// hold this announce and wait for the follow-up turn that synthesizes child output.
|
||||
let hasAnyChildDescendantRuns = false;
|
||||
try {
|
||||
const { listDescendantRunsForRequester } = await import("./subagent-registry.js");
|
||||
hasAnyChildDescendantRuns = listDescendantRunsForRequester(params.childSessionKey).length > 0;
|
||||
} catch {
|
||||
// Best-effort only; fall back to existing behavior when unavailable.
|
||||
}
|
||||
if (hasAnyChildDescendantRuns && isLikelyWaitingForDescendantResult(reply)) {
|
||||
const followupReply = await waitForAssistantReplyChange({
|
||||
sessionKey: params.childSessionKey,
|
||||
previousReply: reply,
|
||||
maxWaitMs: settleTimeoutMs,
|
||||
});
|
||||
if (!followupReply?.trim()) {
|
||||
shouldDeleteChildSession = false;
|
||||
return false;
|
||||
}
|
||||
reply = followupReply;
|
||||
try {
|
||||
const { countActiveDescendantRuns } = await import("./subagent-registry.js");
|
||||
activeChildDescendantRuns = Math.max(0, countActiveDescendantRuns(params.childSessionKey));
|
||||
} catch {
|
||||
activeChildDescendantRuns = 0;
|
||||
}
|
||||
if (
|
||||
activeChildDescendantRuns > 0 ||
|
||||
(hasAnyChildDescendantRuns && isLikelyWaitingForDescendantResult(reply))
|
||||
) {
|
||||
shouldDeleteChildSession = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build status label
|
||||
const statusLabel =
|
||||
@@ -590,12 +843,14 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
// Build instructional message for main agent
|
||||
const announceType = params.announceType ?? "subagent task";
|
||||
const taskLabel = params.label || params.task || "task";
|
||||
const subagentName = resolveAgentIdFromSessionKey(params.childSessionKey);
|
||||
const announceSessionId = childSessionId || "unknown";
|
||||
const findings = reply || "(no output)";
|
||||
let completionMessage = "";
|
||||
let triggerMessage = "";
|
||||
|
||||
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
|
||||
let requesterIsSubagent = requesterDepth >= 1;
|
||||
let requesterIsSubagent = !expectsCompletionMessage && requesterDepth >= 1;
|
||||
// If the requester subagent has already finished, bubble the announce to its
|
||||
// requester (typically main) so descendant completion is not silently lost.
|
||||
// BUT: only fallback if the parent SESSION is deleted, not just if the current
|
||||
@@ -648,43 +903,31 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
remainingActiveSubagentRuns,
|
||||
requesterIsSubagent,
|
||||
announceType,
|
||||
expectsCompletionMessage,
|
||||
});
|
||||
const statsLine = await buildCompactAnnounceStatsLine({
|
||||
sessionKey: params.childSessionKey,
|
||||
startedAt: params.startedAt,
|
||||
endedAt: params.endedAt,
|
||||
});
|
||||
triggerMessage = [
|
||||
completionMessage = buildCompletionDeliveryMessage({
|
||||
findings,
|
||||
subagentName,
|
||||
});
|
||||
const internalSummaryMessage = [
|
||||
`[System Message] [sessionId: ${announceSessionId}] A ${announceType} "${taskLabel}" just ${statusLabel}.`,
|
||||
"",
|
||||
"Result:",
|
||||
findings,
|
||||
"",
|
||||
statsLine,
|
||||
"",
|
||||
replyInstruction,
|
||||
].join("\n");
|
||||
triggerMessage = [internalSummaryMessage, "", replyInstruction].join("\n");
|
||||
|
||||
const announceId = buildAnnounceIdFromChildRun({
|
||||
childSessionKey: params.childSessionKey,
|
||||
childRunId: params.childRunId,
|
||||
});
|
||||
const queued = await maybeQueueSubagentAnnounce({
|
||||
requesterSessionKey: targetRequesterSessionKey,
|
||||
announceId,
|
||||
triggerMessage,
|
||||
summaryLine: taskLabel,
|
||||
requesterOrigin: targetRequesterOrigin,
|
||||
});
|
||||
if (queued === "steered") {
|
||||
didAnnounce = true;
|
||||
return true;
|
||||
}
|
||||
if (queued === "queued") {
|
||||
didAnnounce = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Send to the requester session. For nested subagents this is an internal
|
||||
// follow-up injection (deliver=false) so the orchestrator receives it.
|
||||
let directOrigin = targetRequesterOrigin;
|
||||
@@ -696,26 +939,26 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
// catches duplicates if this announce is also queued by the gateway-
|
||||
// level message queue while the main session is busy (#17122).
|
||||
const directIdempotencyKey = buildAnnounceIdempotencyKey(announceId);
|
||||
await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
sessionKey: targetRequesterSessionKey,
|
||||
message: triggerMessage,
|
||||
deliver: !requesterIsSubagent,
|
||||
channel: requesterIsSubagent ? undefined : directOrigin?.channel,
|
||||
accountId: requesterIsSubagent ? undefined : directOrigin?.accountId,
|
||||
to: requesterIsSubagent ? undefined : directOrigin?.to,
|
||||
threadId:
|
||||
!requesterIsSubagent && directOrigin?.threadId != null && directOrigin.threadId !== ""
|
||||
? String(directOrigin.threadId)
|
||||
: undefined,
|
||||
idempotencyKey: directIdempotencyKey,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: 15_000,
|
||||
const delivery = await deliverSubagentAnnouncement({
|
||||
requesterSessionKey: targetRequesterSessionKey,
|
||||
announceId,
|
||||
triggerMessage,
|
||||
completionMessage,
|
||||
summaryLine: taskLabel,
|
||||
requesterOrigin: targetRequesterOrigin,
|
||||
completionDirectOrigin: targetRequesterOrigin,
|
||||
directOrigin,
|
||||
targetRequesterSessionKey,
|
||||
requesterIsSubagent,
|
||||
expectsCompletionMessage: expectsCompletionMessage,
|
||||
directIdempotencyKey,
|
||||
});
|
||||
|
||||
didAnnounce = true;
|
||||
didAnnounce = delivery.delivered;
|
||||
if (!delivery.delivered && delivery.path === "direct" && delivery.error) {
|
||||
defaultRuntime.error?.(
|
||||
`Subagent completion direct announce failed for run ${params.childRunId}: ${delivery.error}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error?.(`Subagent announce failed: ${String(err)}`);
|
||||
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
|
||||
|
||||
@@ -151,57 +151,4 @@ describe("announce loop guard (#18264)", () => {
|
||||
const stored = runs.find((run) => run.runId === entry.runId);
|
||||
expect(stored?.cleanupCompletedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test("does not consume retry budget while descendants are still active", async () => {
|
||||
announceFn.mockClear();
|
||||
registry.resetSubagentRegistryForTests();
|
||||
|
||||
const now = Date.now();
|
||||
const parentEntry = {
|
||||
runId: "test-parent-ended",
|
||||
childSessionKey: "agent:main:subagent:parent-ended",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "agent:main:main",
|
||||
task: "parent task",
|
||||
cleanup: "keep" as const,
|
||||
createdAt: now - 30_000,
|
||||
startedAt: now - 20_000,
|
||||
endedAt: now - 10_000,
|
||||
expectsCompletionMessage: true,
|
||||
cleanupHandled: false,
|
||||
};
|
||||
const activeDescendant = {
|
||||
runId: "test-desc-active",
|
||||
childSessionKey: "agent:main:subagent:parent-ended:subagent:leaf",
|
||||
requesterSessionKey: "agent:main:subagent:parent-ended",
|
||||
requesterDisplayKey: "agent:main:subagent:parent-ended",
|
||||
task: "leaf task",
|
||||
cleanup: "keep" as const,
|
||||
createdAt: now - 5_000,
|
||||
startedAt: now - 5_000,
|
||||
expectsCompletionMessage: true,
|
||||
cleanupHandled: false,
|
||||
};
|
||||
|
||||
loadSubagentRegistryFromDisk.mockReturnValue(
|
||||
new Map([
|
||||
[parentEntry.runId, parentEntry],
|
||||
[activeDescendant.runId, activeDescendant],
|
||||
]),
|
||||
);
|
||||
|
||||
registry.initSubagentRegistry();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(announceFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ childRunId: parentEntry.runId }),
|
||||
);
|
||||
const parent = registry
|
||||
.listSubagentRunsForRequester("agent:main:main")
|
||||
.find((run) => run.runId === parentEntry.runId);
|
||||
expect(parent?.announceRetryCount).toBeUndefined();
|
||||
expect(parent?.cleanupCompletedAt).toBeUndefined();
|
||||
expect(parent?.cleanupHandled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,7 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor
|
||||
requesterOrigin,
|
||||
requesterDisplayKey: entry.requesterDisplayKey,
|
||||
task: entry.task,
|
||||
expectsCompletionMessage: entry.expectsCompletionMessage,
|
||||
timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
|
||||
cleanup: entry.cleanup,
|
||||
waitForCompletion: false,
|
||||
@@ -323,34 +324,12 @@ function finalizeSubagentCleanup(runId: string, cleanup: "delete" | "keep", didA
|
||||
}
|
||||
if (!didAnnounce) {
|
||||
const now = Date.now();
|
||||
const endedAgo = typeof entry.endedAt === "number" ? now - entry.endedAt : 0;
|
||||
// Normal defer: the run ended, but descendant runs are still active.
|
||||
// Don't consume retry budget in this state or we can give up before
|
||||
// descendants finish and before the parent synthesizes the final reply.
|
||||
const activeDescendantRuns = Math.max(0, countActiveDescendantRuns(entry.childSessionKey));
|
||||
if (entry.expectsCompletionMessage === true && activeDescendantRuns > 0) {
|
||||
if (endedAgo > ANNOUNCE_EXPIRY_MS) {
|
||||
logAnnounceGiveUp(entry, "expiry");
|
||||
entry.cleanupCompletedAt = now;
|
||||
persistSubagentRuns();
|
||||
retryDeferredCompletedAnnounces(runId);
|
||||
return;
|
||||
}
|
||||
entry.lastAnnounceRetryAt = now;
|
||||
entry.cleanupHandled = false;
|
||||
resumedRuns.delete(runId);
|
||||
persistSubagentRuns();
|
||||
setTimeout(() => {
|
||||
resumeSubagentRun(runId);
|
||||
}, MIN_ANNOUNCE_RETRY_DELAY_MS).unref?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const retryCount = (entry.announceRetryCount ?? 0) + 1;
|
||||
entry.announceRetryCount = retryCount;
|
||||
entry.lastAnnounceRetryAt = now;
|
||||
|
||||
// Check if the announce has exceeded retry limits or expired (#18264).
|
||||
const endedAgo = typeof entry.endedAt === "number" ? now - entry.endedAt : 0;
|
||||
if (retryCount >= MAX_ANNOUNCE_RETRY_COUNT || endedAgo > ANNOUNCE_EXPIRY_MS) {
|
||||
// Give up: mark as completed to break the infinite retry loop.
|
||||
logAnnounceGiveUp(entry, retryCount >= MAX_ANNOUNCE_RETRY_COUNT ? "retry-limit" : "expiry");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js";
|
||||
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
@@ -108,8 +107,7 @@ export async function spawnSubagentDirect(
|
||||
});
|
||||
|
||||
const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg });
|
||||
const maxSpawnDepth =
|
||||
cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
|
||||
const maxSpawnDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1;
|
||||
if (callerDepth >= maxSpawnDepth) {
|
||||
return {
|
||||
status: "forbidden",
|
||||
|
||||
@@ -575,15 +575,14 @@ describe("buildSubagentSystemPrompt", () => {
|
||||
expect(prompt).toContain("instead of full-file `cat`");
|
||||
});
|
||||
|
||||
it("defaults to depth 1 and maxSpawnDepth 2 when not provided", () => {
|
||||
it("defaults to depth 1 and maxSpawnDepth 1 when not provided", () => {
|
||||
const prompt = buildSubagentSystemPrompt({
|
||||
childSessionKey: "agent:main:subagent:abc",
|
||||
task: "basic task",
|
||||
});
|
||||
|
||||
// Default maxSpawnDepth is 2, so depth-1 subagents are orchestrators.
|
||||
expect(prompt).toContain("## Sub-Agent Spawning");
|
||||
expect(prompt).toContain("You CAN spawn your own sub-agents");
|
||||
// Should not include spawning guidance (default maxSpawnDepth is 1, depth 1 is leaf)
|
||||
expect(prompt).not.toContain("## Sub-Agent Spawning");
|
||||
expect(prompt).toContain("spawned by the main agent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
sortSubagentRuns,
|
||||
type SubagentTargetResolution,
|
||||
} from "../../auto-reply/reply/subagents-utils.js";
|
||||
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../../config/agent-limits.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
|
||||
@@ -200,8 +199,7 @@ function resolveRequesterKey(params: {
|
||||
// Check if this sub-agent can spawn children (orchestrator).
|
||||
// If so, it should see its own children, not its parent's children.
|
||||
const callerDepth = getSubagentDepthFromSessionStore(callerSessionKey, { cfg: params.cfg });
|
||||
const maxSpawnDepth =
|
||||
params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
|
||||
const maxSpawnDepth = params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1;
|
||||
if (callerDepth < maxSpawnDepth) {
|
||||
// Orchestrator sub-agent: use its own session key as requester
|
||||
// so it sees children it spawned.
|
||||
|
||||
@@ -136,6 +136,7 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma
|
||||
command: params.command,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionKey: params.sessionKey,
|
||||
parentSessionKey: params.ctx.ParentSessionKey,
|
||||
sessionScope: params.sessionScope,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function buildStatusReply(params: {
|
||||
command: CommandContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey: string;
|
||||
parentSessionKey?: string;
|
||||
sessionScope?: SessionScope;
|
||||
storePath?: string;
|
||||
provider: string;
|
||||
@@ -51,6 +52,7 @@ export async function buildStatusReply(params: {
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
parentSessionKey,
|
||||
sessionScope,
|
||||
storePath,
|
||||
provider,
|
||||
@@ -173,6 +175,7 @@ export async function buildStatusReply(params: {
|
||||
agentId: statusAgentId,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
parentSessionKey,
|
||||
sessionScope,
|
||||
sessionStorePath: storePath,
|
||||
groupActivation,
|
||||
|
||||
@@ -168,6 +168,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
parentSessionKey: ctx.ParentSessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -277,6 +277,7 @@ export async function handleInlineActions(params: {
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
parentSessionKey: ctx.ParentSessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { resolveModelRefFromString } from "../../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js";
|
||||
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../../config/config.js";
|
||||
import { applyLinkUnderstanding } from "../../link-understanding/apply.js";
|
||||
import { applyMediaUnderstanding } from "../../media-understanding/apply.js";
|
||||
@@ -179,6 +180,36 @@ export async function getReplyFromConfig(
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
const channelModelOverride = resolveChannelModelOverride({
|
||||
cfg,
|
||||
channel:
|
||||
groupResolution?.channel ??
|
||||
sessionEntry.channel ??
|
||||
sessionEntry.origin?.provider ??
|
||||
(typeof finalized.OriginatingChannel === "string"
|
||||
? finalized.OriginatingChannel
|
||||
: undefined) ??
|
||||
finalized.Provider,
|
||||
groupId: groupResolution?.id ?? sessionEntry.groupId,
|
||||
groupChannel: sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel,
|
||||
groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject,
|
||||
parentSessionKey: sessionCtx.ParentSessionKey,
|
||||
});
|
||||
const hasSessionModelOverride = Boolean(
|
||||
sessionEntry.modelOverride?.trim() || sessionEntry.providerOverride?.trim(),
|
||||
);
|
||||
if (!hasResolvedHeartbeatModelOverride && !hasSessionModelOverride && channelModelOverride) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: channelModelOverride.model,
|
||||
defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (resolved) {
|
||||
provider = resolved.ref.provider;
|
||||
model = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
|
||||
const directiveResult = await resolveReplyDirectives({
|
||||
ctx: finalized,
|
||||
cfg,
|
||||
|
||||
@@ -71,36 +71,6 @@ describe("buildInboundMetaSystemPrompt", () => {
|
||||
const payload = parseInboundMetaPayload(prompt);
|
||||
expect(payload["sender_id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes discord channel topics only for new sessions", () => {
|
||||
const prompt = buildInboundMetaSystemPrompt({
|
||||
OriginatingTo: "discord:channel:123",
|
||||
OriginatingChannel: "discord",
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: "group",
|
||||
ChannelTopic: " Shipping updates ",
|
||||
IsNewSession: "true",
|
||||
} as TemplateContext);
|
||||
|
||||
const payload = parseInboundMetaPayload(prompt);
|
||||
expect(payload["channel_topic"]).toBe("Shipping updates");
|
||||
});
|
||||
|
||||
it("omits discord channel topics for existing sessions", () => {
|
||||
const prompt = buildInboundMetaSystemPrompt({
|
||||
OriginatingTo: "discord:channel:123",
|
||||
OriginatingChannel: "discord",
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: "group",
|
||||
ChannelTopic: "Shipping updates",
|
||||
IsNewSession: "false",
|
||||
} as TemplateContext);
|
||||
|
||||
const payload = parseInboundMetaPayload(prompt);
|
||||
expect(payload["channel_topic"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInboundUserContextPrefix", () => {
|
||||
|
||||
@@ -13,15 +13,8 @@ function safeTrim(value: unknown): string | undefined {
|
||||
export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
|
||||
const chatType = normalizeChatType(ctx.ChatType);
|
||||
const isDirect = !chatType || chatType === "direct";
|
||||
const isNewSession = ctx.IsNewSession === "true";
|
||||
const originatingChannel = safeTrim(ctx.OriginatingChannel);
|
||||
const surface = safeTrim(ctx.Surface);
|
||||
const provider = safeTrim(ctx.Provider);
|
||||
const isDiscord =
|
||||
provider === "discord" || surface === "discord" || originatingChannel === "discord";
|
||||
|
||||
// Keep system metadata strictly free of attacker-controlled strings (sender names, group subjects, etc.)
|
||||
// unless explicitly opted into for new-session context (e.g. Discord channel topics).
|
||||
// Keep system metadata strictly free of attacker-controlled strings (sender names, group subjects, etc.).
|
||||
// Those belong in the user-role "untrusted context" blocks.
|
||||
// Per-message identifiers (message_id, reply_to_id, sender_id) are also excluded here: they change
|
||||
// on every turn and would bust prefix-based prompt caches on local model providers. They are
|
||||
@@ -30,27 +23,25 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
|
||||
// Resolve channel identity: prefer explicit channel, then surface, then provider.
|
||||
// For webchat/Hub Chat sessions (when Surface is 'webchat' or undefined with no real channel),
|
||||
// omit the channel field entirely rather than falling back to an unrelated provider.
|
||||
let channelValue = originatingChannel ?? surface;
|
||||
let channelValue = safeTrim(ctx.OriginatingChannel) ?? safeTrim(ctx.Surface);
|
||||
if (!channelValue) {
|
||||
// Only fall back to Provider if it represents a real messaging channel.
|
||||
// For webchat/internal sessions, ctx.Provider may be unrelated (e.g., the user's configured
|
||||
// default channel), so skip it to avoid incorrect runtime labels like "channel=whatsapp".
|
||||
const provider = safeTrim(ctx.Provider);
|
||||
// Check if provider is "webchat" or if we're in an internal/webchat context
|
||||
if (provider !== "webchat" && surface !== "webchat") {
|
||||
if (provider !== "webchat" && ctx.Surface !== "webchat") {
|
||||
channelValue = provider;
|
||||
}
|
||||
// Otherwise leave channelValue undefined (no channel label)
|
||||
}
|
||||
|
||||
const channelTopic = isNewSession && isDiscord ? safeTrim(ctx.ChannelTopic) : undefined;
|
||||
|
||||
const payload = {
|
||||
schema: "openclaw.inbound_meta.v1",
|
||||
chat_id: safeTrim(ctx.OriginatingTo),
|
||||
channel: channelValue,
|
||||
channel_topic: channelTopic,
|
||||
provider,
|
||||
surface,
|
||||
provider: safeTrim(ctx.Provider),
|
||||
surface: safeTrim(ctx.Surface),
|
||||
chat_type: chatType ?? (isDirect ? "direct" : undefined),
|
||||
flags: {
|
||||
is_group_chat: !isDirect ? true : undefined,
|
||||
|
||||
@@ -90,6 +90,36 @@ describe("buildStatusMessage", () => {
|
||||
expect(normalized).toContain("Queue: collect");
|
||||
});
|
||||
|
||||
it("notes channel model overrides in status output", () => {
|
||||
const text = buildStatusMessage({
|
||||
config: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
discord: {
|
||||
"123": "openai/gpt-4.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
agent: {
|
||||
model: "openai/gpt-4.1",
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "abc",
|
||||
updatedAt: 0,
|
||||
channel: "discord",
|
||||
groupId: "123",
|
||||
},
|
||||
sessionKey: "agent:main:discord:channel:123",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
});
|
||||
const normalized = normalizeTestText(text);
|
||||
|
||||
expect(normalized).toContain("Model: openai/gpt-4.1");
|
||||
expect(normalized).toContain("channel override");
|
||||
});
|
||||
|
||||
it("uses per-agent sandbox config when config and session key are provided", () => {
|
||||
const text = buildStatusMessage({
|
||||
config: {
|
||||
|
||||
@@ -2,10 +2,15 @@ import fs from "node:fs";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveModelAuthMode } from "../agents/model-auth.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js";
|
||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js";
|
||||
import { resolveChannelModelOverride } from "../channels/model-overrides.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveMainSessionKey,
|
||||
@@ -66,6 +71,7 @@ type StatusArgs = {
|
||||
agentId?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
sessionScope?: SessionScope;
|
||||
sessionStorePath?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
@@ -531,7 +537,46 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
state: entry,
|
||||
});
|
||||
const selectedAuthLabel = selectedAuthLabelValue ? ` · 🔑 ${selectedAuthLabelValue}` : "";
|
||||
const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}`;
|
||||
const channelModelNote = (() => {
|
||||
if (!args.config || !entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (entry.modelOverride?.trim() || entry.providerOverride?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const channelOverride = resolveChannelModelOverride({
|
||||
cfg: args.config,
|
||||
channel: entry.channel ?? entry.origin?.provider,
|
||||
groupId: entry.groupId,
|
||||
groupChannel: entry.groupChannel,
|
||||
groupSubject: entry.subject,
|
||||
parentSessionKey: args.parentSessionKey,
|
||||
});
|
||||
if (!channelOverride) {
|
||||
return undefined;
|
||||
}
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: args.config,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const resolvedOverride = resolveModelRefFromString({
|
||||
raw: channelOverride.model,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolvedOverride) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
resolvedOverride.ref.provider !== selectedProvider ||
|
||||
resolvedOverride.ref.model !== selectedModel
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return "channel override";
|
||||
})();
|
||||
const modelNote = channelModelNote ? ` · ${channelModelNote}` : "";
|
||||
const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}${modelNote}`;
|
||||
const showFallbackAuth = activeAuthLabelValue && activeAuthLabelValue !== selectedAuthLabelValue;
|
||||
const fallbackLine = fallbackState.active
|
||||
? `↪️ Fallback: ${activeModelLabel}${
|
||||
|
||||
@@ -98,8 +98,6 @@ export type MsgContext = {
|
||||
GroupSubject?: string;
|
||||
/** Human label for channel-like group conversations (e.g. #general, #support). */
|
||||
GroupChannel?: string;
|
||||
/** Channel topic/description (trusted metadata for new session context). */
|
||||
ChannelTopic?: string;
|
||||
GroupSpace?: string;
|
||||
GroupMembers?: string;
|
||||
GroupSystemPrompt?: string;
|
||||
|
||||
67
src/channels/model-overrides.test.ts
Normal file
67
src/channels/model-overrides.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveChannelModelOverride } from "./model-overrides.js";
|
||||
|
||||
describe("resolveChannelModelOverride", () => {
|
||||
it("matches parent group id when topic suffix is present", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
telegram: {
|
||||
"-100123": "openai/gpt-4.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const resolved = resolveChannelModelOverride({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
groupId: "-100123:topic:99",
|
||||
});
|
||||
|
||||
expect(resolved?.model).toBe("openai/gpt-4.1");
|
||||
expect(resolved?.matchKey).toBe("-100123");
|
||||
});
|
||||
|
||||
it("prefers topic-specific match over parent group id", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
telegram: {
|
||||
"-100123": "openai/gpt-4.1",
|
||||
"-100123:topic:99": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const resolved = resolveChannelModelOverride({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
groupId: "-100123:topic:99",
|
||||
});
|
||||
|
||||
expect(resolved?.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
expect(resolved?.matchKey).toBe("-100123:topic:99");
|
||||
});
|
||||
|
||||
it("falls back to parent session key when thread id does not match", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
discord: {
|
||||
"123": "openai/gpt-4.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const resolved = resolveChannelModelOverride({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
groupId: "999",
|
||||
parentSessionKey: "agent:main:discord:channel:123:thread:456",
|
||||
});
|
||||
|
||||
expect(resolved?.model).toBe("openai/gpt-4.1");
|
||||
expect(resolved?.matchKey).toBe("123");
|
||||
});
|
||||
});
|
||||
142
src/channels/model-overrides.ts
Normal file
142
src/channels/model-overrides.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
type ChannelMatchSource,
|
||||
} from "./channel-config.js";
|
||||
|
||||
const THREAD_SUFFIX_REGEX = /:(?:thread|topic):[^:]+$/i;
|
||||
|
||||
export type ChannelModelOverride = {
|
||||
channel: string;
|
||||
model: string;
|
||||
matchKey?: string;
|
||||
matchSource?: ChannelMatchSource;
|
||||
};
|
||||
|
||||
type ChannelModelByChannelConfig = Record<string, Record<string, string>>;
|
||||
|
||||
type ChannelModelOverrideParams = {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string | null;
|
||||
groupId?: string | null;
|
||||
groupChannel?: string | null;
|
||||
groupSubject?: string | null;
|
||||
parentSessionKey?: string | null;
|
||||
};
|
||||
|
||||
function resolveProviderEntry(
|
||||
modelByChannel: ChannelModelByChannelConfig | undefined,
|
||||
channel: string,
|
||||
): Record<string, string> | undefined {
|
||||
const normalized = normalizeMessageChannel(channel) ?? channel.trim().toLowerCase();
|
||||
return (
|
||||
modelByChannel?.[normalized] ??
|
||||
modelByChannel?.[
|
||||
Object.keys(modelByChannel ?? {}).find((key) => {
|
||||
const normalizedKey = normalizeMessageChannel(key) ?? key.trim().toLowerCase();
|
||||
return normalizedKey === normalized;
|
||||
}) ?? ""
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveParentGroupId(groupId: string | undefined): string | undefined {
|
||||
const raw = groupId?.trim();
|
||||
if (!raw || !THREAD_SUFFIX_REGEX.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const parent = raw.replace(THREAD_SUFFIX_REGEX, "").trim();
|
||||
return parent && parent !== raw ? parent : undefined;
|
||||
}
|
||||
|
||||
function resolveGroupIdFromSessionKey(sessionKey?: string | null): string | undefined {
|
||||
const raw = sessionKey?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
const candidate = parsed?.rest ?? raw;
|
||||
const match = candidate.match(/(?:^|:)(?:group|channel):([^:]+)(?::|$)/i);
|
||||
const id = match?.[1]?.trim();
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
function buildChannelCandidates(
|
||||
params: Pick<
|
||||
ChannelModelOverrideParams,
|
||||
"groupId" | "groupChannel" | "groupSubject" | "parentSessionKey"
|
||||
>,
|
||||
) {
|
||||
const groupId = params.groupId?.trim();
|
||||
const parentGroupId = resolveParentGroupId(groupId);
|
||||
const parentGroupIdFromSession = resolveGroupIdFromSessionKey(params.parentSessionKey);
|
||||
const parentGroupIdResolved =
|
||||
resolveParentGroupId(parentGroupIdFromSession) ?? parentGroupIdFromSession;
|
||||
const groupChannel = params.groupChannel?.trim();
|
||||
const groupSubject = params.groupSubject?.trim();
|
||||
const channelBare = groupChannel ? groupChannel.replace(/^#/, "") : undefined;
|
||||
const subjectBare = groupSubject ? groupSubject.replace(/^#/, "") : undefined;
|
||||
const channelSlug = channelBare ? normalizeChannelSlug(channelBare) : undefined;
|
||||
const subjectSlug = subjectBare ? normalizeChannelSlug(subjectBare) : undefined;
|
||||
|
||||
return buildChannelKeyCandidates(
|
||||
groupId,
|
||||
parentGroupId,
|
||||
parentGroupIdResolved,
|
||||
groupChannel,
|
||||
channelBare,
|
||||
channelSlug,
|
||||
groupSubject,
|
||||
subjectBare,
|
||||
subjectSlug,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveChannelModelOverride(
|
||||
params: ChannelModelOverrideParams,
|
||||
): ChannelModelOverride | null {
|
||||
const channel = params.channel?.trim();
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
const modelByChannel = params.cfg.channels?.modelByChannel as
|
||||
| ChannelModelByChannelConfig
|
||||
| undefined;
|
||||
if (!modelByChannel) {
|
||||
return null;
|
||||
}
|
||||
const providerEntries = resolveProviderEntry(modelByChannel, channel);
|
||||
if (!providerEntries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = buildChannelCandidates(params);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries: providerEntries,
|
||||
keys: candidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: (value) => value.trim().toLowerCase(),
|
||||
});
|
||||
const raw = match.entry ?? match.wildcardEntry;
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const model = raw.trim();
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channel: normalizeMessageChannel(channel) ?? channel.trim().toLowerCase(),
|
||||
model,
|
||||
matchKey: match.matchKey,
|
||||
matchSource: match.matchSource,
|
||||
};
|
||||
}
|
||||
@@ -50,14 +50,14 @@ export type StatusReactionController = {
|
||||
|
||||
export const DEFAULT_EMOJIS: Required<StatusReactionEmojis> = {
|
||||
queued: "👀",
|
||||
thinking: "🧠",
|
||||
tool: "🛠️",
|
||||
coding: "💻",
|
||||
web: "🌐",
|
||||
done: "✅",
|
||||
error: "❌",
|
||||
stallSoft: "⏳",
|
||||
stallHard: "⚠️",
|
||||
thinking: "🤔",
|
||||
tool: "🔥",
|
||||
coding: "👨💻",
|
||||
web: "⚡",
|
||||
done: "👍",
|
||||
error: "😱",
|
||||
stallSoft: "🥱",
|
||||
stallHard: "😨",
|
||||
};
|
||||
|
||||
export const DEFAULT_TIMING: Required<StatusReactionTiming> = {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "./types.js";
|
||||
|
||||
export const DEFAULT_AGENT_MAX_CONCURRENT = 4;
|
||||
export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8;
|
||||
export const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 2;
|
||||
|
||||
export function resolveAgentMaxConcurrent(cfg?: OpenClawConfig): number {
|
||||
const raw = cfg?.agents?.defaults?.maxConcurrent;
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_AGENT_MAX_CONCURRENT,
|
||||
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
|
||||
DEFAULT_SUBAGENT_MAX_CONCURRENT,
|
||||
resolveAgentMaxConcurrent,
|
||||
resolveSubagentMaxConcurrent,
|
||||
@@ -61,7 +60,6 @@ describe("agent concurrency defaults", () => {
|
||||
|
||||
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
|
||||
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
|
||||
expect(cfg.agents?.defaults?.subagents?.maxSpawnDepth).toBe(DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_AGENT_MAX_CONCURRENT,
|
||||
DEFAULT_SUBAGENT_MAX_CONCURRENT,
|
||||
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
|
||||
} from "./agent-limits.js";
|
||||
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { withTempHome } from "./home-env.test-harness.js";
|
||||
|
||||
@@ -57,7 +53,6 @@ describe("config identity defaults", () => {
|
||||
expect(cfg.agents?.list).toBeUndefined();
|
||||
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
|
||||
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
|
||||
expect(cfg.agents?.defaults?.subagents?.maxSpawnDepth).toBe(DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH);
|
||||
expect(cfg.session).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
|
||||
import { parseModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
DEFAULT_AGENT_MAX_CONCURRENT,
|
||||
DEFAULT_SUBAGENT_MAX_CONCURRENT,
|
||||
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
|
||||
} from "./agent-limits.js";
|
||||
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
|
||||
import { resolveTalkApiKey } from "./talk.js";
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
import type { ModelDefinitionConfig } from "./types.models.js";
|
||||
@@ -303,10 +299,7 @@ export function applyAgentDefaults(cfg: OpenClawConfig): OpenClawConfig {
|
||||
const hasSubMax =
|
||||
typeof defaults?.subagents?.maxConcurrent === "number" &&
|
||||
Number.isFinite(defaults.subagents.maxConcurrent);
|
||||
const hasMaxSpawnDepth =
|
||||
typeof defaults?.subagents?.maxSpawnDepth === "number" &&
|
||||
Number.isFinite(defaults.subagents.maxSpawnDepth);
|
||||
if (hasMax && hasSubMax && hasMaxSpawnDepth) {
|
||||
if (hasMax && hasSubMax) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -322,10 +315,6 @@ export function applyAgentDefaults(cfg: OpenClawConfig): OpenClawConfig {
|
||||
nextSubagents.maxConcurrent = DEFAULT_SUBAGENT_MAX_CONCURRENT;
|
||||
mutated = true;
|
||||
}
|
||||
if (!hasMaxSpawnDepth) {
|
||||
nextSubagents.maxSpawnDepth = DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (!mutated) {
|
||||
return cfg;
|
||||
|
||||
@@ -352,6 +352,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Allow iMessage to write config in response to channel events/commands (default: true).",
|
||||
"channels.msteams.configWrites":
|
||||
"Allow Microsoft Teams to write config in response to channel events/commands (default: true).",
|
||||
"channels.modelByChannel":
|
||||
"Map provider -> channel id -> model override (values are provider/model or aliases).",
|
||||
...IRC_FIELD_HELP,
|
||||
"channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").',
|
||||
"channels.discord.commands.nativeSkills":
|
||||
|
||||
@@ -257,6 +257,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.imessage": "iMessage",
|
||||
"channels.bluebubbles": "BlueBubbles",
|
||||
"channels.msteams": "MS Teams",
|
||||
"channels.modelByChannel": "Channel Model Overrides",
|
||||
...IRC_FIELD_LABELS,
|
||||
"channels.telegram.botToken": "Telegram Bot Token",
|
||||
"channels.telegram.dmPolicy": "Telegram DM Policy",
|
||||
|
||||
@@ -241,7 +241,7 @@ export type AgentDefaultsConfig = {
|
||||
subagents?: {
|
||||
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
|
||||
maxConcurrent?: number;
|
||||
/** Maximum depth allowed for sessions_spawn chains. Default behavior: 2 (allows nested spawns). */
|
||||
/** Maximum depth allowed for sessions_spawn chains. Default behavior: 1 (no nested spawns). */
|
||||
maxSpawnDepth?: number;
|
||||
/** Maximum active children a single requester session may spawn. Default behavior: 5. */
|
||||
maxChildrenPerAgent?: number;
|
||||
|
||||
@@ -24,6 +24,8 @@ export type ChannelDefaultsConfig = {
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
};
|
||||
|
||||
export type ChannelModelByChannelConfig = Record<string, Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Base type for extension channel config sections.
|
||||
* Extensions can use this as a starting point for their channel config.
|
||||
@@ -41,6 +43,8 @@ export type ExtensionChannelConfig = {
|
||||
|
||||
export type ChannelsConfig = {
|
||||
defaults?: ChannelDefaultsConfig;
|
||||
/** Map provider -> channel id -> model override. */
|
||||
modelByChannel?: ChannelModelByChannelConfig;
|
||||
whatsapp?: WhatsAppConfig;
|
||||
telegram?: TelegramConfig;
|
||||
discord?: DiscordConfig;
|
||||
|
||||
@@ -150,7 +150,7 @@ export const AgentDefaultsSchema = z
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe(
|
||||
"Maximum nesting depth for sub-agent spawning. Default is 2 (sub-agents can spawn sub-sub-agents).",
|
||||
"Maximum nesting depth for sub-agent spawning. 1 = no nesting (default), 2 = sub-agents can spawn sub-sub-agents.",
|
||||
),
|
||||
maxChildrenPerAgent: z
|
||||
.number()
|
||||
|
||||
@@ -18,6 +18,10 @@ export * from "./zod-schema.providers-core.js";
|
||||
export * from "./zod-schema.providers-whatsapp.js";
|
||||
export { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||
|
||||
const ChannelModelByChannelSchema = z
|
||||
.record(z.string(), z.record(z.string(), z.string()))
|
||||
.optional();
|
||||
|
||||
export const ChannelsSchema = z
|
||||
.object({
|
||||
defaults: z
|
||||
@@ -27,6 +31,7 @@ export const ChannelsSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
modelByChannel: ChannelModelByChannelSchema,
|
||||
whatsapp: WhatsAppConfigSchema.optional(),
|
||||
telegram: TelegramConfigSchema.optional(),
|
||||
discord: DiscordConfigSchema.optional(),
|
||||
|
||||
@@ -4,9 +4,11 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
loadSessionStore: vi.fn(),
|
||||
resolveStorePath: vi.fn().mockReturnValue("/tmp/test-store.json"),
|
||||
evaluateSessionFreshness: vi.fn().mockReturnValue({ fresh: true }),
|
||||
resolveSessionResetPolicy: vi.fn().mockReturnValue({ mode: "idle", idleMinutes: 60 }),
|
||||
}));
|
||||
|
||||
import { loadSessionStore } from "../../config/sessions.js";
|
||||
import { loadSessionStore, evaluateSessionFreshness } from "../../config/sessions.js";
|
||||
import { resolveCronSession } from "./session.js";
|
||||
|
||||
const NOW_MS = 1_737_600_000_000;
|
||||
@@ -15,18 +17,25 @@ type SessionStore = ReturnType<typeof loadSessionStore>;
|
||||
type SessionStoreEntry = SessionStore[string];
|
||||
type MockSessionStoreEntry = Partial<SessionStoreEntry>;
|
||||
|
||||
function resolveWithStoredEntry(params?: { sessionKey?: string; entry?: MockSessionStoreEntry }) {
|
||||
function resolveWithStoredEntry(params?: {
|
||||
sessionKey?: string;
|
||||
entry?: MockSessionStoreEntry;
|
||||
forceNew?: boolean;
|
||||
fresh?: boolean;
|
||||
}) {
|
||||
const sessionKey = params?.sessionKey ?? "webhook:stable-key";
|
||||
const store: SessionStore = params?.entry
|
||||
? ({ [sessionKey]: params.entry as SessionStoreEntry } as SessionStore)
|
||||
: {};
|
||||
vi.mocked(loadSessionStore).mockReturnValue(store);
|
||||
vi.mocked(evaluateSessionFreshness).mockReturnValue({ fresh: params?.fresh ?? true });
|
||||
|
||||
return resolveCronSession({
|
||||
cfg: {} as OpenClawConfig,
|
||||
sessionKey,
|
||||
agentId: "main",
|
||||
nowMs: NOW_MS,
|
||||
forceNew: params?.forceNew,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,51 +85,76 @@ describe("resolveCronSession", () => {
|
||||
expect(result.isNewSession).toBe(true);
|
||||
});
|
||||
|
||||
it("always creates a new sessionId for cron/webhook runs", () => {
|
||||
const result = resolveWithStoredEntry({
|
||||
entry: {
|
||||
sessionId: "existing-session-id-123",
|
||||
updatedAt: NOW_MS - 1000,
|
||||
systemSent: true,
|
||||
},
|
||||
// New tests for session reuse behavior (#18027)
|
||||
describe("session reuse for webhooks/cron", () => {
|
||||
it("reuses existing sessionId when session is fresh", () => {
|
||||
const result = resolveWithStoredEntry({
|
||||
entry: {
|
||||
sessionId: "existing-session-id-123",
|
||||
updatedAt: NOW_MS - 1000,
|
||||
systemSent: true,
|
||||
},
|
||||
fresh: true,
|
||||
});
|
||||
|
||||
expect(result.sessionEntry.sessionId).toBe("existing-session-id-123");
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.systemSent).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.sessionEntry.sessionId).not.toBe("existing-session-id-123");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.systemSent).toBe(false);
|
||||
});
|
||||
it("creates new sessionId when session is stale", () => {
|
||||
const result = resolveWithStoredEntry({
|
||||
entry: {
|
||||
sessionId: "old-session-id",
|
||||
updatedAt: NOW_MS - 86_400_000, // 1 day ago
|
||||
systemSent: true,
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
providerOverride: "openai",
|
||||
sendPolicy: "allow",
|
||||
},
|
||||
fresh: false,
|
||||
});
|
||||
|
||||
it("preserves overrides while rolling a new sessionId", () => {
|
||||
const result = resolveWithStoredEntry({
|
||||
entry: {
|
||||
sessionId: "old-session-id",
|
||||
updatedAt: NOW_MS - 86_400_000,
|
||||
systemSent: true,
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
providerOverride: "openai",
|
||||
sendPolicy: "allow",
|
||||
},
|
||||
expect(result.sessionEntry.sessionId).not.toBe("old-session-id");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.systemSent).toBe(false);
|
||||
expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini");
|
||||
expect(result.sessionEntry.providerOverride).toBe("openai");
|
||||
expect(result.sessionEntry.sendPolicy).toBe("allow");
|
||||
});
|
||||
|
||||
expect(result.sessionEntry.sessionId).not.toBe("old-session-id");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.systemSent).toBe(false);
|
||||
expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini");
|
||||
expect(result.sessionEntry.providerOverride).toBe("openai");
|
||||
expect(result.sessionEntry.sendPolicy).toBe("allow");
|
||||
});
|
||||
it("creates new sessionId when forceNew is true", () => {
|
||||
const result = resolveWithStoredEntry({
|
||||
entry: {
|
||||
sessionId: "existing-session-id-456",
|
||||
updatedAt: NOW_MS - 1000,
|
||||
systemSent: true,
|
||||
modelOverride: "sonnet-4",
|
||||
providerOverride: "anthropic",
|
||||
},
|
||||
fresh: true,
|
||||
forceNew: true,
|
||||
});
|
||||
|
||||
it("creates new sessionId when entry exists but has no sessionId", () => {
|
||||
const result = resolveWithStoredEntry({
|
||||
entry: {
|
||||
updatedAt: NOW_MS - 1000,
|
||||
modelOverride: "some-model",
|
||||
},
|
||||
expect(result.sessionEntry.sessionId).not.toBe("existing-session-id-456");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.systemSent).toBe(false);
|
||||
expect(result.sessionEntry.modelOverride).toBe("sonnet-4");
|
||||
expect(result.sessionEntry.providerOverride).toBe("anthropic");
|
||||
});
|
||||
|
||||
expect(result.sessionEntry.sessionId).toBeDefined();
|
||||
expect(result.isNewSession).toBe(true);
|
||||
// Should still preserve other fields from entry
|
||||
expect(result.sessionEntry.modelOverride).toBe("some-model");
|
||||
it("creates new sessionId when entry exists but has no sessionId", () => {
|
||||
const result = resolveWithStoredEntry({
|
||||
entry: {
|
||||
updatedAt: NOW_MS - 1000,
|
||||
modelOverride: "some-model",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.sessionEntry.sessionId).toBeDefined();
|
||||
expect(result.isNewSession).toBe(true);
|
||||
// Should still preserve other fields from entry
|
||||
expect(result.sessionEntry.modelOverride).toBe("some-model");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../../config/sessions.js";
|
||||
import {
|
||||
evaluateSessionFreshness,
|
||||
loadSessionStore,
|
||||
resolveSessionResetPolicy,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "../../config/sessions.js";
|
||||
|
||||
export function resolveCronSession(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
nowMs: number;
|
||||
agentId: string;
|
||||
forceNew?: boolean;
|
||||
}) {
|
||||
const sessionCfg = params.cfg.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||
@@ -14,8 +21,42 @@ export function resolveCronSession(params: {
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const sessionId = crypto.randomUUID();
|
||||
const systemSent = false;
|
||||
|
||||
// Check if we can reuse an existing session
|
||||
let sessionId: string;
|
||||
let isNewSession: boolean;
|
||||
let systemSent: boolean;
|
||||
|
||||
if (!params.forceNew && entry?.sessionId) {
|
||||
// Evaluate freshness using the configured reset policy
|
||||
// Cron/webhook sessions use "direct" reset type (1:1 conversation style)
|
||||
const resetPolicy = resolveSessionResetPolicy({
|
||||
sessionCfg,
|
||||
resetType: "direct",
|
||||
});
|
||||
const freshness = evaluateSessionFreshness({
|
||||
updatedAt: entry.updatedAt,
|
||||
now: params.nowMs,
|
||||
policy: resetPolicy,
|
||||
});
|
||||
|
||||
if (freshness.fresh) {
|
||||
// Reuse existing session
|
||||
sessionId = entry.sessionId;
|
||||
isNewSession = false;
|
||||
systemSent = entry.systemSent ?? false;
|
||||
} else {
|
||||
// Session expired, create new
|
||||
sessionId = crypto.randomUUID();
|
||||
isNewSession = true;
|
||||
systemSent = false;
|
||||
}
|
||||
} else {
|
||||
// No existing session or forced new
|
||||
sessionId = crypto.randomUUID();
|
||||
isNewSession = true;
|
||||
systemSent = false;
|
||||
}
|
||||
|
||||
const sessionEntry: SessionEntry = {
|
||||
// Preserve existing per-session overrides even when rolling to a new sessionId.
|
||||
@@ -25,5 +66,5 @@ export function resolveCronSession(params: {
|
||||
updatedAt: params.nowMs,
|
||||
systemSent,
|
||||
};
|
||||
return { storePath, store, sessionEntry, systemSent, isNewSession: true };
|
||||
return { storePath, store, sessionEntry, systemSent, isNewSession };
|
||||
}
|
||||
|
||||
@@ -4,36 +4,23 @@ import {
|
||||
MessageCreateListener,
|
||||
MessageReactionAddListener,
|
||||
MessageReactionRemoveListener,
|
||||
MessageUpdateListener,
|
||||
PresenceUpdateListener,
|
||||
type User,
|
||||
} from "@buape/carbon";
|
||||
import type { DmPolicy, GroupPolicy } from "../../config/types.base.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import {
|
||||
allowListMatches,
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordMemberAccessState,
|
||||
resolveGroupDmAllow,
|
||||
shouldEmitDiscordReactionNotification,
|
||||
} from "./allow-list.js";
|
||||
import {
|
||||
formatDiscordReactionEmoji,
|
||||
formatDiscordUserTag,
|
||||
resolveDiscordSystemLocation,
|
||||
} from "./format.js";
|
||||
import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId } from "./message-utils.js";
|
||||
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
||||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||
import { setPresence } from "./presence-cache.js";
|
||||
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
|
||||
|
||||
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
||||
@@ -43,8 +30,6 @@ export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0]
|
||||
|
||||
export type DiscordMessageHandler = (data: DiscordMessageEvent, client: Client) => Promise<void>;
|
||||
|
||||
export type DiscordMessageUpdateEvent = Parameters<MessageUpdateListener["handle"]>[0];
|
||||
|
||||
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
|
||||
|
||||
type DiscordReactionListenerParams = {
|
||||
@@ -56,16 +41,6 @@ type DiscordReactionListenerParams = {
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
type DiscordMessageUpdateListenerParams = DiscordReactionListenerParams & {
|
||||
dmEnabled: boolean;
|
||||
dmPolicy: DmPolicy;
|
||||
allowFrom?: string[];
|
||||
groupPolicy: GroupPolicy;
|
||||
groupDmEnabled: boolean;
|
||||
groupDmChannels?: string[];
|
||||
allowBots: boolean;
|
||||
};
|
||||
|
||||
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000;
|
||||
const discordEventQueueLog = createSubsystemLogger("discord/event-queue");
|
||||
|
||||
@@ -128,22 +103,6 @@ export class DiscordMessageListener extends MessageCreateListener {
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscordMessageUpdateListener extends MessageUpdateListener {
|
||||
constructor(private params: DiscordMessageUpdateListenerParams) {
|
||||
super();
|
||||
}
|
||||
|
||||
async handle(data: DiscordMessageUpdateEvent, client: Client) {
|
||||
await runDiscordMessageUpdateHandler({
|
||||
data,
|
||||
client,
|
||||
handlerParams: this.params,
|
||||
listener: this.constructor.name,
|
||||
event: this.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscordReactionListener extends MessageReactionAddListener {
|
||||
constructor(private params: DiscordReactionListenerParams) {
|
||||
super();
|
||||
@@ -178,30 +137,6 @@ export class DiscordReactionRemoveListener extends MessageReactionRemoveListener
|
||||
}
|
||||
}
|
||||
|
||||
async function runDiscordMessageUpdateHandler(params: {
|
||||
data: DiscordMessageUpdateEvent;
|
||||
client: Client;
|
||||
handlerParams: DiscordMessageUpdateListenerParams;
|
||||
listener: string;
|
||||
event: string;
|
||||
}): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await handleDiscordMessageUpdateEvent({
|
||||
data: params.data,
|
||||
client: params.client,
|
||||
handlerParams: params.handlerParams,
|
||||
});
|
||||
} finally {
|
||||
logSlowDiscordListener({
|
||||
logger: params.handlerParams.logger,
|
||||
listener: params.listener,
|
||||
event: params.event,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runDiscordReactionHandler(params: {
|
||||
data: DiscordReactionEvent;
|
||||
client: Client;
|
||||
@@ -232,223 +167,6 @@ async function runDiscordReactionHandler(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscordMessageUpdateEvent(params: {
|
||||
data: DiscordMessageUpdateEvent;
|
||||
client: Client;
|
||||
handlerParams: DiscordMessageUpdateListenerParams;
|
||||
}) {
|
||||
const { data, client, handlerParams } = params;
|
||||
try {
|
||||
const message = data.message;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const editedTimestamp =
|
||||
message.editedTimestamp ??
|
||||
(data as { edited_timestamp?: string | null }).edited_timestamp ??
|
||||
null;
|
||||
if (!editedTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const author =
|
||||
message.author ?? (message as { rawData?: { author?: User | null } }).rawData?.author;
|
||||
const authorId = author?.id ? String(author.id) : "";
|
||||
if (handlerParams.botUserId && authorId && authorId === handlerParams.botUserId) {
|
||||
return;
|
||||
}
|
||||
if (author?.bot && !handlerParams.allowBots) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageChannelId = resolveDiscordMessageChannelId({
|
||||
message,
|
||||
eventChannelId: data.channel_id,
|
||||
});
|
||||
if (!messageChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelInfo = await resolveDiscordChannelInfo(client, messageChannelId);
|
||||
const isGuildMessage = Boolean(data.guild_id);
|
||||
if (!channelInfo && !isGuildMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDirectMessage = channelInfo?.type === ChannelType.DM;
|
||||
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
|
||||
|
||||
if (isDirectMessage) {
|
||||
if (!handlerParams.dmEnabled) {
|
||||
return;
|
||||
}
|
||||
if (handlerParams.dmPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
if (!authorId) {
|
||||
return;
|
||||
}
|
||||
if (handlerParams.dmPolicy !== "open") {
|
||||
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
||||
const effectiveAllowFrom = [...(handlerParams.allowFrom ?? []), ...storeAllowFrom];
|
||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
|
||||
"discord:",
|
||||
"user:",
|
||||
"pk:",
|
||||
]);
|
||||
if (!allowList) {
|
||||
return;
|
||||
}
|
||||
const authorTag = author ? formatDiscordUserTag(author as User) : undefined;
|
||||
const allowed = allowListMatches(allowList, {
|
||||
id: authorId,
|
||||
name: author?.username ?? undefined,
|
||||
tag: authorTag,
|
||||
});
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroupDm) {
|
||||
if (!handlerParams.groupDmEnabled) {
|
||||
return;
|
||||
}
|
||||
const channelName = channelInfo?.name ?? undefined;
|
||||
const displayChannelName = channelName ?? messageChannelId;
|
||||
const displayChannelSlug = displayChannelName ? normalizeDiscordSlug(displayChannelName) : "";
|
||||
const groupDmAllowed = resolveGroupDmAllow({
|
||||
channels: handlerParams.groupDmChannels,
|
||||
channelId: messageChannelId,
|
||||
channelName: displayChannelName,
|
||||
channelSlug: displayChannelSlug,
|
||||
});
|
||||
if (!groupDmAllowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let threadParentId: string | undefined;
|
||||
let threadParentName: string | undefined;
|
||||
const threadChannel = resolveDiscordThreadChannel({
|
||||
isGuildMessage,
|
||||
message,
|
||||
channelInfo,
|
||||
messageChannelId,
|
||||
});
|
||||
if (threadChannel) {
|
||||
const parentInfo = await resolveDiscordThreadParentInfo({
|
||||
client,
|
||||
threadChannel,
|
||||
channelInfo,
|
||||
});
|
||||
threadParentId = parentInfo.id;
|
||||
threadParentName = parentInfo.name;
|
||||
}
|
||||
|
||||
const guildInfo = isGuildMessage
|
||||
? resolveDiscordGuildEntry({
|
||||
guild: data.guild ?? undefined,
|
||||
guildEntries: handlerParams.guildEntries,
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
isGuildMessage &&
|
||||
handlerParams.guildEntries &&
|
||||
Object.keys(handlerParams.guildEntries).length > 0 &&
|
||||
!guildInfo
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelName = channelInfo?.name ?? threadChannel?.name ?? undefined;
|
||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const parentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||
const channelConfig = isGuildMessage
|
||||
? resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId: messageChannelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
parentId: threadParentId,
|
||||
parentName: threadParentName,
|
||||
parentSlug,
|
||||
scope: threadChannel ? "thread" : "channel",
|
||||
})
|
||||
: null;
|
||||
|
||||
if (isGuildMessage && channelConfig?.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelAllowlistConfigured =
|
||||
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
|
||||
const channelAllowed = channelConfig?.allowed !== false;
|
||||
if (
|
||||
isGuildMessage &&
|
||||
!isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: handlerParams.groupPolicy,
|
||||
guildAllowlisted: Boolean(guildInfo),
|
||||
channelAllowlistConfigured,
|
||||
channelAllowed,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isGuildMessage && channelConfig?.allowed === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberRoles = (data as { member?: { roles?: string[] } }).member?.roles;
|
||||
const memberRoleIds = Array.isArray(memberRoles)
|
||||
? memberRoles.map((roleId) => String(roleId))
|
||||
: [];
|
||||
|
||||
const senderTag = author ? formatDiscordUserTag(author as User) : undefined;
|
||||
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
memberRoleIds,
|
||||
sender: {
|
||||
id: authorId,
|
||||
name: author?.username ?? undefined,
|
||||
tag: senderTag,
|
||||
},
|
||||
});
|
||||
if (isGuildMessage && hasAccessRestrictions && !memberAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg: handlerParams.cfg,
|
||||
channel: "discord",
|
||||
accountId: handlerParams.accountId,
|
||||
guildId: data.guild_id ?? undefined,
|
||||
memberRoleIds,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
||||
id: isDirectMessage ? authorId : messageChannelId,
|
||||
},
|
||||
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
|
||||
});
|
||||
|
||||
const location = resolveDiscordSystemLocation({
|
||||
isDirectMessage,
|
||||
isGroupDm,
|
||||
guild: data.guild ?? undefined,
|
||||
channelName: channelName ?? messageChannelId,
|
||||
});
|
||||
const text = `Discord message edited in ${location}.`;
|
||||
enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `discord:message:edited:${messageChannelId}:${message.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
handlerParams.logger.error(danger(`discord message update handler failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscordReactionEvent(params: {
|
||||
data: DiscordReactionEvent;
|
||||
client: Client;
|
||||
|
||||
@@ -173,7 +173,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
|
||||
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
||||
const channelTopic = isGuildMessage ? channelInfo?.topic : undefined;
|
||||
const untrustedChannelMetadata = isGuildMessage
|
||||
? buildUntrustedChannelMetadata({
|
||||
source: "discord",
|
||||
@@ -335,7 +334,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
SenderTag: senderTag,
|
||||
GroupSubject: groupSubject,
|
||||
GroupChannel: groupChannel,
|
||||
ChannelTopic: channelTopic,
|
||||
UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined,
|
||||
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
|
||||
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { ChannelType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { DiscordMessageUpdateListener, type DiscordMessageUpdateEvent } from "./listeners.js";
|
||||
import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js";
|
||||
|
||||
vi.mock("../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("DiscordMessageUpdateListener", () => {
|
||||
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
||||
|
||||
beforeEach(() => {
|
||||
enqueueSystemEventMock.mockReset();
|
||||
__resetDiscordChannelInfoCacheForTest();
|
||||
});
|
||||
|
||||
it("enqueues system event for edited DMs", async () => {
|
||||
const cfg = { channels: { discord: {} } } as OpenClawConfig;
|
||||
const listener = new DiscordMessageUpdateListener({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
runtime: { error: vi.fn() } as unknown as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "bot-1",
|
||||
guildEntries: undefined,
|
||||
logger: { error: vi.fn(), warn: vi.fn() } as unknown as ReturnType<
|
||||
typeof import("../../logging/subsystem.js").createSubsystemLogger
|
||||
>,
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupPolicy: "open",
|
||||
groupDmEnabled: false,
|
||||
groupDmChannels: undefined,
|
||||
allowBots: false,
|
||||
});
|
||||
|
||||
const message = {
|
||||
id: "msg-1",
|
||||
channelId: "dm-1",
|
||||
editedTimestamp: "2026-02-20T00:00:00.000Z",
|
||||
author: { id: "user-1", username: "Ada", discriminator: "0001", bot: false },
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn(async () => ({ type: ChannelType.DM })),
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
|
||||
await listener.handle(
|
||||
{
|
||||
channel_id: "dm-1",
|
||||
message,
|
||||
} as DiscordMessageUpdateEvent,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
"Discord message edited in DM.",
|
||||
expect.objectContaining({
|
||||
contextKey: "discord:message:edited:dm-1:msg-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips system event when guild allowlist blocks sender", async () => {
|
||||
const cfg = { channels: { discord: {} } } as OpenClawConfig;
|
||||
const listener = new DiscordMessageUpdateListener({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
runtime: { error: vi.fn() } as unknown as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "bot-1",
|
||||
guildEntries: {
|
||||
"guild-1": { users: ["user-allowed"] },
|
||||
},
|
||||
logger: { error: vi.fn(), warn: vi.fn() } as unknown as ReturnType<
|
||||
typeof import("../../logging/subsystem.js").createSubsystemLogger
|
||||
>,
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupPolicy: "open",
|
||||
groupDmEnabled: false,
|
||||
groupDmChannels: undefined,
|
||||
allowBots: false,
|
||||
});
|
||||
|
||||
const message = {
|
||||
id: "msg-2",
|
||||
channelId: "channel-1",
|
||||
editedTimestamp: "2026-02-20T00:00:00.000Z",
|
||||
author: { id: "user-blocked", username: "Ada", discriminator: "0001", bot: false },
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn(async () => ({ type: ChannelType.GuildText })),
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
|
||||
await listener.handle(
|
||||
{
|
||||
channel_id: "channel-1",
|
||||
guild_id: "guild-1",
|
||||
guild: { id: "guild-1", name: "Test Guild" },
|
||||
member: { roles: [] },
|
||||
message,
|
||||
} as DiscordMessageUpdateEvent,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,6 @@ import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordMessageUpdateListener,
|
||||
DiscordPresenceListener,
|
||||
DiscordReactionListener,
|
||||
DiscordReactionRemoveListener,
|
||||
@@ -606,24 +605,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
});
|
||||
|
||||
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordMessageUpdateListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
dmEnabled,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupPolicy,
|
||||
groupDmEnabled,
|
||||
groupDmChannels,
|
||||
allowBots: discordCfg.allowBots ?? false,
|
||||
}),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionListener({
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerDiscordComponentEntries } from "./components-registry.js";
|
||||
import { sendDiscordComponentMessage } from "./send.components.js";
|
||||
import { makeDiscordRest } from "./send.test-harness.js";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } })));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => loadConfigMock(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./components-registry.js", () => ({
|
||||
registerDiscordComponentEntries: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("sendDiscordComponentMessage", () => {
|
||||
const registerMock = vi.mocked(registerDiscordComponentEntries);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps DM channel targets to direct-session component entries", async () => {
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValueOnce({
|
||||
type: ChannelType.DM,
|
||||
recipients: [{ id: "user-1" }],
|
||||
});
|
||||
postMock.mockResolvedValueOnce({ id: "msg1", channel_id: "dm-1" });
|
||||
|
||||
await sendDiscordComponentMessage(
|
||||
"channel:dm-1",
|
||||
{
|
||||
blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }],
|
||||
},
|
||||
{
|
||||
rest,
|
||||
token: "t",
|
||||
sessionKey: "agent:main:discord:channel:dm-1",
|
||||
agentId: "main",
|
||||
},
|
||||
);
|
||||
|
||||
expect(registerMock).toHaveBeenCalledTimes(1);
|
||||
const args = registerMock.mock.calls[0]?.[0];
|
||||
expect(args?.entries[0]?.sessionKey).toBe("agent:main:main");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import type { APIChannel } from "discord-api-types/v10";
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { buildAgentSessionKey } from "../routing/resolve-route.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { registerDiscordComponentEntries } from "./components-registry.js";
|
||||
@@ -30,50 +29,6 @@ import type { DiscordSendResult } from "./send.types.js";
|
||||
|
||||
const DISCORD_FORUM_LIKE_TYPES = new Set<number>([ChannelType.GuildForum, ChannelType.GuildMedia]);
|
||||
|
||||
type DiscordRecipient = Awaited<ReturnType<typeof parseAndResolveRecipient>>;
|
||||
|
||||
function resolveDiscordDmRecipientId(channel?: APIChannel): string | undefined {
|
||||
if (!channel || channel.type !== ChannelType.DM) {
|
||||
return undefined;
|
||||
}
|
||||
const recipients = (channel as { recipients?: Array<{ id?: string }> }).recipients;
|
||||
const recipientId = recipients?.[0]?.id;
|
||||
if (typeof recipientId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = recipientId.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function resolveDiscordComponentSessionKey(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
accountId: string;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
recipient: DiscordRecipient;
|
||||
channel?: APIChannel;
|
||||
}): string | undefined {
|
||||
if (!params.sessionKey || !params.agentId) {
|
||||
return params.sessionKey;
|
||||
}
|
||||
if (params.recipient.kind !== "channel") {
|
||||
return params.sessionKey;
|
||||
}
|
||||
const recipientId = resolveDiscordDmRecipientId(params.channel);
|
||||
if (!recipientId) {
|
||||
return params.sessionKey;
|
||||
}
|
||||
// DM channel IDs should map back to the user session for component interactions.
|
||||
return buildAgentSessionKey({
|
||||
agentId: params.agentId,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
peer: { kind: "direct", id: recipientId },
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
});
|
||||
}
|
||||
|
||||
function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): string[] {
|
||||
const names: string[] = [];
|
||||
for (const block of spec.blocks ?? []) {
|
||||
@@ -108,10 +63,9 @@ export async function sendDiscordComponentMessage(
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
|
||||
let channel: APIChannel | undefined;
|
||||
let channelType: number | undefined;
|
||||
try {
|
||||
channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined;
|
||||
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined;
|
||||
channelType = channel?.type;
|
||||
} catch {
|
||||
channelType = undefined;
|
||||
@@ -121,18 +75,9 @@ export async function sendDiscordComponentMessage(
|
||||
throw new Error("Discord components are not supported in forum-style channels");
|
||||
}
|
||||
|
||||
const componentSessionKey = resolveDiscordComponentSessionKey({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
agentId: opts.agentId,
|
||||
sessionKey: opts.sessionKey,
|
||||
recipient,
|
||||
channel,
|
||||
});
|
||||
|
||||
const buildResult = buildDiscordComponentMessage({
|
||||
spec,
|
||||
sessionKey: componentSessionKey,
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
|
||||
@@ -24,9 +24,7 @@ async function withAudioFixture(
|
||||
await fs.writeFile(tmpPath, Buffer.from("RIFF"));
|
||||
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
|
||||
const media = normalizeMediaAttachments(ctx);
|
||||
const cache = createMediaAttachmentCache(media, {
|
||||
localPathRoots: [os.tmpdir()],
|
||||
});
|
||||
const cache = createMediaAttachmentCache(media);
|
||||
|
||||
try {
|
||||
await run({ ctx, media, cache });
|
||||
|
||||
@@ -17,9 +17,7 @@ describe("runCapability deepgram provider options", () => {
|
||||
await fs.writeFile(tmpPath, Buffer.from("RIFF"));
|
||||
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
|
||||
const media = normalizeMediaAttachments(ctx);
|
||||
const cache = createMediaAttachmentCache(media, {
|
||||
localPathRoots: [os.tmpdir()],
|
||||
});
|
||||
const cache = createMediaAttachmentCache(media);
|
||||
|
||||
let seenQuery: Record<string, string | number | boolean> | undefined;
|
||||
let seenBaseUrl: string | undefined;
|
||||
|
||||
@@ -453,140 +453,6 @@ export const registerTelegramHandlers = ({
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildTelegramEditSenderLabel = (msg: Message) => {
|
||||
const senderChat = msg.sender_chat;
|
||||
const senderName =
|
||||
[msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() ||
|
||||
msg.from?.username ||
|
||||
senderChat?.title ||
|
||||
senderChat?.username;
|
||||
const senderUsername = msg.from?.username ?? senderChat?.username;
|
||||
const senderUsernameLabel = senderUsername ? `@${senderUsername}` : undefined;
|
||||
let senderLabel = senderName;
|
||||
if (senderName && senderUsernameLabel) {
|
||||
senderLabel = `${senderName} (${senderUsernameLabel})`;
|
||||
} else if (!senderName && senderUsernameLabel) {
|
||||
senderLabel = senderUsernameLabel;
|
||||
}
|
||||
const senderId = msg.from?.id ?? senderChat?.id;
|
||||
if (!senderLabel && senderId != null) {
|
||||
senderLabel = `id:${senderId}`;
|
||||
}
|
||||
return senderLabel || "unknown";
|
||||
};
|
||||
|
||||
const handleTelegramEditedMessage = async (params: {
|
||||
ctx: TelegramUpdateKeyContext;
|
||||
msg: Message;
|
||||
requireConfiguredGroup: boolean;
|
||||
}) => {
|
||||
try {
|
||||
if (shouldSkipUpdate(params.ctx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = params.msg;
|
||||
if (msg.from?.is_bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = msg.chat.id;
|
||||
const isChannelPost = msg.chat.type === "channel";
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup" || isChannelPost;
|
||||
const isForum = msg.chat.is_forum === true;
|
||||
const messageThreadId = msg.message_thread_id;
|
||||
const senderId =
|
||||
msg.from?.id != null
|
||||
? String(msg.from.id)
|
||||
: msg.sender_chat?.id != null
|
||||
? String(msg.sender_chat.id)
|
||||
: String(chatId);
|
||||
const senderUsername = msg.from?.username ?? msg.sender_chat?.username ?? "";
|
||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||
chatId,
|
||||
accountId,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
groupAllowFrom,
|
||||
resolveTelegramGroupConfig,
|
||||
});
|
||||
const {
|
||||
resolvedThreadId,
|
||||
storeAllowFrom,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
effectiveGroupAllow,
|
||||
hasGroupAllowOverride,
|
||||
} = groupAllowContext;
|
||||
|
||||
if (params.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) {
|
||||
logVerbose(`Blocked telegram channel ${chatId} (channel disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldSkipGroupMessage({
|
||||
isGroup,
|
||||
chatId,
|
||||
chatTitle: msg.chat.title,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
effectiveGroupAllow,
|
||||
hasGroupAllowOverride,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isGroup) {
|
||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||
if (dmPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
const effectiveDmAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: telegramCfg.allowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
if (dmPolicy !== "open") {
|
||||
const allowed = isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername);
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
||||
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||
const route = resolveAgentRoute({
|
||||
cfg: loadConfig(),
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
peer: { kind: isGroup ? "group" : "direct", id: peerId },
|
||||
parentPeer,
|
||||
});
|
||||
const sessionKey = route.sessionKey;
|
||||
const senderLabel = buildTelegramEditSenderLabel(msg);
|
||||
const chatLabel = isGroup
|
||||
? msg.chat.title?.trim() || (isChannelPost ? "Telegram channel" : "Telegram group")
|
||||
: senderLabel !== "unknown"
|
||||
? `DM with ${senderLabel}`
|
||||
: "DM";
|
||||
const text = `Telegram message edited in ${chatLabel}.`;
|
||||
enqueueSystemEvent(text, {
|
||||
sessionKey,
|
||||
contextKey: `telegram:message:edited:${chatId}:${resolvedThreadId ?? "main"}:${
|
||||
msg.message_id
|
||||
}`,
|
||||
});
|
||||
logVerbose(`telegram: edit event enqueued: ${text}`);
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`telegram edit handler failed: ${String(err)}`));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle emoji reactions to messages.
|
||||
bot.on("message_reaction", async (ctx) => {
|
||||
try {
|
||||
@@ -678,35 +544,6 @@ export const registerTelegramHandlers = ({
|
||||
runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`));
|
||||
}
|
||||
});
|
||||
|
||||
bot.on("edited_message", async (ctx) => {
|
||||
const msg =
|
||||
(ctx as { editedMessage?: Message }).editedMessage ?? ctx.update?.edited_message ?? undefined;
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
await handleTelegramEditedMessage({
|
||||
ctx,
|
||||
msg,
|
||||
requireConfiguredGroup: false,
|
||||
});
|
||||
});
|
||||
|
||||
bot.on("edited_channel_post", async (ctx) => {
|
||||
const msg =
|
||||
(ctx as { editedChannelPost?: Message }).editedChannelPost ??
|
||||
ctx.update?.edited_channel_post ??
|
||||
undefined;
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
await handleTelegramEditedMessage({
|
||||
ctx,
|
||||
msg,
|
||||
requireConfiguredGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
const processInboundMessage = async (params: {
|
||||
ctx: TelegramContext;
|
||||
msg: Message;
|
||||
|
||||
@@ -746,43 +746,6 @@ describe("createTelegramBot", () => {
|
||||
expect(reactionHandler).toBeDefined();
|
||||
});
|
||||
|
||||
it("enqueues system event for edited messages", async () => {
|
||||
onSpy.mockReset();
|
||||
enqueueSystemEventSpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open" },
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("edited_message") as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
const editedMessage = {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_id: 88,
|
||||
from: { id: 9, first_name: "Ada", username: "ada_bot" },
|
||||
date: 1736380800,
|
||||
text: "edited",
|
||||
};
|
||||
|
||||
await handler({
|
||||
update: { update_id: 550, edited_message: editedMessage },
|
||||
editedMessage,
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledWith(
|
||||
"Telegram message edited in DM with Ada (@ada_bot).",
|
||||
expect.objectContaining({
|
||||
contextKey: expect.stringContaining("telegram:message:edited:1234:main:88"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("enqueues system event for reaction", async () => {
|
||||
onSpy.mockReset();
|
||||
enqueueSystemEventSpy.mockReset();
|
||||
|
||||
@@ -6,7 +6,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { sendVoiceMessageDiscord } from "../discord/send.js";
|
||||
import * as ssrf from "../infra/net/ssrf.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { optimizeImageToPng } from "../media/image-ops.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
@@ -51,9 +50,7 @@ async function createLargeTestJpeg(): Promise<{ buffer: Buffer; file: string }>
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-test-"),
|
||||
);
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-test-"));
|
||||
largeJpegBuffer = await sharp({
|
||||
create: {
|
||||
width: 400,
|
||||
@@ -337,9 +334,7 @@ describe("local media root guard", () => {
|
||||
});
|
||||
|
||||
it("allows local paths under an explicit root", async () => {
|
||||
const result = await loadWebMedia(tinyPngFile, 1024 * 1024, {
|
||||
localRoots: [resolvePreferredOpenClawTmpDir()],
|
||||
});
|
||||
const result = await loadWebMedia(tinyPngFile, 1024 * 1024, { localRoots: [os.tmpdir()] });
|
||||
expect(result.kind).toBe("image");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user