diff --git a/CHANGELOG.md b/CHANGELOG.md index cd987d1b852..543bb5bf2dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Telegram: support Mini App `web_app` buttons in generic message presentation payloads, allowing `openclaw message send --presentation` to render Telegram Web App inline buttons for private chats. (#81356) Thanks @jzakirov. - Scripts: add `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree` so high-capacity local worktrees can use independent heavy-check locks while shared locks remain the default. Fixes #80729. (#80734) Thanks @samzong. - Agents/subagents: deliver native `sessions_spawn` tasks in the child session's first visible `[Subagent Task]` message instead of hiding the task in the sub-agent system prompt, keeping delegation auditable without duplicating tokens. Fixes #78592. Thanks @bradestes and @stainlu. +- Messages/queue: make mid-turn prompts steer active runs by default via `/queue steer`, preserve `/queue followup` and `/queue collect` for users who want messages to queue by default, and make `/steer` continue as a normal prompt when steering is unavailable. (#77023) Thanks @fuller-stack-dev. - Voice Call/Telnyx: add realtime media-streaming call support for conversational voice calls. (#81024) Thanks @dynamite-bud. - Gateway/OpenAI HTTP: honor `max_completion_tokens` and `max_tokens` on inbound `/v1/chat/completions` requests so client-provided token caps reach the upstream provider via `streamParams.maxTokens`, with `max_completion_tokens` taking precedence when both are sent. Thanks @Lellansin. - Models/OpenAI CLI auth: make `openclaw models auth login --provider openai` start the ChatGPT/Codex account login by default, while `--method api-key` remains the explicit OpenAI API-key setup path. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 34e7d146f43..af3d170bff6 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -f95819d93e9bec5d059440ab54fb4ccb487425cb91d647c8688cd18ef1d4d848 config-baseline.json -3325af3a6292959bb38166e9136c638dce5d2093d2339076742890848088a972 config-baseline.core.json +bad30fbdd50ecdc6dd0e3dbbea0a1d7ed02a7e3e0cc30d7b1d4459832e4d1bd8 config-baseline.json +932ca6c43b47dc342b6c9999815e5f03c5ff46f6372034a4eb507c629a4e49b1 config-baseline.core.json ad1d3cb596115d66c21e93de95e229c14c585f0dd4799b4ae3cc29b84761adc6 config-baseline.channel.json 0dac8944a0d51ae96f97e3809907f8a04d08413434a1a1190240f7e13bb11c4d config-baseline.plugin.json diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 757b9d3d865..fa8474a4bfc 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -46,7 +46,7 @@ wired end-to-end. - Runs are serialized per session key (session lane) and optionally through a global lane. - This prevents tool/session races and keeps session history consistent. -- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system. +- Messaging channels can choose queue modes (steer/followup/collect/interrupt) that feed this lane system. See [Command Queue](/concepts/queue). - Transcript writes are also protected by a session write lock on the session file. The lock is process-aware and file-based, so it catches writers that bypass the in-process queue or come from diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index b0a7e996a9e..02f4327d82e 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -84,17 +84,15 @@ Legacy session folders from other tools are not read. ## Steering while streaming -When queue mode is `steer`, inbound messages are injected into the current run. -Queued steering is delivered **after the current assistant turn finishes -executing its tool calls**, before the next LLM call. Pi drains all pending -steering messages together for `steer`; legacy `queue` drains one message per -model boundary. Steering no longer skips remaining tool calls from the current -assistant message. +Inbound prompts that arrive mid-run are steered into the current run by default. +Steering is delivered **after the current assistant turn finishes executing its +tool calls**, before the next LLM call, and no longer skips remaining tool calls +from the current assistant message. -When queue mode is `followup` or `collect`, inbound messages are held until the -current turn ends, then a new agent turn starts with the queued payloads. See -[Queue](/concepts/queue) and [Steering queue](/concepts/queue-steering) for mode -and boundary behavior. +`/queue steer` is the default active-run behavior. `/queue followup` and +`/queue collect` make messages wait for a later turn instead of steering. +`/queue interrupt` aborts the active run instead. See [Queue](/concepts/queue) +and [Steering queue](/concepts/queue-steering) for queue and boundary behavior. Block streaming sends completed assistant blocks as soon as they finish; it is **off by default** (`agents.defaults.blockStreamingDefault: "off"`). diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 50b296a97fb..3427b8e957d 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -125,14 +125,14 @@ default) and per-channel overrides like `channels.slack.historyLimit` or ## Queueing and followups -If a run is already active, inbound messages can be queued, steered into the -current run, or collected for a followup turn. +If a run is already active, inbound messages are steered into the current run by +default. `messages.queue` selects whether active-run messages steer, queue for +later, collect into one later turn, or interrupt the active run. - Configure via `messages.queue` (and `messages.queue.byChannel`). -- Default mode is `steer`, with a 500ms followup debounce when steering falls - back to queued followup delivery. -- Modes: `steer`, `followup`, `collect`, `steer-backlog`, `interrupt`, and the - legacy one-at-a-time `queue` mode. +- Default mode is `steer`, with a 500ms debounce for Codex steering batches and + followup/collect queues. +- Modes: `steer`, `followup`, `collect`, and `interrupt`. Details: [Command queue](/concepts/queue) and [Steering queue](/concepts/queue-steering). diff --git a/docs/concepts/queue-steering.md b/docs/concepts/queue-steering.md index 1fba714527d..e659b1319c5 100644 --- a/docs/concepts/queue-steering.md +++ b/docs/concepts/queue-steering.md @@ -3,14 +3,15 @@ summary: "How active-run steering queues messages at runtime boundaries" read_when: - Explaining how steer behaves while an agent is using tools - Changing active-run queue behavior or runtime steering integration - - Comparing steer, queue, collect, and followup modes + - Comparing steering with followup, collect, and interrupt queue modes title: "Steering queue" --- -When a message arrives while a session run is already streaming, OpenClaw can -send that message into the active runtime instead of starting another run for -the same session. The public modes are runtime-neutral; Pi and the native Codex -app-server harness implement the delivery details differently. +When a normal prompt arrives while a session run is already streaming, OpenClaw +tries to send that prompt into the active runtime by default when the queue mode +is `steer`. No config entry and no queue directive are required for that default +behavior. Pi and the native Codex app-server harness implement the delivery +details differently. ## Runtime boundary @@ -27,44 +28,40 @@ This keeps tool results paired with the assistant message that requested them, then lets the next model call see the latest user input. The native Codex app-server harness exposes `turn/steer` instead of Pi's -internal steering queue. OpenClaw adapts the same modes there: - -- `steer` batches queued messages for the configured quiet window, then sends a - single `turn/steer` request with all collected user input in arrival order. -- `queue` keeps the legacy serialized shape by sending separate `turn/steer` - requests. -- `followup`, `collect`, `steer-backlog`, and `interrupt` stay OpenClaw-owned - queue behavior around the active Codex turn. +internal steering queue. OpenClaw batches queued prompts for the configured +quiet window, then sends a single `turn/steer` request with all collected user +input in arrival order. Codex review and manual compaction turns reject same-turn steering. When a -runtime cannot accept steering, OpenClaw falls back to the followup queue where -that mode allows it. +runtime cannot accept steering in `steer` mode, OpenClaw waits for the active +run to finish before starting the prompt. -This page explains queue-mode steering for normal inbound messages. For the -explicit `/steer ` command, see [Steer](/tools/steer). +This page explains queue-mode steering for normal inbound messages when the mode +is `steer`. If the mode is `followup` or `collect`, normal messages do not enter +this steering path; they wait until the active run finishes. For the explicit +`/steer ` command, see [Steer](/tools/steer). ## Modes -| Mode | Active-run behavior | Later followup behavior | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `steer` | Injects all queued steering messages together at the next runtime boundary. This is the default. | Falls back to followup only when steering is unavailable. | -| `queue` | Legacy one-at-a-time steering. Pi injects one queued message per model boundary; Codex sends separate `turn/steer` requests. | Falls back to followup only when steering is unavailable. | -| `steer-backlog` | Same active-run steering behavior as `steer`. | Also keeps the same message for a later followup turn. | -| `followup` | Does not steer the current run. | Runs queued messages later. | -| `collect` | Does not steer the current run. | Coalesces compatible queued messages into one later turn after the debounce window. | -| `interrupt` | Aborts the active run, then starts the newest message. | None. | +| Mode | Active-run behavior | Later behavior | +| ----------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| `steer` | Steers the prompt into the active runtime when it can. | Waits for the active run to finish if steering is unavailable. | +| `followup` | Does not steer. | Runs queued messages later after the active run ends. | +| `collect` | Does not steer. | Coalesces compatible queued messages into one later turn after the debounce window. | +| `interrupt` | Aborts the active run instead of steering it. | Starts the newest message after aborting. | ## Burst example If four users send messages while the agent is executing a tool call: -- `steer`: the active runtime receives all four messages in arrival order before - its next model decision. Pi drains them at the next model boundary; Codex - receives them as one batched `turn/steer`. -- `queue`: legacy serialized steering. Pi injects one queued message at a time; - Codex receives separate `turn/steer` requests. -- `collect`: OpenClaw waits until the active run ends, then creates a followup - turn with compatible queued messages after the debounce window. +- With default behavior, the active runtime receives all four messages in + arrival order before its next model decision. Pi drains them at the next model + boundary; Codex receives them as one batched `turn/steer`. +- With `/queue collect`, OpenClaw does not steer. It waits until the active run + ends, then creates a followup turn with compatible queued messages after the + debounce window. +- With `/queue interrupt`, OpenClaw aborts the active run and starts the newest + message instead of steering. ## Scope @@ -73,18 +70,17 @@ session, change the active run's tool policy, or split messages by sender. In multi-user channels, inbound prompts already include sender and route context, so the next model call can see who sent each message. -Use `collect` when you want OpenClaw to build a later followup turn that can -coalesce compatible messages and preserve followup queue drop policy. Use -`queue` only when you need the older one-at-a-time steering behavior. +Use `followup` or `collect` when you want messages to queue by default instead +of steering the active run. Use `interrupt` when the newest prompt should +replace the active run. ## Debounce -`messages.queue.debounceMs` applies to followup delivery, including `collect`, -`followup`, `steer-backlog`, and `steer` fallback when active-run steering is not -available. For Pi, active `steer` itself does not use the debounce timer because -Pi naturally batches messages until the next model boundary. For the native -Codex harness, OpenClaw uses the same debounce value as the quiet window before -sending the batched `turn/steer`. +`messages.queue.debounceMs` applies to queued `followup` and `collect` delivery. +In `steer` mode with the native Codex harness, it also sets the quiet window +before sending batched `turn/steer`. For Pi, active steering itself does not use +the debounce timer because Pi naturally batches messages until the next model +boundary. ## Related diff --git a/docs/concepts/queue.md b/docs/concepts/queue.md index 8e142162d04..b03688c8479 100644 --- a/docs/concepts/queue.md +++ b/docs/concepts/queue.md @@ -30,25 +30,20 @@ When unset, all inbound channel surfaces use: - `cap: 20` - `drop: "summarize"` -`steer` is the default because it keeps the active model turn responsive without -starting a second session run. It drains all steering messages that arrived -before the next model boundary. If the current run cannot accept steering, -OpenClaw falls back to a followup queue entry. +Same-turn steering is the default. A prompt that arrives mid-run is injected +into the active runtime when the run can accept steering, so no second session +run is started. If the active run cannot accept steering, OpenClaw waits for the +active run to finish before starting the prompt. ## Queue modes -Inbound messages can steer the current run, wait for a followup turn, or do both: +`/queue` controls what normal inbound messages do while a session already has +an active run: -- `steer`: queue steering messages into the active runtime. Pi delivers all pending steering messages **after the current assistant turn finishes executing its tool calls**, before the next LLM call; Codex app-server receives one batched `turn/steer`. If the run is not actively streaming or steering is unavailable, OpenClaw falls back to a followup queue entry. -- `queue` (legacy): old one-at-a-time steering. Pi delivers one queued steering message at each model boundary; Codex app-server receives separate `turn/steer` requests. Prefer `steer` unless you need the previous serialized behavior. -- `followup`: enqueue each message for a later agent turn after the current run ends. -- `collect`: coalesce queued messages into a **single** followup turn after the quiet window. If messages target different channels/threads, they drain individually to preserve routing. -- `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the same message for a followup turn. -- `interrupt` (legacy): abort the active run for that session, then run the newest message. - -Steer-backlog means you can get a followup response after the steered run, so -streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want -one response per inbound message. +- `steer`: inject messages into the active runtime. Pi delivers all pending steering messages **after the current assistant turn finishes executing its tool calls**, before the next LLM call; Codex app-server receives one batched `turn/steer`. If the run is not actively streaming or steering is unavailable, OpenClaw waits until the active run ends before starting the prompt. +- `followup`: do not steer. Enqueue each message for a later agent turn after the current run ends. +- `collect`: do not steer. Coalesce queued messages into a **single** followup turn after the quiet window. If messages target different channels/threads, they drain individually to preserve routing. +- `interrupt`: abort the active run for that session, then run the newest message. For runtime-specific timing and dependency behavior, see [Steering queue](/concepts/queue-steering). For the explicit `/steer ` @@ -72,9 +67,10 @@ Configure globally or per channel via `messages.queue`: ## Queue options -Options apply to `followup`, `collect`, and `steer-backlog` (and to `steer` or legacy `queue` when steering falls back to followup): +Options apply to queued delivery. `debounceMs` also sets the Codex steering +quiet window in `steer` mode: -- `debounceMs`: quiet window before draining queued followups. Bare numbers are milliseconds; units `ms`, `s`, `m`, `h`, and `d` are accepted by `/queue` options. +- `debounceMs`: quiet window before draining queued followups or collect batches; in Codex `steer` mode, quiet window before sending batched `turn/steer`. Bare numbers are milliseconds; units `ms`, `s`, `m`, `h`, and `d` are accepted by `/queue` options. - `cap`: max queued messages per session. Values below `1` are ignored. - `drop: "summarize"`: default. Drop the oldest queued entries as needed, keep compact summaries, and inject them as a synthetic followup prompt. - `drop: "old"`: drop the oldest queued entries as needed, without preserving summaries. @@ -99,7 +95,7 @@ keys. ## Per-session overrides -- Send `/queue ` as a standalone command to store the mode for the current session. +- Send `/queue ` as a standalone command to store the queue mode for the current session. - Options can be combined: `/queue collect debounce:0.5s cap:25 drop:summarize` - `/queue default` or `/queue reset` clears the session override. diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index f6d3d108b44..0c310ab5379 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1280,13 +1280,13 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden ackReactionScope: "group-mentions", // group-mentions | group-all | direct | all removeAckAfterReply: false, queue: { - mode: "steer", // steer | queue (legacy one-at-a-time) | followup | collect | steer-backlog | steer+backlog | interrupt + mode: "followup", // steer | followup | collect | interrupt debounceMs: 500, cap: 20, drop: "summarize", // old | new | summarize byChannel: { - whatsapp: "steer", - telegram: "steer", + whatsapp: "followup", + telegram: "followup", }, }, inbound: { diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index baa23bc4ef0..30bd65527ac 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -113,18 +113,18 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. visibleReplies: "message_tool", // normal final replies stay private in groups/channels }, queue: { - mode: "steer", + mode: "followup", debounceMs: 500, cap: 20, drop: "summarize", byChannel: { - whatsapp: "steer", - telegram: "steer", - discord: "steer", - slack: "steer", - signal: "steer", - imessage: "steer", - webchat: "steer", + whatsapp: "followup", + telegram: "followup", + discord: "collect", + slack: "collect", + signal: "followup", + imessage: "followup", + webchat: "followup", }, }, }, diff --git a/docs/help/faq.md b/docs/help/faq.md index b55a57f8c98..62d0b12a62f 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1941,16 +1941,14 @@ lives on the [Models FAQ](/help/faq-models). - Queue mode controls how new messages interact with an in-flight run. Use `/queue` to change modes: + Mid-run prompts are steered into the active run by default. Use `/queue` to choose active-run behavior: - - `steer` - queue all pending steering for the next model boundary in the current run - - `queue` - legacy one-at-a-time steering - - `followup` - run messages one at a time - - `collect` - batch messages and reply once - - `steer-backlog` - steer now, then process backlog + - `steer` - guide the active run at the next model boundary + - `followup` - queue messages and run them one at a time after the current run ends + - `collect` - queue compatible messages and reply once after the current run ends - `interrupt` - abort current run and start fresh - Default mode is `steer`. You can add options like `debounce:0.5s cap:25 drop:summarize` for followup modes. See [Command queue](/concepts/queue) and [Steering queue](/concepts/queue-steering). + Default mode is `steer`. You can add options like `debounce:0.5s cap:25 drop:summarize` for queued modes. See [Command queue](/concepts/queue) and [Steering queue](/concepts/queue-steering). diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index 810bd650911..309a6d065fe 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -150,13 +150,14 @@ requests fail closed. ## Queue steering Active-run queue steering maps onto Codex app-server `turn/steer`. With the -default `messages.queue.mode: "steer"`, OpenClaw batches queued chat messages -for the configured quiet window and sends them as one `turn/steer` request in -arrival order. Legacy `queue` mode sends separate `turn/steer` requests. +default `messages.queue.mode: "steer"`, OpenClaw batches steer-mode chat +messages for the configured quiet window and sends them as one `turn/steer` +request in arrival order. Codex review and manual compaction turns can reject same-turn steering. In that -case, OpenClaw uses the follow-up queue when the selected mode allows fallback. -See [Steering queue](/concepts/queue-steering). +case, OpenClaw waits for the active run to finish before starting the prompt. +Use `/queue followup` or `/queue collect` when messages should queue by default +instead of steering. See [Steering queue](/concepts/queue-steering). ## Codex feedback upload diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index fda4efe1e03..cd4e4956795 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -144,8 +144,8 @@ Current source-of-truth: - `/exec host= security= ask= node=` shows or sets exec defaults. - `/model [name|#|status]` shows or sets the model. - `/models [provider] [page] [limit=|size=|all]` lists configured/auth-available providers or models for a provider; add `all` to browse that provider's full catalog. `provider/*` entries in `agents.defaults.models` make `/model` and `/models` show discovered models only for those providers. - - `/queue ` manages queue behavior (`steer`, legacy `queue`, `followup`, `collect`, `steer-backlog`, `interrupt`) plus options like `debounce:0.5s cap:25 drop:summarize`; `/queue default` or `/queue reset` clears the session override. See [Command queue](/concepts/queue) and [Steering queue](/concepts/queue-steering). - - `/steer ` injects guidance into the active run for the current session, independent of `/queue` mode. It does not start a new run when the session is idle. Alias: `/tell`. See [Steer](/tools/steer). + - `/queue ` manages active-run queue behavior (`steer`, `followup`, `collect`, `interrupt`) plus options like `debounce:0.5s cap:25 drop:summarize`; `/queue default` or `/queue reset` clears the session override. Mid-run prompts steer by default without a queue directive. See [Command queue](/concepts/queue) and [Steering queue](/concepts/queue-steering). + - `/steer ` injects guidance into the active run for the current session, independent of `/queue` mode. If steering is unavailable or the session is idle, `` continues as a normal prompt. Alias: `/tell`. See [Steer](/tools/steer). diff --git a/docs/tools/steer.md b/docs/tools/steer.md index e7aa85ba2d5..766fae33b85 100644 --- a/docs/tools/steer.md +++ b/docs/tools/steer.md @@ -2,14 +2,16 @@ summary: "Steer an active run without changing queue mode" read_when: - Using /steer or /tell while an agent is already running - - Comparing /steer with /queue steer + - Comparing /steer with /queue modes - Deciding whether to steer the current run, a sub-agent, or an ACP session title: "Steer" sidebarTitle: "Steer" --- -`/steer` sends guidance to an already-active run. It is for "adjust this -run while it is still working" moments, not for starting a new turn. +`/steer` first tries to send guidance to an already-active run. It is for +"adjust this run while it is still working" moments. If the current runtime +cannot accept steering, OpenClaw sends the message as a normal prompt instead +of dropping it. ## Current session @@ -24,27 +26,31 @@ Behavior: - Targets only the current session's active run. - Works independently of the session's `/queue` mode. -- Does not start a new run when the session is idle. -- Replies with a warning when there is no active run to steer. +- Starts a normal turn with the same message when the session is idle or the + active run cannot accept steering. - Uses the active runtime's steering path, so the model sees the guidance at the next supported runtime boundary. ## Steer vs queue -`/queue steer` changes how normal inbound messages behave when they arrive -while a run is active. `/steer ` is an explicit command that tries to -inject that command's message into the active run at the next supported runtime -boundary, regardless of the stored `/queue` setting. +`/queue steer` makes normal inbound messages try to steer the active run when +they arrive while a run is active. `/steer ` is an explicit command +that tries to inject that command's message into the active run at the next +supported runtime boundary, regardless of the stored `/queue` setting. When +that injection is not available, the command prefix is stripped and `` +continues as a normal prompt. Use: - `/steer ` when you want to guide the active run right now. - `/queue steer` when you want future normal messages to steer active runs by default. -- `/queue collect` or `/queue followup` when new messages should wait for a - later turn instead of steering the active run. +- `/queue collect` or `/queue followup` when future normal messages should wait + for a later turn instead of steering the active run. +- `/queue interrupt` when the newest message should replace the active run + instead of steering it. -For queue modes and fallback behavior, see [Command queue](/concepts/queue) and +For queue modes and steering boundaries, see [Command queue](/concepts/queue) and [Steering queue](/concepts/queue-steering). ## Sub-agents diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index c4cf0c5917b..0e336e057b2 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -3173,6 +3173,67 @@ describe("runCodexAppServerAttempt", () => { await run; }); + it("resolves queued steering only after turn/steer is accepted", async () => { + const request = vi.fn(async () => ({ turnId: "turn-1" })); + const queue = __testing.createCodexSteeringQueue({ + client: { request } as never, + threadId: "thread-1", + turnId: "turn-1", + answerPendingUserInput: () => false, + signal: new AbortController().signal, + }); + + await expect(queue.queue("accepted", { debounceMs: 0 })).resolves.toBeUndefined(); + + expect(request).toHaveBeenCalledWith("turn/steer", { + threadId: "thread-1", + expectedTurnId: "turn-1", + input: [{ type: "text", text: "accepted", text_elements: [] }], + }); + }); + + it("rejects queued steering when turn/steer is rejected", async () => { + const request = vi.fn(async () => { + throw new Error("cannot steer a compact turn"); + }); + const queue = __testing.createCodexSteeringQueue({ + client: { request } as never, + threadId: "thread-1", + turnId: "turn-1", + answerPendingUserInput: () => false, + signal: new AbortController().signal, + }); + + await expect(queue.queue("rejected", { debounceMs: 0 })).rejects.toThrow( + "cannot steer a compact turn", + ); + + expect(request).toHaveBeenCalledWith("turn/steer", { + threadId: "thread-1", + expectedTurnId: "turn-1", + input: [{ type: "text", text: "rejected", text_elements: [] }], + }); + }); + + it("rejects queued steering when the run aborts before debounce flush", async () => { + const controller = new AbortController(); + const request = vi.fn(async () => ({ turnId: "turn-1" })); + const queue = __testing.createCodexSteeringQueue({ + client: { request } as never, + threadId: "thread-1", + turnId: "turn-1", + answerPendingUserInput: () => false, + signal: controller.signal, + }); + + const queued = queue.queue("aborted", { debounceMs: 0 }); + const rejected = expect(queued).rejects.toThrow("codex app-server steering queue aborted"); + controller.abort(); + + await rejected; + expect(request).not.toHaveBeenCalled(); + }); + it("flushes pending default queued steering during normal turn cleanup", async () => { const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(); @@ -3200,7 +3261,7 @@ describe("runCodexAppServerAttempt", () => { ]); }); - it("keeps legacy queue steering as separate turn/steer requests", async () => { + it("batches explicit all-mode steering before sending turn/steer", async () => { const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(); const run = runCodexAppServerAttempt( @@ -3208,12 +3269,8 @@ describe("runCodexAppServerAttempt", () => { ); await waitForMethod("turn/start"); - expect( - queueActiveRunMessageForTest("session-1", "first", { steeringMode: "one-at-a-time" }), - ).toBe(true); - expect( - queueActiveRunMessageForTest("session-1", "second", { steeringMode: "one-at-a-time" }), - ).toBe(true); + expect(queueActiveRunMessageForTest("session-1", "first", { steeringMode: "all" })).toBe(true); + expect(queueActiveRunMessageForTest("session-1", "second", { steeringMode: "all" })).toBe(true); await vi.waitFor( () => @@ -3223,15 +3280,10 @@ describe("runCodexAppServerAttempt", () => { params: { threadId: "thread-1", expectedTurnId: "turn-1", - input: [{ type: "text", text: "first", text_elements: [] }], - }, - }, - { - method: "turn/steer", - params: { - threadId: "thread-1", - expectedTurnId: "turn-1", - input: [{ type: "text", text: "second", text_elements: [] }], + input: [ + { type: "text", text: "first", text_elements: [] }, + { type: "text", text: "second", text_elements: [] }, + ], }, }, ]), diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index e8b625da4a8..774b683856f 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -214,7 +214,6 @@ function collectTerminalAssistantText(result: EmbeddedRunAttemptResult): string } type CodexSteeringQueueOptions = { - steeringMode?: "all" | "one-at-a-time"; debounceMs?: number; }; @@ -312,7 +311,12 @@ function createCodexSteeringQueue(params: { answerPendingUserInput: (text: string) => boolean; signal: AbortSignal; }) { - let batchedTexts: string[] = []; + type PendingSteerText = { + text: string; + resolve: () => void; + reject: (error: unknown) => void; + }; + let batchedTexts: PendingSteerText[] = []; let batchTimer: NodeJS.Timeout | undefined; let sendChain: Promise = Promise.resolve(); @@ -324,9 +328,12 @@ function createCodexSteeringQueue(params: { }; const sendTexts = async (texts: string[]) => { - if (texts.length === 0 || params.signal.aborted) { + if (texts.length === 0) { return; } + if (params.signal.aborted) { + throw new Error("codex app-server steering queue aborted"); + } await params.client.request("turn/steer", { threadId: params.threadId, expectedTurnId: params.turnId, @@ -335,19 +342,31 @@ function createCodexSteeringQueue(params: { }; const enqueueSend = (texts: string[]) => { - sendChain = sendChain - .then(() => sendTexts(texts)) - .catch((error: unknown) => { - embeddedAgentLog.debug("codex app-server queued steer failed", { error }); - }); - return sendChain; + const send = sendChain.then(() => sendTexts(texts)); + sendChain = send.catch((error: unknown) => { + embeddedAgentLog.debug("codex app-server queued steer failed", { error }); + }); + return send; }; const flushBatch = () => { clearBatchTimer(); - const texts = batchedTexts; + const items = batchedTexts; batchedTexts = []; - return enqueueSend(texts); + const send = enqueueSend(items.map((item) => item.text)); + void send.then( + () => { + for (const item of items) { + item.resolve(); + } + }, + (error: unknown) => { + for (const item of items) { + item.reject(error); + } + }, + ); + return send; }; return { @@ -355,25 +374,26 @@ function createCodexSteeringQueue(params: { if (params.answerPendingUserInput(text)) { return; } - if (options?.steeringMode === "one-at-a-time") { - await flushBatch(); - await enqueueSend([text]); - return; - } - batchedTexts.push(text); - clearBatchTimer(); - const debounceMs = normalizeCodexSteerDebounceMs(options?.debounceMs); - batchTimer = setTimeout(() => { - batchTimer = undefined; - void flushBatch(); - }, debounceMs); + return await new Promise((resolve, reject) => { + batchedTexts.push({ text, resolve, reject }); + clearBatchTimer(); + const debounceMs = normalizeCodexSteerDebounceMs(options?.debounceMs); + batchTimer = setTimeout(() => { + batchTimer = undefined; + void flushBatch().catch(() => undefined); + }, debounceMs); + }); }, async flushPending() { - await flushBatch(); + await flushBatch().catch(() => undefined); }, cancel() { clearBatchTimer(); + const items = batchedTexts; batchedTexts = []; + for (const item of items) { + item.reject(new Error("codex app-server steering queue cancelled")); + } }, }; } @@ -3102,6 +3122,7 @@ export const __testing = { CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS, CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS, CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS, + createCodexSteeringQueue, buildCodexNativeHookRelayId, filterCodexDynamicTools, buildDynamicTools, diff --git a/src/agents/announce-idempotency.ts b/src/agents/announce-idempotency.ts index 7aac7fbba71..0f232785cfe 100644 --- a/src/agents/announce-idempotency.ts +++ b/src/agents/announce-idempotency.ts @@ -10,16 +10,3 @@ export function buildAnnounceIdFromChildRun(params: AnnounceIdFromChildRunParams export function buildAnnounceIdempotencyKey(announceId: string): string { return `announce:${announceId}`; } - -export function resolveQueueAnnounceId(params: { - announceId?: string; - sessionKey: string; - enqueuedAt: number; -}): string { - const announceId = params.announceId?.trim(); - if (announceId) { - return announceId; - } - // Backward-compatible fallback for queue items that predate announceId. - return `legacy:${params.sessionKey}:${params.enqueuedAt}`; -} diff --git a/src/agents/pi-embedded-runner/run-state.ts b/src/agents/pi-embedded-runner/run-state.ts index 373cf3ddaec..4b1305bf2f8 100644 --- a/src/agents/pi-embedded-runner/run-state.ts +++ b/src/agents/pi-embedded-runner/run-state.ts @@ -14,7 +14,7 @@ export type EmbeddedPiQueueHandle = { }; export type EmbeddedPiQueueMessageOptions = { - steeringMode?: "all" | "one-at-a-time"; + steeringMode?: "all"; debounceMs?: number; }; diff --git a/src/agents/pi-embedded-runner/runs.test.ts b/src/agents/pi-embedded-runner/runs.test.ts index c7be417edc3..4989c54811f 100644 --- a/src/agents/pi-embedded-runner/runs.test.ts +++ b/src/agents/pi-embedded-runner/runs.test.ts @@ -10,6 +10,7 @@ import { isEmbeddedPiRunHandleActive, formatEmbeddedPiQueueFailureSummary, queueEmbeddedPiMessageWithOutcome, + queueEmbeddedPiMessageWithOutcomeAsync, requestEmbeddedRunModelSwitch, resolveActiveEmbeddedRunHandleSessionId, setActiveEmbeddedRun, @@ -77,11 +78,11 @@ describe("pi-embedded runner run registry", () => { expect( queueEmbeddedPiMessageWithOutcome("session-steer", "continue", { - steeringMode: "one-at-a-time", + steeringMode: "all", }).queued, ).toBe(true); - expect(queueMessage).toHaveBeenCalledWith("continue", { steeringMode: "one-at-a-time" }); + expect(queueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" }); }); it("defaults active embedded steering to all pending messages", () => { @@ -130,6 +131,28 @@ describe("pi-embedded runner run registry", () => { }); }); + it("returns runtime rejection details when async queue delivery fails", async () => { + setActiveEmbeddedRun("session-rejected", { + ...createRunHandle(), + queueMessage: async () => { + throw new Error("cannot steer a compact turn"); + }, + }); + + const outcome = await queueEmbeddedPiMessageWithOutcomeAsync("session-rejected", "continue"); + + expect(outcome).toEqual({ + queued: false, + sessionId: "session-rejected", + reason: "runtime_rejected", + gatewayHealth: "live", + errorMessage: "cannot steer a compact turn", + }); + expect(formatEmbeddedPiQueueFailureSummary(outcome)).toBe( + "queue_message_failed reason=runtime_rejected sessionId=session-rejected gatewayHealth=live error=cannot steer a compact turn", + ); + }); + it("force-clears an aborted run that does not drain", async () => { vi.useFakeTimers(); try { diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index 70470cc2b02..fbc2494e9fa 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -40,7 +40,11 @@ export { type EmbeddedRunModelSwitchRequest, } from "./run-state.js"; -export type EmbeddedPiQueueFailureReason = "no_active_run" | "not_streaming" | "compacting"; +export type EmbeddedPiQueueFailureReason = + | "no_active_run" + | "not_streaming" + | "compacting" + | "runtime_rejected"; export type EmbeddedPiQueueMessageOutcome = | { @@ -54,17 +58,30 @@ export type EmbeddedPiQueueMessageOutcome = sessionId: string; reason: EmbeddedPiQueueFailureReason; gatewayHealth: "live"; + errorMessage?: string; + }; + +type PreparedEmbeddedPiQueueMessage = + | { + kind: "complete"; + outcome: EmbeddedPiQueueMessageOutcome; + } + | { + kind: "embedded_run"; + handle: EmbeddedPiQueueHandle; }; function createQueueFailureOutcome( sessionId: string, reason: EmbeddedPiQueueFailureReason, + errorMessage?: string, ): EmbeddedPiQueueMessageOutcome { return { queued: false, sessionId, reason, gatewayHealth: "live", + ...(errorMessage ? { errorMessage } : {}), }; } @@ -74,9 +91,9 @@ export function formatEmbeddedPiQueueFailureSummary( if (outcome.queued) { return undefined; } - return `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=${outcome.gatewayHealth}`; + const errorPart = outcome.errorMessage ? ` error=${outcome.errorMessage}` : ""; + return `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=${outcome.gatewayHealth}${errorPart}`; } - function setActiveRunSessionKey(sessionKey: string | undefined, sessionId: string): void { const normalizedSessionKey = sessionKey?.trim(); if (!normalizedSessionKey) { @@ -101,7 +118,9 @@ function clearActiveRunSessionKeys(sessionId: string, sessionKey?: string): void } /** - * @deprecated Use queueEmbeddedPiMessageWithOutcome so callers preserve failure reasons. + * @deprecated Use queueEmbeddedPiMessageWithOutcomeAsync for delivery decisions. + * This boolean helper only reports immediate queue eligibility; it cannot surface + * async runtime rejection from the active run. */ export function queueEmbeddedPiMessage( sessionId: string, @@ -111,36 +130,28 @@ export function queueEmbeddedPiMessage( return queueEmbeddedPiMessageWithOutcome(sessionId, text, options).queued; } +/** + * @deprecated Prefer queueEmbeddedPiMessageWithOutcomeAsync when callers need to + * know whether steering was accepted. This sync helper is fire-and-forget after + * initial eligibility and only logs later runtime rejection. + */ export function queueEmbeddedPiMessageWithOutcome( sessionId: string, text: string, options?: EmbeddedPiQueueMessageOptions, ): EmbeddedPiQueueMessageOutcome { - const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); - if (!handle) { - const queuedReplyRunMessage = queueReplyRunMessage(sessionId, text); - if (queuedReplyRunMessage) { - logMessageQueued({ sessionId, source: "pi-embedded-runner" }); - return { - queued: true, - sessionId, - target: "reply_run", - gatewayHealth: "live", - }; - } - diag.debug(`queue message failed: sessionId=${sessionId} reason=no_active_run`); - return createQueueFailureOutcome(sessionId, "no_active_run"); - } - if (!handle.isStreaming()) { - diag.debug(`queue message failed: sessionId=${sessionId} reason=not_streaming`); - return createQueueFailureOutcome(sessionId, "not_streaming"); - } - if (handle.isCompacting()) { - diag.debug(`queue message failed: sessionId=${sessionId} reason=compacting`); - return createQueueFailureOutcome(sessionId, "compacting"); + const prepared = prepareEmbeddedPiQueueMessage(sessionId, text); + if (prepared.kind === "complete") { + return prepared.outcome; } logMessageQueued({ sessionId, source: "pi-embedded-runner" }); - void handle.queueMessage(text, options ?? { steeringMode: "all" }); + void prepared.handle + .queueMessage(text, options ?? { steeringMode: "all" }) + .catch((err: unknown) => { + diag.debug( + `queue message rejected after enqueue: sessionId=${sessionId} err=${formatQueueError(err)}`, + ); + }); return { queued: true, sessionId, @@ -149,6 +160,68 @@ export function queueEmbeddedPiMessageWithOutcome( }; } +function formatQueueError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export async function queueEmbeddedPiMessageWithOutcomeAsync( + sessionId: string, + text: string, + options?: EmbeddedPiQueueMessageOptions, +): Promise { + const prepared = prepareEmbeddedPiQueueMessage(sessionId, text); + if (prepared.kind === "complete") { + return prepared.outcome; + } + try { + await prepared.handle.queueMessage(text, options ?? { steeringMode: "all" }); + } catch (err) { + const errorMessage = formatQueueError(err); + diag.debug(`queue message rejected: sessionId=${sessionId} err=${errorMessage}`); + return createQueueFailureOutcome(sessionId, "runtime_rejected", errorMessage); + } + logMessageQueued({ sessionId, source: "pi-embedded-runner" }); + return { + queued: true, + sessionId, + target: "embedded_run", + gatewayHealth: "live", + }; +} + +function prepareEmbeddedPiQueueMessage( + sessionId: string, + text: string, +): PreparedEmbeddedPiQueueMessage { + const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); + if (!handle) { + const queuedReplyRunMessage = queueReplyRunMessage(sessionId, text); + if (queuedReplyRunMessage) { + logMessageQueued({ sessionId, source: "pi-embedded-runner" }); + return { + kind: "complete", + outcome: { + queued: true, + sessionId, + target: "reply_run", + gatewayHealth: "live", + }, + }; + } + diag.debug(`queue message failed: sessionId=${sessionId} reason=no_active_run`); + return { kind: "complete", outcome: createQueueFailureOutcome(sessionId, "no_active_run") }; + } + if (!handle.isStreaming()) { + diag.debug(`queue message failed: sessionId=${sessionId} reason=not_streaming`); + return { kind: "complete", outcome: createQueueFailureOutcome(sessionId, "not_streaming") }; + } + if (handle.isCompacting()) { + diag.debug(`queue message failed: sessionId=${sessionId} reason=compacting`); + return { kind: "complete", outcome: createQueueFailureOutcome(sessionId, "compacting") }; + } + return { kind: "embedded_run", handle }; +} + /** * Abort embedded PI runs. * diff --git a/src/agents/subagent-announce-delivery.runtime.ts b/src/agents/subagent-announce-delivery.runtime.ts index 928e17b6f75..608ef087286 100644 --- a/src/agents/subagent-announce-delivery.runtime.ts +++ b/src/agents/subagent-announce-delivery.runtime.ts @@ -5,11 +5,7 @@ export { resolveStorePath, } from "../config/sessions.js"; export { callGateway } from "../gateway/call.js"; -export { - isSteeringQueueMode, - resolvePiSteeringModeForQueueMode, - resolveQueueSettings, -} from "../auto-reply/reply/queue.js"; +export { resolveQueueSettings } from "../auto-reply/reply/queue.js"; export { resolveExternalBestEffortDeliveryTarget } from "../infra/outbound/best-effort-delivery.js"; export { sendMessage } from "../infra/outbound/message.js"; export { createBoundDeliveryRouter } from "../infra/outbound/bound-delivery-router.js"; @@ -18,6 +14,6 @@ export { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; export { formatEmbeddedPiQueueFailureSummary, isEmbeddedPiRunActive, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedPiMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, } from "./pi-embedded-runner/runs.js"; diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index a0d1b7b1dab..cffec9d9234 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -18,10 +18,8 @@ import { sendMessage as runtimeSendMessage, } from "./subagent-announce-delivery.runtime.js"; import { resolveAnnounceOrigin } from "./subagent-announce-origin.js"; -import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; afterEach(() => { - resetAnnounceQueuesForTests(); sessionBindingServiceTesting.resetSessionBindingAdaptersForTests(); __testing.setDepsForTest(); }); @@ -423,8 +421,10 @@ describe("resolveSubagentCompletionOrigin", () => { }); }); -describe("deliverSubagentAnnouncement queued delivery", () => { - async function deliverQueuedAnnouncement(params: { +describe("deliverSubagentAnnouncement active requester steering", () => { + async function deliverSteeredAnnouncement(params: { + mode?: "followup" | "collect" | "interrupt"; + queueEmbeddedPiMessageWithOutcome?: QueueEmbeddedPiMessageWithOutcome; requesterOrigin?: { channel?: string; to?: string; @@ -440,11 +440,13 @@ describe("deliverSubagentAnnouncement queued delivery", () => { sessionId: "paperclip-session", isActive: activityChecks++ === 0, }), + queueEmbeddedPiMessageWithOutcome: + params.queueEmbeddedPiMessageWithOutcome ?? createQueueOutcomeMock(true), getRuntimeConfig: () => ({ messages: { queue: { - mode: "followup", + mode: params.mode ?? "followup", debounceMs: 0, }, }, @@ -464,41 +466,29 @@ describe("deliverSubagentAnnouncement queued delivery", () => { expectRecordFields(result, { delivered: true, - path: "queued", + path: "steered", }); - await vi.waitFor(() => expect(callGateway).toHaveBeenCalledTimes(1)); return callGateway; } - it("keeps queued announces with no external route session-only", async () => { - const callGateway = await deliverQueuedAnnouncement({}); + it("steers active announces with no external route", async () => { + const callGateway = await deliverSteeredAnnouncement({}); - expectGatewayAgentParams(callGateway, { - sessionKey: "agent:eng:paperclip:issue:123", - deliver: false, - channel: undefined, - accountId: undefined, - to: undefined, - threadId: undefined, - }); + expect(callGateway).not.toHaveBeenCalled(); }); - it("keeps queued announces with channel-only origins session-only", async () => { - const callGateway = await deliverQueuedAnnouncement({ + it("steers active announces with channel-only origins", async () => { + const callGateway = await deliverSteeredAnnouncement({ requesterOrigin: { channel: "slack", }, }); - expectGatewayAgentParams(callGateway, { - deliver: false, - channel: undefined, - to: undefined, - }); + expect(callGateway).not.toHaveBeenCalled(); }); - it("keeps queued announces with internal origins session-only", async () => { - const callGateway = await deliverQueuedAnnouncement({ + it("steers active announces with internal origins", async () => { + const callGateway = await deliverSteeredAnnouncement({ requesterOrigin: { channel: "webchat", to: "internal:room", @@ -507,17 +497,11 @@ describe("deliverSubagentAnnouncement queued delivery", () => { }, }); - expectGatewayAgentParams(callGateway, { - deliver: false, - channel: undefined, - accountId: undefined, - to: undefined, - threadId: undefined, - }); + expect(callGateway).not.toHaveBeenCalled(); }); - it("preserves queued external route fields when channel and target are present", async () => { - const callGateway = await deliverQueuedAnnouncement({ + it("steers active announces with external route fields", async () => { + const callGateway = await deliverSteeredAnnouncement({ requesterOrigin: { channel: "slack", to: "channel:C123", @@ -526,13 +510,70 @@ describe("deliverSubagentAnnouncement queued delivery", () => { }, }); - expectGatewayAgentParams(callGateway, { - deliver: true, - channel: "slack", - accountId: "acct-1", - to: "channel:C123", - threadId: "171.222", + expect(callGateway).not.toHaveBeenCalled(); + }); + + it.each(["followup", "collect", "interrupt"] as const)( + "steers active requester announces even in %s mode", + async (mode) => { + const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + await deliverSteeredAnnouncement({ + mode, + queueEmbeddedPiMessageWithOutcome, + requesterOrigin: { + channel: "slack", + to: "channel:C123", + accountId: "acct-1", + }, + }); + + expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledOnce(); + }, + ); + + it("does not report delivery when active requester steering is rejected", async () => { + const queueEmbeddedPiMessageWithOutcome = vi.fn(async (sessionId: string) => ({ + queued: false as const, + sessionId, + reason: "runtime_rejected" as const, + gatewayHealth: "live" as const, + errorMessage: "cannot steer a compact turn", + })); + const callGateway = createGatewayMock(); + __testing.setDepsForTest({ + callGateway, + getRequesterSessionActivity: () => ({ + sessionId: "paperclip-session", + isActive: true, + }), + queueEmbeddedPiMessageWithOutcome, + getRuntimeConfig: () => + ({ + messages: { + queue: { + mode: "steer", + debounceMs: 0, + }, + }, + }) as never, }); + + const result = await deliverSubagentAnnouncement({ + requesterSessionKey: "agent:eng:paperclip:issue:123", + targetRequesterSessionKey: "agent:eng:paperclip:issue:123", + triggerMessage: "child done", + steerMessage: "child done", + requesterIsSubagent: false, + expectsCompletionMessage: false, + directIdempotencyKey: "announce-rejected-steer", + }); + + expectRecordFields(result, { + delivered: false, + path: "none", + phases: [{ phase: "steer-primary", delivered: false, path: "none", error: undefined }], + }); + expect(callGateway).not.toHaveBeenCalled(); }); }); @@ -976,7 +1017,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { expect(sendMessage).not.toHaveBeenCalled(); }); - it("queues when an active Telegram requester cannot be woken directly", async () => { + it("does not queue when an active Telegram requester cannot be woken directly", async () => { const callGateway = createGatewayMock(); const sendMessage = createSendMessageMock(); const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(false); @@ -1002,8 +1043,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }); expectRecordFields(result, { - delivered: true, - path: "queued", + delivered: false, + path: "direct", phases: [ { phase: "direct-primary", @@ -1013,14 +1054,25 @@ describe("deliverSubagentAnnouncement completion delivery", () => { "active requester session could not be woken: queue_message_failed reason=not_streaming sessionId=requester-session-telegram gatewayHealth=live", }, { - phase: "queue-fallback", - delivered: true, - path: "queued", + phase: "steer-fallback", + delivered: false, + path: "none", error: undefined, }, ], }); - expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledWith( + expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(2); + expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenNthCalledWith( + 1, + "requester-session-telegram", + "child done", + { + steeringMode: "all", + debounceMs: 500, + }, + ); + expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenNthCalledWith( + 2, "requester-session-telegram", "child done", { diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 3f6bee9c457..c9444702287 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -19,7 +19,6 @@ import { isInternalMessageChannel, normalizeMessageChannel, } from "../utils/message-channel.js"; -import { buildAnnounceIdempotencyKey, resolveQueueAnnounceId } from "./announce-idempotency.js"; import type { AgentInternalEvent } from "./internal-events.js"; import { getAgentCommandDeliveryFailure, @@ -28,6 +27,7 @@ import { hasVisibleAgentPayload, } from "./pi-embedded-runner/delivery-evidence.js"; import type { EmbeddedPiQueueMessageOptions } from "./pi-embedded-runner/run-state.js"; +import type { EmbeddedPiQueueMessageOutcome } from "./pi-embedded-runner/runs.js"; import { callGateway, createBoundDeliveryRouter, @@ -35,10 +35,8 @@ import { isEmbeddedPiRunActive, getRuntimeConfig, formatEmbeddedPiQueueFailureSummary, - isSteeringQueueMode, loadSessionStore, - queueEmbeddedPiMessageWithOutcome, - resolvePiSteeringModeForQueueMode, + queueEmbeddedPiMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, resolveAgentIdFromSessionKey, resolveConversationIdFromTargets, @@ -50,8 +48,7 @@ import { runSubagentAnnounceDispatch, type SubagentAnnounceDeliveryResult, } from "./subagent-announce-dispatch.js"; -import { resolveAnnounceOrigin, type DeliveryContext } from "./subagent-announce-origin.js"; -import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; +import type { DeliveryContext } from "./subagent-announce-origin.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { resolveRequesterStoreKey } from "./subagent-requester-store-key.js"; import type { SpawnSubagentMode } from "./subagent-spawn.types.js"; @@ -67,7 +64,11 @@ type SubagentAnnounceDeliveryDeps = { sessionId?: string; isActive: boolean; }; - queueEmbeddedPiMessageWithOutcome: typeof queueEmbeddedPiMessageWithOutcome; + queueEmbeddedPiMessageWithOutcome: ( + sessionId: string, + text: string, + options?: EmbeddedPiQueueMessageOptions, + ) => EmbeddedPiQueueMessageOutcome | Promise; }; const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = { @@ -82,23 +83,27 @@ const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = { isActive: Boolean(sessionId && isEmbeddedPiRunActive(sessionId)), }; }, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedPiMessageWithOutcome: queueEmbeddedPiMessageWithOutcomeAsync, }; let subagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = defaultSubagentAnnounceDeliveryDeps; -function resolveQueueEmbeddedPiMessageOutcome( +async function resolveQueueEmbeddedPiMessageOutcome( sessionId: string, text: string, options?: EmbeddedPiQueueMessageOptions, -): ReturnType { - return subagentAnnounceDeliveryDeps.queueEmbeddedPiMessageWithOutcome(sessionId, text, options); +): Promise { + return await subagentAnnounceDeliveryDeps.queueEmbeddedPiMessageWithOutcome( + sessionId, + text, + options, + ); } function formatQueueWakeFailureError( fallback: string, - outcome: ReturnType, + outcome: EmbeddedPiQueueMessageOutcome, ): string { const summary = formatEmbeddedPiQueueFailureSummary(outcome); return summary ? `${fallback}: ${summary}` : fallback; @@ -402,53 +407,6 @@ export async function resolveSubagentCompletionOrigin(params: { } } -async function sendAnnounce(item: AnnounceQueueItem) { - const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); - const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); - const requesterIsSubagent = isInternalAnnounceRequesterSession(item.sessionKey); - const origin = item.origin; - const threadId = - origin?.threadId != null && origin.threadId !== "" - ? stringifyRouteThreadId(origin.threadId) - : undefined; - const deliveryTarget = !requesterIsSubagent - ? resolveExternalBestEffortDeliveryTarget({ - channel: origin?.channel, - to: origin?.to, - accountId: origin?.accountId, - threadId, - }) - : { deliver: false }; - const idempotencyKey = buildAnnounceIdempotencyKey( - resolveQueueAnnounceId({ - announceId: item.announceId, - sessionKey: item.sessionKey, - enqueuedAt: item.enqueuedAt, - }), - ); - await subagentAnnounceDeliveryDeps.callGateway({ - method: "agent", - params: { - sessionKey: item.sessionKey, - message: item.prompt, - channel: deliveryTarget.deliver ? deliveryTarget.channel : undefined, - accountId: deliveryTarget.deliver ? deliveryTarget.accountId : undefined, - to: deliveryTarget.deliver ? deliveryTarget.to : undefined, - threadId: deliveryTarget.deliver ? deliveryTarget.threadId : undefined, - deliver: deliveryTarget.deliver, - internalEvents: item.internalEvents, - inputProvenance: { - kind: "inter_session", - sourceSessionKey: item.sourceSessionKey, - sourceChannel: item.sourceChannel ?? INTERNAL_MESSAGE_CHANNEL, - sourceTool: item.sourceTool ?? "subagent_announce", - }, - idempotencyKey, - }, - timeoutMs: announceTimeoutMs, - }); -} - export function loadRequesterSessionEntry(requesterSessionKey: string) { const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); const canonicalKey = resolveRequesterStoreKey(cfg, requesterSessionKey); @@ -467,27 +425,11 @@ export function loadSessionEntryByKey(sessionKey: string) { return store[sessionKey]; } -function buildAnnounceQueueKey(sessionKey: string, origin?: DeliveryContext): string { - const accountId = normalizeAccountId(origin?.accountId); - if (!accountId) { - return sessionKey; - } - return `${sessionKey}:acct:${accountId}`; -} - -async function maybeQueueSubagentAnnounce(params: { +async function maybeSteerSubagentAnnounce(params: { requesterSessionKey: string; - announceId?: string; - triggerMessage: string; steerMessage: string; - summaryLine?: string; - requesterOrigin?: DeliveryContext; - sourceSessionKey?: string; - sourceChannel?: string; - sourceTool?: string; - internalEvents?: AgentInternalEvent[]; signal?: AbortSignal; -}): Promise<"steered" | "queued" | "none" | "dropped"> { +}): Promise<"steered" | "none" | "dropped"> { if (params.signal?.aborted) { return "none"; } @@ -504,49 +446,17 @@ async function maybeQueueSubagentAnnounce(params: { sessionEntry: entry, }); - const shouldSteer = isSteeringQueueMode(queueSettings.mode); - if (shouldSteer) { - const queueOutcome = resolveQueueEmbeddedPiMessageOutcome(sessionId, params.steerMessage, { - steeringMode: resolvePiSteeringModeForQueueMode(queueSettings.mode), - ...(queueSettings.debounceMs !== undefined ? { debounceMs: queueSettings.debounceMs } : {}), - }); - if (queueOutcome.queued) { - return "steered"; - } + // Subagent announcements are internal handoffs into an active requester turn. + // Queue modes such as followup/collect apply to user prompts, not this path. + const queueOutcome = await resolveQueueEmbeddedPiMessageOutcome(sessionId, params.steerMessage, { + steeringMode: "all", + ...(queueSettings.debounceMs !== undefined ? { debounceMs: queueSettings.debounceMs } : {}), + }); + if (queueOutcome.queued) { + return "steered"; } - const shouldFollowup = - queueSettings.mode === "followup" || - queueSettings.mode === "collect" || - queueSettings.mode === "steer-backlog" || - queueSettings.mode === "interrupt"; - if ( - isActive && - (shouldFollowup || queueSettings.mode === "steer" || queueSettings.mode === "queue") - ) { - const origin = resolveAnnounceOrigin(entry, params.requesterOrigin); - const didQueue = enqueueAnnounce({ - key: buildAnnounceQueueKey(canonicalKey, origin), - item: { - announceId: params.announceId, - prompt: params.triggerMessage, - summaryLine: params.summaryLine, - internalEvents: params.internalEvents, - enqueuedAt: Date.now(), - sessionKey: canonicalKey, - origin, - sourceSessionKey: params.sourceSessionKey, - sourceChannel: params.sourceChannel, - sourceTool: params.sourceTool, - }, - settings: queueSettings, - send: sendAnnounce, - shouldDefer: (item) => resolveRequesterSessionActivity(item.sessionKey).isActive, - }); - return didQueue ? "queued" : "dropped"; - } - - return "none"; + return isActive ? "dropped" : "none"; } function hasVisibleGatewayAgentPayload(response: unknown): boolean { @@ -692,7 +602,7 @@ async function sendSubagentAnnounceDirectly(params: { sessionEntry: requesterEntry, }); if (params.expectsCompletionMessage && requesterActivity.sessionId) { - const wakeOutcome = resolveQueueEmbeddedPiMessageOutcome( + const wakeOutcome = await resolveQueueEmbeddedPiMessageOutcome( requesterActivity.sessionId, params.triggerMessage, { @@ -710,7 +620,7 @@ async function sendSubagentAnnounceDirectly(params: { } if (requesterActivity.isActive) { // Active requester sessions should receive completion data through their - // running agent turn. If wake fails, let the dispatch layer queue/retry; + // running agent turn. If wake fails, let the dispatch layer steer/retry; // do not bypass the requester agent with raw child output. return { delivered: false, @@ -777,7 +687,7 @@ async function sendSubagentAnnounceDirectly(params: { throw err; } // The requester-agent handoff is the delivery contract for background - // completions. A failed handoff should retry/queue/fail visibly instead + // completions. A failed handoff should retry/fail visibly instead // of sending the child result directly to the external channel. throw err; } @@ -859,18 +769,10 @@ export async function deliverSubagentAnnouncement(params: { return await runSubagentAnnounceDispatch({ expectsCompletionMessage: params.expectsCompletionMessage, signal: params.signal, - queue: async () => - await maybeQueueSubagentAnnounce({ + steer: async () => + await maybeSteerSubagentAnnounce({ requesterSessionKey: params.requesterSessionKey, - announceId: params.announceId, - triggerMessage: params.triggerMessage, steerMessage: params.steerMessage, - summaryLine: params.summaryLine, - requesterOrigin: params.requesterOrigin, - sourceSessionKey: params.sourceSessionKey, - sourceChannel: params.sourceChannel, - sourceTool: params.sourceTool, - internalEvents: params.internalEvents, signal: params.signal, }), direct: async () => diff --git a/src/agents/subagent-announce-dispatch.test.ts b/src/agents/subagent-announce-dispatch.test.ts index d5e9daf2887..a02675248a0 100644 --- a/src/agents/subagent-announce-dispatch.test.ts +++ b/src/agents/subagent-announce-dispatch.test.ts @@ -1,26 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { - mapQueueOutcomeToDeliveryResult, + mapSteerOutcomeToDeliveryResult, runSubagentAnnounceDispatch, } from "./subagent-announce-dispatch.js"; -describe("mapQueueOutcomeToDeliveryResult", () => { +describe("mapSteerOutcomeToDeliveryResult", () => { it("maps steered to delivered", () => { - expect(mapQueueOutcomeToDeliveryResult("steered")).toEqual({ + expect(mapSteerOutcomeToDeliveryResult("steered")).toEqual({ delivered: true, path: "steered", }); }); - it("maps queued to delivered", () => { - expect(mapQueueOutcomeToDeliveryResult("queued")).toEqual({ - delivered: true, - path: "queued", - }); - }); - it("maps none to not-delivered", () => { - expect(mapQueueOutcomeToDeliveryResult("none")).toEqual({ + expect(mapSteerOutcomeToDeliveryResult("none")).toEqual({ delivered: false, path: "none", }); @@ -29,66 +22,66 @@ describe("mapQueueOutcomeToDeliveryResult", () => { describe("runSubagentAnnounceDispatch", () => { async function runNonCompletionDispatch(params: { - queueOutcome: "none" | "queued" | "steered"; + steerOutcome: "none" | "steered"; directDelivered?: boolean; }) { - const queue = vi.fn(async () => params.queueOutcome); + const steer = vi.fn(async () => params.steerOutcome); const direct = vi.fn(async () => ({ delivered: params.directDelivered ?? true, path: "direct" as const, })); const result = await runSubagentAnnounceDispatch({ expectsCompletionMessage: false, - queue, + steer, direct, }); - return { queue, direct, result }; + return { steer, direct, result }; } - it("uses queue-first ordering for non-completion mode", async () => { - const { queue, direct, result } = await runNonCompletionDispatch({ queueOutcome: "none" }); + it("uses steer-first ordering for non-completion mode", async () => { + const { steer, direct, result } = await runNonCompletionDispatch({ steerOutcome: "none" }); - expect(queue).toHaveBeenCalledTimes(1); + expect(steer).toHaveBeenCalledTimes(1); expect(direct).toHaveBeenCalledTimes(1); expect(result.delivered).toBe(true); expect(result.path).toBe("direct"); expect(result.phases).toEqual([ - { phase: "queue-primary", delivered: false, path: "none", error: undefined }, + { phase: "steer-primary", delivered: false, path: "none", error: undefined }, { phase: "direct-primary", delivered: true, path: "direct", error: undefined }, ]); }); - it("short-circuits direct send when non-completion queue delivers", async () => { - const { queue, direct, result } = await runNonCompletionDispatch({ queueOutcome: "queued" }); + it("short-circuits direct send when non-completion steering delivers", async () => { + const { steer, direct, result } = await runNonCompletionDispatch({ steerOutcome: "steered" }); - expect(queue).toHaveBeenCalledTimes(1); + expect(steer).toHaveBeenCalledTimes(1); expect(direct).not.toHaveBeenCalled(); - expect(result.path).toBe("queued"); + expect(result.path).toBe("steered"); expect(result.phases).toEqual([ - { phase: "queue-primary", delivered: true, path: "queued", error: undefined }, + { phase: "steer-primary", delivered: true, path: "steered", error: undefined }, ]); }); it("uses direct-first ordering for completion mode", async () => { - const queue = vi.fn(async () => "queued" as const); + const steer = vi.fn(async () => "steered" as const); const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); const result = await runSubagentAnnounceDispatch({ expectsCompletionMessage: true, - queue, + steer, direct, }); expect(direct).toHaveBeenCalledTimes(1); - expect(queue).not.toHaveBeenCalled(); + expect(steer).not.toHaveBeenCalled(); expect(result.path).toBe("direct"); expect(result.phases).toEqual([ { phase: "direct-primary", delivered: true, path: "direct", error: undefined }, ]); }); - it("falls back to queue when completion direct send fails", async () => { - const queue = vi.fn(async () => "steered" as const); + it("falls back to steering when completion direct send fails", async () => { + const steer = vi.fn(async () => "steered" as const); const direct = vi.fn(async () => ({ delivered: false, path: "direct" as const, @@ -97,21 +90,21 @@ describe("runSubagentAnnounceDispatch", () => { const result = await runSubagentAnnounceDispatch({ expectsCompletionMessage: true, - queue, + steer, direct, }); expect(direct).toHaveBeenCalledTimes(1); - expect(queue).toHaveBeenCalledTimes(1); + expect(steer).toHaveBeenCalledTimes(1); expect(result.path).toBe("steered"); expect(result.phases).toEqual([ { phase: "direct-primary", delivered: false, path: "direct", error: "network" }, - { phase: "queue-fallback", delivered: true, path: "steered", error: undefined }, + { phase: "steer-fallback", delivered: true, path: "steered", error: undefined }, ]); }); - it("returns direct failure when completion fallback queue cannot deliver", async () => { - const queue = vi.fn(async () => "none" as const); + it("returns direct failure when completion fallback steering cannot deliver", async () => { + const steer = vi.fn(async () => "none" as const); const direct = vi.fn(async () => ({ delivered: false, path: "direct" as const, @@ -120,7 +113,7 @@ describe("runSubagentAnnounceDispatch", () => { const result = await runSubagentAnnounceDispatch({ expectsCompletionMessage: true, - queue, + steer, direct, }); @@ -129,32 +122,32 @@ describe("runSubagentAnnounceDispatch", () => { expect(result.error).toBe("failed"); expect(result.phases).toEqual([ { phase: "direct-primary", delivered: false, path: "direct", error: "failed" }, - { phase: "queue-fallback", delivered: false, path: "none", error: undefined }, + { phase: "steer-fallback", delivered: false, path: "none", error: undefined }, ]); }); - it("does not fall through to direct delivery when non-completion queue drops the new item", async () => { - const queue = vi.fn(async () => "dropped" as const); + it("does not fall through to direct delivery when non-completion steering drops the new item", async () => { + const steer = vi.fn(async () => "dropped" as const); const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); const result = await runSubagentAnnounceDispatch({ expectsCompletionMessage: false, - queue, + steer, direct, }); - expect(queue).toHaveBeenCalledTimes(1); + expect(steer).toHaveBeenCalledTimes(1); expect(direct).not.toHaveBeenCalled(); expect(result).toEqual({ delivered: false, path: "none", - phases: [{ phase: "queue-primary", delivered: false, path: "none", error: undefined }], + phases: [{ phase: "steer-primary", delivered: false, path: "none", error: undefined }], }); }); - it("preserves direct failure when completion dispatch aborts before fallback queue", async () => { + it("preserves direct failure when completion dispatch aborts before fallback steering", async () => { const controller = new AbortController(); - const queue = vi.fn(async () => "queued" as const); + const steer = vi.fn(async () => "steered" as const); const direct = vi.fn(async () => { controller.abort(); return { @@ -167,12 +160,12 @@ describe("runSubagentAnnounceDispatch", () => { const result = await runSubagentAnnounceDispatch({ expectsCompletionMessage: true, signal: controller.signal, - queue, + steer, direct, }); expect(direct).toHaveBeenCalledTimes(1); - expect(queue).not.toHaveBeenCalled(); + expect(steer).not.toHaveBeenCalled(); expect(result.delivered).toBe(false); expect(result.path).toBe("direct"); expect(result.error).toBe("direct failed before abort"); @@ -187,7 +180,7 @@ describe("runSubagentAnnounceDispatch", () => { }); it("returns none immediately when signal is already aborted", async () => { - const queue = vi.fn(async () => "none" as const); + const steer = vi.fn(async () => "none" as const); const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); const controller = new AbortController(); controller.abort(); @@ -195,11 +188,11 @@ describe("runSubagentAnnounceDispatch", () => { const result = await runSubagentAnnounceDispatch({ expectsCompletionMessage: true, signal: controller.signal, - queue, + steer, direct, }); - expect(queue).not.toHaveBeenCalled(); + expect(steer).not.toHaveBeenCalled(); expect(direct).not.toHaveBeenCalled(); expect(result).toEqual({ delivered: false, diff --git a/src/agents/subagent-announce-dispatch.ts b/src/agents/subagent-announce-dispatch.ts index 52404d5bd0c..88efbc528d8 100644 --- a/src/agents/subagent-announce-dispatch.ts +++ b/src/agents/subagent-announce-dispatch.ts @@ -1,6 +1,6 @@ -type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none"; +type SubagentDeliveryPath = "steered" | "direct" | "none"; -type SubagentAnnounceQueueOutcome = "steered" | "queued" | "none" | "dropped"; +type SubagentAnnounceSteerOutcome = "steered" | "none" | "dropped"; export type SubagentAnnounceDeliveryResult = { delivered: boolean; @@ -9,7 +9,7 @@ export type SubagentAnnounceDeliveryResult = { phases?: SubagentAnnounceDispatchPhaseResult[]; }; -type SubagentAnnounceDispatchPhase = "queue-primary" | "direct-primary" | "queue-fallback"; +type SubagentAnnounceDispatchPhase = "steer-primary" | "direct-primary" | "steer-fallback"; type SubagentAnnounceDispatchPhaseResult = { phase: SubagentAnnounceDispatchPhase; @@ -18,8 +18,8 @@ type SubagentAnnounceDispatchPhaseResult = { error?: string; }; -export function mapQueueOutcomeToDeliveryResult( - outcome: SubagentAnnounceQueueOutcome, +export function mapSteerOutcomeToDeliveryResult( + outcome: SubagentAnnounceSteerOutcome, ): SubagentAnnounceDeliveryResult { if (outcome === "steered") { return { @@ -27,12 +27,6 @@ export function mapQueueOutcomeToDeliveryResult( path: "steered", }; } - if (outcome === "queued") { - return { - delivered: true, - path: "queued", - }; - } return { delivered: false, path: "none", @@ -42,7 +36,7 @@ export function mapQueueOutcomeToDeliveryResult( export async function runSubagentAnnounceDispatch(params: { expectsCompletionMessage: boolean; signal?: AbortSignal; - queue: () => Promise; + steer: () => Promise; direct: () => Promise; }): Promise { const phases: SubagentAnnounceDispatchPhaseResult[] = []; @@ -70,14 +64,14 @@ export async function runSubagentAnnounceDispatch(params: { } if (!params.expectsCompletionMessage) { - const primaryQueueOutcome = await params.queue(); - const primaryQueue = mapQueueOutcomeToDeliveryResult(primaryQueueOutcome); - appendPhase("queue-primary", primaryQueue); - if (primaryQueue.delivered) { - return withPhases(primaryQueue); + const primarySteerOutcome = await params.steer(); + const primarySteer = mapSteerOutcomeToDeliveryResult(primarySteerOutcome); + appendPhase("steer-primary", primarySteer); + if (primarySteer.delivered) { + return withPhases(primarySteer); } - if (primaryQueueOutcome === "dropped") { - return withPhases(primaryQueue); + if (primarySteerOutcome === "dropped") { + return withPhases(primarySteer); } const primaryDirect = await params.direct(); @@ -95,11 +89,11 @@ export async function runSubagentAnnounceDispatch(params: { return withPhases(primaryDirect); } - const fallbackQueueOutcome = await params.queue(); - const fallbackQueue = mapQueueOutcomeToDeliveryResult(fallbackQueueOutcome); - appendPhase("queue-fallback", fallbackQueue); - if (fallbackQueue.delivered) { - return withPhases(fallbackQueue); + const fallbackSteerOutcome = await params.steer(); + const fallbackSteer = mapSteerOutcomeToDeliveryResult(fallbackSteerOutcome); + appendPhase("steer-fallback", fallbackSteer); + if (fallbackSteer.delivered) { + return withPhases(fallbackSteer); } return withPhases(primaryDirect); diff --git a/src/agents/subagent-announce-queue.test.ts b/src/agents/subagent-announce-queue.test.ts deleted file mode 100644 index 7f73f8343cb..00000000000 --- a/src/agents/subagent-announce-queue.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - type AnnounceQueueItem, - enqueueAnnounce, - resetAnnounceQueuesForTests, -} from "./subagent-announce-queue.js"; - -function createRetryingSend() { - const prompts: string[] = []; - let attempts = 0; - let resolved = false; - let resolveSecondAttempt = () => {}; - const waitForSecondAttempt = new Promise((resolve) => { - resolveSecondAttempt = resolve; - }); - - const send = vi.fn(async (item: { prompt: string }) => { - attempts += 1; - prompts.push(item.prompt); - if (attempts >= 2 && !resolved) { - resolved = true; - resolveSecondAttempt(); - } - if (attempts === 1) { - throw new Error("gateway timeout after 60000ms"); - } - }); - - return { send, prompts, waitForSecondAttempt }; -} - -function createCollectSendRecorder() { - const calls: AnnounceQueueItem[] = []; - const send = vi.fn(async (item: AnnounceQueueItem) => { - calls.push(item); - }); - return { calls, send }; -} - -describe("subagent-announce-queue", () => { - afterEach(() => { - vi.useRealTimers(); - resetAnnounceQueuesForTests(); - }); - - it("retries failed sends without dropping queued announce items", async () => { - const sender = createRetryingSend(); - - enqueueAnnounce({ - key: "announce:test:retry", - item: { - prompt: "subagent completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 0 }, - send: sender.send, - }); - - await sender.waitForSecondAttempt; - expect(sender.send).toHaveBeenCalledTimes(2); - expect(sender.prompts).toEqual(["subagent completed", "subagent completed"]); - }); - - it("preserves queue summary state across failed summary delivery retries", async () => { - const sender = createRetryingSend(); - - enqueueAnnounce({ - key: "announce:test:summary-retry", - item: { - prompt: "first result", - summaryLine: "first result", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 0, cap: 1, dropPolicy: "summarize" }, - send: sender.send, - }); - enqueueAnnounce({ - key: "announce:test:summary-retry", - item: { - prompt: "second result", - summaryLine: "second result", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 0, cap: 1, dropPolicy: "summarize" }, - send: sender.send, - }); - - await sender.waitForSecondAttempt; - expect(sender.send).toHaveBeenCalledTimes(2); - expect(sender.prompts[0]).toContain("[Queue overflow]"); - expect(sender.prompts[1]).toContain("[Queue overflow]"); - }); - - it("retries collect-mode batches without losing queued items", async () => { - const sender = createRetryingSend(); - - enqueueAnnounce({ - key: "announce:test:collect-retry", - item: { - prompt: "queued item one", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "collect", debounceMs: 0 }, - send: sender.send, - }); - enqueueAnnounce({ - key: "announce:test:collect-retry", - item: { - prompt: "queued item two", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "collect", debounceMs: 0 }, - send: sender.send, - }); - - await sender.waitForSecondAttempt; - expect(sender.send).toHaveBeenCalledTimes(2); - expect(sender.prompts[0]).toContain("Queued #1"); - expect(sender.prompts[0]).toContain("queued item one"); - expect(sender.prompts[0]).toContain("Queued #2"); - expect(sender.prompts[0]).toContain("queued item two"); - expect(sender.prompts[1]).toContain("Queued #1"); - expect(sender.prompts[1]).toContain("queued item one"); - expect(sender.prompts[1]).toContain("Queued #2"); - expect(sender.prompts[1]).toContain("queued item two"); - }); - - it("splits collect-mode batches when target authorization context changes", async () => { - const sender = createCollectSendRecorder(); - const settings = { mode: "collect", debounceMs: 0 } as const; - const origin = { channel: "slack", to: "channel:C123", accountId: "acct-1" }; - - enqueueAnnounce({ - key: "announce:test:collect-auth-split", - item: { - prompt: "first child completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:slack:thread:a", - origin, - }, - settings, - send: sender.send, - }); - enqueueAnnounce({ - key: "announce:test:collect-auth-split", - item: { - prompt: "second child completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:slack:thread:b", - origin, - }, - settings, - send: sender.send, - }); - - await vi.waitFor(() => { - expect(sender.send).toHaveBeenCalledTimes(2); - }); - expect(sender.calls.map((call) => call.sessionKey)).toEqual([ - "agent:main:slack:thread:a", - "agent:main:slack:thread:b", - ]); - expect(sender.calls[0]?.prompt).toContain("first child completed"); - expect(sender.calls[0]?.prompt).not.toContain("second child completed"); - expect(sender.calls[1]?.prompt).toContain("second child completed"); - }); - - it("keeps one collect-mode batch when target authorization context matches", async () => { - const sender = createCollectSendRecorder(); - const settings = { mode: "collect", debounceMs: 0 } as const; - const origin = { channel: "slack", to: "channel:C123", accountId: "acct-1" }; - - enqueueAnnounce({ - key: "announce:test:collect-auth-match", - item: { - prompt: "first child completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:slack:thread:a", - origin, - }, - settings, - send: sender.send, - }); - enqueueAnnounce({ - key: "announce:test:collect-auth-match", - item: { - prompt: "second child completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:slack:thread:a", - origin, - }, - settings, - send: sender.send, - }); - - await vi.waitFor(() => { - expect(sender.send).toHaveBeenCalledTimes(1); - }); - expect(sender.calls[0]?.sessionKey).toBe("agent:main:slack:thread:a"); - expect(sender.calls[0]?.prompt).toContain("first child completed"); - expect(sender.calls[0]?.prompt).toContain("second child completed"); - }); - - it("waits until a busy parent session becomes idle before draining", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - - let parentBusy = true; - const send = vi.fn(async (_item: AnnounceQueueItem) => {}); - - enqueueAnnounce({ - key: "announce:test:busy-parent", - item: { - prompt: "child completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 0 }, - send, - shouldDefer: () => parentBusy, - }); - - await vi.advanceTimersByTimeAsync(249); - expect(send).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(send).not.toHaveBeenCalled(); - - parentBusy = false; - await vi.advanceTimersByTimeAsync(250); - expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0]?.[0]?.prompt).toBe("child completed"); - }); - - it("preserves an existing defer hook when the same queue is reused without one", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - - let parentBusy = true; - const send = vi.fn(async (_item: AnnounceQueueItem) => {}); - - enqueueAnnounce({ - key: "announce:test:reuse-keeps-defer", - item: { - prompt: "first child completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 0 }, - send, - shouldDefer: () => parentBusy, - }); - - enqueueAnnounce({ - key: "announce:test:reuse-keeps-defer", - item: { - prompt: "second child completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 0 }, - send, - }); - - await vi.advanceTimersByTimeAsync(250); - expect(send).not.toHaveBeenCalled(); - - parentBusy = false; - await vi.advanceTimersByTimeAsync(250); - expect(send).toHaveBeenCalledTimes(2); - }); - - it("polls deferred items at the configured cadence after the first debounce", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - - let parentBusy = true; - const send = vi.fn(async (_item: AnnounceQueueItem) => {}); - - enqueueAnnounce({ - key: "announce:test:defer-cadence", - item: { - prompt: "child completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 1_000 }, - send, - shouldDefer: () => parentBusy, - }); - - await vi.advanceTimersByTimeAsync(1_000); - expect(send).not.toHaveBeenCalled(); - - parentBusy = false; - await vi.advanceTimersByTimeAsync(999); - expect(send).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(send).toHaveBeenCalledTimes(1); - }); - - it("falls back to delivery when busy-parent deferral exceeds the safety cap", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - - const send = vi.fn(async (_item: AnnounceQueueItem) => {}); - - enqueueAnnounce({ - key: "announce:test:busy-parent-timeout", - item: { - prompt: "child completed after stale busy state", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 0 }, - send, - shouldDefer: () => true, - }); - - await vi.advanceTimersByTimeAsync(14_999); - expect(send).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0]?.[0]?.prompt).toBe("child completed after stale busy state"); - }); - - it("uses debounce floor for retries when debounce exceeds backoff", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - const previousFast = process.env.OPENCLAW_TEST_FAST; - delete process.env.OPENCLAW_TEST_FAST; - - try { - const attempts: number[] = []; - const send = vi.fn(async () => { - attempts.push(Date.now()); - if (attempts.length === 1) { - throw new Error("transient timeout"); - } - }); - - enqueueAnnounce({ - key: "announce:test:retry-debounce-floor", - item: { - prompt: "subagent completed", - enqueuedAt: Date.now(), - sessionKey: "agent:main:telegram:dm:u1", - }, - settings: { mode: "followup", debounceMs: 5_000 }, - send, - }); - - await vi.advanceTimersByTimeAsync(5_000); - expect(send).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(4_999); - expect(send).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - expect(send).toHaveBeenCalledTimes(2); - const [firstAttempt, secondAttempt] = attempts; - if (firstAttempt === undefined || secondAttempt === undefined) { - throw new Error("expected two retry attempts"); - } - expect(secondAttempt - firstAttempt).toBeGreaterThanOrEqual(5_000); - } finally { - if (previousFast === undefined) { - delete process.env.OPENCLAW_TEST_FAST; - } else { - process.env.OPENCLAW_TEST_FAST = previousFast; - } - } - }); -}); diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts deleted file mode 100644 index a0174d8defd..00000000000 --- a/src/agents/subagent-announce-queue.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { type QueueDropPolicy, type QueueMode } from "../auto-reply/reply/queue.js"; -import { defaultRuntime } from "../runtime.js"; -import { deliveryContextKey, normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; -import type { DeliveryContext } from "../utils/delivery-context.types.js"; -import { - applyQueueRuntimeSettings, - applyQueueDropPolicy, - beginQueueDrain, - buildCollectPrompt, - clearQueueSummaryState, - drainCollectQueueStep, - drainNextQueueItem, - hasCrossChannelItems, - previewQueueSummaryPrompt, - waitForQueueDebounce, -} from "../utils/queue-helpers.js"; -import type { AgentInternalEvent } from "./internal-events.js"; - -export type AnnounceQueueItem = { - // Stable announce identity shared by direct + queued delivery paths. - // Optional for backward compatibility with previously queued items. - announceId?: string; - prompt: string; - summaryLine?: string; - internalEvents?: AgentInternalEvent[]; - enqueuedAt: number; - sessionKey: string; - origin?: DeliveryContext; - originKey?: string; - sourceSessionKey?: string; - sourceChannel?: string; - sourceTool?: string; -}; - -type AnnounceQueueSettings = { - mode: QueueMode; - debounceMs?: number; - cap?: number; - dropPolicy?: QueueDropPolicy; -}; - -type AnnounceQueueState = { - items: AnnounceQueueItem[]; - draining: boolean; - lastEnqueuedAt: number; - mode: QueueMode; - debounceMs: number; - cap: number; - dropPolicy: QueueDropPolicy; - droppedCount: number; - summaryLines: string[]; - send: (item: AnnounceQueueItem) => Promise; - /** Return true while the target parent session is still busy and delivery should wait. */ - shouldDefer?: (item: AnnounceQueueItem) => boolean; - /** Consecutive drain failures — drives exponential backoff on errors. */ - consecutiveFailures: number; -}; - -const ANNOUNCE_QUEUES = new Map(); -const MAX_DEFER_WHILE_BUSY_MS = 15_000; - -export function resetAnnounceQueuesForTests() { - // Test isolation: other suites may leave a draining queue behind in the worker. - // Clearing the map alone isn't enough because drain loops capture `queue` by reference. - for (const queue of ANNOUNCE_QUEUES.values()) { - queue.items.length = 0; - queue.summaryLines.length = 0; - queue.droppedCount = 0; - queue.lastEnqueuedAt = 0; - } - ANNOUNCE_QUEUES.clear(); -} - -function getAnnounceQueue( - key: string, - settings: AnnounceQueueSettings, - send: (item: AnnounceQueueItem) => Promise, - shouldDefer?: (item: AnnounceQueueItem) => boolean, -) { - const existing = ANNOUNCE_QUEUES.get(key); - if (existing) { - applyQueueRuntimeSettings({ - target: existing, - settings, - }); - existing.send = send; - if (shouldDefer !== undefined) { - existing.shouldDefer = shouldDefer; - } - return existing; - } - const created: AnnounceQueueState = { - items: [], - draining: false, - lastEnqueuedAt: 0, - mode: settings.mode, - debounceMs: typeof settings.debounceMs === "number" ? Math.max(0, settings.debounceMs) : 1000, - cap: typeof settings.cap === "number" && settings.cap > 0 ? Math.floor(settings.cap) : 20, - dropPolicy: settings.dropPolicy ?? "summarize", - droppedCount: 0, - summaryLines: [], - send, - shouldDefer, - consecutiveFailures: 0, - }; - applyQueueRuntimeSettings({ - target: created, - settings, - }); - ANNOUNCE_QUEUES.set(key, created); - return created; -} - -function resolveAnnounceAuthorizationKey(item: AnnounceQueueItem): string { - return JSON.stringify([item.sessionKey, item.originKey ?? ""]); -} - -function splitCollectItemsByAuthorization(items: AnnounceQueueItem[]): AnnounceQueueItem[][] { - if (items.length <= 1) { - return items.length === 0 ? [] : [items]; - } - - const groups: AnnounceQueueItem[][] = []; - let currentGroup: AnnounceQueueItem[] = []; - let currentKey: string | undefined; - - for (const item of items) { - const itemKey = resolveAnnounceAuthorizationKey(item); - if (currentGroup.length === 0 || itemKey === currentKey) { - currentGroup.push(item); - currentKey = itemKey; - continue; - } - - groups.push(currentGroup); - currentGroup = [item]; - currentKey = itemKey; - } - - if (currentGroup.length > 0) { - groups.push(currentGroup); - } - - return groups; -} - -function hasAnnounceCrossChannelItems(items: AnnounceQueueItem[]): boolean { - return hasCrossChannelItems(items, (item) => { - if (!item.origin) { - return {}; - } - if (!item.originKey) { - return { cross: true }; - } - return { key: item.originKey }; - }); -} - -function shouldDeferAnnounceQueueItem(queue: AnnounceQueueState, item: AnnounceQueueItem): boolean { - if (!queue.shouldDefer?.(item)) { - return false; - } - return Date.now() - item.enqueuedAt < MAX_DEFER_WHILE_BUSY_MS; -} - -function waitBeforeDeferredAnnounceRetry(queue: AnnounceQueueState): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, Math.max(250, queue.debounceMs)); - timer.unref?.(); - }); -} - -function scheduleAnnounceDrain(key: string) { - const queue = beginQueueDrain(ANNOUNCE_QUEUES, key); - if (!queue) { - return; - } - void (async () => { - try { - const collectState = { forceIndividualCollect: false }; - for (;;) { - if (queue.items.length === 0 && queue.droppedCount === 0) { - break; - } - await waitForQueueDebounce(queue); - const nextItem = queue.items[0]; - if (nextItem && shouldDeferAnnounceQueueItem(queue, nextItem)) { - await waitBeforeDeferredAnnounceRetry(queue); - queue.lastEnqueuedAt = Date.now() - queue.debounceMs; - continue; - } - if (queue.mode === "collect") { - const collectDrainResult = await drainCollectQueueStep({ - collectState, - isCrossChannel: hasAnnounceCrossChannelItems(queue.items), - items: queue.items, - run: async (item) => await queue.send(item), - }); - if (collectDrainResult === "empty") { - break; - } - if (collectDrainResult === "drained") { - continue; - } - const items = queue.items.slice(); - const summary = previewQueueSummaryPrompt({ state: queue, noun: "announce" }); - const authGroups = splitCollectItemsByAuthorization(items); - if (authGroups.length === 0) { - break; - } - - let pendingSummary = summary; - for (const groupItems of authGroups) { - const prompt = buildCollectPrompt({ - title: "[Queued announce messages while agent was busy]", - items: groupItems, - summary: pendingSummary, - renderItem: (item, idx) => `---\nQueued #${idx + 1}\n${item.prompt}`.trim(), - }); - const internalEvents = groupItems.flatMap((item) => item.internalEvents ?? []); - const last = groupItems.at(-1); - if (!last) { - break; - } - await queue.send({ - ...last, - prompt, - internalEvents: internalEvents.length > 0 ? internalEvents : last.internalEvents, - }); - queue.items.splice(0, groupItems.length); - if (pendingSummary) { - clearQueueSummaryState(queue); - pendingSummary = undefined; - } - } - continue; - } - - const summaryPrompt = previewQueueSummaryPrompt({ state: queue, noun: "announce" }); - if (summaryPrompt) { - if ( - !(await drainNextQueueItem( - queue.items, - async (item) => await queue.send({ ...item, prompt: summaryPrompt }), - )) - ) { - break; - } - clearQueueSummaryState(queue); - continue; - } - - if (!(await drainNextQueueItem(queue.items, async (item) => await queue.send(item)))) { - break; - } - } - // Drain succeeded — reset failure counter. - queue.consecutiveFailures = 0; - } catch (err) { - queue.consecutiveFailures++; - // Exponential backoff on consecutive failures: 2s, 4s, 8s, ... capped at 60s. - const errorBackoffMs = Math.min(1000 * 2 ** queue.consecutiveFailures, 60_000); - const retryDelayMs = Math.max(errorBackoffMs, queue.debounceMs); - queue.lastEnqueuedAt = Date.now() + retryDelayMs - queue.debounceMs; - defaultRuntime.error?.( - `announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(retryDelayMs / 1000)}s): ${String(err)}`, - ); - } finally { - queue.draining = false; - if (queue.items.length === 0 && queue.droppedCount === 0) { - ANNOUNCE_QUEUES.delete(key); - } else { - scheduleAnnounceDrain(key); - } - } - })(); -} - -export function enqueueAnnounce(params: { - key: string; - item: AnnounceQueueItem; - settings: AnnounceQueueSettings; - send: (item: AnnounceQueueItem) => Promise; - shouldDefer?: (item: AnnounceQueueItem) => boolean; -}): boolean { - const queue = getAnnounceQueue(params.key, params.settings, params.send, params.shouldDefer); - // Preserve any retry backoff marker already encoded in lastEnqueuedAt. - queue.lastEnqueuedAt = Math.max(queue.lastEnqueuedAt, Date.now()); - - const shouldEnqueue = applyQueueDropPolicy({ - queue, - summarize: (item) => item.summaryLine?.trim() || item.prompt.trim(), - }); - if (!shouldEnqueue) { - if (queue.dropPolicy === "new") { - scheduleAnnounceDrain(params.key); - } - return false; - } - - const origin = normalizeDeliveryContext(params.item.origin); - const originKey = deliveryContextKey(origin); - queue.items.push({ ...params.item, origin, originKey }); - scheduleAnnounceDrain(params.key); - return true; -} diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 65c577f7054..2c7a4652234 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -23,7 +23,6 @@ import { import * as piEmbedded from "./pi-embedded-runner/runs.js"; import { __testing as subagentAnnounceDeliveryTesting } from "./subagent-announce-delivery.js"; import { runSubagentAnnounceDispatch } from "./subagent-announce-dispatch.js"; -import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; import * as agentStep from "./tools/agent-step.js"; type AgentCallRequest = { @@ -331,12 +330,10 @@ describe("subagent announce formatting", () => { afterEach(() => { vi.useRealTimers(); - resetAnnounceQueuesForTests(); }); beforeEach(() => { vi.useRealTimers(); - resetAnnounceQueuesForTests(); // OPENCLAW_TEST_FAST is set in beforeAll before module import // to ensure the module-level constant picks it up. agentSpy @@ -1734,11 +1731,11 @@ describe("subagent announce formatting", () => { expect(call?.params?.threadId).toBeUndefined(); }); - it("steers announcements into an active run when queue mode is steer", async () => { + it("steers announcements into an active run", async () => { const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); const delivery = await runSubagentAnnounceDispatch({ expectsCompletionMessage: false, - queue: async () => "steered", + steer: async () => "steered", direct, }); @@ -1747,46 +1744,33 @@ describe("subagent announce formatting", () => { expect(direct).not.toHaveBeenCalled(); }); - it("queues announce delivery with origin account routing", async () => { - const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + it("reports cron announce as delivered when it successfully steers into an active requester run", async () => { const delivery = await runSubagentAnnounceDispatch({ expectsCompletionMessage: false, - queue: async () => "queued", - direct, - }); - - expect(delivery.delivered).toBe(true); - expect(delivery.path).toBe("queued"); - expect(direct).not.toHaveBeenCalled(); - }); - - it("reports cron announce as delivered when it successfully queues into an active requester run", async () => { - const delivery = await runSubagentAnnounceDispatch({ - expectsCompletionMessage: false, - queue: async () => "queued", + steer: async () => "steered", direct: async () => ({ delivered: false, path: "direct" as const }), }); expect(delivery.delivered).toBe(true); - expect(delivery.path).toBe("queued"); + expect(delivery.path).toBe("steered"); }); - it("does not report queued delivery when active announce queue drops a new item", async () => { + it("does not fall through to direct delivery when active steering drops a new item", async () => { const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); const delivery = await runSubagentAnnounceDispatch({ expectsCompletionMessage: false, - queue: async () => "dropped", + steer: async () => "dropped", direct, }); expect(delivery.delivered).toBe(false); expect(delivery.phases).toEqual([ - { phase: "queue-primary", delivered: false, path: "none", error: undefined }, + { phase: "steer-primary", delivered: false, path: "none", error: undefined }, ]); expect(direct).not.toHaveBeenCalled(); }); - it("keeps queued idempotency unique for same-ms distinct child runs", async () => { + it("keeps direct announce idempotency unique for same-ms distinct child runs", async () => { const activeResponses = [true, false, true, false]; embeddedRunMock.isEmbeddedPiRunActive.mockImplementation( () => activeResponses.shift() ?? false, @@ -1846,7 +1830,7 @@ describe("subagent announce formatting", () => { expect(new Set(idempotencyKeys).size).toBe(2); }); - it("falls back to queued follow-up delivery when an active completion wake cannot be injected", async () => { + it("falls back to steering when an active completion wake cannot be injected", async () => { embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); sessionStore = { @@ -1866,11 +1850,11 @@ describe("subagent announce formatting", () => { const delivery = await runSubagentAnnounceDispatch({ expectsCompletionMessage: true, direct, - queue: async () => "queued", + steer: async () => "steered", }); expect(delivery.delivered).toBe(true); - expect(delivery.path).toBe("queued"); + expect(delivery.path).toBe("steered"); expect(direct).toHaveBeenCalledTimes(1); }); @@ -1937,7 +1921,7 @@ describe("subagent announce formatting", () => { }); }); - it("returns failure for completion-mode when direct delivery fails and queue fallback is unavailable", async () => { + it("returns failure for completion-mode when direct delivery fails and steering fallback is unavailable", async () => { embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); sessionStore = { @@ -2060,7 +2044,7 @@ describe("subagent announce formatting", () => { expect(msg).not.toContain("user prompt should not be announced"); }); - it("queues announce delivery back into requester subagent session", async () => { + it("keeps announce delivery inside requester subagent session", async () => { embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); sessionStore = { @@ -2074,7 +2058,7 @@ describe("subagent announce formatting", () => { const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:worker", - childRunId: "run-worker-queued", + childRunId: "run-worker-session", requesterSessionKey: "agent:main:subagent:orchestrator", requesterDisplayKey: "agent:main:subagent:orchestrator", requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct" }, @@ -2117,7 +2101,7 @@ describe("subagent announce formatting", () => { expect(params.threadId).toBe(testCase.expectedThreadId); }); - it("splits collect-mode queues when accountId differs", async () => { + it("preserves account routing for separate collect-mode announcements", async () => { const activeResponses = [true, false, true, false]; embeddedRunMock.isEmbeddedPiRunActive.mockImplementation( () => activeResponses.shift() ?? false, @@ -2162,7 +2146,7 @@ describe("subagent announce formatting", () => { it.each([ { - testName: "uses requester origin for direct announce when not queued", + testName: "uses requester origin for direct announce", childRunId: "run-direct", requesterOrigin: { channel: "whatsapp", accountId: "acct-123" }, expectedChannel: "whatsapp", @@ -3001,7 +2985,7 @@ describe("subagent announce formatting", () => { } }); - it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => { + it("prefers requesterOrigin channel over stale session lastChannel in direct announce", async () => { embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); // Session store has stale whatsapp channel, but the requesterOrigin says imessage. diff --git a/src/agents/subagent-announce.test-support.ts b/src/agents/subagent-announce.test-support.ts index dda9937cfb5..c3bb909f1ef 100644 --- a/src/agents/subagent-announce.test-support.ts +++ b/src/agents/subagent-announce.test-support.ts @@ -45,7 +45,8 @@ function resolveQueueSettings(params: { channel?: string; }) { return { - mode: (params.channel && params.cfg?.messages?.queue?.byChannel?.[params.channel]) ?? "none", + mode: + (params.channel && params.cfg?.messages?.queue?.byChannel?.[params.channel]) ?? "followup", }; } @@ -64,10 +65,6 @@ export function createSubagentAnnounceDeliveryRuntimeMock(options: DeliveryRunti outcome.reason && outcome.sessionId ? `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=live` : undefined, - isSteeringQueueMode: (mode: string) => - mode === "steer" || mode === "queue" || mode === "steer-backlog", - resolvePiSteeringModeForQueueMode: (mode: string) => - mode === "queue" ? "one-at-a-time" : "all", getGlobalHookRunner: () => ({ hasHooks: () => options.hasHooks?.() ?? false }), createBoundDeliveryRouter: () => ({ resolveDestination: () => ({ mode: "none" }), diff --git a/src/agents/subagent-announce.test.ts b/src/agents/subagent-announce.test.ts index eeeccf93032..6b5b770fd26 100644 --- a/src/agents/subagent-announce.test.ts +++ b/src/agents/subagent-announce.test.ts @@ -109,7 +109,7 @@ vi.mock("./subagent-announce-delivery.js", () => ({ `[Internal task completion event]\n${params.triggerMessage}`, { steeringMode: "all" }, ); - return { delivered: true, path: "queue" }; + return { delivered: true, path: "steered" }; } const effectiveOrigin = @@ -350,7 +350,7 @@ describe("subagent announce seam flow", () => { messages: { queue: { byChannel: { - discord: "steer", + discord: "followup", }, }, }, diff --git a/src/agents/subagent-registry-lifecycle.test.ts b/src/agents/subagent-registry-lifecycle.test.ts index 92ec0199330..8a31f536316 100644 --- a/src/agents/subagent-registry-lifecycle.test.ts +++ b/src/agents/subagent-registry-lifecycle.test.ts @@ -711,7 +711,7 @@ describe("subagent registry lifecycle hardening", () => { path: "direct"; error: string; phases: Array<{ - phase: "direct-primary" | "queue-fallback"; + phase: "direct-primary" | "steer-fallback"; delivered: boolean; path: "direct" | "none"; error?: string; @@ -730,7 +730,7 @@ describe("subagent registry lifecycle hardening", () => { error: "UNAVAILABLE: requester wake failed", }, { - phase: "queue-fallback", + phase: "steer-fallback", delivered: false, path: "none", }, diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index eba02ce175c..25927ec5083 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -22,7 +22,6 @@ const mocks = vi.hoisted(() => ({ captureSubagentCompletionReply: vi.fn(), loadSubagentRegistryFromDisk: vi.fn(() => new Map()), saveSubagentRegistryToDisk: vi.fn(), - resetAnnounceQueuesForTests: vi.fn(), resolveAgentTimeoutMs: vi.fn(() => 60_000), scheduleOrphanRecovery: vi.fn(), })); @@ -59,10 +58,6 @@ vi.mock("./subagent-registry.store.js", () => ({ saveSubagentRegistryToDisk: mocks.saveSubagentRegistryToDisk, })); -vi.mock("./subagent-announce-queue.js", () => ({ - resetAnnounceQueuesForTests: mocks.resetAnnounceQueuesForTests, -})); - vi.mock("./timeout.js", () => ({ resolveAgentTimeoutMs: mocks.resolveAgentTimeoutMs, })); @@ -97,7 +92,6 @@ describe("announce loop guard (#18264)", () => { mocks.onAgentEventStop.mockClear(); mocks.onAgentEvent.mockReset(); mocks.onAgentEvent.mockReturnValue(mocks.onAgentEventStop); - mocks.resetAnnounceQueuesForTests.mockClear(); mocks.resolveAgentTimeoutMs.mockClear(); mocks.runSubagentAnnounceFlow.mockReset(); mocks.runSubagentAnnounceFlow.mockResolvedValue(false); diff --git a/src/agents/subagent-registry.test-helpers.ts b/src/agents/subagent-registry.test-helpers.ts index 5309bb057e5..be7ac417567 100644 --- a/src/agents/subagent-registry.test-helpers.ts +++ b/src/agents/subagent-registry.test-helpers.ts @@ -1,10 +1,8 @@ -import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; import { subagentRuns } from "./subagent-registry-memory.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; export function resetSubagentRegistryForTests() { subagentRuns.clear(); - resetAnnounceQueuesForTests(); } export function addSubagentRunForTests(entry: SubagentRunRecord) { diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index 41cd6b9e2a3..67d736a03b1 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -88,7 +88,6 @@ const mocks = vi.hoisted(() => ({ getSubagentRunsSnapshotForRead: vi.fn( (runs: Map) => new Map(runs), ), - resetAnnounceQueuesForTests: vi.fn(), captureSubagentCompletionReply: vi.fn(async () => "final completion reply"), runSubagentAnnounceFlow: vi.fn(async () => true), getGlobalHookRunner: vi.fn(() => null), @@ -133,10 +132,6 @@ vi.mock("./subagent-registry-state.js", () => ({ restoreSubagentRunsFromDisk: mocks.restoreSubagentRunsFromDisk, })); -vi.mock("./subagent-announce-queue.js", () => ({ - resetAnnounceQueuesForTests: mocks.resetAnnounceQueuesForTests, -})); - vi.mock("./subagent-announce.js", () => ({ captureSubagentCompletionReply: mocks.captureSubagentCompletionReply, runSubagentAnnounceFlow: mocks.runSubagentAnnounceFlow, diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 89d891f48f5..8884577f5a2 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -19,7 +19,6 @@ import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; import type { ensureRuntimePluginsLoaded as ensureRuntimePluginsLoadedFn } from "./runtime-plugins.js"; import type { SubagentRunOutcome } from "./subagent-announce-output.js"; -import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; import { SUBAGENT_ENDED_REASON_COMPLETE, SUBAGENT_ENDED_REASON_ERROR, @@ -1036,7 +1035,6 @@ export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) { runtimePluginsLoader.clear(); subagentAnnounceLoader.clear(); browserCleanupLoader.clear(); - resetAnnounceQueuesForTests(); stopSweeper(); sweepInProgress = false; restoreAttempted = false; diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 05fea14000f..e3570fb026e 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -916,7 +916,7 @@ export function buildBuiltinChatCommands( name: "mode", description: "queue mode", type: "string", - choices: ["steer", "queue", "interrupt", "followup", "collect", "steer-backlog"], + choices: ["steer", "followup", "collect", "interrupt"], }, { name: "debounce", diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index 2f0f3ecbb35..842789edc41 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -192,10 +192,11 @@ describe("directive parsing", () => { expect(res.cleaned).toBe("please now"); }); - it("keeps legacy queue directive as queue mode", () => { - const res = extractQueueDirective("please /queue queue now"); + it("matches steer queue directive", () => { + const res = extractQueueDirective("please /queue steer now"); expect(res.hasDirective).toBe(true); - expect(res.queueMode).toBe("queue"); + expect(res.queueMode).toBe("steer"); + expect(res.rawMode).toBe("steer"); expect(res.cleaned).toBe("please now"); }); @@ -242,11 +243,9 @@ describe("directive parsing", () => { }); it("parses queue options and modes", () => { - const res = extractQueueDirective( - "please /queue steer+backlog debounce:2s cap:5 drop:summarize now", - ); + const res = extractQueueDirective("please /queue collect debounce:2s cap:5 drop:summarize now"); expect(res.hasDirective).toBe(true); - expect(res.queueMode).toBe("steer-backlog"); + expect(res.queueMode).toBe("collect"); expect(res.debounceMs).toBe(2000); expect(res.cap).toBe(5); expect(res.dropPolicy).toBe("summarize"); diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index 5b3923c024f..05c414f39b2 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -11,8 +11,12 @@ const abortEmbeddedPiRunMock = vi.fn(); const compactEmbeddedPiSessionMock = vi.fn(); const isEmbeddedPiRunActiveMock = vi.fn(() => false); const isEmbeddedPiRunStreamingMock = vi.fn(() => false); -const queueEmbeddedPiMessageWithOutcomeMock = vi.fn( - (sessionId: string, _text: string, _options?: unknown): EmbeddedPiQueueMessageOutcome => ({ +const queueEmbeddedPiMessageWithOutcomeAsyncMock = vi.fn( + async ( + sessionId: string, + _text: string, + _options?: unknown, + ): Promise => ({ queued: false, sessionId, reason: "not_streaming", @@ -44,7 +48,7 @@ vi.mock("../../agents/pi-embedded.js", () => ({ compactEmbeddedPiSession: compactEmbeddedPiSessionMock, isEmbeddedPiRunActive: isEmbeddedPiRunActiveMock, isEmbeddedPiRunStreaming: isEmbeddedPiRunStreamingMock, - queueEmbeddedPiMessageWithOutcome: queueEmbeddedPiMessageWithOutcomeMock, + queueEmbeddedPiMessageWithOutcomeAsync: queueEmbeddedPiMessageWithOutcomeAsyncMock, resolveEmbeddedSessionLane: resolveEmbeddedSessionLaneMock, runEmbeddedPiAgent: runEmbeddedPiAgentMock, waitForEmbeddedPiRunEnd: waitForEmbeddedPiRunEndMock, @@ -55,13 +59,12 @@ vi.mock("../../agents/pi-embedded-runner/runs.js", () => ({ outcome.reason && outcome.sessionId ? `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=live` : undefined, - queueEmbeddedPiMessageWithOutcome: queueEmbeddedPiMessageWithOutcomeMock, + queueEmbeddedPiMessageWithOutcomeAsync: queueEmbeddedPiMessageWithOutcomeAsyncMock, })); vi.mock("./queue.js", () => ({ enqueueFollowupRun: enqueueFollowupRunMock, refreshQueuedFollowupSession: refreshQueuedFollowupSessionMock, - resolvePiSteeringModeForQueueMode: (mode: string) => (mode === "queue" ? "one-at-a-time" : "all"), scheduleFollowupDrain: scheduleFollowupDrainMock, })); @@ -147,8 +150,8 @@ describe("runReplyAgent media path normalization", () => { isEmbeddedPiRunActiveMock.mockReturnValue(false); isEmbeddedPiRunStreamingMock.mockReset(); isEmbeddedPiRunStreamingMock.mockReturnValue(false); - queueEmbeddedPiMessageWithOutcomeMock.mockReset(); - queueEmbeddedPiMessageWithOutcomeMock.mockImplementation((sessionId: string) => ({ + queueEmbeddedPiMessageWithOutcomeAsyncMock.mockReset(); + queueEmbeddedPiMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ queued: false, sessionId, reason: "not_streaming", @@ -220,8 +223,8 @@ describe("runReplyAgent media path normalization", () => { expect(outboundAttachmentOptions?.mediaAccess?.workspaceDir).toBe("/tmp/workspace"); }); - it("maps steer queue modes to Pi steering drain modes", async () => { - queueEmbeddedPiMessageWithOutcomeMock.mockImplementation((sessionId: string) => ({ + it("steers active prompts in steer queue mode", async () => { + queueEmbeddedPiMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ queued: true, sessionId, target: "embedded_run", @@ -232,33 +235,60 @@ describe("runReplyAgent media path normalization", () => { makeRunReplyAgentParams({ resolvedQueue: { mode: "steer" } as QueueSettings, shouldSteer: true, + shouldFollowup: true, isStreaming: true, }), ); - expect(queueEmbeddedPiMessageWithOutcomeMock).toHaveBeenLastCalledWith( + expect(queueEmbeddedPiMessageWithOutcomeAsyncMock).toHaveBeenLastCalledWith( "session", "generate chart", { steeringMode: "all", }, ); + expect(enqueueFollowupRunMock).not.toHaveBeenCalled(); + }); + it("queues active prompts in followup mode without steering", async () => { await runReplyAgent( makeRunReplyAgentParams({ - resolvedQueue: { mode: "queue" } as QueueSettings, - shouldSteer: true, + resolvedQueue: { mode: "followup" } as QueueSettings, + shouldSteer: false, + shouldFollowup: true, + isActive: true, + isRunActive: () => true, isStreaming: true, }), ); - expect(queueEmbeddedPiMessageWithOutcomeMock).toHaveBeenLastCalledWith( - "session", - "generate chart", - { - steeringMode: "one-at-a-time", - }, + expect(queueEmbeddedPiMessageWithOutcomeAsyncMock).not.toHaveBeenCalled(); + expect(enqueueFollowupRunMock).toHaveBeenCalledOnce(); + expect(enqueueFollowupRunMock.mock.calls[0]?.[1].prompt).toBe("generate chart"); + }); + + it("falls back to a queued followup when active steering is rejected", async () => { + queueEmbeddedPiMessageWithOutcomeAsyncMock.mockImplementation(async (sessionId: string) => ({ + queued: false, + sessionId, + reason: "runtime_rejected", + gatewayHealth: "live", + errorMessage: "cannot steer a compact turn", + })); + + await runReplyAgent( + makeRunReplyAgentParams({ + resolvedQueue: { mode: "steer" } as QueueSettings, + shouldSteer: true, + shouldFollowup: true, + isActive: true, + isRunActive: () => true, + isStreaming: true, + }), ); + + expect(enqueueFollowupRunMock).toHaveBeenCalledOnce(); + expect(enqueueFollowupRunMock.mock.calls[0]?.[1].prompt).toBe("generate chart"); }); it("shares one media cache between block accumulation and final payload delivery", async () => { diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 310abb03147..5835f724d77 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -27,6 +27,7 @@ type AgentRunParams = { const state = vi.hoisted(() => ({ compactEmbeddedPiSessionMock: vi.fn(), + queueEmbeddedPiMessageMock: vi.fn(), runEmbeddedPiAgentMock: vi.fn(), })); @@ -96,6 +97,11 @@ vi.mock("../../agents/pi-embedded.js", () => ({ runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), })); +vi.mock("../../agents/pi-embedded-runner/runs.js", () => ({ + queueEmbeddedPiMessage: (sessionId: string, prompt: string, options: unknown) => + state.queueEmbeddedPiMessageMock(sessionId, prompt, options), +})); + vi.mock("./queue.js", () => ({ enqueueFollowupRun: vi.fn(), refreshQueuedFollowupSession: vi.fn(), @@ -121,6 +127,8 @@ beforeEach(() => { payloads: [{ text: "final" }], meta: { agentMeta: { usage: { input: 1, output: 1 } } }, }); + state.queueEmbeddedPiMessageMock.mockReset(); + state.queueEmbeddedPiMessageMock.mockReturnValue(false); vi.mocked(enqueueFollowupRun).mockClear(); vi.mocked(refreshQueuedFollowupSession).mockClear(); vi.mocked(scheduleFollowupDrain).mockClear(); @@ -138,6 +146,8 @@ function createMinimalRun(params?: { blockStreamingEnabled?: boolean; isActive?: boolean; isRunActive?: () => boolean; + isStreaming?: boolean; + shouldSteer?: boolean; shouldFollowup?: boolean; resolvedQueueMode?: string; sessionCtx?: Partial; @@ -193,11 +203,11 @@ function createMinimalRun(params?: { followupRun, queueKey: "main", resolvedQueue, - shouldSteer: false, + shouldSteer: params?.shouldSteer ?? false, shouldFollowup: params?.shouldFollowup ?? false, isActive: params?.isActive ?? false, isRunActive: params?.isRunActive, - isStreaming: false, + isStreaming: params?.isStreaming ?? false, opts, typing, sessionEntry: params?.sessionEntry, @@ -234,6 +244,26 @@ describe("runReplyAgent heartbeat followup guard", () => { expect(typing.cleanup).toHaveBeenCalledTimes(1); }); + it("drops heartbeat runs before steering active streams", async () => { + state.queueEmbeddedPiMessageMock.mockReturnValueOnce(true); + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: true }, + isActive: true, + isStreaming: true, + shouldSteer: true, + shouldFollowup: true, + resolvedQueueMode: "collect", + }); + + const result = await run(); + + expect(result).toBeUndefined(); + expect(state.queueEmbeddedPiMessageMock).not.toHaveBeenCalled(); + expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled(); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(typing.cleanup).toHaveBeenCalledTimes(1); + }); + it("still enqueues non-heartbeat runs when another run is active", async () => { const { run } = createMinimalRun({ opts: { isHeartbeat: false }, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index f1beb0386d4..7ed38361222 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -10,7 +10,7 @@ import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { isCliProvider } from "../../agents/model-selection.js"; import { formatEmbeddedPiQueueFailureSummary, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedPiMessageWithOutcomeAsync, } from "../../agents/pi-embedded-runner/runs.js"; import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js"; import { enqueueCommitmentExtraction } from "../../commitments/runtime.js"; @@ -86,7 +86,6 @@ import { resolveActiveRunQueueAction } from "./queue-policy.js"; import { enqueueFollowupRun, refreshQueuedFollowupSession, - resolvePiSteeringModeForQueueMode, scheduleFollowupDrain, type FollowupRun, type QueueSettings, @@ -1083,8 +1082,6 @@ export async function runReplyAgent(params: { let activeIsNewSession = isNewSession; const effectiveResetTriggered = resetTriggered === true; const activeRunQueueMode = effectiveResetTriggered ? "interrupt" : resolvedQueue.mode; - const effectiveShouldSteer = !effectiveResetTriggered && shouldSteer; - const effectiveShouldFollowup = !effectiveResetTriggered && shouldFollowup; const isHeartbeat = opts?.isHeartbeat === true; const traceAttributes = { @@ -1101,6 +1098,8 @@ export async function runReplyAgent(params: { config: followupRun.run.config, attributes: traceAttributes, }); + const effectiveShouldSteer = !isHeartbeat && !effectiveResetTriggered && shouldSteer; + const effectiveShouldFollowup = !effectiveResetTriggered && shouldFollowup; const typingSignals = createTypingSignaler({ typing, mode: typingMode, @@ -1140,21 +1139,21 @@ export async function runReplyAgent(params: { const steerSessionId = (sessionKey ? replyRunRegistry.resolveSessionId(sessionKey) : undefined) ?? followupRun.run.sessionId; - const steerOutcome = queueEmbeddedPiMessageWithOutcome(steerSessionId, followupRun.prompt, { - steeringMode: resolvePiSteeringModeForQueueMode(resolvedQueue.mode), - ...(resolvedQueue.debounceMs !== undefined ? { debounceMs: resolvedQueue.debounceMs } : {}), - }); - if (steerOutcome.queued && !effectiveShouldFollowup) { + const steerOutcome = await queueEmbeddedPiMessageWithOutcomeAsync( + steerSessionId, + followupRun.prompt, + { + steeringMode: "all", + ...(resolvedQueue.debounceMs !== undefined ? { debounceMs: resolvedQueue.debounceMs } : {}), + }, + ); + if (steerOutcome.queued) { await touchActiveSessionEntry(); typing.cleanup(); return undefined; } - if (!steerOutcome.queued) { - const summary = formatEmbeddedPiQueueFailureSummary(steerOutcome); - if (summary) { - logVerbose(`reply queue steering failed: ${summary}`); - } - } + const summary = formatEmbeddedPiQueueFailureSummary(steerOutcome); + logVerbose(`queue: active session ${steerSessionId} rejected steering injection: ${summary}`); } const activeRunQueueAction = resolveActiveRunQueueAction({ diff --git a/src/auto-reply/reply/commands-steer.runtime.ts b/src/auto-reply/reply/commands-steer.runtime.ts index ae6d202db65..24a9013ffa3 100644 --- a/src/auto-reply/reply/commands-steer.runtime.ts +++ b/src/auto-reply/reply/commands-steer.runtime.ts @@ -1,6 +1,7 @@ export { formatEmbeddedPiQueueFailureSummary, isEmbeddedPiRunActive, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedPiMessage, + queueEmbeddedPiMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, } from "../../agents/pi-embedded-runner/runs.js"; diff --git a/src/auto-reply/reply/commands-steer.test.ts b/src/auto-reply/reply/commands-steer.test.ts index 3578cbeba2e..06f01b727aa 100644 --- a/src/auto-reply/reply/commands-steer.test.ts +++ b/src/auto-reply/reply/commands-steer.test.ts @@ -5,7 +5,7 @@ import { buildCommandTestParams } from "./commands.test-harness.js"; const steerRuntimeMocks = vi.hoisted(() => ({ formatEmbeddedPiQueueFailureSummary: vi.fn(), isEmbeddedPiRunActive: vi.fn(), - queueEmbeddedPiMessageWithOutcome: vi.fn(), + queueEmbeddedPiMessageWithOutcomeAsync: vi.fn(), resolveActiveEmbeddedRunSessionId: vi.fn(), })); @@ -30,7 +30,7 @@ describe("handleSteerCommand", () => { "queue_message_failed reason=not_streaming sessionId=session-active gatewayHealth=live", ); steerRuntimeMocks.isEmbeddedPiRunActive.mockReset().mockReturnValue(false); - steerRuntimeMocks.queueEmbeddedPiMessageWithOutcome.mockReset().mockReturnValue({ + steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync.mockReset().mockResolvedValue({ queued: true, sessionId: "session-active", target: "embedded_run", @@ -51,7 +51,7 @@ describe("handleSteerCommand", () => { expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( "agent:main:main", ); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledWith( + expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).toHaveBeenCalledWith( "session-active", "keep going", { @@ -74,7 +74,7 @@ describe("handleSteerCommand", () => { expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( "agent:main:discord:direct:target", ); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledWith( + expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).toHaveBeenCalledWith( "session-target", "check the target", { @@ -96,7 +96,7 @@ describe("handleSteerCommand", () => { "agent:main:main", ); expect(steerRuntimeMocks.isEmbeddedPiRunActive).toHaveBeenCalledWith("stored-session-id"); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledWith( + expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).toHaveBeenCalledWith( "stored-session-id", "continue from state", { @@ -113,34 +113,40 @@ describe("handleSteerCommand", () => { shouldContinue: false, reply: { text: "Usage: /steer " }, }); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcome).not.toHaveBeenCalled(); + expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); - it("does not start a new run when no current session run is active", async () => { - const result = await handleSteerCommand(buildParams("/steer keep going"), true); + it("continues as a normal prompt when no current session run is active", async () => { + const params = buildParams("/steer keep going"); + const result = await handleSteerCommand(params, true); expect(result).toEqual({ - shouldContinue: false, - reply: { text: "⚠️ No active run to steer in this session." }, + shouldContinue: true, }); - expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcome).not.toHaveBeenCalled(); + expect(params.ctx.Body).toBe("keep going"); + expect(params.ctx.BodyForAgent).toBe("keep going"); + expect((params.ctx as Record).BodyStripped).toBe("keep going"); + expect(params.command.commandBodyNormalized).toBe("keep going"); + expect(steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync).not.toHaveBeenCalled(); }); - it("reports when the active run rejects steering injection", async () => { + it("continues as a normal prompt when the active run rejects steering injection", async () => { steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); - steerRuntimeMocks.queueEmbeddedPiMessageWithOutcome.mockReturnValue({ + steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync.mockResolvedValue({ queued: false, sessionId: "session-active", reason: "not_streaming", gatewayHealth: "live", }); - const result = await handleSteerCommand(buildParams("/steer keep going"), true); + const params = buildParams("/steer keep going"); + const result = await handleSteerCommand(params, true); expect(result).toEqual({ - shouldContinue: false, - reply: { text: "⚠️ Current run is active but not accepting steering right now." }, + shouldContinue: true, }); + expect(params.ctx.BodyForAgent).toBe("keep going"); + expect(params.command.commandBodyNormalized).toBe("keep going"); expect(steerRuntimeMocks.formatEmbeddedPiQueueFailureSummary).toHaveBeenCalledWith({ queued: false, sessionId: "session-active", @@ -149,20 +155,37 @@ describe("handleSteerCommand", () => { }); }); - it("reports compacting runs distinctly", async () => { + it("continues as a normal prompt when steering throws", async () => { steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); - steerRuntimeMocks.queueEmbeddedPiMessageWithOutcome.mockReturnValue({ + steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync.mockRejectedValue( + new Error("socket closed"), + ); + + const params = buildParams("/steer keep going"); + const result = await handleSteerCommand(params, true); + + expect(result).toEqual({ + shouldContinue: true, + }); + expect(params.ctx.BodyForAgent).toBe("keep going"); + expect(params.command.commandBodyNormalized).toBe("keep going"); + }); + + it("continues as a normal prompt when the active run is compacting", async () => { + steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); + steerRuntimeMocks.queueEmbeddedPiMessageWithOutcomeAsync.mockResolvedValue({ queued: false, sessionId: "session-active", reason: "compacting", gatewayHealth: "live", }); - const result = await handleSteerCommand(buildParams("/steer keep going"), true); + const params = buildParams("/steer keep going"); + const result = await handleSteerCommand(params, true); expect(result).toEqual({ - shouldContinue: false, - reply: { text: "⚠️ Current run is compacting; retry after compaction finishes." }, + shouldContinue: true, }); + expect(params.ctx.BodyForAgent).toBe("keep going"); }); }); diff --git a/src/auto-reply/reply/commands-steer.ts b/src/auto-reply/reply/commands-steer.ts index 008232593d8..9e3274127f7 100644 --- a/src/auto-reply/reply/commands-steer.ts +++ b/src/auto-reply/reply/commands-steer.ts @@ -9,26 +9,17 @@ import { rejectUnauthorizedCommand } from "./command-gates.js"; import { formatEmbeddedPiQueueFailureSummary, isEmbeddedPiRunActive, - queueEmbeddedPiMessageWithOutcome, + queueEmbeddedPiMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, } from "./commands-steer.runtime.js"; -import type { CommandHandler, HandleCommandsParams } from "./commands-types.js"; +import type { + CommandHandler, + CommandHandlerResult, + HandleCommandsParams, +} from "./commands-types.js"; const STEER_USAGE = "Usage: /steer "; -function formatSteerQueueFailureReply(reason: string): string { - if (reason === "no_active_run") { - return "⚠️ This session no longer has an active run to steer."; - } - if (reason === "not_streaming") { - return "⚠️ Current run is active but not accepting steering right now."; - } - if (reason === "compacting") { - return "⚠️ Current run is compacting; retry after compaction finishes."; - } - return "⚠️ Current run is active but not accepting steering right now."; -} - function parseSteerMessage(raw: string): string | null { const match = raw.trim().match(/^\/(?:steer|tell)(?:\s+([\s\S]*))?$/i); if (!match) { @@ -82,6 +73,35 @@ function resolveSteerSessionId(params: { return sessionId; } +function applySteerFallbackPrompt(ctx: HandleCommandsParams["ctx"], message: string): void { + const mutableCtx = ctx as Record; + mutableCtx.Body = message; + mutableCtx.RawBody = message; + mutableCtx.CommandBody = message; + mutableCtx.BodyForCommands = message; + mutableCtx.BodyForAgent = message; + mutableCtx.BodyStripped = message; +} + +function formatSteerError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function continueWithSteerFallback( + params: HandleCommandsParams, + message: string, + logMessage: string, +): CommandHandlerResult { + logVerbose(logMessage); + applySteerFallbackPrompt(params.ctx, message); + if (params.rootCtx && params.rootCtx !== params.ctx) { + applySteerFallbackPrompt(params.rootCtx, message); + } + params.command.rawBodyNormalized = message; + params.command.commandBodyNormalized = message; + return { shouldContinue: true }; +} + export const handleSteerCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -103,25 +123,42 @@ export const handleSteerCommand: CommandHandler = async (params, allowTextComman const targetSessionKey = resolveSteerTargetSessionKey(params); if (!targetSessionKey) { - return { shouldContinue: false, reply: { text: "⚠️ No current session to steer." } }; + return continueWithSteerFallback( + params, + message, + "steer: no current session; continuing with /steer payload as a normal prompt", + ); } const sessionId = resolveSteerSessionId({ commandParams: params, targetSessionKey }); if (!sessionId) { - return { shouldContinue: false, reply: { text: "⚠️ No active run to steer in this session." } }; + return continueWithSteerFallback( + params, + message, + `steer: no active run for ${targetSessionKey}; continuing with /steer payload as a normal prompt`, + ); } - const queueOutcome = queueEmbeddedPiMessageWithOutcome(sessionId, message, { + const queueOutcome = await queueEmbeddedPiMessageWithOutcomeAsync(sessionId, message, { steeringMode: "all", debounceMs: 0, + }).catch((err: unknown): CommandHandlerResult => { + return continueWithSteerFallback( + params, + message, + `steer: active session ${sessionId} threw while steering: ${formatSteerError(err)}; continuing with /steer payload as a normal prompt`, + ); }); + if ("shouldContinue" in queueOutcome) { + return queueOutcome; + } if (!queueOutcome.queued) { const summary = formatEmbeddedPiQueueFailureSummary(queueOutcome); - logVerbose(`steer: active session ${sessionId} rejected steering injection: ${summary}`); - return { - shouldContinue: false, - reply: { text: formatSteerQueueFailureReply(queueOutcome.reason) }, - }; + return continueWithSteerFallback( + params, + message, + `steer: active session ${sessionId} rejected steering injection: ${summary}; continuing with /steer payload as a normal prompt`, + ); } return { shouldContinue: false, reply: { text: "steered current session." } }; diff --git a/src/auto-reply/reply/directive-handling.queue-validation.test.ts b/src/auto-reply/reply/directive-handling.queue-validation.test.ts index c0640f2c0d1..a644dd5e9f3 100644 --- a/src/auto-reply/reply/directive-handling.queue-validation.test.ts +++ b/src/auto-reply/reply/directive-handling.queue-validation.test.ts @@ -14,6 +14,15 @@ describe("maybeHandleQueueDirective", () => { expect(invalid?.text).toContain("Invalid cap"); expect(invalid?.text).toContain("Invalid drop policy"); + const invalidMode = maybeHandleQueueDirective({ + directives: parseInlineDirectives("/queue backlog"), + cfg: {} as OpenClawConfig, + channel: "quietchat", + }); + expect(invalidMode?.text).toContain( + 'Unrecognized queue mode "backlog". Valid modes: steer, followup, collect, interrupt.', + ); + const current = maybeHandleQueueDirective({ directives: parseInlineDirectives("/queue"), cfg: { @@ -32,7 +41,7 @@ describe("maybeHandleQueueDirective", () => { "Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.", ); expect(current?.text).toContain( - "Options: modes steer, queue, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize.", + "Options: modes steer, followup, collect, interrupt; debounce:, cap:, drop:old|new|summarize.", ); }); }); diff --git a/src/auto-reply/reply/directive-handling.queue-validation.ts b/src/auto-reply/reply/directive-handling.queue-validation.ts index 9f7221662aa..01fe111ad48 100644 --- a/src/auto-reply/reply/directive-handling.queue-validation.ts +++ b/src/auto-reply/reply/directive-handling.queue-validation.ts @@ -37,7 +37,7 @@ export function maybeHandleQueueDirective(params: { return { text: withOptions( `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`, - "modes steer, queue, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize", + "modes steer, followup, collect, interrupt; debounce:, cap:, drop:old|new|summarize", ), }; } @@ -53,7 +53,7 @@ export function maybeHandleQueueDirective(params: { const errors: string[] = []; if (queueModeInvalid) { errors.push( - `Unrecognized queue mode "${directives.rawQueueMode ?? ""}". Valid modes: steer, queue, followup, collect, steer+backlog, interrupt.`, + `Unrecognized queue mode "${directives.rawQueueMode ?? ""}". Valid modes: steer, followup, collect, interrupt.`, ); } if (queueDebounceInvalid) { diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index a11c2d071b0..1ed6d05d228 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -847,7 +847,7 @@ describe("createFollowupRunner compaction", () => { sessionFile: path.join(path.dirname(storePath), "session.jsonl"), }, }); - const queueSettings: QueueSettings = { mode: "queue" }; + const queueSettings: QueueSettings = { mode: "followup" }; enqueueFollowupRun("main", queuedNext, queueSettings); const current = createQueuedRun({ diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index a4da7a6fa17..7eacbc43c35 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -417,6 +417,7 @@ describe("handleInlineActions", () => { kind: "continue", directives: clearInlineDirectives("<@123> what's next?"), abortedLastRun: false, + cleanedBody: "<@123> what's next?", }); expect(buildStatusReplyMock).toHaveBeenCalledTimes(1); expect(handleCommandsMock).toHaveBeenCalledTimes(1); @@ -488,6 +489,7 @@ describe("handleInlineActions", () => { kind: "continue", directives: clearInlineDirectives("new message"), abortedLastRun: false, + cleanedBody: "new message", }); expect(sessionStore["s:main"]?.abortCutoffMessageSid).toBeUndefined(); expect(sessionStore["s:main"]?.abortCutoffTimestamp).toBeUndefined(); diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 900cf09b89c..12b731753e8 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -127,6 +127,7 @@ export type InlineActionResult = kind: "continue"; directives: InlineDirectives; abortedLastRun: boolean; + cleanedBody: string; }; function extractTextFromToolResult(result: unknown): string | null { @@ -537,6 +538,7 @@ export async function handleInlineActions(params: { kind: "continue", directives, abortedLastRun, + cleanedBody, }; } const remainingBodyAfterInlineStatus = (() => { @@ -555,15 +557,26 @@ export async function handleInlineActions(params: { return { kind: "reply", reply: undefined }; } + const commandBodyBeforeRun = command.commandBodyNormalized; + const bodyBeforeRun = sessionCtx.BodyStripped ?? sessionCtx.BodyForAgent; const commandResult = await runCommands(command); if (!commandResult.shouldContinue) { typing.cleanup(); return { kind: "reply", reply: commandResult.reply }; } + if (command.commandBodyNormalized !== commandBodyBeforeRun) { + cleanedBody = command.commandBodyNormalized; + } else { + const bodyAfterRun = sessionCtx.BodyStripped ?? sessionCtx.BodyForAgent; + if (bodyAfterRun !== undefined && bodyAfterRun !== bodyBeforeRun) { + cleanedBody = bodyAfterRun; + } + } return { kind: "continue", directives, abortedLastRun, + cleanedBody, }; } diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 07b20a13d19..0798f2ea337 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -103,7 +103,7 @@ vi.mock("./inbound-meta.js", () => ({ })); vi.mock("./queue/settings-runtime.js", () => ({ - resolveQueueSettings: vi.fn().mockReturnValue({ mode: "followup" }), + resolveQueueSettings: vi.fn().mockReturnValue({ mode: "steer" }), })); vi.mock("./route-reply.runtime.js", () => ({ @@ -505,6 +505,71 @@ describe("runPreparedReply media-only handling", () => { expect(call.followupRun.prompt).toContain("[User sent media without caption]"); }); + it.each([ + "discord", + "telegram", + "slack", + "whatsapp", + "signal", + "imessage", + "matrix", + "msteams", + "webchat", + ] as const)("enables default same-turn steering for active %s runs", async (channel) => { + const queueSettings = await import("./queue/settings-runtime.js"); + const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ + mode: "steer", + debounceMs: 500, + cap: 20, + dropPolicy: "summarize", + }); + vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId) + .mockReturnValueOnce("active-session") + .mockReturnValueOnce("active-session"); + vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValueOnce(true); + vi.mocked(piRuntime.isEmbeddedPiRunStreaming).mockReturnValueOnce(true); + + const params = baseParams({ + sessionKey: `agent:main:${channel}:direct:steer-smoke`, + }); + params.ctx = { + ...params.ctx, + Provider: channel, + OriginatingChannel: channel, + OriginatingTo: `${channel}-target`, + ChatType: "direct", + } as never; + params.sessionCtx = { + ...params.sessionCtx, + Provider: channel, + OriginatingChannel: channel, + OriginatingTo: `${channel}-target`, + ChatType: "direct", + } as never; + params.command = { + ...(params.command as Record), + surface: channel, + channel, + } as never; + + await runPreparedReply(params); + + expect(queueSettings.resolveQueueSettings).toHaveBeenCalledWith( + expect.objectContaining({ channel }), + ); + const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0]; + expect(call).toMatchObject({ + shouldSteer: true, + shouldFollowup: true, + isActive: true, + isStreaming: true, + resolvedQueue: expect.objectContaining({ mode: "steer" }), + }); + expect(call?.followupRun.run.messageProvider).toBe(channel); + expect(call?.followupRun.originatingChannel).toBe(channel); + }); + it("keeps thread history context on follow-up turns", async () => { const result = await runPreparedReply( baseParams({ @@ -940,11 +1005,11 @@ describe("runPreparedReply media-only handling", () => { await expect(runPromise).resolves.toEqual({ text: "ok" }); expect(vi.mocked(runReplyAgent)).toHaveBeenCalledOnce(); }); - it("treats reset-triggered steer mode as interrupt when the session lane is empty", async () => { + it("treats reset-triggered followup mode as interrupt when the session lane is empty", async () => { const queueSettings = await import("./queue/settings-runtime.js"); const piRuntime = await import("../../agents/pi-embedded.runtime.js"); const commandQueue = await import("../../process/command-queue.js"); - vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "steer" }); + vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ mode: "followup" }); vi.mocked(commandQueue.getQueueSize).mockReturnValueOnce(0); vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId).mockReturnValue("session-active"); vi.mocked(piRuntime.abortEmbeddedPiRun).mockReturnValue(true); @@ -966,6 +1031,33 @@ describe("runPreparedReply media-only handling", () => { expect(call?.shouldFollowup).toBe(false); expect(call?.resetTriggered).toBe(true); }); + it("does not enable steering for active heartbeat runs", async () => { + const queueSettings = await import("./queue/settings-runtime.js"); + const piRuntime = await import("../../agents/pi-embedded.runtime.js"); + vi.mocked(queueSettings.resolveQueueSettings).mockReturnValueOnce({ + mode: "followup", + debounceMs: 500, + cap: 20, + dropPolicy: "summarize", + }); + vi.mocked(piRuntime.resolveActiveEmbeddedRunSessionId) + .mockReturnValueOnce("active-session") + .mockReturnValueOnce("active-session"); + vi.mocked(piRuntime.isEmbeddedPiRunActive).mockReturnValueOnce(true); + vi.mocked(piRuntime.isEmbeddedPiRunStreaming).mockReturnValueOnce(true); + + await runPreparedReply( + baseParams({ + opts: { isHeartbeat: true }, + }), + ); + + const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0]; + expect(call?.shouldSteer).toBe(false); + expect(call?.shouldFollowup).toBe(true); + expect(call?.isActive).toBe(true); + expect(call?.isStreaming).toBe(true); + }); it("rechecks same-session ownership after async prep before registering a new reply operation", async () => { const { resolveSessionAuthProfileOverride } = await import("../../agents/auth-profiles/session-override.js"); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 8732ea83029..e9b6d480a03 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -68,7 +68,6 @@ import { resolveOriginMessageProvider } from "./origin-routing.js"; import { buildReplyPromptEnvelope, buildReplyPromptEnvelopeBase } from "./prompt-prelude.js"; import { resolveActiveRunQueueAction } from "./queue-policy.js"; import { resolveQueueSettings } from "./queue/settings-runtime.js"; -import { isSteeringQueueMode } from "./queue/steering.js"; import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; import { resolveBareSessionResetPromptState } from "./session-reset-prompt.js"; import { resolveBareResetBootstrapFileAccess } from "./session-reset-prompt.js"; @@ -908,15 +907,16 @@ export async function runPreparedReply( }; }; let { activeSessionId, isActive, isStreaming } = resolveQueueBusyState(); - const shouldSteer = !effectiveResetTriggered && isSteeringQueueMode(resolvedQueue.mode); + const isHeartbeatRun = opts?.isHeartbeat === true; + const shouldSteer = !isHeartbeatRun && !effectiveResetTriggered && resolvedQueue.mode === "steer"; const shouldFollowup = !effectiveResetTriggered && - (resolvedQueue.mode === "followup" || - resolvedQueue.mode === "collect" || - resolvedQueue.mode === "steer-backlog"); + (resolvedQueue.mode === "steer" || + resolvedQueue.mode === "followup" || + resolvedQueue.mode === "collect"); const activeRunQueueAction = resolveActiveRunQueueAction({ isActive, - isHeartbeat: opts?.isHeartbeat === true, + isHeartbeat: isHeartbeatRun, shouldFollowup, queueMode: activeRunQueueMode, resetTriggered: effectiveResetTriggered, diff --git a/src/auto-reply/reply/get-reply.before-agent-reply.test.ts b/src/auto-reply/reply/get-reply.before-agent-reply.test.ts index f1ad69b615c..9ef52a482d2 100644 --- a/src/auto-reply/reply/get-reply.before-agent-reply.test.ts +++ b/src/auto-reply/reply/get-reply.before-agent-reply.test.ts @@ -71,6 +71,7 @@ describe("getReplyFromConfig before_agent_reply wiring", () => { kind: "continue", directives: {}, abortedLastRun: false, + cleanedBody: "hello world", }); mocks.hasHooks.mockImplementation((hookName) => hookName === "before_agent_reply"); }); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 8ee6ff90126..e03d897ae92 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -760,6 +760,7 @@ export async function getReplyFromConfig( } await maybeEmitMissingResetHooks(); directives = inlineActionResult.directives; + cleanedBody = inlineActionResult.cleanedBody; abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun; // Allow plugins to intercept and return a synthetic reply before the LLM runs. diff --git a/src/auto-reply/reply/queue-policy.test.ts b/src/auto-reply/reply/queue-policy.test.ts index c630c4687ba..df6b7f46c3b 100644 --- a/src/auto-reply/reply/queue-policy.test.ts +++ b/src/auto-reply/reply/queue-policy.test.ts @@ -35,21 +35,8 @@ describe("resolveActiveRunQueueAction", () => { ).toBe("enqueue-followup"); }); - it("enqueues steer mode runs while active", () => { - for (const queueMode of ["steer", "queue"] as const) { - expect( - resolveActiveRunQueueAction({ - isActive: true, - isHeartbeat: false, - shouldFollowup: false, - queueMode, - }), - ).toBe("enqueue-followup"); - } - }); - it("runs reset-triggered turns immediately while another run is active", () => { - for (const queueMode of ["steer", "queue", "collect", "followup"] as const) { + for (const queueMode of ["collect", "followup"] as const) { expect( resolveActiveRunQueueAction({ isActive: true, @@ -68,7 +55,7 @@ describe("resolveActiveRunQueueAction", () => { isActive: true, isHeartbeat: true, shouldFollowup: true, - queueMode: "steer", + queueMode: "followup", resetTriggered: true, }), ).toBe("drop"); diff --git a/src/auto-reply/reply/queue-policy.ts b/src/auto-reply/reply/queue-policy.ts index e34c661989f..b47d3f16dad 100644 --- a/src/auto-reply/reply/queue-policy.ts +++ b/src/auto-reply/reply/queue-policy.ts @@ -18,7 +18,7 @@ export function resolveActiveRunQueueAction(params: { if (params.resetTriggered) { return "run-now"; } - if (params.shouldFollowup || params.queueMode === "steer" || params.queueMode === "queue") { + if (params.shouldFollowup) { return "enqueue-followup"; } return "run-now"; diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index d3c7a5b2e64..b293b56262a 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -9,11 +9,6 @@ export { } from "./queue/enqueue.js"; export { resolveQueueSettings } from "./queue/settings-runtime.js"; export { clearFollowupQueue, refreshQueuedFollowupSession } from "./queue/state.js"; -export { - isSteeringQueueMode, - resolvePiSteeringModeForQueueMode, - type PiSteeringMode, -} from "./queue/steering.js"; export type { FollowupRun, QueueDedupeMode, diff --git a/src/auto-reply/reply/queue/directive.ts b/src/auto-reply/reply/queue/directive.ts index 0579dd1a005..15819edd681 100644 --- a/src/auto-reply/reply/queue/directive.ts +++ b/src/auto-reply/reply/queue/directive.ts @@ -110,6 +110,10 @@ function parseQueueDirectiveArgs(raw: string): { consumed = i; continue; } + if (consumed === skipDirectiveArgPrefix(raw) && !queueReset && !hasOptions) { + rawMode = token; + consumed = i; + } // Stop at first unrecognized token. break; } diff --git a/src/auto-reply/reply/queue/normalize.ts b/src/auto-reply/reply/queue/normalize.ts index 4e3a7339b82..07c1be592e7 100644 --- a/src/auto-reply/reply/queue/normalize.ts +++ b/src/auto-reply/reply/queue/normalize.ts @@ -6,9 +6,6 @@ export function normalizeQueueMode(raw?: string): QueueMode | undefined { if (!cleaned) { return undefined; } - if (cleaned === "queue" || cleaned === "queued") { - return "queue"; - } if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort") { return "interrupt"; } @@ -21,8 +18,20 @@ export function normalizeQueueMode(raw?: string): QueueMode | undefined { if (cleaned === "collect" || cleaned === "coalesce") { return "collect"; } + return undefined; +} + +export function normalizePersistedQueueMode(raw?: string): QueueMode | undefined { + const normalized = normalizeQueueMode(raw); + if (normalized) { + return normalized; + } + const cleaned = normalizeOptionalLowercaseString(raw); + if (cleaned === "queue" || cleaned === "queued") { + return "steer"; + } if (cleaned === "steer+backlog" || cleaned === "steer-backlog" || cleaned === "steer_backlog") { - return "steer-backlog"; + return "followup"; } return undefined; } diff --git a/src/auto-reply/reply/queue/settings.test.ts b/src/auto-reply/reply/queue/settings.test.ts index 6fa88152961..10b001ba219 100644 --- a/src/auto-reply/reply/queue/settings.test.ts +++ b/src/auto-reply/reply/queue/settings.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { resolveQueueSettings } from "./settings.js"; describe("resolveQueueSettings", () => { - it("defaults inbound channels to steer with a short followup debounce", () => { + it("defaults inbound channels to steering settings", () => { expect(resolveQueueSettings({ cfg: {} as OpenClawConfig })).toEqual({ mode: "steer", debounceMs: 500, @@ -37,7 +37,7 @@ describe("resolveQueueSettings", () => { cfg: { messages: { queue: { - mode: "steer", + mode: "followup", debounceMs: 750, byChannel: { discord: "collect", @@ -55,16 +55,62 @@ describe("resolveQueueSettings", () => { }); }); - it("keeps legacy queue mode distinct from steer", () => { - const settings = resolveQueueSettings({ - cfg: { - messages: { - queue: { - mode: "queue", + it("uses explicit steer mode from config", () => { + expect( + resolveQueueSettings({ + cfg: { + messages: { + queue: { + mode: "steer", + }, }, - }, - } as OpenClawConfig, + } as OpenClawConfig, + }), + ).toEqual({ + mode: "steer", + debounceMs: 500, + cap: 20, + dropPolicy: "summarize", }); - expect(settings.mode).toBe("queue"); + }); + + it("ignores removed steering queue modes from stale config", () => { + expect( + resolveQueueSettings({ + cfg: { + messages: { + queue: { + mode: "steer-backlog" as never, + }, + }, + } as OpenClawConfig, + }), + ).toEqual({ + mode: "steer", + debounceMs: 500, + cap: 20, + dropPolicy: "summarize", + }); + }); + + it("maps retired persisted session queue modes to compatible modes", () => { + expect( + resolveQueueSettings({ + cfg: {} as OpenClawConfig, + sessionEntry: { queueMode: "queue" as never }, + }).mode, + ).toBe("steer"); + expect( + resolveQueueSettings({ + cfg: {} as OpenClawConfig, + sessionEntry: { queueMode: "steer-backlog" as never }, + }).mode, + ).toBe("followup"); + expect( + resolveQueueSettings({ + cfg: {} as OpenClawConfig, + sessionEntry: { queueMode: "steer+backlog" as never }, + }).mode, + ).toBe("followup"); }); }); diff --git a/src/auto-reply/reply/queue/settings.ts b/src/auto-reply/reply/queue/settings.ts index 0d7b03f115d..15c61a02296 100644 --- a/src/auto-reply/reply/queue/settings.ts +++ b/src/auto-reply/reply/queue/settings.ts @@ -1,6 +1,10 @@ import type { InboundDebounceByProvider } from "../../../config/types.messages.js"; import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; -import { normalizeQueueDropPolicy, normalizeQueueMode } from "./normalize.js"; +import { + normalizePersistedQueueMode, + normalizeQueueDropPolicy, + normalizeQueueMode, +} from "./normalize.js"; import { DEFAULT_QUEUE_CAP, DEFAULT_QUEUE_DEBOUNCE_MS, DEFAULT_QUEUE_DROP } from "./state.js"; import type { QueueMode, QueueSettings, ResolveQueueSettingsParams } from "./types.js"; @@ -29,7 +33,7 @@ export function resolveQueueSettings(params: ResolveQueueSettingsParams): QueueS : undefined; const resolvedMode = params.inlineMode ?? - normalizeQueueMode(params.sessionEntry?.queueMode) ?? + normalizePersistedQueueMode(params.sessionEntry?.queueMode) ?? normalizeQueueMode(providerModeRaw) ?? normalizeQueueMode(queueCfg?.mode) ?? defaultQueueModeForChannel(channelKey); diff --git a/src/auto-reply/reply/queue/state.test.ts b/src/auto-reply/reply/queue/state.test.ts index 0153d360e95..ee3994760f3 100644 --- a/src/auto-reply/reply/queue/state.test.ts +++ b/src/auto-reply/reply/queue/state.test.ts @@ -28,7 +28,7 @@ function makeRun(): FollowupRun["run"] { describe("refreshQueuedFollowupSession", () => { it("retargets queued runs to the persisted selection", () => { - const queue = getFollowupQueue(QUEUE_KEY, { mode: "queue" }); + const queue = getFollowupQueue(QUEUE_KEY, { mode: "followup" }); const lastRun = makeRun(); const queuedRun: FollowupRun = { prompt: "queued message", @@ -63,7 +63,7 @@ describe("refreshQueuedFollowupSession", () => { }); it("retargets queued runs with user model override source", () => { - const queue = getFollowupQueue(QUEUE_KEY, { mode: "queue" }); + const queue = getFollowupQueue(QUEUE_KEY, { mode: "followup" }); const queuedRun: FollowupRun = { prompt: "queued message", enqueuedAt: Date.now(), diff --git a/src/auto-reply/reply/queue/steering.ts b/src/auto-reply/reply/queue/steering.ts deleted file mode 100644 index d0c388eb82a..00000000000 --- a/src/auto-reply/reply/queue/steering.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { QueueMode } from "./types.js"; - -export type PiSteeringMode = "all" | "one-at-a-time"; - -export function isSteeringQueueMode(mode: QueueMode): boolean { - return mode === "steer" || mode === "queue" || mode === "steer-backlog"; -} - -export function resolvePiSteeringModeForQueueMode(mode: QueueMode): PiSteeringMode { - return mode === "queue" ? "one-at-a-time" : "all"; -} diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 1397fa9dbf8..730946bafbd 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -10,7 +10,7 @@ import type { SourceReplyDeliveryMode } from "../../get-reply-options.types.js"; import type { OriginatingChannelType } from "../../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js"; -export type QueueMode = "steer" | "followup" | "collect" | "steer-backlog" | "interrupt" | "queue"; +export type QueueMode = "steer" | "followup" | "collect" | "interrupt"; export type QueueDropPolicy = "old" | "new" | "summarize"; diff --git a/src/commands/doctor/shared/deprecation-compat.test.ts b/src/commands/doctor/shared/deprecation-compat.test.ts index 10f102b8711..51245ac6fa9 100644 --- a/src/commands/doctor/shared/deprecation-compat.test.ts +++ b/src/commands/doctor/shared/deprecation-compat.test.ts @@ -13,6 +13,7 @@ const requiredDoctorCompatCodes = [ "doctor-agent-runtime-embedded-harness", "doctor-plugin-install-config-ledger", "doctor-bundled-plugin-load-paths", + "doctor-message-queue-steering-modes", "doctor-web-search-plugin-config", "doctor-web-fetch-plugin-config", "doctor-x-search-plugin-config", diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index 81ba7879306..7cacc5c9446 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -153,6 +153,16 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ docsPath: "/channels/channel-routing", tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], }), + deprecatedCompatRecord({ + code: "doctor-message-queue-steering-modes", + owner: "config", + introduced: "2026-05-04", + source: "messages.queue.mode and messages.queue.byChannel retired queue modes", + migration: "src/commands/doctor/shared/legacy-config-migrations.queue.ts", + replacement: "steer, followup, collect, or interrupt queue modes", + docsPath: "/concepts/queue", + tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], + }), deprecatedCompatRecord({ code: "doctor-channel-dm-aliases", owner: "channel", diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index b8bce151c97..defdbf0ac23 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -116,6 +116,38 @@ describe("legacy thread binding spawn migrate", () => { }); }); +describe("legacy message queue mode migrate", () => { + it("moves retired queue steering modes to followup mode", () => { + const res = migrateLegacyConfigForTest({ + messages: { + queue: { + mode: "queue", + byChannel: { + discord: "steer-backlog", + telegram: "collect", + slack: "steer", + }, + }, + }, + }); + + expect(res.config?.messages?.queue).toEqual({ + mode: "steer", + byChannel: { + discord: "followup", + telegram: "collect", + slack: "steer", + }, + }); + expect(res.changes).toContain( + 'Moved deprecated messages.queue.mode "queue" → "steer"; use "steer" for default active-run steering.', + ); + expect(res.changes).toContain( + 'Moved deprecated messages.queue.byChannel.discord "steer-backlog" → "followup"; use "steer" for default active-run steering.', + ); + }); +}); + describe("legacy migrate audio transcription", () => { it("does not rewrite removed routing.transcribeAudio migrations", () => { const res = migrateLegacyConfigForTest({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.queue.ts b/src/commands/doctor/shared/legacy-config-migrations.queue.ts new file mode 100644 index 00000000000..22bb8fa6c52 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.queue.ts @@ -0,0 +1,84 @@ +import { + defineLegacyConfigMigration, + getRecord, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "../../../config/legacy.shared.js"; + +const RETIRED_QUEUE_MODES = new Set(["queue", "steer-backlog", "steer+backlog"]); + +function isRetiredQueueMode(value: unknown): value is string { + return typeof value === "string" && RETIRED_QUEUE_MODES.has(value); +} + +function hasRetiredQueueModeByChannel(value: unknown): boolean { + const byChannel = getRecord(value); + return Boolean(byChannel && Object.values(byChannel).some(isRetiredQueueMode)); +} + +function migrateQueueMode(params: { + owner: Record; + key: string; + path: string; + changes: string[]; +}): boolean { + const value = params.owner[params.key]; + if (!isRetiredQueueMode(value)) { + return false; + } + const replacement = value === "queue" ? "steer" : "followup"; + params.owner[params.key] = replacement; + params.changes.push( + `Moved deprecated ${params.path} "${value}" → "${replacement}"; use "steer" for default active-run steering.`, + ); + return true; +} + +const QUEUE_MODE_RULES: LegacyConfigRule[] = [ + { + path: ["messages", "queue", "mode"], + message: + 'messages.queue.mode uses a retired queue mode; use steer, followup, collect, or interrupt. Run "openclaw doctor --fix".', + match: isRetiredQueueMode, + }, + { + path: ["messages", "queue", "byChannel"], + message: + 'messages.queue.byChannel contains a retired queue mode; use steer, followup, collect, or interrupt. Run "openclaw doctor --fix".', + match: hasRetiredQueueModeByChannel, + }, +]; + +export const LEGACY_CONFIG_MIGRATIONS_QUEUE: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "messages.queue.retired-steering-modes", + describe: "Move retired messages.queue modes to followup mode", + legacyRules: QUEUE_MODE_RULES, + apply: (raw, changes) => { + const queue = getRecord(getRecord(raw.messages)?.queue); + if (!queue) { + return; + } + + migrateQueueMode({ + owner: queue, + key: "mode", + path: "messages.queue.mode", + changes, + }); + + const byChannel = getRecord(queue.byChannel); + if (byChannel) { + for (const [channelId, _value] of Object.entries(byChannel)) { + migrateQueueMode({ + owner: byChannel, + key: channelId, + path: `messages.queue.byChannel.${channelId}`, + changes, + }); + } + queue.byChannel = byChannel; + } + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.ts b/src/commands/doctor/shared/legacy-config-migrations.ts index 4b02a0cd7b2..3cf3066427c 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.ts @@ -1,11 +1,13 @@ import { LEGACY_CONFIG_MIGRATIONS_AUDIO } from "./legacy-config-migrations.audio.js"; import { LEGACY_CONFIG_MIGRATIONS_CHANNELS } from "./legacy-config-migrations.channels.js"; +import { LEGACY_CONFIG_MIGRATIONS_QUEUE } from "./legacy-config-migrations.queue.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME } from "./legacy-config-migrations.runtime.js"; import { LEGACY_CONFIG_MIGRATIONS_WEB_SEARCH } from "./legacy-config-migrations.web-search.js"; const LEGACY_CONFIG_MIGRATION_SPECS = [ ...LEGACY_CONFIG_MIGRATIONS_CHANNELS, ...LEGACY_CONFIG_MIGRATIONS_AUDIO, + ...LEGACY_CONFIG_MIGRATIONS_QUEUE, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME, ...LEGACY_CONFIG_MIGRATIONS_WEB_SEARCH, ]; diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 90100d26313..f35dfe1b080 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -1107,6 +1107,26 @@ describe("config strict validation", () => { expect(raw.messages.tts).not.toHaveProperty("providers"); }); + it("reports retired queue steering modes without read-time auto-migration", async () => { + const raw = { + messages: { + queue: { + mode: "queue", + byChannel: { + discord: "steer-backlog", + telegram: "collect", + }, + }, + }, + }; + const issues = findLegacyConfigIssues(raw); + + expect(issues.some((issue) => issue.path === "messages.queue.mode")).toBe(true); + expect(issues.some((issue) => issue.path === "messages.queue.byChannel")).toBe(true); + expect(raw.messages.queue.mode).toBe("queue"); + expect(raw.messages.queue.byChannel.discord).toBe("steer-backlog"); + }); + it("rejects legacy sandbox perSession without read-time auto-migration", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts index 7b3445efcdc..8eb880fb0ef 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts @@ -133,4 +133,21 @@ describe("legacy config detection", () => { expectedValue: "queue", }); }); + it("rejects retired messages.queue.mode without mutating the source", () => { + expectOpenClawSchemaInvalidPreservesField({ + config: { messages: { queue: { mode: "queue" } } }, + readValue: (parsed) => + ( + parsed as { + messages?: { + queue?: { + mode?: unknown; + }; + }; + } + ).messages?.queue?.mode, + expectedValue: "queue", + expectedPath: "messages.queue.mode", + }); + }); }); diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 9e74a2a7c9c..39c4183dcbb 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -434,15 +434,7 @@ const ENUM_EXPECTATIONS: Record = { "hooks.mappings[].wakeMode": ['"now"', '"next-heartbeat"'], "hooks.gmail.tailscale.mode": ['"off"', '"serve"', '"funnel"'], "hooks.gmail.thinking": ['"off"', '"minimal"', '"low"', '"medium"', '"high"'], - "messages.queue.mode": [ - '"steer"', - '"followup"', - '"collect"', - '"steer-backlog"', - '"steer+backlog"', - '"queue"', - '"interrupt"', - ], + "messages.queue.mode": ['"steer"', '"followup"', '"collect"', '"interrupt"'], "messages.queue.drop": ['"old"', '"new"', '"summarize"'], "channels.defaults.groupPolicy": ['"open"', '"disabled"', '"allowlist"'], "channels.defaults.contextVisibility": ['"all"', '"allowlist"', '"allowlist_quote"'], @@ -783,7 +775,7 @@ describe("config help copy quality", () => { const queueMode = FIELD_HELP["messages.queue.mode"]; expect(queueMode.includes('"interrupt"')).toBe(true); - expect(queueMode.includes('"steer+backlog"')).toBe(true); + expect(queueMode.includes('"steer"')).toBe(true); }); it("documents gateway bind modes and web reconnect semantics", () => { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 86157fe5240..3f6c5c51ad2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1759,13 +1759,13 @@ export const FIELD_HELP: Record = { "messages.groupChat.visibleReplies": 'Overrides visible source replies for group/channel conversations. Defaults to "message_tool" when no global visible reply policy is set. "message_tool" keeps normal final replies private and requires message(action=send) for room output; "automatic" posts normal replies as before.', "messages.queue": - "Inbound message queue strategy for messages that arrive while a session run is active. Default mode is steer, with followup fallback when steering is unavailable.", + "Queue strategy for inbound messages that arrive while a session run is active. Use this to tune steering, deferred followups, batching, or interruption.", "messages.queue.mode": - 'Queue behavior mode. Use "steer" to inject all queued steering messages at the next model boundary; "queue" is legacy one-at-a-time steering; "followup" runs later; "collect" batches later; "steer-backlog" (alias "steer+backlog") does both; "interrupt" aborts the active run.', + 'Queue mode for active runs. Use "steer" to inject prompts into the active run, "followup" to run later, "collect" to batch compatible messages later, or "interrupt" to abort the active run before starting the newest prompt.', "messages.queue.byChannel": - "Per-channel queue mode overrides keyed by provider id (for example telegram, discord, slack). Use this when one channel’s traffic pattern needs different queue behavior than global defaults.", + "Per-channel queue mode overrides keyed by provider id (for example telegram, discord, slack). Use this when one channel's traffic pattern needs different behavior than global defaults.", "messages.queue.debounceMs": - "Global followup queue debounce window in milliseconds before draining buffered inbound messages. Default is 500ms; higher values coalesce bursts, lower values reduce latency.", + "Global fallback followup queue debounce window in milliseconds before draining buffered inbound messages. Default is 500ms; higher values coalesce bursts, lower values reduce latency.", "messages.queue.debounceMsByChannel": "Per-channel debounce overrides for queue behavior keyed by provider id. Use this to tune burst handling independently for chat surfaces with different pacing.", "messages.queue.cap": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 1086603d845..fe863e92e45 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -889,8 +889,8 @@ export const FIELD_LABELS: Record = { "messages.queue": "Inbound Queue", "messages.queue.mode": "Queue Mode", "messages.queue.byChannel": "Queue Mode by Channel", - "messages.queue.debounceMs": "Queue Debounce (ms)", - "messages.queue.debounceMsByChannel": "Queue Debounce by Channel (ms)", + "messages.queue.debounceMs": "Queue Fallback Debounce (ms)", + "messages.queue.debounceMsByChannel": "Queue Fallback Debounce by Channel (ms)", "messages.queue.cap": "Queue Capacity", "messages.queue.drop": "Queue Drop Strategy", "messages.inbound": "Inbound Debounce", diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 5a35f63ad86..c312c31fd0f 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -290,14 +290,7 @@ export type SessionEntry = { groupActivation?: "mention" | "always"; groupActivationNeedsSystemIntro?: boolean; sendPolicy?: "allow" | "deny"; - queueMode?: - | "steer" - | "followup" - | "collect" - | "steer-backlog" - | "steer+backlog" - | "queue" - | "interrupt"; + queueMode?: "steer" | "followup" | "collect" | "interrupt"; queueDebounceMs?: number; queueCap?: number; queueDrop?: "old" | "new" | "summarize"; diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts index 5795db2b977..423f90530d5 100644 --- a/src/config/types.queue.ts +++ b/src/config/types.queue.ts @@ -1,11 +1,4 @@ -export type QueueMode = - | "steer" - | "followup" - | "collect" - | "steer-backlog" - | "steer+backlog" - | "queue" - | "interrupt"; +export type QueueMode = "steer" | "followup" | "collect" | "interrupt"; export type QueueDropPolicy = "old" | "new" | "summarize"; export type QueueModeByProvider = { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index ac6e8bd734a..92b4eb41886 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -455,9 +455,6 @@ const QueueModeSchema = z.union([ z.literal("steer"), z.literal("followup"), z.literal("collect"), - z.literal("steer-backlog"), - z.literal("steer+backlog"), - z.literal("queue"), z.literal("interrupt"), ]); const QueueDropSchema = z.union([z.literal("old"), z.literal("new"), z.literal("summarize")]); diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index d905d110f68..7820d570150 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -114,8 +114,10 @@ export { }; /** - * @deprecated Active-run queueing is an internal runtime concern. Use current - * runtime hooks instead of steering a harness through this legacy boolean API. + * @deprecated Active-run queueing is an internal runtime concern. This legacy + * boolean API only reports immediate queue eligibility and cannot observe async + * runtime rejection; runtime-owned delivery paths should use acceptance-aware + * steering instead of public SDK queueing. */ export function queueAgentHarnessMessage( sessionId: string,