diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index 2a9aee841408..5de1abdc8a83 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -17435,6 +17435,7 @@ public struct ChatSendParams: Codable, Sendable {
public let systemprovenancereceipt: String?
public let suppresscommandinterpretation: Bool?
public let expectedleafentryid: AnyCodable?
+ public let expectedrunid: String?
public let expectedsessionroutingcontract: String?
public let idempotencykey: String
@@ -17460,6 +17461,7 @@ public struct ChatSendParams: Codable, Sendable {
systemprovenancereceipt: String? = nil,
suppresscommandinterpretation: Bool? = nil,
expectedleafentryid: AnyCodable? = nil,
+ expectedrunid: String? = nil,
expectedsessionroutingcontract: String? = nil,
idempotencykey: String)
{
@@ -17484,6 +17486,7 @@ public struct ChatSendParams: Codable, Sendable {
self.systemprovenancereceipt = systemprovenancereceipt
self.suppresscommandinterpretation = suppresscommandinterpretation
self.expectedleafentryid = expectedleafentryid
+ self.expectedrunid = expectedrunid
self.expectedsessionroutingcontract = expectedsessionroutingcontract
self.idempotencykey = idempotencykey
}
@@ -17509,6 +17512,7 @@ public struct ChatSendParams: Codable, Sendable {
systemprovenancereceipt: String? = nil,
suppresscommandinterpretation: Bool? = nil,
expectedleafentryid: AnyCodable? = nil,
+ expectedrunid: String? = nil,
expectedsessionroutingcontract: String? = nil,
idempotencykey: String)
{
@@ -17534,6 +17538,7 @@ public struct ChatSendParams: Codable, Sendable {
systemprovenancereceipt: systemprovenancereceipt,
suppresscommandinterpretation: suppresscommandinterpretation,
expectedleafentryid: expectedleafentryid,
+ expectedrunid: expectedrunid,
expectedsessionroutingcontract: expectedsessionroutingcontract,
idempotencykey: idempotencykey)
}
@@ -17560,6 +17565,7 @@ public struct ChatSendParams: Codable, Sendable {
case systemprovenancereceipt = "systemProvenanceReceipt"
case suppresscommandinterpretation = "suppressCommandInterpretation"
case expectedleafentryid = "expectedLeafEntryId"
+ case expectedrunid = "expectedRunId"
case expectedsessionroutingcontract = "expectedSessionRoutingContract"
case idempotencykey = "idempotencyKey"
}
diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md
index 689e9b083b20..b52f8a71d8e2 100644
--- a/docs/gateway/protocol.md
+++ b/docs/gateway/protocol.md
@@ -632,7 +632,7 @@ methods. Treat this as feature discovery, not a full enumeration of
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`...`, `...`, `...`, `...`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders.
- `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Pass `sessionKey`, optional `agentId` when session selection is agent-scoped, and a transcript `messageId` previously surfaced through `chat.history`; the gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized.
- `chat.toolTitles` returns short purpose titles for tool calls rendered in the Control UI (batched, max 24 items with bounded inputs). The feature is opt-in via `gateway.controlUi.toolTitles` (default off); disabled gateways answer `{ titles: {}, disabled: true }` with no model call so clients stop asking. When enabled, titles use standard utility-model routing: an explicitly configured `utilityModel` (an operator decision that, like all utility tasks, may send bounded task content to the chosen provider), else the session provider's declared small-model default so no new egress destination appears implicitly; an empty `utilityModel` disables them entirely. Titles never fall back to the primary model. Results cache in the per-agent state database keyed by tool name + input, so repeated views never re-bill the same calls.
- - `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds (`DEFAULT_FAST_MODE_AUTO_ON_SECONDS`) and can be configured per model with `agents.defaults.models["/"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request. Pass `queueMode` (`steer`, `followup`, `collect`, or `interrupt`) to override the stored queue mode for this request only; explicit Control UI steer actions use `queueMode: "steer"`. Interactive clients can pass `expectedLeafEntryId` with the active transcript-branch leaf they display, or `null` for an authoritative empty transcript; the Gateway rejects the send with `details.reason: "active-leaf-changed"` if another client switched branches first.
+ - `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds (`DEFAULT_FAST_MODE_AUTO_ON_SECONDS`) and can be configured per model with `agents.defaults.models["/"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request. Pass `queueMode` (`steer`, `followup`, `collect`, or `interrupt`) to override the stored queue mode for this request only; explicit Control UI steer actions use `queueMode: "steer"`. Modern clients, especially clients that persist or retry a steer, should also pass the active `expectedRunId`; the Gateway binds it to one exact run so a retry cannot reach a successor. Older targetless `queueMode: "steer"` requests remain accepted only as a leaf-bound compatibility path: they must pass the active operation's immutable `expectedLeafEntryId` (or deliberate `null` for an authoritative empty transcript), and can reject with `details.reason: "active-leaf-changed"` when the leaf, owner, freshness, or injection capability cannot be proven. Other interactive sends may pass `expectedLeafEntryId` to reject if another client switched transcript branches first.
diff --git a/extensions/codex/src/app-server/run-attempt-active-turn.ts b/extensions/codex/src/app-server/run-attempt-active-turn.ts
index da98dbb1e7e7..05f0cc06ef08 100644
--- a/extensions/codex/src/app-server/run-attempt-active-turn.ts
+++ b/extensions/codex/src/app-server/run-attempt-active-turn.ts
@@ -171,44 +171,49 @@ export async function activateCodexAttemptTurn(
signal: runAbortController.signal,
});
steeringQueueRef.current = activeSteeringQueue;
+ const queueMessage = async (text: string, optionsLocal?: CodexSteeringQueueOptions) => {
+ const isInboundUserMessage = optionsLocal?.isInboundUserMessage === true;
+ if (isInboundUserMessage && !optionsLocal?.images?.length) {
+ const claimed = await claimPendingAgentQuestionAnswer({
+ sessionKey: params.sessionKey ?? params.sessionId,
+ text,
+ });
+ if (claimed) {
+ return undefined;
+ }
+ } else if (isInboundUserMessage) {
+ try {
+ await cancelPendingAgentQuestionForSession({
+ sessionKey: params.sessionKey ?? params.sessionId,
+ resolvedBy: "image-reply",
+ });
+ } catch (error) {
+ // Cleanup failure must not drop the user's image turn.
+ embeddedAgentLog.warn("failed to cancel codex gateway question before image steering", {
+ error,
+ });
+ }
+ }
+ try {
+ await activeSteeringQueue.queue(text, optionsLocal);
+ } catch (error) {
+ if (error instanceof CodexSteeringAcceptedUnconfirmedError) {
+ return {
+ transcriptCommit: "unconfirmed" as const,
+ errorMessage: formatErrorMessage(error),
+ };
+ }
+ throw error;
+ }
+ return undefined;
+ };
const handle = {
kind: "embedded" as const,
runId: params.runId,
- queueMessage: async (text: string, optionsLocal?: CodexSteeringQueueOptions) => {
- const isInboundUserMessage = optionsLocal?.isInboundUserMessage === true;
- if (isInboundUserMessage && !optionsLocal?.images?.length) {
- const claimed = await claimPendingAgentQuestionAnswer({
- sessionKey: params.sessionKey ?? params.sessionId,
- text,
- });
- if (claimed) {
- return undefined;
- }
- } else if (isInboundUserMessage) {
- try {
- await cancelPendingAgentQuestionForSession({
- sessionKey: params.sessionKey ?? params.sessionId,
- resolvedBy: "image-reply",
- });
- } catch (error) {
- // Cleanup failure must not drop the user's image turn.
- embeddedAgentLog.warn("failed to cancel codex gateway question before image steering", {
- error,
- });
- }
- }
- try {
- await activeSteeringQueue.queue(text, optionsLocal);
- } catch (error) {
- if (error instanceof CodexSteeringAcceptedUnconfirmedError) {
- return {
- transcriptCommit: "unconfirmed" as const,
- errorMessage: formatErrorMessage(error),
- };
- }
- throw error;
- }
- return undefined;
+ queueMessage,
+ messageInjection: {
+ isAvailable: () => !state.completed && !state.timedOut && !runAbortController.signal.aborted,
+ queueMessage,
},
isStreaming: () => !state.completed && !runAbortController.signal.aborted,
isAborted: () => runAbortController.signal.aborted,
@@ -221,6 +226,7 @@ export async function activateCodexAttemptTurn(
supportsTranscriptCommitWait: true,
supportsQueueMessageImages: true,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
+ taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode,
cancel: () => abortExplicitly("cancelled"),
abort: () => abortExplicitly("aborted"),
};
diff --git a/extensions/codex/src/app-server/run-attempt.steering.test.ts b/extensions/codex/src/app-server/run-attempt.steering.test.ts
index 270cc591e6ed..6f58c9b8c36e 100644
--- a/extensions/codex/src/app-server/run-attempt.steering.test.ts
+++ b/extensions/codex/src/app-server/run-attempt.steering.test.ts
@@ -145,17 +145,27 @@ describe("runCodexAppServerAttempt steering", () => {
it("accepts Gateway transcript-backed steering for the active Codex turn", async () => {
const { requests, waitForMethod, completeTurn, notify } = createStartedThreadHarness();
const params = createSteeringParams();
+ params.taskSuggestionDeliveryMode = "gateway";
const run = runCodexAppServerAttempt(params, {
pluginConfig: { appServer: { mode: "yolo" } },
});
await waitForMethod("turn/start");
+ await vi.waitFor(() => {
+ expect(
+ activeRunRegistrationMocks.setActiveEmbeddedRun.mock.calls.findLast(
+ (call) => call[0] === params.sessionId,
+ )?.[1],
+ ).toMatchObject({ taskSuggestionDeliveryMode: "gateway" });
+ }, fastWait);
+
// This public queue returns immediate eligibility; the handle's delivery
// promise stays pending until the matching item/completed notification below.
await waitAndQueueActiveRunMessage(params.sessionId, "steer this active turn", {
debounceMs: 0,
isInboundUserMessage: true,
+ taskSuggestionDeliveryMode: "gateway",
waitForTranscriptCommit: true,
});
await vi.waitFor(
diff --git a/extensions/copilot/src/attempt-active-run.ts b/extensions/copilot/src/attempt-active-run.ts
index ac7cdb0cb039..22cc04972314 100644
--- a/extensions/copilot/src/attempt-active-run.ts
+++ b/extensions/copilot/src/attempt-active-run.ts
@@ -33,47 +33,53 @@ export function registerCopilotActiveRun(params: {
embeddedAgentLog.warn("failed to cancel copilot gateway question during shutdown", { error });
});
};
+ const queueMessage = async (text: string, options?: CopilotQueueMessageOptions) => {
+ if (
+ options?.isInboundUserMessage === true &&
+ (await claimPendingAgentQuestionAnswer({
+ sessionKey: params.input.sessionKey ?? params.input.sessionId,
+ text,
+ persist: options.userTurnTranscriptRecorder
+ ? async () => {
+ await options.userTurnTranscriptRecorder?.persistApproved();
+ }
+ : undefined,
+ }))
+ ) {
+ return undefined;
+ }
+ if (params.isSettled() || params.isAborted()) {
+ throw new Error("Copilot steering is unavailable after the active run ended");
+ }
+ if (!params.canAcceptSteering()) {
+ throw new Error("Copilot steering is unavailable before initial user validation");
+ }
+ const messageId = await params.session.send({ prompt: text });
+ if (options?.waitForTranscriptCommit === true) {
+ try {
+ await waitForPersistenceReceipt(
+ params.transcriptJournal.waitForSdkUserPersisted(messageId),
+ options.deliveryTimeoutMs,
+ );
+ } catch (error) {
+ return {
+ transcriptCommit: "unconfirmed" as const,
+ errorMessage:
+ error instanceof Error
+ ? error.message
+ : "Copilot accepted steering but its transcript receipt was not confirmed",
+ };
+ }
+ }
+ return undefined;
+ };
const activeRunHandle = {
kind: "embedded" as const,
- queueMessage: async (text: string, options?: CopilotQueueMessageOptions) => {
- if (
- options?.isInboundUserMessage === true &&
- (await claimPendingAgentQuestionAnswer({
- sessionKey: params.input.sessionKey ?? params.input.sessionId,
- text,
- persist: options.userTurnTranscriptRecorder
- ? async () => {
- await options.userTurnTranscriptRecorder?.persistApproved();
- }
- : undefined,
- }))
- ) {
- return undefined;
- }
- if (params.isSettled() || params.isAborted()) {
- throw new Error("Copilot steering is unavailable after the active run ended");
- }
- if (!params.canAcceptSteering()) {
- throw new Error("Copilot steering is unavailable before initial user validation");
- }
- const messageId = await params.session.send({ prompt: text });
- if (options?.waitForTranscriptCommit === true) {
- try {
- await waitForPersistenceReceipt(
- params.transcriptJournal.waitForSdkUserPersisted(messageId),
- options.deliveryTimeoutMs,
- );
- } catch (error) {
- return {
- transcriptCommit: "unconfirmed" as const,
- errorMessage:
- error instanceof Error
- ? error.message
- : "Copilot accepted steering but its transcript receipt was not confirmed",
- };
- }
- }
- return undefined;
+ runId: params.input.runId,
+ queueMessage,
+ messageInjection: {
+ isAvailable: () => params.canAcceptSteering() && !params.isSettled() && !params.isAborted(),
+ queueMessage,
},
isStreaming: () => params.canAcceptSteering() && !params.isSettled() && !params.isAborted(),
isAborted: params.isAborted,
@@ -82,6 +88,7 @@ export function registerCopilotActiveRun(params: {
// receipt resolves only after that exact SDK event reaches canonical history.
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: params.input.sourceReplyDeliveryMode,
+ taskSuggestionDeliveryMode: params.input.taskSuggestionDeliveryMode,
cancel: () => {
cancelGatewayQuestionBestEffort("run-cancel");
params.userInputBridge.cancelPending();
@@ -99,6 +106,7 @@ export function registerCopilotActiveRun(params: {
params.input.sessionKey,
params.input.sessionFile,
);
+ params.input.replyOperation?.attachBackend(activeRunHandle);
return activeRunHandle;
}
diff --git a/extensions/copilot/src/attempt-execution.ts b/extensions/copilot/src/attempt-execution.ts
index fc042a7ca106..020706b83234 100644
--- a/extensions/copilot/src/attempt-execution.ts
+++ b/extensions/copilot/src/attempt-execution.ts
@@ -114,7 +114,7 @@ export async function runCopilotExecution(context: {
now,
scope: input.agentHarnessTaskRuntimeScope,
});
- let activeRunHandleRef: Parameters[1] | undefined;
+ let activeRunHandleRef: ReturnType | undefined;
let userInputBridgeRef: CopilotUserInputBridge | undefined;
let cleanupToolBridge: (() => void) | undefined;
let releaseError: Error | undefined;
@@ -539,6 +539,7 @@ export async function runCopilotExecution(context: {
}
userInputBridgeRef?.cancelPending();
if (activeRunHandleRef) {
+ input.replyOperation?.detachBackend(activeRunHandleRef);
clearActiveEmbeddedRun(
input.sessionId,
activeRunHandleRef,
diff --git a/extensions/copilot/src/attempt.test.ts b/extensions/copilot/src/attempt.test.ts
index 5da72a3c6014..58f182a00698 100644
--- a/extensions/copilot/src/attempt.test.ts
+++ b/extensions/copilot/src/attempt.test.ts
@@ -2001,7 +2001,9 @@ describe("runCopilotAttempt", () => {
});
},
});
- const attempt = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) });
+ const attempt = runCopilotAttempt(makeParams({ taskSuggestionDeliveryMode: "gateway" }), {
+ pool: makeFakePool(sdk),
+ });
await vi.waitFor(() => {
expect(requireSession(sdk).sendAndWait).toHaveBeenCalledTimes(1);
@@ -2016,22 +2018,29 @@ describe("runCopilotAttempt", () => {
},
) => Promise;
supportsTranscriptCommitWait?: boolean;
+ taskSuggestionDeliveryMode?: "gateway";
}
| undefined;
expect(handle?.supportsTranscriptCommitWait).toBe(true);
+ expect(handle?.taskSuggestionDeliveryMode).toBe("gateway");
- await handle?.queueMessage("change course", {
- deliveryTimeoutMs: 1_000,
- waitForTranscriptCommit: true,
- });
-
- expect(requireSession(sdk).send).toHaveBeenCalledWith({ prompt: "change course" });
- expect(transcriptRuntimeMock.appendStrict).toHaveBeenCalledWith(
- expect.objectContaining({
- eventId: "steered-user",
- message: expect.objectContaining({ role: "user", content: "change course" }),
+ expect(
+ queueAgentHarnessMessage("session-1", "change course", {
+ deliveryTimeoutMs: 1_000,
+ taskSuggestionDeliveryMode: "gateway",
+ waitForTranscriptCommit: true,
}),
- );
+ ).toBe(true);
+
+ await vi.waitFor(() => {
+ expect(requireSession(sdk).send).toHaveBeenCalledWith({ prompt: "change course" });
+ expect(transcriptRuntimeMock.appendStrict).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventId: "steered-user",
+ message: expect.objectContaining({ role: "user", content: "change course" }),
+ }),
+ );
+ });
initialTurn.resolve(makeAssistantMessageEvent("done"));
await expect(attempt).resolves.toMatchObject({ terminal: { kind: "ok" } });
diff --git a/packages/gateway-protocol/src/schema/audit-activity.ts b/packages/gateway-protocol/src/schema/audit-activity.ts
index 8064bd12110f..51606b384b99 100644
--- a/packages/gateway-protocol/src/schema/audit-activity.ts
+++ b/packages/gateway-protocol/src/schema/audit-activity.ts
@@ -259,6 +259,7 @@ const inboundCompletedReasonSchema = Type.Union([
Type.Literal("before_dispatch_handled"),
Type.Literal("acp_dispatch_completed"),
Type.Literal("acp_dispatch_empty"),
+ Type.Literal("active_run_injected"),
]);
const inboundSkippedReasonSchema = Type.Union([
@@ -524,7 +525,8 @@ type AuditActivityInboundMessageV1Terminal =
| "plugin_bound_declined"
| "before_dispatch_handled"
| "acp_dispatch_completed"
- | "acp_dispatch_empty";
+ | "acp_dispatch_empty"
+ | "active_run_injected";
}
| {
status: "blocked";
diff --git a/packages/gateway-protocol/src/schema/logs-chat.test.ts b/packages/gateway-protocol/src/schema/logs-chat.test.ts
index 94211bb53429..923900031acc 100644
--- a/packages/gateway-protocol/src/schema/logs-chat.test.ts
+++ b/packages/gateway-protocol/src/schema/logs-chat.test.ts
@@ -49,6 +49,14 @@ describe("ChatSendParamsSchema", () => {
true,
);
expect(Value.Check(ChatSendParamsSchema, { ...send, expectedLeafEntryId: null })).toBe(true);
+ expect(
+ Value.Check(ChatSendParamsSchema, {
+ ...send,
+ queueMode: "steer",
+ expectedLeafEntryId: "leaf-1",
+ }),
+ ).toBe(true);
+ expect(Value.Check(ChatSendParamsSchema, { ...send, expectedRunId: "run-1" })).toBe(true);
expect(Value.Check(ChatSendParamsSchema, { ...send, unknown: true })).toBe(false);
});
});
diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts
index 90171716e3a3..1547d1ef2627 100644
--- a/packages/gateway-protocol/src/schema/logs-chat.ts
+++ b/packages/gateway-protocol/src/schema/logs-chat.ts
@@ -138,9 +138,12 @@ export const ChatSendParamsSchema = closedObject({
systemInputProvenance: Type.Optional(InputProvenanceSchema),
systemProvenanceReceipt: Type.Optional(Type.String()),
suppressCommandInterpretation: Type.Optional(Type.Boolean()),
- // Client's believed active-branch leaf entry id. A mismatch with the
- // session's current active leaf rejects the send so stale views cannot post elsewhere.
+ // Client's believed active-branch leaf entry id. Legacy targetless steering
+ // requires this immutable fence and may reject; null means an authoritative empty transcript.
expectedLeafEntryId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
+ // Optional for wire compatibility. Modern/durable steer clients should always
+ // send this exact run precondition so a retry cannot move to a successor run.
+ expectedRunId: Type.Optional(NonEmptyString),
expectedSessionRoutingContract: Type.Optional(NonEmptyString),
idempotencyKey: NonEmptyString,
});
diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts
index 8ffa929c2bae..89ce38b5f3ab 100644
--- a/src/agents/cli-runner.reliability.test.ts
+++ b/src/agents/cli-runner.reliability.test.ts
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
-import { createReplyOperation, replyRunRegistry } from "../auto-reply/reply/reply-run-registry.js";
+import { createReplyOperation } from "../auto-reply/reply/reply-run-registry.js";
import { testing as replyRunTesting } from "../auto-reply/reply/reply-run-registry.test-support.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import {
@@ -2650,7 +2650,7 @@ describe("runCliAgent reliability", () => {
expect(result.meta.finalPromptText).toContain("hi");
});
- it("reports CLI reply backends as streaming until the managed run finishes", async () => {
+ it("keeps CLI reply backend cancellation attached until the managed run finishes", async () => {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "s1",
@@ -2678,14 +2678,9 @@ describe("runCliAgent reliability", () => {
},
});
- await vi.waitFor(() => {
- expect(replyRunRegistry.isStreaming("agent:main:main")).toBe(true);
- });
-
finishRun?.();
const result = await run;
expect(result.text).toBe("hello from cli");
- expect(replyRunRegistry.isStreaming("agent:main:main")).toBe(false);
operation.complete();
});
diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts
index d821299748d9..290a092d671a 100644
--- a/src/agents/cli-runner.spawn.test.ts
+++ b/src/agents/cli-runner.spawn.test.ts
@@ -6,7 +6,7 @@ import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
-import { createReplyOperation, replyRunRegistry } from "../auto-reply/reply/reply-run-registry.js";
+import { createReplyOperation } from "../auto-reply/reply/reply-run-registry.js";
import { testing as replyRunTesting } from "../auto-reply/reply/reply-run-registry.test-support.js";
import {
markMcpLoopbackToolCallFinished,
@@ -2814,8 +2814,6 @@ describe("runCliAgent spawn path", () => {
});
await writeReady;
- expect(replyRunRegistry.isStreaming("agent:main:main")).toBe(true);
-
live.emit([
{ type: "system", subtype: "init", session_id: "live-session-reply" },
{ type: "result", session_id: "live-session-reply", result: "done" },
@@ -2823,7 +2821,6 @@ describe("runCliAgent spawn path", () => {
const result = await run;
expect(result.text).toBe("done");
- expect(replyRunRegistry.isStreaming("agent:main:main")).toBe(false);
operation.complete();
});
diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts
index 033b36402fae..9cf66e0102ad 100644
--- a/src/agents/cli-runner/claude-live-session.ts
+++ b/src/agents/cli-runner/claude-live-session.ts
@@ -2175,12 +2175,10 @@ async function runSerializedClaudeLiveSessionTurn(
void outputPromise.catch(() => undefined);
const abort = () =>
abortTurn(liveSession, createAbortError(params.context.params.abortSignal?.reason));
- let replyBackendCompleted = false;
const replyBackendHandle: ReplyBackendHandle | undefined = params.context.params.replyOperation
? {
kind: "cli",
cancel: abort,
- isStreaming: () => !replyBackendCompleted,
}
: undefined;
params.context.params.abortSignal?.addEventListener("abort", abort, { once: true });
@@ -2201,7 +2199,6 @@ async function runSerializedClaudeLiveSessionTurn(
}
return { output: await outputPromise };
} finally {
- replyBackendCompleted = true;
params.context.params.abortSignal?.removeEventListener("abort", abort);
try {
if (replyBackendHandle) {
diff --git a/src/agents/cli-runner/execute-node-claude.ts b/src/agents/cli-runner/execute-node-claude.ts
index 9eca36a46ed1..264e532a2f0f 100644
--- a/src/agents/cli-runner/execute-node-claude.ts
+++ b/src/agents/cli-runner/execute-node-claude.ts
@@ -197,12 +197,10 @@ export async function executeNodeClaudeRun(params: {
if (contextParams.abortSignal?.aborted) {
abortNodeRun();
}
- let replyBackendCompleted = false;
const replyBackendHandle = contextParams.replyOperation
? {
kind: "cli" as const,
cancel: abortNodeRun,
- isStreaming: () => !replyBackendCompleted,
}
: undefined;
if (replyBackendHandle) {
@@ -307,7 +305,6 @@ export async function executeNodeClaudeRun(params: {
};
} finally {
clearTimeout(hardDeadlineTimer);
- replyBackendCompleted = true;
if (replyBackendHandle) {
contextParams.replyOperation?.detachBackend(replyBackendHandle);
}
diff --git a/src/agents/cli-runner/execute-process.ts b/src/agents/cli-runner/execute-process.ts
index d7ed67e5e6b6..d996b5b8d88a 100644
--- a/src/agents/cli-runner/execute-process.ts
+++ b/src/agents/cli-runner/execute-process.ts
@@ -284,12 +284,10 @@ export async function executeCliProcess(params: {
onStderr: consumeStderr,
});
managedRunPid = managedRun.pid;
- let replyBackendCompleted = false;
const replyBackendHandle = runParams.replyOperation
? {
kind: "cli" as const,
cancel: () => managedRun.cancel("manual-cancel"),
- isStreaming: () => !replyBackendCompleted,
}
: undefined;
if (replyBackendHandle) {
@@ -298,7 +296,6 @@ export async function executeCliProcess(params: {
try {
result = await managedRun.wait();
} finally {
- replyBackendCompleted = true;
if (replyBackendHandle) {
runParams.replyOperation?.detachBackend(replyBackendHandle);
}
diff --git a/src/agents/embedded-agent-runner/run-state.ts b/src/agents/embedded-agent-runner/run-state.ts
index cc880a2750b4..aefe3bb3647d 100644
--- a/src/agents/embedded-agent-runner/run-state.ts
+++ b/src/agents/embedded-agent-runner/run-state.ts
@@ -12,6 +12,7 @@ import {
resolveActiveReplyRunSessionId,
type ReplyBackendQueueMessageOptions,
type ReplyBackendQueueMessageResult,
+ type ReplyBackendMessageInjection,
} from "../../auto-reply/reply/reply-run-registry.js";
import {
isAgentEventLifecycleGenerationCurrent,
@@ -32,6 +33,7 @@ export type EmbeddedAgentQueueHandle = {
text: string,
options?: EmbeddedAgentQueueMessageOptions,
) => Promise;
+ messageInjection?: ReplyBackendMessageInjection;
isStreaming: () => boolean;
isStopped?: () => boolean;
/** True after this handle has accepted an abort, even while cleanup retains it. */
diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts
index ecbb75d6d2b1..9355ef00227d 100644
--- a/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts
+++ b/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts
@@ -395,27 +395,35 @@ export function prepareEmbeddedAttemptStream(input: {
attempt.onAttemptAbort?.();
input.abortRun(false, reason === "restart" ? createAgentRunRestartAbortError() : undefined);
};
+ const queueMessage: AttemptStreamQueueHandle["queueMessage"] = async (text, options) => {
+ if (!acceptingSteerMessages) {
+ throw new Error("active session is finalizing");
+ }
+ activeQueueAdmissions++;
+ try {
+ if (options?.steeringMode) {
+ input.activeSession.agent.steeringMode = options.steeringMode;
+ }
+ return await steerActiveSessionWithOptionalDeliveryWait(
+ input.activeSession,
+ text,
+ options,
+ attempt.sessionKey,
+ );
+ } finally {
+ activeQueueAdmissions--;
+ }
+ };
const queueHandle: AttemptStreamQueueHandle = {
kind: "embedded",
runId: attempt.runId,
- queueMessage: async (text: string, options) => {
- if (!acceptingSteerMessages) {
- throw new Error("active session is finalizing");
- }
- activeQueueAdmissions++;
- try {
- if (options?.steeringMode) {
- input.activeSession.agent.steeringMode = options.steeringMode;
- }
- return await steerActiveSessionWithOptionalDeliveryWait(
- input.activeSession,
- text,
- options,
- attempt.sessionKey,
- );
- } finally {
- activeQueueAdmissions--;
- }
+ queueMessage,
+ messageInjection: {
+ isAvailable: () =>
+ acceptingSteerMessages &&
+ !input.getRunState().aborted &&
+ !input.runAbortController.signal.aborted,
+ queueMessage,
},
isStreaming: () => input.activeSession.isStreaming,
isAborted: () => input.getRunState().aborted,
diff --git a/src/agents/embedded-agent-runner/runs.diagnostics.test.ts b/src/agents/embedded-agent-runner/runs.diagnostics.test.ts
index 4fd58cb83b0f..7154ef6ee2eb 100644
--- a/src/agents/embedded-agent-runner/runs.diagnostics.test.ts
+++ b/src/agents/embedded-agent-runner/runs.diagnostics.test.ts
@@ -69,7 +69,7 @@ describe("active run injection diagnostics", () => {
expect(queuedDepths).toEqual([1, 1]);
});
- it("does not retain reply-run steering as idle session backlog", () => {
+ it("does not treat a CLI cancellation backend as message injection", () => {
setDiagnosticsEnabledForProcess(true);
const queuedDepths: Array = [];
const unsubscribe = onDiagnosticEvent((event) => {
@@ -86,17 +86,13 @@ describe("active run injection diagnostics", () => {
kind: "cli",
cancel: () => {},
isStreaming: () => true,
- queueMessage: async () => {},
});
operation.setPhase("running");
try {
startDiagnosticTurn("session-reply-steer-diagnostics");
expect(
- queueEmbeddedAgentMessageWithOutcome("session-reply-steer-diagnostics", "first").queued,
- ).toBe(true);
- expect(
- queueEmbeddedAgentMessageWithOutcome("session-reply-steer-diagnostics", "second").queued,
- ).toBe(true);
+ queueEmbeddedAgentMessageWithOutcome("session-reply-steer-diagnostics", "first"),
+ ).toMatchObject({ queued: false, reason: "no_active_run" });
} finally {
operation.complete();
finishDiagnosticTurn("session-reply-steer-diagnostics");
@@ -106,6 +102,6 @@ describe("active run injection diagnostics", () => {
expect(
getDiagnosticSessionState({ sessionId: "session-reply-steer-diagnostics" }).queueDepth,
).toBe(0);
- expect(queuedDepths).toEqual([1, 1]);
+ expect(queuedDepths).toEqual([]);
});
});
diff --git a/src/agents/embedded-agent-runner/runs.test.ts b/src/agents/embedded-agent-runner/runs.test.ts
index c5af9576f835..0e7f9705f659 100644
--- a/src/agents/embedded-agent-runner/runs.test.ts
+++ b/src/agents/embedded-agent-runner/runs.test.ts
@@ -51,6 +51,7 @@ function createRunHandle(
isCompacting?: boolean;
isStreaming?: boolean;
isStopped?: () => boolean;
+ messageInjection?: RunHandle["messageInjection"];
runId?: string;
queueMessage?: RunHandle["queueMessage"];
supportsQueueMessageImages?: boolean;
@@ -63,6 +64,7 @@ function createRunHandle(
return {
runId: overrides.runId,
queueMessage: overrides.queueMessage ?? (async () => {}),
+ ...(overrides.messageInjection ? { messageInjection: overrides.messageInjection } : {}),
isStreaming: () => overrides.isStreaming ?? true,
...(overrides.isStopped ? { isStopped: overrides.isStopped } : {}),
...(overrides.isAbortable !== undefined
@@ -709,21 +711,33 @@ describe("embedded-agent runner run registry", () => {
);
});
- it("returns structured queue failures for inactive active-run states", () => {
- setActiveEmbeddedRun("session-not-streaming", createRunHandle({ isStreaming: false }));
+ it("returns structured queue failures for legacy, unavailable, or compacting runs", () => {
+ const legacyQueue = vi.fn(async () => {});
+ const unavailableQueue = vi.fn(async () => {});
+ setActiveEmbeddedRun(
+ "session-not-streaming",
+ createRunHandle({ isStreaming: false, queueMessage: legacyQueue }),
+ );
+ setActiveEmbeddedRun(
+ "session-unavailable",
+ createRunHandle({
+ messageInjection: { isAvailable: () => false, queueMessage: unavailableQueue },
+ }),
+ );
setActiveEmbeddedRun("session-compacting", createRunHandle({ isCompacting: true }));
- expect(queueEmbeddedAgentMessageWithOutcome("session-not-streaming", "continue")).toEqual({
+ expect(queueEmbeddedAgentMessageWithOutcome("session-not-streaming", "continue")).toMatchObject(
+ { queued: false, reason: "not_streaming" },
+ );
+ expect(legacyQueue).not.toHaveBeenCalled();
+ expect(queueEmbeddedAgentMessageWithOutcome("session-unavailable", "continue")).toMatchObject({
queued: false,
- sessionId: "session-not-streaming",
reason: "not_streaming",
- gatewayHealth: "live",
});
- expect(queueEmbeddedAgentMessageWithOutcome("session-compacting", "continue")).toEqual({
+ expect(unavailableQueue).not.toHaveBeenCalled();
+ expect(queueEmbeddedAgentMessageWithOutcome("session-compacting", "continue")).toMatchObject({
queued: false,
- sessionId: "session-compacting",
reason: "compacting",
- gatewayHealth: "live",
});
});
diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts
index 33061efcf945..aad2be07bd27 100644
--- a/src/agents/embedded-agent-runner/runs.ts
+++ b/src/agents/embedded-agent-runner/runs.ts
@@ -11,9 +11,7 @@ import {
isReplyRunEvidenceStaleBySessionId,
isReplyRunActiveForSessionId,
isReplyRunAbortableForCompaction,
- isReplyRunStreamingForSessionId,
listActiveReplyRunSessionIds,
- queueReplyRunMessage,
resolveActiveReplyOperationForSessionId,
resolveActiveReplyRunSessionId,
resolveReplyBackendQueueMessageMismatch,
@@ -109,7 +107,7 @@ type PreparedEmbeddedAgentQueueMessage =
}
| {
kind: "embedded_run";
- handle: EmbeddedAgentQueueHandle;
+ queueMessage: EmbeddedAgentQueueHandle["queueMessage"];
};
function createQueueFailureOutcome(
@@ -340,18 +338,16 @@ export function queueEmbeddedAgentMessageWithOutcome(
text: string,
options?: EmbeddedAgentQueueMessageOptions,
): EmbeddedAgentQueueMessageOutcome {
- const prepared = prepareEmbeddedAgentQueueMessage(sessionId, text, options);
+ const prepared = prepareEmbeddedAgentQueueMessage(sessionId, options);
if (prepared.kind === "complete") {
return prepared.outcome;
}
logActiveRunMessageAccepted(sessionId);
- void prepared.handle
- .queueMessage(text, options ?? { steeringMode: "all" })
- .catch((err: unknown) => {
- diag.debug(
- `queue message rejected after enqueue: sessionId=${sessionId} err=${formatErrorMessage(err)}`,
- );
- });
+ void prepared.queueMessage(text, options ?? { steeringMode: "all" }).catch((err: unknown) => {
+ diag.debug(
+ `queue message rejected after enqueue: sessionId=${sessionId} err=${formatErrorMessage(err)}`,
+ );
+ });
return {
queued: true,
sessionId,
@@ -373,17 +369,26 @@ function logActiveRunMessageAccepted(sessionId: string): void {
);
}
-function isEmbeddedQueueHandleMessageInjectable(
+function resolveEmbeddedQueueMessage(
sessionId: string,
handle: EmbeddedAgentQueueHandle,
-): boolean {
+): EmbeddedAgentQueueHandle["queueMessage"] | undefined {
try {
- return handle.isStopped === undefined ? handle.isStreaming() : !handle.isStopped();
+ const injection = handle.messageInjection;
+ if (injection) {
+ return injection.isAvailable()
+ ? (text, options) => injection.queueMessage(text, options)
+ : undefined;
+ }
+ // Legacy handles predate explicit injection capability. Preserve their
+ // shipped eligibility probe while modern backends use messageInjection.
+ const isAvailable = handle.isStopped ? !handle.isStopped() : handle.isStreaming();
+ return isAvailable ? (text, options) => handle.queueMessage(text, options) : undefined;
} catch (err) {
diag.warn(
`queue message failed: sessionId=${sessionId} reason=injectable_check_failed err=${String(err)}`,
);
- return false;
+ return undefined;
}
}
@@ -447,16 +452,13 @@ export async function queueEmbeddedAgentMessageWithOutcomeAsync(
text: string,
options?: EmbeddedAgentQueueMessageOptions,
): Promise {
- const prepared = prepareEmbeddedAgentQueueMessage(sessionId, text, options);
+ const prepared = prepareEmbeddedAgentQueueMessage(sessionId, options);
if (prepared.kind === "complete") {
return prepared.outcome;
}
const enqueuedAtMs = Date.now();
try {
- const queueResult = await prepared.handle.queueMessage(
- text,
- options ?? { steeringMode: "all" },
- );
+ const queueResult = await prepared.queueMessage(text, options ?? { steeringMode: "all" });
if (queueResult?.transcriptCommit === "unconfirmed") {
diag.warn(
`queue message accepted without transcript confirmation: sessionId=${sessionId} err=${queueResult.errorMessage}`,
@@ -491,7 +493,6 @@ export async function queueEmbeddedAgentMessageWithOutcomeAsync(
function prepareEmbeddedAgentQueueMessage(
sessionId: string,
- text: string,
options?: EmbeddedAgentQueueMessageOptions,
): PreparedEmbeddedAgentQueueMessage {
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
@@ -512,24 +513,10 @@ function prepareEmbeddedAgentQueueMessage(
outcome: createQueueFailureOutcome(sessionId, "transcript_commit_wait_unsupported"),
};
}
- const queuedReplyRunMessage = queueReplyRunMessage(sessionId, text, options);
- if (queuedReplyRunMessage) {
- logActiveRunMessageAccepted(sessionId);
- return {
- kind: "complete",
- outcome: {
- queued: true,
- sessionId,
- target: "reply_run",
- gatewayHealth: "live",
- enqueuedAtMs: Date.now(),
- },
- };
- }
- diag.debug(`queue message failed: sessionId=${sessionId} reason=no_active_run`);
return { kind: "complete", outcome: createQueueFailureOutcome(sessionId, "no_active_run") };
}
- if (!isEmbeddedQueueHandleMessageInjectable(sessionId, handle)) {
+ const queueMessage = resolveEmbeddedQueueMessage(sessionId, handle);
+ if (!queueMessage) {
diag.debug(`queue message failed: sessionId=${sessionId} reason=not_streaming`);
return { kind: "complete", outcome: createQueueFailureOutcome(sessionId, "not_streaming") };
}
@@ -562,7 +549,7 @@ function prepareEmbeddedAgentQueueMessage(
outcome: createQueueFailureOutcome(sessionId, deliveryModeMismatch),
};
}
- return { kind: "embedded_run", handle };
+ return { kind: "embedded_run", queueMessage };
}
/**
@@ -726,10 +713,7 @@ export function isEmbeddedAgentRunAbortableForCompaction(sessionId: string): boo
export function isEmbeddedAgentRunStreaming(sessionId: string): boolean {
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
- if (!handle) {
- return isReplyRunStreamingForSessionId(sessionId);
- }
- return handle.isStreaming();
+ return handle?.isStreaming() ?? false;
}
export function resolveActiveEmbeddedRunHandleSessionId(sessionKey: string): string | undefined {
diff --git a/src/audit/audit-event-types.ts b/src/audit/audit-event-types.ts
index e71ae9ed299e..b45f0aaa6271 100644
--- a/src/audit/audit-event-types.ts
+++ b/src/audit/audit-event-types.ts
@@ -26,6 +26,7 @@ export const AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS = [
"before_dispatch_handled",
"acp_dispatch_completed",
"acp_dispatch_empty",
+ "active_run_injected",
] as const;
export type AuditInboundMessageCompletedReasonCode =
diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts
index 2659e559a509..8665bf9335a9 100644
--- a/src/auto-reply/get-reply-options.types.ts
+++ b/src/auto-reply/get-reply-options.types.ts
@@ -128,6 +128,8 @@ export type GetReplyOptions = {
turnAdoptionLifecycle?: TurnAdoptionLifecycle;
/** Shared lifecycle owner for the current user-turn transcript append. */
userTurnTranscriptRecorder?: UserTurnTranscriptRecorder;
+ /** Gateway already attempted exact active-run injection for this turn. */
+ messageInjectionAttempted?: true;
/** Current user turn is already durable; replay it without appending another copy. */
suppressNextUserMessagePersistence?: boolean;
onReplyStart?: () => Promise | void;
diff --git a/src/auto-reply/reply/agent-runner-core.ts b/src/auto-reply/reply/agent-runner-core.ts
index e8a64a4134d8..ca754154192e 100644
--- a/src/auto-reply/reply/agent-runner-core.ts
+++ b/src/auto-reply/reply/agent-runner-core.ts
@@ -529,7 +529,6 @@ export type RunReplyAgentParams = {
shouldFollowup: boolean;
isActive: boolean;
isRunActive?: () => boolean;
- isStreaming: boolean;
opts?: InternalGetReplyOptions;
typing: TypingController;
sessionEntry?: SessionEntry;
diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts
index 7b1f7ea2427c..69f48ed07f20 100644
--- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts
+++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts
@@ -186,7 +186,6 @@ function createDirectRuntimeReplyParams({
shouldSteer: false,
shouldFollowup,
isActive,
- isStreaming: false,
typing: createMockTypingController(),
sessionCtx: createTelegramSessionCtx(),
defaultModel: "openai/gpt-5.4",
diff --git a/src/auto-reply/reply/agent-runner-execute.ts b/src/auto-reply/reply/agent-runner-execute.ts
index af40df5f43a8..a3f8acccc987 100644
--- a/src/auto-reply/reply/agent-runner-execute.ts
+++ b/src/auto-reply/reply/agent-runner-execute.ts
@@ -65,7 +65,6 @@ type ExecutePreparedReplyAgentRunInput = Pick<
activeSessionStore: Record | undefined;
admitUserTurn: ReturnType["admitUserTurn"];
applyReplyToMode: (payload: ReplyPayload) => ReplyPayload;
- beforeAgentReplyDispatchedForSteer: boolean;
beginBeforeAgentReply: ReturnType<
typeof createReplyRestartRecoveryClaimController
>["beginBeforeAgentReply"];
@@ -109,7 +108,6 @@ export async function executePreparedReplyAgentRun(
admitUserTurn: admitUserTurnWithRecovery,
agentCfgContextTokens,
applyReplyToMode,
- beforeAgentReplyDispatchedForSteer,
beginBeforeAgentReply: beginBeforeAgentReplyWithRecovery,
blockReplyChunking,
blockReplyPipeline,
@@ -301,14 +299,7 @@ export async function executePreparedReplyAgentRun(
const runOutcome = await withBeforeAgentReplyObserver(
{
beforeDispatch: async () => {
- const shouldDispatch = await beginBeforeAgentReply();
- if (!shouldDispatch || !beforeAgentReplyDispatchedForSteer) {
- return shouldDispatch;
- }
- // The same source fell through from steering. Advance recovery while
- // preserving the hook decision made before the attempted injection.
- await checkpointBeforeAgentReply({ state: "continue" });
- return false;
+ return await beginBeforeAgentReply();
},
afterDispatch: async (hookResult) => {
if (!hookResult?.handled) {
diff --git a/src/auto-reply/reply/agent-runner-run.ts b/src/auto-reply/reply/agent-runner-run.ts
index 0f7226701612..7c3f78a6fad8 100644
--- a/src/auto-reply/reply/agent-runner-run.ts
+++ b/src/auto-reply/reply/agent-runner-run.ts
@@ -1,5 +1,3 @@
-import { expectDefined } from "@openclaw/normalization-core";
-import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js";
import {
formatEmbeddedAgentQueueFailureSummary,
@@ -11,15 +9,6 @@ import { loadSessionEntry, updateSessionEntry } from "../../config/sessions/sess
import { logVerbose } from "../../globals.js";
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js";
-import {
- buildHandledBeforeAgentReplyPayloads,
- runBeforeAgentReplyForTurn,
- withBeforeAgentReplyObserver,
-} from "../../plugins/before-agent-reply.js";
-import {
- buildAgentHookContextChannelFields,
- buildAgentHookContextIdentityFields,
-} from "../../plugins/hook-agent-context.js";
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
@@ -55,7 +44,7 @@ import { createFollowupRunner } from "./followup-runner.js";
import { REPLY_RUN_STILL_SHUTTING_DOWN_TEXT } from "./get-reply-run-queue.js";
import { resolveOriginMessageProvider } from "./origin-routing.js";
import { resolveActiveRunQueueAction } from "./queue-policy.js";
-import { enqueueFollowupRun, type FollowupRun, scheduleFollowupDrain } from "./queue.js";
+import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js";
import { createReplyMediaContext } from "./reply-media-paths.js";
import * as replyRunState from "./reply-operation-run-state.js";
import { type ReplyOperation, replyRunRegistry } from "./reply-run-registry.js";
@@ -67,7 +56,7 @@ import {
retireTerminalRestartRecoverySourceClaim,
} from "./restart-recovery-claim.js";
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
-import { buildChannelSourceTurnId, readChannelSourceTurnId } from "./source-turn-id.js";
+import { readChannelSourceTurnId } from "./source-turn-id.js";
import { createTypingSignaler } from "./typing-mode.js";
export async function runReplyAgent(
params: RunReplyAgentParams,
@@ -221,8 +210,7 @@ export async function runReplyAgent(
};
let shouldQueueAfterSteerRejection = false;
- let beforeAgentReplyDispatchedForSteer = false;
- if (effectiveShouldSteer && isActive) {
+ if (effectiveShouldSteer && isActive && opts?.messageInjectionAttempted !== true) {
// Steer against the operation that owns THIS session's run slot. A native
// command continuation whose slot adoption was skipped (#104844) still
// carries a source-keyed reservation; steering by its stale sessionId
@@ -233,64 +221,6 @@ export async function runReplyAgent(
? providedReplyOperation
: (registeredReplyOperation ?? providedReplyOperation);
const steerSessionId = activeReplyOperation?.sessionId ?? followupRun.run.sessionId;
- // Channel dispatch normally stamps the route-scoped source id. Internal
- // callers can derive the same per-message identity from the prepared turn.
- const steerRunId = expectDefined(
- restartRecoverySourceTurnId ??
- buildChannelSourceTurnId({
- provider:
- followupRun.originatingChannel ??
- followupRun.run.messageProvider ??
- sessionCtx.Provider,
- accountId:
- followupRun.originatingAccountId ??
- followupRun.run.agentAccountId ??
- sessionCtx.AccountId,
- conversationId:
- followupRun.originatingTo ??
- followupRun.originatingChatId ??
- sessionKey ??
- followupRun.run.sessionKey,
- messageId: followupRun.messageId ?? sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
- }) ??
- normalizeOptionalString(opts?.runId),
- "steered turn id",
- );
- const trigger = "user";
- const hookResult = await runBeforeAgentReplyForTurn({
- runId: steerRunId,
- trigger,
- event: { cleanedBody: followupRun.prompt },
- context: {
- runId: steerRunId,
- agentId: followupRun.run.agentId,
- sessionKey: sessionKey ?? followupRun.run.sessionKey,
- sessionId: steerSessionId,
- workspaceDir: followupRun.run.workspaceDir,
- modelProviderId: followupRun.run.provider,
- modelId: followupRun.run.model,
- trigger,
- ...buildAgentHookContextChannelFields({
- sessionKey: sessionKey ?? followupRun.run.sessionKey,
- messageChannel: followupRun.originatingChannel,
- messageProvider: followupRun.run.messageProvider,
- currentChannelId: followupRun.originatingChatId,
- messageTo: followupRun.originatingTo,
- senderId: followupRun.run.senderId,
- }),
- ...buildAgentHookContextIdentityFields({
- trigger,
- senderId: followupRun.run.senderId,
- chatId: followupRun.originatingChatId,
- channelContext: followupRun.run.channelContext,
- }),
- },
- });
- beforeAgentReplyDispatchedForSteer = true;
- if (hookResult?.handled) {
- typing.cleanup();
- return buildHandledBeforeAgentReplyPayloads(hookResult.reply);
- }
const steerOutcome = await queueEmbeddedAgentMessageWithOutcomeAsync(
steerSessionId,
followupRun.prompt,
@@ -356,7 +286,7 @@ export async function runReplyAgent(
resetTriggered: effectiveResetTriggered,
});
- const baseQueuedRunFollowupTurn = createFollowupRunner({
+ const queuedRunFollowupTurn = createFollowupRunner({
opts,
typing,
typingMode,
@@ -368,19 +298,6 @@ export async function runReplyAgent(
agentCfgContextTokens,
toolProgressDetail,
});
- // A transcript-rejected steer can become this exact queued turn. Preserve its
- // earlier hook decision without suppressing hooks for other queued messages.
- const queuedRunFollowupTurn = (queued: FollowupRun) =>
- beforeAgentReplyDispatchedForSteer && queued === followupRun
- ? withBeforeAgentReplyObserver(
- {
- beforeDispatch: async () => false,
- afterDispatch: async (result) => result,
- },
- () => baseQueuedRunFollowupTurn(queued),
- )
- : baseQueuedRunFollowupTurn(queued);
-
if (activeRunQueueAction === "drop") {
if (replyOperationRunState) {
replyOperationRunState.admission = { status: "skipped", reason: "active-run" };
@@ -639,7 +556,6 @@ export async function runReplyAgent(
admitUserTurn,
agentCfgContextTokens,
applyReplyToMode,
- beforeAgentReplyDispatchedForSteer,
beginBeforeAgentReply,
blockReplyChunking,
blockReplyPipeline,
diff --git a/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts
index 5dbea7b73814..b3cda58b76da 100644
--- a/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts
+++ b/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts
@@ -146,7 +146,6 @@ function makeRunReplyAgentParams(
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing: createMockTypingController(),
sessionCtx: {
Provider: provider,
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 ee51e3619231..0f212406b2b1 100644
--- a/src/auto-reply/reply/agent-runner.media-paths.test.ts
+++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts
@@ -315,7 +315,6 @@ function makeRunReplyAgentParams(
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing: createMockTypingController(),
sessionCtx: {
Provider: provider,
@@ -445,7 +444,6 @@ describe("runReplyAgent media path normalization", () => {
shouldSteer: true,
shouldFollowup: true,
isActive: true,
- isStreaming: false,
followupRun,
}),
);
@@ -580,7 +578,6 @@ describe("runReplyAgent media path normalization", () => {
shouldFollowup: true,
isActive: true,
isRunActive: () => true,
- isStreaming: true,
}),
);
@@ -605,7 +602,6 @@ describe("runReplyAgent media path normalization", () => {
shouldFollowup: true,
isActive: true,
isRunActive: () => true,
- isStreaming: true,
}),
);
diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
index 7d43670db5d6..221205265cc2 100644
--- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
+++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
@@ -438,7 +438,6 @@ describe("runReplyAgent auto-compaction token update", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
opts: options?.onBlockReply ? { onBlockReply: options.onBlockReply } : undefined,
typing,
sessionCtx,
@@ -503,7 +502,6 @@ describe("runReplyAgent auto-compaction token update", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -567,7 +565,6 @@ describe("runReplyAgent auto-compaction token update", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -708,7 +705,6 @@ describe("runReplyAgent auto-compaction token update", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -765,7 +761,6 @@ describe("runReplyAgent auto-compaction token update", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -843,7 +838,6 @@ describe("runReplyAgent auto-compaction token update", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -1068,7 +1062,6 @@ describe("runReplyAgent block streaming", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
opts: { onBlockReply },
typing,
sessionCtx,
@@ -1174,7 +1167,6 @@ describe("runReplyAgent block streaming", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
opts: { onBlockReply, blockReplyTimeoutMs: 1 },
typing,
sessionCtx,
@@ -1289,7 +1281,6 @@ describe("runReplyAgent Active Memory inline debug", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -1378,7 +1369,6 @@ describe("runReplyAgent Active Memory inline debug", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -1466,7 +1456,6 @@ describe("runReplyAgent Active Memory inline debug", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -1631,7 +1620,6 @@ describe("runReplyAgent Active Memory inline debug", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -1802,7 +1790,6 @@ describe("runReplyAgent Active Memory inline debug", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -1908,7 +1895,6 @@ describe("runReplyAgent Active Memory inline debug", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -2008,7 +1994,6 @@ describe("runReplyAgent Active Memory inline debug", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -2075,7 +2060,6 @@ describe("runReplyAgent claude-cli routing", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
defaultModel: "claude-cli/opus-4.5",
@@ -2195,7 +2179,6 @@ describe("runReplyAgent claude-cli routing", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -2285,7 +2268,6 @@ describe("runReplyAgent claude-cli routing", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -2357,7 +2339,6 @@ describe("runReplyAgent messaging tool dedupe", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionKey,
@@ -2491,7 +2472,6 @@ describe("runReplyAgent reminder commitment guard", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
...(params?.omitSessionKey ? {} : { sessionKey: params?.sessionKey ?? "main" }),
@@ -2723,7 +2703,6 @@ describe("runReplyAgent fallback reasoning tags", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry: params?.sessionEntry,
@@ -2864,7 +2843,6 @@ describe("runReplyAgent response usage footer", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
sessionEntry,
@@ -3108,7 +3086,6 @@ describe("runReplyAgent transient HTTP retry", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
defaultModel: "anthropic/claude-opus-4-6",
@@ -3184,7 +3161,6 @@ describe("runReplyAgent billing error classification", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
defaultModel: "anthropic/claude",
@@ -3245,7 +3221,6 @@ describe("runReplyAgent mid-turn rate-limit fallback", () => {
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing,
sessionCtx,
defaultModel: "anthropic/claude",
@@ -3425,7 +3400,6 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
shouldSteer: false,
shouldFollowup: false,
isActive: false,
- isStreaming: false,
typing: createMockTypingController(),
sessionCtx,
sessionEntry,
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 3b5b31e49e3c..fd144622ea59 100644
--- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts
+++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts
@@ -317,7 +317,6 @@ function createMinimalRun(params?: {
blockStreamingEnabled?: boolean;
isActive?: boolean;
isRunActive?: () => boolean;
- isStreaming?: boolean;
shouldSteer?: boolean;
shouldFollowup?: boolean;
resolvedQueueMode?: string;
@@ -396,7 +395,6 @@ function createMinimalRun(params?: {
shouldFollowup: params?.shouldFollowup ?? false,
isActive: params?.isActive ?? false,
isRunActive: params?.isRunActive,
- isStreaming: params?.isStreaming ?? false,
opts,
typing,
sessionEntry: params?.sessionEntry,
@@ -499,7 +497,6 @@ describe("runReplyAgent active steering", () => {
bindReplyOperationTyping(active, taskTyping);
const { run, typing } = createMinimalRun({
isActive: true,
- isStreaming: true,
shouldSteer: true,
resolvedQueueMode: "steer",
sessionCtx: {
@@ -524,17 +521,16 @@ describe("runReplyAgent active steering", () => {
expect(taskTyping.cleanup).toHaveBeenCalledOnce();
});
- it("dispatches a declined steer once with its source-turn identity", async () => {
+ it("injects a steer without claiming a new agent reply", async () => {
const runState: ReplyOperationRunState = {};
state.beforeAgentReplyHasHooksMock.mockImplementation(
(hookName) => hookName === "before_agent_reply",
);
state.beforeAgentReplyRunMock.mockResolvedValue(undefined);
state.queueEmbeddedAgentMessageMock.mockReturnValueOnce(true);
- const { run, sourceTurnId } = createMinimalRun({
+ const { run } = createMinimalRun({
opts: { [REPLY_OPERATION_RUN_STATE]: runState },
isActive: true,
- isStreaming: true,
shouldSteer: true,
resolvedQueueMode: "steer",
sessionCtx: {
@@ -555,24 +551,7 @@ describe("runReplyAgent active steering", () => {
await expect(run()).resolves.toBeUndefined();
expect(runState.admission).toEqual({ status: "accepted", mode: "steer" });
- expect(state.beforeAgentReplyRunMock).toHaveBeenCalledOnce();
- expect(state.beforeAgentReplyRunMock).toHaveBeenCalledWith(
- { cleanedBody: "hello" },
- expect.objectContaining({
- runId: sourceTurnId,
- agentId: "main",
- sessionKey: "main",
- sessionId: "session",
- workspaceDir: "/tmp",
- modelProviderId: "anthropic",
- modelId: "claude",
- trigger: "user",
- channel: "discord",
- channelId: "24680",
- chatId: "24680",
- senderId: "sender-42",
- }),
- );
+ expect(state.beforeAgentReplyRunMock).not.toHaveBeenCalled();
expect(state.queueEmbeddedAgentMessageMock).toHaveBeenCalledOnce();
expect(state.queueEmbeddedAgentMessageMock).toHaveBeenCalledWith(
"session",
@@ -581,7 +560,7 @@ describe("runReplyAgent active steering", () => {
);
});
- it("returns a claimed steer without disturbing the active run", async () => {
+ it("does not let before_agent_reply claim an accepted steer", async () => {
state.beforeAgentReplyHasHooksMock.mockImplementation(
(hookName) => hookName === "before_agent_reply",
);
@@ -589,6 +568,7 @@ describe("runReplyAgent active steering", () => {
handled: true,
reply: { text: "claimed steer" },
});
+ state.queueEmbeddedAgentMessageMock.mockReturnValueOnce(true);
const active = createReplyOperation({
sessionKey: "main",
sessionId: "session",
@@ -597,7 +577,6 @@ describe("runReplyAgent active steering", () => {
active.setPhase("running");
const { run } = createMinimalRun({
isActive: true,
- isStreaming: true,
shouldSteer: true,
resolvedQueueMode: "steer",
sessionCtx: {
@@ -609,10 +588,10 @@ describe("runReplyAgent active steering", () => {
runOverrides: { agentId: "main", messageProvider: "discord" },
});
- await expect(run()).resolves.toEqual([{ text: "claimed steer" }]);
+ await expect(run()).resolves.toBeUndefined();
- expect(state.beforeAgentReplyRunMock).toHaveBeenCalledOnce();
- expect(state.queueEmbeddedAgentMessageMock).not.toHaveBeenCalled();
+ expect(state.beforeAgentReplyRunMock).not.toHaveBeenCalled();
+ expect(state.queueEmbeddedAgentMessageMock).toHaveBeenCalledOnce();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(active.phase).toBe("running");
expect(active.result).toBeNull();
@@ -628,7 +607,6 @@ describe("runReplyAgent active steering", () => {
state.runEmbeddedAgentMock.mockImplementationOnce(runHookBackedEmbeddedAgent);
const { run } = createMinimalRun({
isActive: true,
- isStreaming: true,
shouldSteer: true,
resolvedQueueMode: "steer",
sessionCtx: {
@@ -647,6 +625,33 @@ describe("runReplyAgent active steering", () => {
expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce();
});
+ it("runs normal reply hooks once after Gateway already attempted injection", async () => {
+ state.beforeAgentReplyHasHooksMock.mockImplementation(
+ (hookName) => hookName === "before_agent_reply",
+ );
+ state.beforeAgentReplyRunMock.mockResolvedValue(undefined);
+ state.runEmbeddedAgentMock.mockImplementationOnce(runHookBackedEmbeddedAgent);
+ const { run } = createMinimalRun({
+ opts: { messageInjectionAttempted: true },
+ isActive: true,
+ shouldSteer: true,
+ resolvedQueueMode: "steer",
+ sessionCtx: {
+ Provider: "discord",
+ OriginatingChannel: "discord",
+ OriginatingTo: "channel:24680",
+ MessageSid: "steer-pre-attempted",
+ },
+ runOverrides: { agentId: "main", messageProvider: "discord" },
+ });
+
+ await expect(run()).resolves.toEqual(expect.objectContaining({ text: "model reply" }));
+
+ expect(state.queueEmbeddedAgentMessageMock).not.toHaveBeenCalled();
+ expect(state.beforeAgentReplyRunMock).toHaveBeenCalledOnce();
+ expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce();
+ });
+
it("carries the prepared user-turn recorder into the embedded queue", async () => {
state.queueEmbeddedAgentMessageMock.mockReturnValueOnce(true);
const recorder = createUserTurnTranscriptRecorder({
@@ -658,7 +663,6 @@ describe("runReplyAgent active steering", () => {
});
const { followupRun, run } = createMinimalRun({
isActive: true,
- isStreaming: true,
shouldSteer: true,
resolvedQueueMode: "steer",
});
@@ -694,7 +698,6 @@ describe("runReplyAgent active steering", () => {
});
const { run } = createMinimalRun({
isActive: true,
- isStreaming: true,
shouldSteer: true,
resolvedQueueMode: "steer",
replyOperation: sourceReservation,
@@ -732,7 +735,6 @@ describe("runReplyAgent active steering", () => {
const { run, typing } = createMinimalRun({
opts: { turnAdoptionLifecycle: { onAdopted } },
isActive: true,
- isStreaming: true,
shouldSteer: true,
resolvedQueueMode: "steer",
});
@@ -774,7 +776,6 @@ describe("runReplyAgent active steering", () => {
const { run } = createMinimalRun({
opts: { onBlockReply, turnAdoptionLifecycle: { onAdopted } },
isActive: true,
- isStreaming: true,
shouldSteer: true,
shouldFollowup: true,
resolvedQueueMode: "steer",
@@ -822,7 +823,6 @@ describe("runReplyAgent active steering", () => {
const { run, typing } = createMinimalRun({
opts: { turnAdoptionLifecycle: { onAdopted } },
isActive: true,
- isStreaming: true,
shouldSteer: true,
shouldFollowup: true,
resolvedQueueMode: "steer",
@@ -847,7 +847,6 @@ describe("runReplyAgent active steering", () => {
const { followupRun, run, sourceTurnId } = createMinimalRun({
opts: { turnAdoptionLifecycle: { onAdopted } },
isActive: true,
- isStreaming: true,
shouldSteer: true,
resolvedQueueMode: "steer",
sessionCtx: {
@@ -1059,7 +1058,6 @@ describe("runReplyAgent heartbeat followup guard", () => {
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: true },
isActive: true,
- isStreaming: true,
shouldSteer: true,
shouldFollowup: true,
resolvedQueueMode: "collect",
diff --git a/src/auto-reply/reply/dispatch-from-config.audit.ts b/src/auto-reply/reply/dispatch-from-config.audit.ts
index 81451ce5d94d..c43c0c0af115 100644
--- a/src/auto-reply/reply/dispatch-from-config.audit.ts
+++ b/src/auto-reply/reply/dispatch-from-config.audit.ts
@@ -45,6 +45,8 @@ function resolveCompletedInboundAuditReason(
return "acp_dispatch_completed";
case "acp_empty_prompt":
return "acp_dispatch_empty";
+ case "active_run_injected":
+ return "active_run_injected";
default:
return undefined;
}
@@ -131,6 +133,74 @@ export type InboundMessageAuditTerminalRecorder = {
finishError: () => void;
};
+export function emitInboundMessageAuditTerminal(params: {
+ cfg: DispatchFromConfigParams["cfg"];
+ counts: Record;
+ ctx: DispatchFromConfigParams["ctx"];
+ observedRunId?: string;
+ startedAt: number;
+ terminal: { outcome: DispatchProcessedOutcome; options?: DispatchProcessedOptions };
+}): void {
+ const { ctx, cfg } = params;
+ const occurredAt = Date.now();
+ const sessionKey =
+ normalizeOptionalString(ctx.SessionKey) ?? normalizeOptionalString(ctx.CommandTargetSessionKey);
+ const actorId = normalizeOptionalString(ctx.SenderId);
+ const accountId = normalizeOptionalString(ctx.AccountId);
+ const conversationId =
+ normalizeOptionalString(ctx.NativeChannelId) ??
+ normalizeOptionalString(ctx.OriginatingTo) ??
+ normalizeOptionalString(ctx.To) ??
+ normalizeOptionalString(ctx.From);
+ const messageId =
+ normalizeOptionalString(ctx.MessageSidFull) ??
+ normalizeOptionalString(ctx.MessageSid) ??
+ normalizeOptionalString(ctx.MessageSidFirst) ??
+ normalizeOptionalString(ctx.MessageSidLast);
+ const terminalFields = resolveInboundMessageAuditTerminal(
+ params.terminal.outcome,
+ params.terminal.options?.reason,
+ );
+ let agentId = normalizeOptionalString(ctx.AgentId);
+ try {
+ agentId = resolveSessionAgentId({
+ sessionKey,
+ config: cfg,
+ agentId: ctx.AgentId,
+ });
+ } catch {
+ // Malformed setup must still produce a content-free terminal with available attribution.
+ }
+ try {
+ emitTrustedMessageAuditEvent({
+ occurredAt,
+ kind: "message",
+ action: "message.inbound.processed",
+ ...terminalFields,
+ actorType: actorId ? "channel_sender" : "system",
+ actorId: actorId ?? "gateway",
+ ...(agentId ? { agentId } : {}),
+ ...(normalizeOptionalString(params.observedRunId)
+ ? { runId: normalizeOptionalString(params.observedRunId) }
+ : {}),
+ direction: "inbound",
+ channel:
+ normalizeLowercaseStringOrEmpty(ctx.OriginatingChannel) ||
+ normalizeLowercaseStringOrEmpty(ctx.Surface) ||
+ normalizeLowercaseStringOrEmpty(ctx.Provider) ||
+ "unknown",
+ conversationKind: normalizeChatType(ctx.ChatType) ?? "unknown",
+ durationMs: Math.max(0, occurredAt - params.startedAt),
+ resultCount: params.counts.tool + params.counts.block + params.counts.final,
+ ...(accountId ? { accountId } : {}),
+ ...(conversationId ? { conversationId } : {}),
+ ...(messageId ? { messageId } : {}),
+ });
+ } catch {
+ // Optional audit observers must never alter message dispatch semantics.
+ }
+}
+
/**
* Captures one terminal event for the reply-processing boundary. Channel admission and
* pre-dispatch drops remain outside this boundary and need their own ingress projection.
@@ -157,66 +227,14 @@ export function createInboundMessageAuditTerminal(
return;
}
finished = true;
- const { ctx, cfg } = params;
- const occurredAt = Date.now();
- const sessionKey =
- normalizeOptionalString(ctx.SessionKey) ??
- normalizeOptionalString(ctx.CommandTargetSessionKey);
- const actorId = normalizeOptionalString(ctx.SenderId);
- const accountId = normalizeOptionalString(ctx.AccountId);
- const conversationId =
- normalizeOptionalString(ctx.NativeChannelId) ??
- normalizeOptionalString(ctx.OriginatingTo) ??
- normalizeOptionalString(ctx.To) ??
- normalizeOptionalString(ctx.From);
- const messageId =
- normalizeOptionalString(ctx.MessageSidFull) ??
- normalizeOptionalString(ctx.MessageSid) ??
- normalizeOptionalString(ctx.MessageSidFirst) ??
- normalizeOptionalString(ctx.MessageSidLast);
- const terminalFields = resolveInboundMessageAuditTerminal(
- terminal.outcome,
- terminal.options?.reason,
- );
- let agentId = normalizeOptionalString(ctx.AgentId);
- try {
- agentId = resolveSessionAgentId({
- sessionKey,
- config: cfg,
- agentId: ctx.AgentId,
- });
- } catch {
- // Malformed setup must still produce a content-free terminal with available attribution.
- }
- try {
- emitTrustedMessageAuditEvent({
- occurredAt,
- kind: "message",
- action: "message.inbound.processed",
- ...terminalFields,
- actorType: actorId ? "channel_sender" : "system",
- actorId: actorId ?? "gateway",
- ...(agentId ? { agentId } : {}),
- ...(observedRunId ? { runId: observedRunId } : {}),
- direction: "inbound",
- // OriginatingChannel is the canonical routing channel id and matches
- // outbound rows' channel; Surface/Provider can be UI-surface variants
- // and plugin channels may set only OriginatingChannel.
- channel:
- normalizeLowercaseStringOrEmpty(ctx.OriginatingChannel) ||
- normalizeLowercaseStringOrEmpty(ctx.Surface) ||
- normalizeLowercaseStringOrEmpty(ctx.Provider) ||
- "unknown",
- conversationKind: normalizeChatType(ctx.ChatType) ?? "unknown",
- durationMs: Math.max(0, occurredAt - startedAt),
- resultCount: counts.tool + counts.block + counts.final,
- ...(accountId ? { accountId } : {}),
- ...(conversationId ? { conversationId } : {}),
- ...(messageId ? { messageId } : {}),
- });
- } catch {
- // Optional audit observers must never alter message dispatch semantics.
- }
+ emitInboundMessageAuditTerminal({
+ cfg: params.cfg,
+ counts,
+ ctx: params.ctx,
+ observedRunId,
+ startedAt,
+ terminal,
+ });
};
return {
diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts
index 1c2d52dd5a31..1612ba7ff537 100644
--- a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts
+++ b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts
@@ -19,12 +19,6 @@ import { resolveConversationBindingRecord } from "../../bindings/records.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
import { logVerbose } from "../../globals.js";
-import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
-import {
- toInternalMessageReceivedContext,
- toPluginMessageContext,
- toPluginMessageReceivedEvent,
-} from "../../hooks/message-hook-mappers.js";
import {
isPluginOwnedSessionBindingRecord,
toPluginConversationBinding,
@@ -46,9 +40,9 @@ import {
} from "./dispatch-from-config.harness-defaults.js";
import { extendPreparedDispatchState } from "./dispatch-from-config.phase-state.js";
import type { PrepareDispatchDeliveryReadyState } from "./dispatch-from-config.prepare-delivery.js";
-import { createInternalHookEvent, triggerInternalHook } from "./dispatch-from-config.runtime.js";
import type { DispatchFromConfigResult } from "./dispatch-from-config.types.js";
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
+import { emitMessageReceivedHooks as emitSharedMessageReceivedHooks } from "./message-received-hooks.js";
import { resolveOriginMessageProvider } from "./origin-routing.js";
import { waitForReplyDispatcherIdle } from "./reply-dispatcher.js";
import { isDuplicateRestartRecoverySource } from "./restart-recovery-claim.js";
@@ -464,31 +458,13 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli
| "plugin-bound-fallback-no-handler";
} = {};
const emitMessageReceivedHooks = () => {
- if (
- ctx.SuppressMessageReceivedHooks !== true &&
- hookRunner?.hasHooks("message_received") === true
- ) {
- const messageReceivedHookContext = buildMessageReceivedHookContext();
- fireAndForgetHook(
- hookRunner.runMessageReceived(
- toPluginMessageReceivedEvent(messageReceivedHookContext),
- toPluginMessageContext(messageReceivedHookContext),
- ),
- "dispatch-from-config: message_received plugin hook failed",
- );
- }
- if (ctx.SuppressMessageReceivedHooks !== true && sessionKey) {
- const messageReceivedHookContext = buildMessageReceivedHookContext();
- fireAndForgetHook(
- triggerInternalHook(
- createInternalHookEvent("message", "received", sessionKey, {
- ...toInternalMessageReceivedContext(messageReceivedHookContext),
- timestamp: state.timestamp,
- }),
- ),
- "dispatch-from-config: message_received internal hook failed",
- );
- }
+ emitSharedMessageReceivedHooks({
+ ctx,
+ hookRunner,
+ sessionKey,
+ timestamp: state.timestamp,
+ buildContext: buildMessageReceivedHookContext,
+ });
};
state.markProcessing();
if (await capturePendingConversationTurnReply({ cfg, ctx })) {
diff --git a/src/auto-reply/reply/get-reply-run-admission.ts b/src/auto-reply/reply/get-reply-run-admission.ts
index 14d1e8504412..843115351671 100644
--- a/src/auto-reply/reply/get-reply-run-admission.ts
+++ b/src/auto-reply/reply/get-reply-run-admission.ts
@@ -38,7 +38,6 @@ import {
REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS,
abortReplyRunBySessionId,
isReplyRunActiveForSessionId,
- isReplyRunStreamingForSessionId,
resolveActiveReplyRunThreadId,
resolveActiveReplyRunSessionId,
waitForReplyRunEndBySessionId,
@@ -449,10 +448,10 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext)
const activeSessionId =
embeddedActiveSessionId ?? replyOperationActiveSessionId ?? preparedSessionState.sessionId;
if (!activeSessionId || (!embeddedAgentRuntime && !replyOperationActiveSessionId)) {
- return { activeSessionId: undefined, isActive: false, isStreaming: false };
+ return { activeSessionId: undefined, isActive: false };
}
if (isOwnPreDispatchOperationSession(activeSessionId)) {
- return { activeSessionId, isActive: false, isStreaming: false };
+ return { activeSessionId, isActive: false };
}
const replyOperationActive =
replyOperationActiveSessionId != null &&
@@ -463,11 +462,6 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext)
(embeddedActiveSessionId != null &&
(embeddedAgentRuntime?.isEmbeddedAgentRunActive(embeddedActiveSessionId) ?? false)) ||
replyOperationActive,
- isStreaming:
- (embeddedActiveSessionId != null &&
- (embeddedAgentRuntime?.isEmbeddedAgentRunStreaming(embeddedActiveSessionId) ?? false)) ||
- (replyOperationActiveSessionId != null &&
- isReplyRunStreamingForSessionId(replyOperationActiveSessionId)),
};
};
if (commandTurnContinuationTargetKey && providedReplyOperation) {
@@ -499,7 +493,7 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext)
providedReplyOperation.updateSessionId(sessionId);
}
}
- const { activeSessionId, isActive, isStreaming } = resolveQueueBusyState();
+ const { activeSessionId, isActive } = resolveQueueBusyState();
const activeRunAcceptsCurrentThread = resolveActiveRunAcceptsCurrentThread({ isActive });
const shouldSteer =
!isRoomEvent &&
@@ -588,7 +582,6 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext)
shouldSteer,
shouldFollowup,
isActive,
- isStreaming,
authProfileId,
authProfileIdSource,
} as const;
diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts
index 82285bdf0f9b..e41a8ec28273 100644
--- a/src/auto-reply/reply/get-reply-run-execute.ts
+++ b/src/auto-reply/reply/get-reply-run-execute.ts
@@ -60,7 +60,6 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
shouldSteer,
shouldFollowup,
isActive,
- isStreaming,
authProfileId,
authProfileIdSource,
} = state;
@@ -461,7 +460,6 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
latestSessionState.sessionId;
return embeddedAgentRuntime?.isEmbeddedAgentRunActive(latestActiveSessionId) ?? false;
},
- isStreaming,
opts,
typing,
sessionEntry: preparedSessionState.sessionEntry,
diff --git a/src/auto-reply/reply/get-reply-run-queue.ts b/src/auto-reply/reply/get-reply-run-queue.ts
index 64c95e91797c..a5fe5bb806cb 100644
--- a/src/auto-reply/reply/get-reply-run-queue.ts
+++ b/src/auto-reply/reply/get-reply-run-queue.ts
@@ -8,7 +8,6 @@ import type { QueueSettings } from "./queue.js";
type ReplyRunQueueBusyState = {
activeSessionId: string | undefined;
isActive: boolean;
- isStreaming: boolean;
};
export const REPLY_RUN_STILL_SHUTTING_DOWN_TEXT =
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 a0d2408072b9..fda6abba2e7c 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
@@ -827,7 +827,6 @@ describe("runPreparedReply media-only handling", () => {
shouldSteer: true,
shouldFollowup: true,
isActive: true,
- isStreaming: true,
resolvedQueue: expect.objectContaining({ mode: "steer" }),
});
expect(call?.followupRun.run.messageProvider).toBe(channel);
@@ -1913,7 +1912,6 @@ describe("runPreparedReply media-only handling", () => {
expect(call?.shouldSteer).toBe(false);
expect(call?.shouldFollowup).toBe(true);
expect(call?.isActive).toBe(true);
- expect(call?.isStreaming).toBe(true);
});
it.each([
@@ -1969,7 +1967,6 @@ describe("runPreparedReply media-only handling", () => {
expect(call.shouldSteer).toBe(false);
expect(call.shouldFollowup).toBe(true);
expect(call.isActive).toBe(true);
- expect(call.isStreaming).toBe(true);
expect(call.followupRun.originatingThreadId).toBe("501.000");
},
);
@@ -2022,7 +2019,6 @@ describe("runPreparedReply media-only handling", () => {
expect(call.shouldSteer).toBe(true);
expect(call.shouldFollowup).toBe(true);
expect(call.isActive).toBe(true);
- expect(call.isStreaming).toBe(true);
expect(call.followupRun.originatingThreadId).toBe(43);
});
diff --git a/src/auto-reply/reply/message-received-hooks.ts b/src/auto-reply/reply/message-received-hooks.ts
new file mode 100644
index 000000000000..e774b7f52168
--- /dev/null
+++ b/src/auto-reply/reply/message-received-hooks.ts
@@ -0,0 +1,57 @@
+import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
+import {
+ deriveInboundMessageHookContext,
+ toInternalMessageReceivedContext,
+ toPluginMessageContext,
+ toPluginMessageReceivedEvent,
+} from "../../hooks/message-hook-mappers.js";
+import type { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
+import type { FinalizedMsgContext } from "../templating.js";
+import { createInternalHookEvent, triggerInternalHook } from "./dispatch-from-config.runtime.js";
+
+type MessageReceivedHookContext = ReturnType;
+
+/** Emit observation hooks once for an accepted inbound turn, independent of reply dispatch. */
+export function emitMessageReceivedHooks(params: {
+ ctx: FinalizedMsgContext;
+ hookRunner: ReturnType;
+ sessionKey: string | undefined;
+ timestamp?: number;
+ buildContext?: () => MessageReceivedHookContext;
+}): void {
+ if (params.ctx.SuppressMessageReceivedHooks === true) {
+ return;
+ }
+ const buildContext =
+ params.buildContext ??
+ (() =>
+ deriveInboundMessageHookContext(params.ctx, {
+ messageId:
+ params.ctx.MessageSidFull ??
+ params.ctx.MessageSid ??
+ params.ctx.MessageSidFirst ??
+ params.ctx.MessageSidLast,
+ }));
+ if (params.hookRunner?.hasHooks("message_received") === true) {
+ const context = buildContext();
+ fireAndForgetHook(
+ params.hookRunner.runMessageReceived(
+ toPluginMessageReceivedEvent(context),
+ toPluginMessageContext(context),
+ ),
+ "message_received plugin hook failed",
+ );
+ }
+ if (params.sessionKey) {
+ const context = buildContext();
+ fireAndForgetHook(
+ triggerInternalHook(
+ createInternalHookEvent("message", "received", params.sessionKey, {
+ ...toInternalMessageReceivedContext(context),
+ timestamp: params.timestamp,
+ }),
+ ),
+ "message_received internal hook failed",
+ );
+ }
+}
diff --git a/src/auto-reply/reply/reply-run-registry.test.ts b/src/auto-reply/reply/reply-run-registry.test.ts
index e341762b6f0c..250478d7ffb3 100644
--- a/src/auto-reply/reply/reply-run-registry.test.ts
+++ b/src/auto-reply/reply/reply-run-registry.test.ts
@@ -15,6 +15,8 @@ import { MAX_TIMER_TIMEOUT_MS } from "../../shared/number-coercion.js";
import { beginReplyOperationFinalizationWork } from "./reply-run-finalization-lease.js";
import {
abortActiveReplyRuns,
+ abortReplyMessageInjectionTarget,
+ beginReplyMessageInjectionTarget,
createReplyOperation,
expireStaleReplyOperation,
forceClearReplyOperation,
@@ -24,7 +26,6 @@ import {
isReplyRunAbortableForCompaction,
isReplyRunAbortableForSignal,
clearReplyRunForResetBySessionId,
- queueReplyRunMessage,
REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS,
REPLY_RUN_TERMINAL_SETTLE_TIMEOUT_MS,
ReplyRunAlreadyActiveError,
@@ -32,6 +33,7 @@ import {
markReplyOperationGlobalLaneWaitProgress,
runAfterReplyOperationClear,
resolveActiveReplyRunSessionId,
+ resolveActiveReplyOperationForSessionId,
resolveReplyRunPhaseForSessionId,
waitForReplyOperationOwnerSettlement,
waitForReplyRunEndBySessionId,
@@ -52,6 +54,29 @@ function createTestReplyOperation(
});
}
+async function queueCurrentReplyRunMessage(
+ sessionId: string,
+ text: string,
+ options?: Parameters[2],
+) {
+ const operation = resolveActiveReplyOperationForSessionId(sessionId);
+ const target = operation
+ ? replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: operation.key,
+ originatingLeafEntryId: operation.originatingLeafEntryId,
+ })
+ : undefined;
+ return target
+ ? await queueReplyMessageInjectionTarget(target, text, options)
+ : { status: "rejected" as const, reason: "injection_unavailable" as const };
+}
+
+async function queueReplyMessageInjectionTarget(
+ ...args: Parameters
+) {
+ return await beginReplyMessageInjectionTarget(...args).outcome;
+}
+
describe("reply run registry", () => {
afterEach(() => {
testing.resetReplyRunRegistry();
@@ -110,32 +135,71 @@ describe("reply run registry", () => {
expect(isReplyRunAbortableForCompaction("session-compact")).toBe(true);
});
- it("matches injectable owners only to their immutable originating leaf", () => {
+ it("binds modern targets by run while preserving leaf-only legacy targeting", async () => {
const operation = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
let stopped = false;
const queueMessage = vi.fn(async () => {});
operation.setPhase("running");
operation.attachBackend({
kind: "embedded",
+ runId: "run-a",
cancel: () => {},
- isStreaming: () => false,
- isStopped: () => stopped,
- queueMessage,
+ messageInjection: { isAvailable: () => !stopped, queueMessage },
+ });
+
+ const target = replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: "agent:main:main",
+ originatingLeafEntryId: "leaf-b",
+ expectedRunId: "run-a",
+ });
+ expect(target).toMatchObject({ identity: "run", runId: "run-a" });
+ const legacyTarget = replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: "agent:main:main",
+ originatingLeafEntryId: "leaf-a",
+ });
+ expect(legacyTarget).toMatchObject({ identity: "leaf", runId: "run-a" });
+ expect(
+ replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: "agent:main:main",
+ originatingLeafEntryId: "leaf-b",
+ }),
+ ).toBeUndefined();
+ await expect(
+ queueReplyMessageInjectionTarget(target!, "steer during tool work"),
+ ).resolves.toEqual({ status: "accepted" });
+ await expect(queueReplyMessageInjectionTarget(legacyTarget!, "legacy steer")).resolves.toEqual({
+ status: "accepted",
+ });
+ expect(queueMessage).toHaveBeenCalledWith("steer during tool work");
+ expect(queueMessage).toHaveBeenCalledWith("legacy steer");
+ stopped = true;
+ await expect(queueReplyMessageInjectionTarget(target!, "late steer")).resolves.toEqual({
+ status: "rejected",
+ reason: "injection_unavailable",
+ });
+ });
+
+ it("requires an explicit legacy leaf while preserving deliberate null", () => {
+ const operation = createTestReplyOperation({ originatingLeafEntryId: null });
+ operation.setPhase("running");
+ operation.attachBackend({
+ kind: "embedded",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: vi.fn(async () => {}) },
});
expect(
- replyRunRegistry.isMessageInjectableFromOriginatingLeaf("agent:main:main", "leaf-a"),
- ).toBe(true);
+ replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: operation.key,
+ originatingLeafEntryId: undefined,
+ }),
+ ).toBeUndefined();
expect(
- replyRunRegistry.isMessageInjectableFromOriginatingLeaf("agent:main:main", "leaf-b"),
- ).toBe(false);
- expect(queueReplyRunMessage(operation.sessionId, "steer during tool work")).toBe(true);
- expect(queueMessage).toHaveBeenCalledWith("steer during tool work");
- stopped = true;
- expect(
- replyRunRegistry.isMessageInjectableFromOriginatingLeaf("agent:main:main", "leaf-a"),
- ).toBe(false);
- expect(queueReplyRunMessage(operation.sessionId, "late steer")).toBe(false);
+ replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: operation.key,
+ originatingLeafEntryId: null,
+ }),
+ ).toMatchObject({ identity: "leaf", originatingLeafEntryId: null });
});
it("records reply-operation progress without claiming embedded-run activity", () => {
@@ -1496,7 +1560,7 @@ describe("reply run registry", () => {
}
});
- it("queues messages only through the active running backend", () => {
+ it("queues messages only through the active running backend", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createTestReplyOperation({
sessionId: "session-running",
@@ -1509,15 +1573,19 @@ describe("reply run registry", () => {
queueMessage,
});
- expect(queueReplyRunMessage("session-running", "before running")).toBe(false);
+ await expect(
+ queueCurrentReplyRunMessage("session-running", "before running"),
+ ).resolves.toMatchObject({ status: "rejected" });
operation.setPhase("running");
- expect(queueReplyRunMessage("session-running", "hello")).toBe(true);
+ await expect(queueCurrentReplyRunMessage("session-running", "hello")).resolves.toEqual({
+ status: "accepted",
+ });
expect(queueMessage).toHaveBeenCalledWith("hello");
});
- it("queues messages only when the task-suggestion tool surface matches", () => {
+ it("queues messages only when the task-suggestion tool surface matches", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createTestReplyOperation({
sessionId: "session-task-suggestions",
@@ -1531,17 +1599,19 @@ describe("reply run registry", () => {
});
operation.setPhase("running");
- expect(
- queueReplyRunMessage("session-task-suggestions", "legacy client", {
+ await expect(
+ queueCurrentReplyRunMessage("session-task-suggestions", "legacy client", {
taskSuggestionDeliveryMode: undefined,
}),
- ).toBe(false);
- expect(
- queueReplyRunMessage("session-task-suggestions", "capable client", {
+ ).resolves.toEqual({ status: "rejected", reason: "task_suggestion_delivery_mode_mismatch" });
+ await expect(
+ queueCurrentReplyRunMessage("session-task-suggestions", "capable client", {
taskSuggestionDeliveryMode: "gateway",
}),
- ).toBe(true);
- expect(queueReplyRunMessage("session-task-suggestions", "internal completion")).toBe(true);
+ ).resolves.toEqual({ status: "accepted" });
+ await expect(
+ queueCurrentReplyRunMessage("session-task-suggestions", "internal completion"),
+ ).resolves.toEqual({ status: "accepted" });
expect(queueMessage).toHaveBeenCalledTimes(2);
expect(queueMessage).toHaveBeenNthCalledWith(1, "capable client", {
taskSuggestionDeliveryMode: "gateway",
@@ -1549,7 +1619,7 @@ describe("reply run registry", () => {
expect(queueMessage).toHaveBeenNthCalledWith(2, "internal completion");
});
- it("queues images only through backends that preserve them", () => {
+ it("queues images only through backends that preserve them", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createTestReplyOperation({
sessionId: "session-images",
@@ -1563,7 +1633,9 @@ describe("reply run registry", () => {
operation.setPhase("running");
const images = [{ type: "image" as const, data: "png", mimeType: "image/png" }];
- expect(queueReplyRunMessage("session-images", "inspect", { images })).toBe(false);
+ await expect(
+ queueCurrentReplyRunMessage("session-images", "inspect", { images }),
+ ).resolves.toMatchObject({ status: "rejected", reason: "image_input_unsupported" });
expect(queueMessage).not.toHaveBeenCalled();
operation.attachBackend({
@@ -1574,11 +1646,13 @@ describe("reply run registry", () => {
supportsQueueMessageImages: true,
});
- expect(queueReplyRunMessage("session-images", "inspect", { images })).toBe(true);
+ await expect(
+ queueCurrentReplyRunMessage("session-images", "inspect", { images }),
+ ).resolves.toEqual({ status: "accepted" });
expect(queueMessage).toHaveBeenCalledWith("inspect", { images });
});
- it("queues messages through active non-streaming backends with live stopped state", () => {
+ it("queues messages through queue-first legacy backends while token streaming is idle", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createTestReplyOperation({
sessionId: "session-running",
@@ -1588,16 +1662,17 @@ describe("reply run registry", () => {
kind: "embedded",
cancel: vi.fn(),
isStreaming: () => false,
- isStopped: () => false,
queueMessage,
});
operation.setPhase("running");
- expect(queueReplyRunMessage("session-running", "hello")).toBe(true);
+ await expect(queueCurrentReplyRunMessage("session-running", "hello")).resolves.toEqual({
+ status: "accepted",
+ });
expect(queueMessage).toHaveBeenCalledWith("hello");
});
- it("refuses stale injectable owners for admission and delivery until activity resumes", () => {
+ it("refuses stale injectable owners for admission and delivery until activity resumes", async () => {
vi.useFakeTimers();
try {
const queueMessage = vi.fn(async () => {});
@@ -1614,31 +1689,44 @@ describe("reply run registry", () => {
});
operation.setPhase("running");
- expect(
- replyRunRegistry.isMessageInjectableFromOriginatingLeaf("agent:main:main", "leaf-a"),
- ).toBe(true);
+ const target = replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: "agent:main:main",
+ originatingLeafEntryId: "leaf-a",
+ });
+ expect(target).toBeDefined();
vi.advanceTimersByTime(RUN_STALE_TAKEOVER_MS + 1);
expect(
- replyRunRegistry.isMessageInjectableFromOriginatingLeaf("agent:main:main", "leaf-a"),
- ).toBe(false);
- expect(queueReplyRunMessage("session-running", "stale")).toBe(false);
+ replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: "agent:main:main",
+ originatingLeafEntryId: "leaf-a",
+ }),
+ ).toBeUndefined();
+ await expect(queueReplyMessageInjectionTarget(target!, "stale")).resolves.toMatchObject({
+ status: "rejected",
+ reason: "stale_run",
+ });
expect(queueMessage).not.toHaveBeenCalled();
operation.recordActivity();
expect(
- replyRunRegistry.isMessageInjectableFromOriginatingLeaf("agent:main:main", "leaf-a"),
- ).toBe(true);
- expect(queueReplyRunMessage("session-running", "fresh")).toBe(true);
+ replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: "agent:main:main",
+ originatingLeafEntryId: "leaf-a",
+ }),
+ ).toBeDefined();
+ await expect(queueReplyMessageInjectionTarget(target!, "fresh")).resolves.toEqual({
+ status: "accepted",
+ });
expect(queueMessage).toHaveBeenCalledWith("fresh");
} finally {
vi.useRealTimers();
}
});
- it("does not queue messages through stopped backends", () => {
+ it("does not queue messages through stopped backends", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createTestReplyOperation({
sessionId: "session-running",
@@ -1653,11 +1741,14 @@ describe("reply run registry", () => {
});
operation.setPhase("running");
- expect(queueReplyRunMessage("session-running", "hello")).toBe(false);
+ await expect(queueCurrentReplyRunMessage("session-running", "hello")).resolves.toMatchObject({
+ status: "rejected",
+ reason: "injection_unavailable",
+ });
expect(queueMessage).not.toHaveBeenCalled();
});
- it("fails closed when backend stopped state checks throw", () => {
+ it("fails closed when backend stopped state checks throw", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createTestReplyOperation({
sessionId: "session-running",
@@ -1674,10 +1765,207 @@ describe("reply run registry", () => {
});
operation.setPhase("running");
- expect(queueReplyRunMessage("session-running", "hello")).toBe(false);
+ await expect(queueCurrentReplyRunMessage("session-running", "hello")).resolves.toMatchObject({
+ status: "rejected",
+ reason: "injection_unavailable",
+ });
expect(queueMessage).not.toHaveBeenCalled();
});
+ it("requires a real injection capability", () => {
+ const operation = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ operation.setPhase("running");
+ operation.attachBackend({ kind: "cli", runId: "run-a", cancel: vi.fn() });
+
+ expect(
+ replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: operation.key,
+ originatingLeafEntryId: "leaf-a",
+ expectedRunId: "run-a",
+ }),
+ ).toBeUndefined();
+ });
+
+ it("rejects a different expected run id", () => {
+ const operation = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ operation.setPhase("running");
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: vi.fn(async () => {}) },
+ });
+
+ expect(
+ replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: operation.key,
+ originatingLeafEntryId: "leaf-a",
+ expectedRunId: "run-b",
+ }),
+ ).toBeUndefined();
+ });
+
+ it("returns synchronous and asynchronous queue failures as typed rejections", async () => {
+ const operation = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ operation.setPhase("running");
+ const synchronous = vi.fn(() => {
+ throw new Error("sync rejection");
+ });
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: synchronous },
+ });
+ const target = replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: operation.key,
+ originatingLeafEntryId: "leaf-a",
+ expectedRunId: "run-a",
+ })!;
+
+ const synchronousAttempt = beginReplyMessageInjectionTarget(target, "first");
+ expect(synchronous).toHaveBeenCalledOnce();
+ await expect(synchronousAttempt.outcome).resolves.toMatchObject({
+ status: "rejected",
+ reason: "runtime_rejected",
+ errorMessage: "Error: sync rejection",
+ });
+
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: {
+ isAvailable: () => true,
+ queueMessage: vi.fn(async () => {
+ throw new Error("async rejection");
+ }),
+ },
+ });
+ await expect(queueReplyMessageInjectionTarget(target, "second")).resolves.toMatchObject({
+ status: "rejected",
+ reason: "runtime_rejected",
+ errorMessage: "Error: async rejection",
+ });
+ });
+
+ it("rejects an ABA successor even when key and leaf are reused", async () => {
+ const first = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ first.setPhase("running");
+ first.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: vi.fn(async () => {}) },
+ });
+ const target = replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: first.key,
+ originatingLeafEntryId: "leaf-a",
+ expectedRunId: "run-a",
+ })!;
+ first.complete();
+ const successorQueue = vi.fn(async () => {});
+ const successor = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ successor.setPhase("running");
+ successor.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: successorQueue },
+ });
+
+ await expect(queueReplyMessageInjectionTarget(target, "must not move")).resolves.toMatchObject({
+ status: "rejected",
+ reason: "no_active_run",
+ });
+ expect(successorQueue).not.toHaveBeenCalled();
+ });
+
+ it("exact-target abort cannot abort a same-key successor", () => {
+ const first = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ first.setPhase("running");
+ first.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: vi.fn(async () => {}) },
+ });
+ const target = replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: first.key,
+ originatingLeafEntryId: "leaf-a",
+ expectedRunId: "run-a",
+ })!;
+ first.complete();
+ const successorCancel = vi.fn();
+ const successor = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ successor.setPhase("running");
+ successor.attachBackend({
+ kind: "embedded",
+ runId: "run-b",
+ cancel: successorCancel,
+ messageInjection: { isAvailable: () => true, queueMessage: vi.fn(async () => {}) },
+ });
+
+ expect(abortReplyMessageInjectionTarget(target)).toBe(false);
+ expect(successor.result).toBeNull();
+ expect(successorCancel).not.toHaveBeenCalled();
+ });
+
+ it("uses a replacement backend on the same operation", async () => {
+ const operation = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ operation.setPhase("running");
+ const firstQueue = vi.fn(async () => {});
+ const first = {
+ kind: "embedded" as const,
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: firstQueue },
+ };
+ operation.attachBackend(first);
+ const target = replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: operation.key,
+ originatingLeafEntryId: "leaf-a",
+ expectedRunId: "run-a",
+ })!;
+ const replacementQueue = vi.fn(async () => {});
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: replacementQueue },
+ });
+
+ await expect(queueReplyMessageInjectionTarget(target, "replacement")).resolves.toEqual({
+ status: "accepted",
+ });
+ expect(firstQueue).not.toHaveBeenCalled();
+ expect(replacementQueue).toHaveBeenCalledWith("replacement");
+ });
+
+ it("keeps an invoked queue authoritative when the owner clears synchronously", async () => {
+ const operation = createTestReplyOperation({ originatingLeafEntryId: "leaf-a" });
+ operation.setPhase("running");
+ const queueMessage = vi.fn(async () => {
+ operation.complete();
+ });
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage },
+ });
+ const target = replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: operation.key,
+ originatingLeafEntryId: "leaf-a",
+ expectedRunId: "run-a",
+ })!;
+
+ await expect(queueReplyMessageInjectionTarget(target, "last input")).resolves.toEqual({
+ status: "accepted",
+ });
+ expect(replyRunRegistry.isActive(operation.key)).toBe(false);
+ });
+
it("aborts compacting runs through the registry compatibility helper", () => {
const compactingOperation = createTestReplyOperation({
sessionId: "session-compacting",
diff --git a/src/auto-reply/reply/reply-run-registry.ts b/src/auto-reply/reply/reply-run-registry.ts
index 765d2bbf2cab..f78f3b16766c 100644
--- a/src/auto-reply/reply/reply-run-registry.ts
+++ b/src/auto-reply/reply/reply-run-registry.ts
@@ -60,16 +60,29 @@ export type ReplyBackendQueueMessageResult = {
errorMessage: string;
};
+export type ReplyBackendMessageInjection = {
+ /** Runtime-owned admission state; independent from token streaming. */
+ isAvailable(): boolean;
+ queueMessage(
+ text: string,
+ options?: ReplyBackendQueueMessageOptions,
+ ): Promise;
+};
+
export type ReplyBackendHandle = {
readonly kind: ReplyBackendKind;
+ readonly runId?: string;
readonly sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
readonly taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode;
/** True only when queueMessage preserves images supplied in its options. */
readonly supportsQueueMessageImages?: boolean;
cancel(reason?: ReplyBackendCancelReason): void;
- isStreaming(): boolean;
+ readonly messageInjection?: ReplyBackendMessageInjection;
+ /** @deprecated Compatibility for shipped embedded handles. Use messageInjection. */
+ isStreaming?: () => boolean;
isStopped?: () => boolean;
isAbortable?: () => boolean;
+ /** @deprecated Compatibility for shipped embedded handles. Use messageInjection. */
queueMessage?: (
text: string,
options?: ReplyBackendQueueMessageOptions,
@@ -81,6 +94,38 @@ export type ReplyBackendHandle = {
isCompacting?: () => boolean;
};
+const replyMessageInjectionTargetOperation = Symbol("replyMessageInjectionTargetOperation");
+export type ReplyMessageInjectionTarget = {
+ readonly [replyMessageInjectionTargetOperation]: ReplyOperation;
+ /** Legacy targets stay leaf-bound even when their backend exposes a run id. */
+ readonly identity: "leaf" | "run";
+ readonly runId?: string;
+ readonly originatingLeafEntryId: string | null | undefined;
+};
+
+type ReplyMessageInjectionRejectionReason =
+ | "no_active_run"
+ | "not_running"
+ | "stale_run"
+ | "leaf_mismatch"
+ | "run_mismatch"
+ | "injection_unavailable"
+ | ReplyBackendQueueMessageMismatch
+ | "runtime_rejected";
+
+export type ReplyMessageInjectionOutcome =
+ | { status: "accepted"; result?: ReplyBackendQueueMessageResult }
+ | { status: "rejected"; reason: ReplyMessageInjectionRejectionReason; errorMessage?: string };
+
+export type ReplyMessageInjectionAttempt = {
+ /** Native run identity captured with the opaque operation target. */
+ targetRunId: string | undefined;
+ /** Leaf-bound compatibility must reject before ACK instead of falling through. */
+ rejectBeforeAck?: true;
+ /** Settles after the backend confirms or rejects this exact injection. */
+ outcome: Promise;
+};
+
type ReplyBackendQueueMessageMismatch =
| "image_input_unsupported"
| "source_reply_delivery_mode_mismatch"
@@ -242,11 +287,11 @@ type ReplyRunRegistry = {
}): ReplyOperation;
get(sessionKey: string): ReplyOperation | undefined;
isActive(sessionKey: string): boolean;
- isStreaming(sessionKey: string): boolean;
- isMessageInjectableFromOriginatingLeaf(
- sessionKey: string,
- originatingLeafEntryId: string | null,
- ): boolean;
+ resolveMessageInjectionTarget(params: {
+ sessionKey: string;
+ originatingLeafEntryId: string | null | undefined;
+ expectedRunId?: string;
+ }): ReplyMessageInjectionTarget | undefined;
abort(sessionKey: string): boolean;
waitForIdle(
sessionKey: string,
@@ -428,21 +473,72 @@ export function retainReplyOperationUntilComplete(operation: ReplyOperation): vo
retainStateUntilCompleteOperations.add(operation);
}
-function isReplyBackendMessageInjectable(backend: ReplyBackendHandle): boolean {
- try {
- return backend.isStopped === undefined ? backend.isStreaming() : !backend.isStopped();
- } catch {
- return false;
+/** Queue-first compatibility adapter for shipped Plugin SDK/embedded handles. */
+function resolveReplyBackendMessageInjection(
+ backend: ReplyBackendHandle,
+): ReplyBackendMessageInjection | undefined {
+ if (backend.messageInjection) {
+ return backend.messageInjection;
}
+ if (!backend.queueMessage) {
+ return undefined;
+ }
+ return {
+ isAvailable: () => {
+ if (backend.isStopped) {
+ return !backend.isStopped();
+ }
+ // Legacy handles already expose the only capability that matters here:
+ // queueMessage. Let the runtime accept or reject instead of guessing from
+ // unrelated token-stream state.
+ return true;
+ },
+ queueMessage: (text, options) =>
+ options ? backend.queueMessage!(text, options) : backend.queueMessage!(text),
+ };
}
-function isReplyOperationMessageInjectable(
- operation: ReplyOperation,
- backend: ReplyBackendHandle,
-): boolean {
- // Admission and delivery must share freshness or a stale owner can waive the
- // transcript-leaf fence and then reject the same steer during injection.
- return !isReplyRunEvidenceStale(operation) && isReplyBackendMessageInjectable(backend);
+function resolveReplyMessageInjectionRejection(params: {
+ operation: ReplyOperation | undefined;
+ originatingLeafEntryId: string | null | undefined;
+ expectedRunId?: string;
+ options?: ReplyBackendQueueMessageOptions;
+}):
+ | { reason: ReplyMessageInjectionRejectionReason; errorMessage?: string }
+ | { backend: ReplyBackendHandle; injection: ReplyBackendMessageInjection } {
+ const { operation } = params;
+ if (!operation || replyRunState.activeRunsByKey.get(operation.key) !== operation) {
+ return { reason: "no_active_run" };
+ }
+ if (operation.result || operation.phase !== "running") {
+ return { reason: "not_running" };
+ }
+ const expectedRunId = normalizeOptionalString(params.expectedRunId);
+ // Exact run identity supersedes the operation's immutable origin leaf. The
+ // same run advances its transcript leaf during ordinary tool/output progress.
+ if (!expectedRunId && operation.originatingLeafEntryId !== params.originatingLeafEntryId) {
+ return { reason: "leaf_mismatch" };
+ }
+ if (isReplyRunEvidenceStale(operation)) {
+ return { reason: "stale_run" };
+ }
+ const backend = getAttachedBackend(operation);
+ const injection = backend ? resolveReplyBackendMessageInjection(backend) : undefined;
+ if (!backend || !injection) {
+ return { reason: "injection_unavailable" };
+ }
+ if (expectedRunId && normalizeOptionalString(backend.runId) !== expectedRunId) {
+ return { reason: "run_mismatch" };
+ }
+ try {
+ if (!injection.isAvailable()) {
+ return { reason: "injection_unavailable" };
+ }
+ } catch (error) {
+ return { reason: "injection_unavailable", errorMessage: String(error) };
+ }
+ const mismatch = resolveReplyBackendQueueMessageMismatch(backend, params.options);
+ return mismatch ? { reason: mismatch } : { backend, injection };
}
/** Run work after an operation no longer owns its session lane. */
@@ -1283,24 +1379,23 @@ export const replyRunRegistry: ReplyRunRegistry = {
}
return replyRunState.activeRunsByKey.has(normalizedSessionKey);
},
- isStreaming(sessionKey) {
+ resolveMessageInjectionTarget({ sessionKey, originatingLeafEntryId, expectedRunId }) {
const operation = this.get(sessionKey);
- if (!operation || operation.phase !== "running") {
- return false;
+ const resolved = resolveReplyMessageInjectionRejection({
+ operation,
+ originatingLeafEntryId,
+ expectedRunId,
+ });
+ if (!("injection" in resolved)) {
+ return undefined;
}
- return getAttachedBackend(operation)?.isStreaming() ?? false;
- },
- isMessageInjectableFromOriginatingLeaf(sessionKey, originatingLeafEntryId) {
- const operation = this.get(sessionKey);
- if (
- !operation ||
- operation.phase !== "running" ||
- operation.originatingLeafEntryId !== originatingLeafEntryId
- ) {
- return false;
- }
- const backend = getAttachedBackend(operation);
- return backend ? isReplyOperationMessageInjectable(operation, backend) : false;
+ const target: ReplyMessageInjectionTarget = {
+ [replyMessageInjectionTargetOperation]: operation!,
+ identity: normalizeOptionalString(expectedRunId) ? "run" : "leaf",
+ ...(resolved.backend.runId ? { runId: resolved.backend.runId } : {}),
+ originatingLeafEntryId,
+ };
+ return target;
},
abort(sessionKey) {
const operation = this.get(sessionKey);
@@ -1391,37 +1486,74 @@ export function isReplyRunAbortableForCompaction(sessionId: string): boolean {
return Boolean(operation && !isReplyOperationPreBackendPhase(operation.phase));
}
-export function isReplyRunStreamingForSessionId(sessionId: string): boolean {
- const operation = resolveReplyRunForCurrentSessionId(sessionId);
- if (!operation || operation.phase !== "running") {
- return false;
- }
- return getAttachedBackend(operation)?.isStreaming() ?? false;
-}
-
-export function queueReplyRunMessage(
- sessionId: string,
+export function beginReplyMessageInjectionTarget(
+ target: ReplyMessageInjectionTarget,
text: string,
options?: ReplyBackendQueueMessageOptions,
-): boolean {
- const operation = resolveReplyRunForCurrentSessionId(sessionId);
- const backend = operation ? getAttachedBackend(operation) : undefined;
- if (!operation || operation.phase !== "running" || !backend?.queueMessage) {
- return false;
- }
- if (!isReplyOperationMessageInjectable(operation, backend)) {
- return false;
- }
- if (resolveReplyBackendQueueMessageMismatch(backend, options)) {
- return false;
+): ReplyMessageInjectionAttempt {
+ const resolved = resolveReplyMessageInjectionRejection({
+ operation: target[replyMessageInjectionTargetOperation],
+ originatingLeafEntryId: target.originatingLeafEntryId,
+ expectedRunId: target.identity === "run" ? target.runId : undefined,
+ options,
+ });
+ if (!("injection" in resolved)) {
+ const immediateRejection = { status: "rejected" as const, ...resolved };
+ return {
+ targetRunId: target.runId,
+ ...(target.identity === "leaf" ? { rejectBeforeAck: true as const } : {}),
+ outcome: Promise.resolve(immediateRejection),
+ };
}
// Injection is user input, not run evidence: stamping activity here would let
// sub-10-minute user messages re-arm a wedged run's staleness window forever.
- const queued = options ? backend.queueMessage(text, options) : backend.queueMessage(text);
- queued.catch((error: unknown) => {
- diag.debug(`queued reply run message rejected: sessionId=${sessionId} error=${String(error)}`);
- });
- return true;
+ // Invoke before the first await. The capability owns the final synchronous
+ // admission check, matching Codex's active-turn lock boundary.
+ let queued: Promise;
+ try {
+ queued = options
+ ? resolved.injection.queueMessage(text, options)
+ : resolved.injection.queueMessage(text);
+ } catch (error) {
+ const immediateRejection = {
+ status: "rejected" as const,
+ reason: "runtime_rejected" as const,
+ errorMessage: String(error),
+ };
+ return {
+ targetRunId: target.runId,
+ outcome: Promise.resolve(immediateRejection),
+ };
+ }
+ return {
+ targetRunId: target.runId,
+ outcome: queued.then(
+ (result): ReplyMessageInjectionOutcome =>
+ result ? { status: "accepted", result } : { status: "accepted" },
+ (error: unknown): ReplyMessageInjectionOutcome => ({
+ status: "rejected",
+ reason: "runtime_rejected",
+ errorMessage: String(error),
+ }),
+ ),
+ };
+}
+
+/** Abort only the operation captured by this target; never a same-key successor. */
+export function abortReplyMessageInjectionTarget(target: ReplyMessageInjectionTarget): boolean {
+ return target[replyMessageInjectionTargetOperation].abortByUser();
+}
+
+/** Record accepted input on the exact operation without rediscovering its session slot. */
+export function recordAcceptedReplyMessageInjectionTarget(
+ target: ReplyMessageInjectionTarget,
+ options?: { inboundAudio?: boolean },
+): void {
+ const operation = target[replyMessageInjectionTargetOperation];
+ operation.recordActivity();
+ if (options?.inboundAudio === true) {
+ operation.markAcceptedSteeredInboundAudio();
+ }
}
export function abortReplyRunBySessionId(sessionId: string): boolean {
diff --git a/src/gateway/server-methods/chat-send-admission.ts b/src/gateway/server-methods/chat-send-admission.ts
index 38017f7a3a89..6d2d0f343a9a 100644
--- a/src/gateway/server-methods/chat-send-admission.ts
+++ b/src/gateway/server-methods/chat-send-admission.ts
@@ -30,10 +30,13 @@ import {
hasRestartRecoveryTerminalRun,
isRetryableUnadoptedChatClaim,
resolveRestartSafeChatAdmission,
+ terminalizeRestartSafeChatAdmission,
} from "./chat-restart-recovery.js";
import {
ACTIVE_LEAF_CHANGED_ERROR_REASON,
+ ACTIVE_RUN_CHANGED_ERROR_REASON,
respondChatActiveLeafChanged,
+ respondChatActiveRunChanged,
respondChatSessionRoutingChanged,
} from "./chat-send-pre-admission.js";
import type { NormalizedChatSendRequest } from "./chat-send-request.js";
@@ -74,6 +77,7 @@ export async function admitChatSend(params: {
now,
restartSafeRequest,
expectedLeafEntryId,
+ expectedRunId,
} = session;
const chatSendTraceAttributes = {
runId: clientRunId,
@@ -134,6 +138,7 @@ export async function admitChatSend(params: {
let gatewayWorkAdmission: Awaited> | undefined;
let admittedRunAbort: ReturnType | undefined;
let restartSafeAdmission: ReturnType;
+ let messageInjectionTarget: ReturnType;
let reservationSuperseded = false;
let supersedingResult: DedupeEntry | undefined;
const assertChatWorkAdmissionAllowed = (commitOutcome: boolean) => {
@@ -200,14 +205,24 @@ export async function admitChatSend(params: {
// An active owner can advance this branch while a steer is being composed.
// The lifecycle admission keeps branch identity fixed after this check; if
// the owner clears, acceptance-aware dispatch preserves this turn as follow-up.
- const hasActiveSteeringOwner =
- p.queueMode === "steer" &&
- expectedLeafEntryId !== undefined &&
- replyRunRegistry.isMessageInjectableFromOriginatingLeaf(
- activeRunScopeKey,
- expectedLeafEntryId,
+ const hasSteerIdentity = expectedRunId !== undefined || expectedLeafEntryId !== undefined;
+ const resolvedInjectionTarget =
+ p.queueMode === "steer" && hasSteerIdentity
+ ? replyRunRegistry.resolveMessageInjectionTarget({
+ sessionKey: activeRunScopeKey,
+ originatingLeafEntryId: expectedLeafEntryId,
+ expectedRunId,
+ })
+ : undefined;
+ if (commitOutcome && resolvedInjectionTarget) {
+ messageInjectionTarget = resolvedInjectionTarget;
+ }
+ if (commitOutcome && p.queueMode === "steer" && !resolvedInjectionTarget) {
+ throw new Error(
+ expectedRunId ? ACTIVE_RUN_CHANGED_ERROR_REASON : ACTIVE_LEAF_CHANGED_ERROR_REASON,
);
- if (commitOutcome && expectedLeafEntryId !== undefined && !hasActiveSteeringOwner) {
+ }
+ if (commitOutcome && expectedLeafEntryId !== undefined && !resolvedInjectionTarget) {
// Runtime session identity resolves through the canonical SQLite accessor;
// legacy/reset-archive files are read-only history fallbacks, never send targets.
const currentLeafEntryId = latestEntry?.sessionId
@@ -325,6 +340,10 @@ export async function admitChatSend(params: {
respondChatActiveLeafChanged(respond);
return { ok: false as const };
}
+ if (err instanceof Error && err.message === ACTIVE_RUN_CHANGED_ERROR_REASON) {
+ respondChatActiveRunChanged(respond);
+ return { ok: false as const };
+ }
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err)));
return { ok: false as const };
}
@@ -447,6 +466,30 @@ export async function admitChatSend(params: {
releaseGatewayRootContinuation?.();
releaseGatewayRootContinuation = undefined;
};
+ const rejectActiveLeafChanged = async () => {
+ if (
+ restartSafeAdmission &&
+ !(await terminalizeRestartSafeChatAdmission({
+ admittedSessionId,
+ clientRunId,
+ sessionKey,
+ startedAt: now,
+ status: "failed",
+ storePath,
+ retryable: true,
+ }))
+ ) {
+ throw new Error("chat admission ownership changed before terminalization");
+ }
+ cleanupAdmittedRun({ force: true });
+ clearAgentRunContext(clientRunId, lifecycleGeneration);
+ respondChatActiveLeafChanged(respond);
+ };
+ const rejectSessionRoutingChanged = () => {
+ cleanupAdmittedRun({ force: true });
+ clearAgentRunContext(clientRunId, lifecycleGeneration);
+ respondChatSessionRoutingChanged(respond);
+ };
const finishAbortedChatSend = () => {
const stopReason = activeRunAbort.entry?.abortStopReason ?? "rpc";
const endedAt = Date.now();
@@ -476,7 +519,10 @@ export async function admitChatSend(params: {
finishAbortedChatSend,
gatewayWorkAdmission,
lifecycleGeneration,
+ messageInjectionTarget,
originatingRoute,
+ rejectActiveLeafChanged,
+ rejectSessionRoutingChanged,
retainGatewayWorkAdmission,
restartSafeAdmission,
setReleaseGatewayRootContinuation: (release: (() => void) | undefined) => {
diff --git a/src/gateway/server-methods/chat-send-dispatch-errors.ts b/src/gateway/server-methods/chat-send-dispatch-errors.ts
index b6b81675aebf..d7203955851c 100644
--- a/src/gateway/server-methods/chat-send-dispatch-errors.ts
+++ b/src/gateway/server-methods/chat-send-dispatch-errors.ts
@@ -10,10 +10,11 @@ import { setGatewayDedupeEntry } from "./agent-job.js";
import { buildAbortedChatSendPayload } from "./chat-abort-authorization.js";
import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js";
import type { AdmittedChatSend } from "./chat-send-admission.js";
+import { ACTIVE_RUN_CHANGED_ERROR_REASON } from "./chat-send-pre-admission.js";
import type { PreparedChatSendSession } from "./chat-send-session.js";
import { hasTrackedActiveSessionRun } from "./session-active-runs.js";
import { emitSessionsChanged } from "./session-change-event.js";
-import type { GatewayRequestContext } from "./types.js";
+import type { GatewayRequestContext, RespondFn } from "./types.js";
type PendingDispatchLifecycleError = {
endedAt: number;
@@ -22,6 +23,63 @@ type PendingDispatchLifecycleError = {
startedAt: number;
};
+/** Finalize a chat.send that throws before detached dispatch owns cleanup. */
+export async function handleChatSendSetupError(params: {
+ admission: Pick<
+ AdmittedChatSend,
+ "cleanupAdmittedRun" | "lifecycleGeneration" | "restartSafeAdmission"
+ >;
+ context: GatewayRequestContext;
+ error: unknown;
+ respond: RespondFn;
+ session: Pick;
+ terminalizeRestartSafeAdmission: (state: {
+ retryable: boolean;
+ status: "failed" | "killed";
+ }) => Promise;
+}): Promise {
+ const { cleanupAdmittedRun, lifecycleGeneration, restartSafeAdmission } = params.admission;
+ const { agentId, clientRunId, sessionKey } = params.session;
+ if (restartSafeAdmission) {
+ const terminalized = await params
+ .terminalizeRestartSafeAdmission({ retryable: true, status: "failed" })
+ .catch((terminalizeError: unknown) => {
+ params.context.logGateway.warn(
+ `failed to release restart-safe chat admission after setup error: ${formatForLog(
+ terminalizeError,
+ )}`,
+ );
+ return false;
+ });
+ if (terminalized) {
+ emitSessionsChanged(params.context, {
+ sessionKey,
+ ...(agentId ? { agentId } : {}),
+ reason: "chat.dispatch-error",
+ });
+ }
+ }
+ cleanupAdmittedRun({ force: true });
+ clearAgentRunContext(clientRunId, lifecycleGeneration);
+ params.context.removeChatRun(clientRunId, clientRunId, sessionKey);
+ const errorMessage = String(params.error);
+ const error = errorShape(ErrorCodes.UNAVAILABLE, errorMessage);
+ const payload = { runId: clientRunId, status: "error" as const, summary: errorMessage };
+ setGatewayDedupeEntry({
+ dedupe: params.context.dedupe,
+ key: `chat:${clientRunId}`,
+ entry: { ts: Date.now(), ok: false, payload, error },
+ });
+ params.respond(false, payload, error, { runId: clientRunId, error: formatForLog(params.error) });
+ broadcastChatError({
+ context: params.context,
+ runId: clientRunId,
+ sessionKey,
+ agentId,
+ errorMessage,
+ });
+}
+
/** Own dispatch rejection projection and post-cleanup lifecycle persistence. */
export function createChatSendDispatchErrorLifecycle(params: {
admission: Pick<
@@ -58,6 +116,11 @@ export function createChatSendDispatchErrorLifecycle(params: {
const handleError = async (err: unknown) => {
const errorMessage = String(err);
+ const activeRunChanged =
+ err instanceof Error && err.message === ACTIVE_RUN_CHANGED_ERROR_REASON;
+ const visibleErrorMessage = activeRunChanged
+ ? "active run changed; review and retry"
+ : errorMessage;
const queuedFollowupEnqueued = isQueuedFollowupEnqueued();
if (queuedFollowupEnqueued) {
context.logGateway.warn(
@@ -182,7 +245,7 @@ export function createChatSendDispatchErrorLifecycle(params: {
) {
pendingDispatchLifecycleError = {
endedAt: Date.now(),
- error: errorMessage,
+ error: visibleErrorMessage,
sessionId: activeRunAbort.entry?.sessionId ?? backingSessionId ?? clientRunId,
startedAt: activeRunAbort.entry?.startedAtMs ?? now,
};
@@ -190,7 +253,11 @@ export function createChatSendDispatchErrorLifecycle(params: {
if (!agentTerminalPersistenceOwnedAtDispatchReject) {
// The lifecycle owner may have already cached its authoritative
// terminal; a late dispatch error must not replace that replay result.
- const error = errorShape(ErrorCodes.UNAVAILABLE, errorMessage);
+ const error = activeRunChanged
+ ? errorShape(ErrorCodes.INVALID_REQUEST, visibleErrorMessage, {
+ details: { reason: ACTIVE_RUN_CHANGED_ERROR_REASON },
+ })
+ : errorShape(ErrorCodes.UNAVAILABLE, visibleErrorMessage);
setGatewayDedupeEntry({
dedupe: context.dedupe,
key: `chat:${clientRunId}`,
@@ -200,7 +267,7 @@ export function createChatSendDispatchErrorLifecycle(params: {
payload: {
runId: clientRunId,
status: "error" as const,
- summary: errorMessage,
+ summary: visibleErrorMessage,
},
error,
},
@@ -210,7 +277,7 @@ export function createChatSendDispatchErrorLifecycle(params: {
runId: clientRunId,
sessionKey,
agentId,
- errorMessage,
+ errorMessage: visibleErrorMessage,
});
}
};
diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts
index 5fa8c6444dbc..cd17e41dd188 100644
--- a/src/gateway/server-methods/chat-send-handler.ts
+++ b/src/gateway/server-methods/chat-send-handler.ts
@@ -10,7 +10,6 @@ import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
import { dispatchInboundMessageWithProjectedDispatcher } from "../../auto-reply/dispatch.js";
import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js";
-import { clearAgentRunContext } from "../../infra/agent-run-registry.js";
import {
emitDiagnosticsTimelineEvent,
measureDiagnosticsTimelineSpan,
@@ -24,7 +23,6 @@ import {
retireQueuedChatTurnCancellation,
} from "../chat-queued-turns.js";
import type { ChatRunTiming } from "../server-chat-state.js";
-import { formatForLog } from "../ws-log.js";
import { setGatewayDedupeEntry } from "./agent-job.js";
import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js";
import { hasGatewayAdminScope } from "./chat-origin-routing.js";
@@ -34,9 +32,16 @@ import {
resolveWebchatPromptCacheKey,
scheduleChatDashboardSessionTitle,
} from "./chat-send-background.js";
-import { createChatSendDispatchErrorLifecycle } from "./chat-send-dispatch-errors.js";
+import {
+ createChatSendDispatchErrorLifecycle,
+ handleChatSendSetupError,
+} from "./chat-send-dispatch-errors.js";
+import {
+ beginChatSendMessageInjection,
+ finalizeAcceptedChatSendMessageInjection,
+} from "./chat-send-message-injection.js";
import { finalizeChatSendNonAgentReplies } from "./chat-send-nonagent-finalization.js";
-import { respondChatSessionRoutingChanged } from "./chat-send-pre-admission.js";
+import { ACTIVE_RUN_CHANGED_ERROR_REASON } from "./chat-send-pre-admission.js";
import {
applyChatSendReplyContextFields,
resolveChatSendReplyContext,
@@ -101,10 +106,10 @@ export async function handleChatSend(
activeRunAbort,
admittedSessionId,
chatSendTraceAttributes,
- cleanupAdmittedRun,
finishAbortedChatSend,
gatewayWorkAdmission,
lifecycleGeneration,
+ messageInjectionTarget,
retainGatewayWorkAdmission,
restartSafeAdmission,
setReleaseGatewayRootContinuation,
@@ -127,9 +132,7 @@ export async function handleChatSend(
// Attachment preparation can suspend. Recheck immediately before the
// synchronous ACK path so aborts and hot routing reloads cannot cross it.
if (sessionRoutingChanged(context.getRuntimeConfig())) {
- cleanupAdmittedRun({ force: true });
- clearAgentRunContext(clientRunId, lifecycleGeneration);
- respondChatSessionRoutingChanged(respond);
+ admitted.value.rejectSessionRoutingChanged();
return;
}
const { imageOrder, prepareAttachmentsMs } = preparedAttachments.value;
@@ -205,13 +208,59 @@ export async function handleChatSend(
if (!(await terminalizeRestartSafeAdmission({ retryable: true, status: "failed" }))) {
throw new Error("chat admission ownership changed before terminalization");
}
- cleanupAdmittedRun({ force: true });
- clearAgentRunContext(clientRunId, lifecycleGeneration);
- respondChatSessionRoutingChanged(respond);
+ admitted.value.rejectSessionRoutingChanged();
return;
}
}
+ const {
+ accountId,
+ ctx,
+ isInternalTextSlashCommandTurn,
+ pluginBoundMediaPromise,
+ queuedFollowupOwnerKey,
+ replyOptionImages,
+ replyOptionMedia,
+ } = prepareChatSendUserTurn({
+ request: normalizedRequest.value,
+ session: preparedSession.value,
+ admission: admitted.value,
+ attachments: preparedAttachments.value,
+ client,
+ logGateway: context.logGateway,
+ userTurn,
+ });
+ const beginCapturedMessageInjection = () =>
+ messageInjectionTarget && !isInternalTextSlashCommandTurn
+ ? beginChatSendMessageInjection({
+ target: messageInjectionTarget,
+ text: ctx.BodyForAgent ?? ctx.Body ?? rawMessage,
+ replyContext: p.replyToId
+ ? { body: ctx.BodyForAgent ?? ctx.Body ?? rawMessage, cfg, ctx, sessionEntry: entry }
+ : undefined,
+ images: replyOptionImages,
+ imageOrder,
+ media: replyOptionMedia,
+ queueSettings: {
+ cfg,
+ channel: ctx.Provider,
+ sessionEntry: entry,
+ inlineMode: p.queueMode,
+ },
+ taskSuggestionDeliveryMode: supportsTaskSuggestions ? "gateway" : undefined,
+ userTurnTranscriptRecorder: userTurnRecorder,
+ })
+ : undefined;
+ let messageInjectionAttempt = p.replyToId ? undefined : beginCapturedMessageInjection();
+ if (messageInjectionTarget && !isInternalTextSlashCommandTurn) {
+ // Accepted injection never consumes plugin-bound media, but the shared
+ // persistence promise still needs a rejection observer.
+ void pluginBoundMediaPromise.catch(() => undefined);
+ }
+ if (messageInjectionAttempt?.rejectBeforeAck) {
+ return admitted.value.rejectActiveLeafChanged();
+ }
+
const serverTiming = shouldIncludeChatSendAckServerTiming(clientInfo)
? {
receivedToAckMs: roundedChatSendTimingMs(performance.now() - chatSendReceivedAtMs),
@@ -264,23 +313,6 @@ export async function handleChatSend(
sessionLoadOptions,
storePath,
});
- const {
- accountId,
- ctx,
- isInternalTextSlashCommandTurn,
- pluginBoundMediaPromise,
- queuedFollowupOwnerKey,
- replyOptionImages,
- replyOptionMedia,
- } = prepareChatSendUserTurn({
- request: normalizedRequest.value,
- session: preparedSession.value,
- admission: admitted.value,
- attachments: preparedAttachments.value,
- client,
- logGateway: context.logGateway,
- userTurn,
- });
// Resolve the reply target from session history in parallel with the
// remaining dispatch prep so replies do not delay the first model call.
// Skipped entirely for non-reply sends so their dispatch path keeps its
@@ -340,6 +372,7 @@ export async function handleChatSend(
}
emitServerTiming("dispatch-started");
let firstAssistantServerTimingEmitted = false;
+ let acceptedMessageInjection = false;
const emitFirstAssistantServerTiming = () => {
if (firstAssistantServerTimingEmitted || chatSendTiming?.firstAssistantEventSent) {
return;
@@ -358,10 +391,34 @@ export async function handleChatSend(
measureDiagnosticsTimelineSpan(
"gateway.chat_send.dispatch_inbound",
async () => {
- applyChatSendManagedMedia(ctx, await pluginBoundMediaPromise);
if (replyContextFieldsPromise) {
applyChatSendReplyContextFields(ctx, await replyContextFieldsPromise);
+ messageInjectionAttempt = beginCapturedMessageInjection();
}
+ if (messageInjectionAttempt) {
+ const outcome = await messageInjectionAttempt.outcome;
+ if (outcome.status === "accepted") {
+ acceptedMessageInjection = true;
+ await finalizeAcceptedChatSendMessageInjection({
+ context,
+ ctx,
+ outcome,
+ persistUserTurnTranscriptBestEffort: persistGatewayUserTurnTranscriptBestEffort,
+ session: preparedSession.value,
+ startedAt: admissionStartedAt,
+ target: messageInjectionTarget!,
+ targetRunId: messageInjectionAttempt.targetRunId,
+ });
+ return {
+ queuedFinal: false,
+ counts: { tool: 0, block: 0, final: 0 },
+ };
+ }
+ if (p.replyToId) {
+ throw new Error(ACTIVE_RUN_CHANGED_ERROR_REASON);
+ }
+ }
+ applyChatSendManagedMedia(ctx, await pluginBoundMediaPromise);
const dispatchResult = await dispatchInboundMessageWithProjectedDispatcher({
ctx,
cfg,
@@ -462,6 +519,9 @@ export async function handleChatSend(
fastModeOverride: p.fastMode,
queueModeOverride: p.queueMode,
userTurnTranscriptRecorder: userTurnRecorder,
+ ...(messageInjectionTarget && !isInternalTextSlashCommandTurn
+ ? { messageInjectionAttempted: true as const }
+ : {}),
...(restartSafeAdmission ? { suppressNextUserMessagePersistence: true } : {}),
fastModeAutoOnSecondsOverride: p.fastAutoOnSeconds,
onAgentRunStart: (runId) => {
@@ -537,6 +597,9 @@ export async function handleChatSend(
),
)
.then(async () => {
+ if (acceptedMessageInjection) {
+ return;
+ }
emitServerTiming("dispatch-completed", undefined, dispatchStartedAtMs);
const postDispatchStartedAtMs = performance.now();
await measureDiagnosticsTimelineSpan(
@@ -660,55 +723,13 @@ export async function handleChatSend(
.catch(dispatchErrorLifecycle.handleError)
.finally(dispatchErrorLifecycle.finalize);
} catch (err) {
- if (restartSafeAdmission) {
- const terminalized = await terminalizeRestartSafeAdmission({
- retryable: true,
- status: "failed",
- }).catch((terminalizeError: unknown) => {
- context.logGateway.warn(
- `failed to release restart-safe chat admission after setup error: ${formatForLog(
- terminalizeError,
- )}`,
- );
- return false;
- });
- if (terminalized) {
- emitSessionsChanged(context, {
- sessionKey,
- ...(agentId ? { agentId } : {}),
- reason: "chat.dispatch-error",
- });
- }
- }
- cleanupAdmittedRun({ force: true });
- clearAgentRunContext(clientRunId, lifecycleGeneration);
- context.removeChatRun(clientRunId, clientRunId, sessionKey);
- const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
- const payload = {
- runId: clientRunId,
- status: "error" as const,
- summary: String(err),
- };
- setGatewayDedupeEntry({
- dedupe: context.dedupe,
- key: `chat:${clientRunId}`,
- entry: {
- ts: Date.now(),
- ok: false,
- payload,
- error,
- },
- });
- respond(false, payload, error, {
- runId: clientRunId,
- error: formatForLog(err),
- });
- broadcastChatError({
+ await handleChatSendSetupError({
+ admission: admitted.value,
context,
- runId: clientRunId,
- sessionKey,
- agentId,
- errorMessage: String(err),
+ error: err,
+ respond,
+ session: preparedSession.value,
+ terminalizeRestartSafeAdmission,
});
}
}
diff --git a/src/gateway/server-methods/chat-send-message-injection.ts b/src/gateway/server-methods/chat-send-message-injection.ts
new file mode 100644
index 000000000000..2177ce165b92
--- /dev/null
+++ b/src/gateway/server-methods/chat-send-message-injection.ts
@@ -0,0 +1,151 @@
+import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
+import { emitInboundMessageAuditTerminal } from "../../auto-reply/reply/dispatch-from-config.audit.js";
+import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
+import { hasInboundAudio } from "../../auto-reply/reply/inbound-media.js";
+import { emitMessageReceivedHooks } from "../../auto-reply/reply/message-received-hooks.js";
+import { resolveQueueSettings } from "../../auto-reply/reply/queue/settings-runtime.js";
+import {
+ abortReplyMessageInjectionTarget,
+ beginReplyMessageInjectionTarget,
+ recordAcceptedReplyMessageInjectionTarget,
+ type ReplyBackendQueueMessageOptions,
+ type ReplyMessageInjectionAttempt,
+ type ReplyMessageInjectionOutcome,
+ type ReplyMessageInjectionTarget,
+} from "../../auto-reply/reply/reply-run-registry.js";
+import type { RuntimeMsgContext } from "../../auto-reply/templating.js";
+import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
+import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
+import { logMessageProcessed, logMessageReceived } from "../../logging/diagnostic.js";
+import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
+import { setGatewayDedupeEntry } from "./agent-job.js";
+import { broadcastChatFinal } from "./chat-broadcast.js";
+import { buildChatSendReplyInjectionText } from "./chat-send-reply-context.js";
+import type { PreparedChatSendSession } from "./chat-send-session.js";
+import type { GatewayRequestContext } from "./types.js";
+
+/** Starts injection with the exact admission-captured target and prepared turn data. */
+export function beginChatSendMessageInjection(params: {
+ target: ReplyMessageInjectionTarget;
+ text: string;
+ replyContext?: Parameters[0];
+ images: ReplyBackendQueueMessageOptions["images"];
+ imageOrder: ReplyBackendQueueMessageOptions["imageOrder"];
+ media: ReplyBackendQueueMessageOptions["media"];
+ queueSettings: Parameters[0];
+ taskSuggestionDeliveryMode: ReplyBackendQueueMessageOptions["taskSuggestionDeliveryMode"];
+ userTurnTranscriptRecorder: ReplyBackendQueueMessageOptions["userTurnTranscriptRecorder"];
+}): ReplyMessageInjectionAttempt {
+ const { debounceMs } = resolveQueueSettings(params.queueSettings);
+ return beginReplyMessageInjectionTarget(
+ params.target,
+ params.replyContext ? buildChatSendReplyInjectionText(params.replyContext) : params.text,
+ {
+ steeringMode: "all",
+ isInboundUserMessage: true,
+ ...(params.images?.length ? { images: params.images } : {}),
+ ...(params.imageOrder?.length ? { imageOrder: params.imageOrder } : {}),
+ ...(params.media?.length ? { media: params.media } : {}),
+ waitForTranscriptCommit: true,
+ ...(debounceMs !== undefined ? { debounceMs } : {}),
+ taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode,
+ userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
+ },
+ );
+}
+
+/** Finish an irrevocably accepted steer without entering reply dispatch. */
+export async function finalizeAcceptedChatSendMessageInjection(params: {
+ context: GatewayRequestContext;
+ ctx: RuntimeMsgContext;
+ outcome: Extract;
+ persistUserTurnTranscriptBestEffort: () => Promise;
+ session: Pick<
+ PreparedChatSendSession,
+ "agentId" | "cfg" | "clientRunId" | "entry" | "sessionKey" | "storePath"
+ >;
+ startedAt: number;
+ target: ReplyMessageInjectionTarget;
+ targetRunId: string | undefined;
+}): Promise {
+ const { context, ctx, outcome, session, target } = params;
+ const { agentId, cfg, clientRunId, entry, sessionKey, storePath } = session;
+ const finalizedCtx = finalizeInboundContext(ctx);
+ const channel = normalizeLowercaseStringOrEmpty(
+ finalizedCtx.Surface ?? finalizedCtx.Provider ?? "unknown",
+ );
+ const chatId = finalizedCtx.To ?? finalizedCtx.From;
+ const messageId =
+ finalizedCtx.MessageSidFull ??
+ finalizedCtx.MessageSid ??
+ finalizedCtx.MessageSidFirst ??
+ finalizedCtx.MessageSidLast;
+ recordAcceptedReplyMessageInjectionTarget(target, {
+ inboundAudio: hasInboundAudio(finalizedCtx),
+ });
+ if (outcome.result?.transcriptCommit === "unconfirmed") {
+ abortReplyMessageInjectionTarget(target);
+ context.logGateway.warn(
+ `active run ${params.targetRunId ?? "unknown"} accepted chat steering without transcript confirmation; aborted exact target without replay`,
+ );
+ }
+ await params.persistUserTurnTranscriptBestEffort();
+ if (isDiagnosticsEnabled(cfg)) {
+ logMessageReceived({
+ sessionKey,
+ channel,
+ chatId,
+ messageId,
+ source: "dispatchInboundMessage",
+ });
+ logMessageProcessed({
+ channel,
+ chatId,
+ messageId,
+ sessionId: entry?.sessionId,
+ sessionKey,
+ durationMs: Math.max(0, Date.now() - params.startedAt),
+ outcome: "completed",
+ reason: "active_run_injected",
+ });
+ }
+ emitMessageReceivedHooks({
+ ctx: finalizedCtx,
+ hookRunner: getGlobalHookRunner(),
+ sessionKey,
+ timestamp:
+ typeof finalizedCtx.Timestamp === "number" && Number.isFinite(finalizedCtx.Timestamp)
+ ? finalizedCtx.Timestamp
+ : undefined,
+ });
+ emitInboundMessageAuditTerminal({
+ cfg,
+ counts: { tool: 0, block: 0, final: 0 },
+ ctx: finalizedCtx,
+ observedRunId: clientRunId,
+ startedAt: params.startedAt,
+ terminal: { outcome: "completed", options: { reason: "active_run_injected" } },
+ });
+ const updatedAt = Date.now();
+ if (entry) {
+ entry.updatedAt = updatedAt;
+ }
+ await updateSessionEntry({ storePath, sessionKey }, () => ({ updatedAt }), {
+ skipMaintenance: true,
+ takeCacheOwnership: true,
+ }).catch((error: unknown) => {
+ context.logGateway.warn(`failed to touch session after accepted steering: ${String(error)}`);
+ });
+ if (!context.chatRunState.hasAbortMarker(clientRunId)) {
+ setGatewayDedupeEntry({
+ dedupe: context.dedupe,
+ key: `chat:${clientRunId}`,
+ entry: {
+ ts: Date.now(),
+ ok: true,
+ payload: { runId: clientRunId, status: "ok" as const },
+ },
+ });
+ broadcastChatFinal({ context, runId: clientRunId, sessionKey, agentId });
+ }
+}
diff --git a/src/gateway/server-methods/chat-send-pre-admission.ts b/src/gateway/server-methods/chat-send-pre-admission.ts
index 66c23e491f4b..dff646b68de1 100644
--- a/src/gateway/server-methods/chat-send-pre-admission.ts
+++ b/src/gateway/server-methods/chat-send-pre-admission.ts
@@ -23,6 +23,7 @@ import type { PreparedChatSendSession } from "./chat-send-session.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
export const ACTIVE_LEAF_CHANGED_ERROR_REASON = "active-leaf-changed";
+export const ACTIVE_RUN_CHANGED_ERROR_REASON = "active-run-changed";
export function respondChatSessionRoutingChanged(respond: GatewayRequestHandlerOptions["respond"]) {
respond(
@@ -34,16 +35,28 @@ export function respondChatSessionRoutingChanged(respond: GatewayRequestHandlerO
);
}
-export function respondChatActiveLeafChanged(respond: GatewayRequestHandlerOptions["respond"]) {
+function respondChatTargetChanged(
+ respond: GatewayRequestHandlerOptions["respond"],
+ target: "branch" | "run",
+ reason: string,
+) {
respond(
false,
undefined,
- errorShape(ErrorCodes.INVALID_REQUEST, "active branch changed; review and resend", {
- details: { reason: ACTIVE_LEAF_CHANGED_ERROR_REASON },
+ errorShape(ErrorCodes.INVALID_REQUEST, `active ${target} changed; review and retry`, {
+ details: { reason },
}),
);
}
+export function respondChatActiveLeafChanged(respond: GatewayRequestHandlerOptions["respond"]) {
+ respondChatTargetChanged(respond, "branch", ACTIVE_LEAF_CHANGED_ERROR_REASON);
+}
+
+export function respondChatActiveRunChanged(respond: GatewayRequestHandlerOptions["respond"]) {
+ respondChatTargetChanged(respond, "run", ACTIVE_RUN_CHANGED_ERROR_REASON);
+}
+
/** Settle stop/retry/dedupe cases before reserving lifecycle admission. */
export async function runChatSendPreAdmission(params: {
request: NormalizedChatSendRequest;
diff --git a/src/gateway/server-methods/chat-send-reply-context.ts b/src/gateway/server-methods/chat-send-reply-context.ts
index 6c5ff67613e9..dbc4dc18744e 100644
--- a/src/gateway/server-methods/chat-send-reply-context.ts
+++ b/src/gateway/server-methods/chat-send-reply-context.ts
@@ -3,6 +3,8 @@
// Discord path (reply_to_id + "Reply target of current user message" block).
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
+import { resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
+import { buildInboundUserContextPrefix } from "../../auto-reply/reply/inbound-meta.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { sanitizeAssistantVisibleTextWithProfile } from "../../shared/text/assistant-visible-text.js";
@@ -21,6 +23,21 @@ type ChatSendReplyContextFields = Partial<
Pick
>;
+/** Adds hydrated reply metadata to the direct-injection user prompt. */
+export function buildChatSendReplyInjectionText(params: {
+ body: string;
+ cfg: OpenClawConfig;
+ ctx: MsgContext;
+ sessionEntry?: Parameters[2];
+}): string {
+ const prefix = buildInboundUserContextPrefix(
+ params.ctx,
+ resolveEnvelopeFormatOptions(params.cfg),
+ params.sessionEntry,
+ );
+ return prefix ? `${prefix}\n\n${params.body}` : params.body;
+}
+
function extractReplyTargetText(message: unknown): string | undefined {
const entry = asOptionalRecord(message);
if (!entry) {
diff --git a/src/gateway/server-methods/chat-send-request.test.ts b/src/gateway/server-methods/chat-send-request.test.ts
index bd0edae6b93b..b8f57bf0d9a1 100644
--- a/src/gateway/server-methods/chat-send-request.test.ts
+++ b/src/gateway/server-methods/chat-send-request.test.ts
@@ -55,6 +55,30 @@ describe("normalizeChatSendRequest", () => {
expect(result).toEqual({ ok: false, error: "message or attachment required" });
});
+ it("preserves targetless steer for leaf-bound compatibility admission", () => {
+ expect(
+ normalizeChatSendRequest({
+ params: validParams({ queueMode: "steer" }),
+ client: null,
+ }),
+ ).toMatchObject({ ok: true });
+ expect(
+ normalizeChatSendRequest({
+ params: validParams({
+ queueMode: "steer",
+ expectedLeafEntryId: "leaf-1",
+ }),
+ client: null,
+ }),
+ ).toMatchObject({ ok: true });
+ expect(
+ normalizeChatSendRequest({
+ params: validParams({ queueMode: "steer", expectedRunId: " run-1 " }),
+ client: null,
+ }),
+ ).toMatchObject({ ok: true });
+ });
+
it("accepts an attachment-only request after attachment normalization", () => {
const result = normalizeChatSendRequest({
params: validParams({
diff --git a/src/gateway/server-methods/chat-send-request.ts b/src/gateway/server-methods/chat-send-request.ts
index 89bc471e2906..6d86b7800afe 100644
--- a/src/gateway/server-methods/chat-send-request.ts
+++ b/src/gateway/server-methods/chat-send-request.ts
@@ -54,6 +54,7 @@ type ChatSendRequestParams = {
systemProvenanceReceipt?: string;
suppressCommandInterpretation?: boolean;
expectedLeafEntryId?: string | null;
+ expectedRunId?: string;
expectedSessionRoutingContract?: string;
idempotencyKey: string;
};
@@ -78,7 +79,7 @@ export type NormalizedChatSendRequest = {
type NormalizeChatSendRequestResult =
| { ok: true; value: NormalizedChatSendRequest }
- | { ok: false; error: string };
+ | { ok: false; error: string; reason?: string };
/** Validate and normalize the wire request before session or lifecycle work begins. */
export function normalizeChatSendRequest(params: {
diff --git a/src/gateway/server-methods/chat-send-session.ts b/src/gateway/server-methods/chat-send-session.ts
index fbeaf6ae245e..c38ce9692d00 100644
--- a/src/gateway/server-methods/chat-send-session.ts
+++ b/src/gateway/server-methods/chat-send-session.ts
@@ -76,6 +76,7 @@ function loadChatSendSessionContext(params: {
);
const expectedLeafEntryId =
p.expectedLeafEntryId === null ? null : normalizeOptionalChatText(p.expectedLeafEntryId);
+ const expectedRunId = normalizeOptionalChatText(p.expectedRunId);
const sessionRoutingChanged = (candidateConfig: OpenClawConfig) =>
expectedSessionRoutingContract !== undefined &&
expectedSessionRoutingContract.toLowerCase() !== resolveSessionRoutingContract(candidateConfig);
@@ -93,6 +94,7 @@ function loadChatSendSessionContext(params: {
legacyKey,
sessionRoutingChanged,
expectedLeafEntryId,
+ expectedRunId,
requestedAgentId,
};
}
diff --git a/src/gateway/server-methods/chat-send-setup.ts b/src/gateway/server-methods/chat-send-setup.ts
index 8d9802c99b2b..ea01210a0f06 100644
--- a/src/gateway/server-methods/chat-send-setup.ts
+++ b/src/gateway/server-methods/chat-send-setup.ts
@@ -17,7 +17,15 @@ export async function prepareAndAdmitChatSend(
) {
const normalizedRequest = normalizeChatSendRequest({ params, client });
if (!normalizedRequest.ok) {
- respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, normalizedRequest.error));
+ respond(
+ false,
+ undefined,
+ errorShape(
+ ErrorCodes.INVALID_REQUEST,
+ normalizedRequest.error,
+ normalizedRequest.reason ? { details: { reason: normalizedRequest.reason } } : undefined,
+ ),
+ );
return undefined;
}
const preparedSession = prepareChatSendSession({
diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts
index bd7c3f1c6dbf..01f7e57e0c0d 100644
--- a/src/gateway/server-methods/chat.directive-tags.test.ts
+++ b/src/gateway/server-methods/chat.directive-tags.test.ts
@@ -15,10 +15,14 @@ import {
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
+import { onTrustedMessageAuditEvent } from "../../audit/message-audit-events.js";
import { setReplyPayloadMetadata } from "../../auto-reply/reply-payload.js";
import { getTotalPendingReplies } from "../../auto-reply/reply/dispatcher-registry.js";
import { markInboundContextLabel } from "../../auto-reply/reply/inbound-context-marker.js";
-import { replyRunRegistry } from "../../auto-reply/reply/reply-run-registry.js";
+import {
+ replyRunRegistry,
+ type ReplyBackendQueueMessageOptions,
+} from "../../auto-reply/reply/reply-run-registry.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import {
appendTranscriptMessage,
@@ -45,6 +49,7 @@ import { createDeferred } from "../../test-utils/deferred.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js";
import { createChatRunState } from "../server-chat-state.js";
+import { handleChatSend } from "./chat-send-handler.js";
import type { GatewayRequestContext } from "./types.js";
type ProjectedDispatchParams = Parameters<
@@ -131,10 +136,19 @@ const mockState = vi.hoisted(() => ({
saveMediaWait: null as Promise | null,
activeSaveMediaCalls: 0,
maxActiveSaveMediaCalls: 0,
+ replyContextCalls: 0,
+ replyContextResult: null as {
+ ReplyToId?: string;
+ ReplyToBody?: string;
+ ReplyToSender?: string;
+ } | null,
+ replyContextWait: null as Promise | null,
sandboxWorkspace: null as { workspaceDir: string; containerWorkdir?: string } | null,
stageSandboxMediaError: null as Error | null,
stagedRelativePaths: null as string[] | null,
hasBeforeAgentRunHooks: false,
+ hasMessageReceivedHooks: false,
+ messageReceivedCalls: [] as Array<{ event: unknown; context: unknown }>,
beforeMessageWriteBlock: false,
beforeMessageWriteContent: null as string | null,
beforeMessageWriteCalls: [] as Array<{ message: unknown; ctx: unknown }>,
@@ -448,9 +462,26 @@ vi.mock("../../infra/outbound/session-binding-service.js", async () => {
};
});
+vi.mock("./chat-send-reply-context.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveChatSendReplyContext: async (
+ ...args: Parameters
+ ) => {
+ mockState.replyContextCalls += 1;
+ if (mockState.replyContextWait) {
+ await mockState.replyContextWait;
+ }
+ return mockState.replyContextResult ?? actual.resolveChatSendReplyContext(...args);
+ },
+ };
+});
+
vi.mock("../../plugins/hook-runner-global.js", () => {
const hasHooks = (hookName: string) =>
(hookName === "before_agent_run" && mockState.hasBeforeAgentRunHooks) ||
+ (hookName === "message_received" && mockState.hasMessageReceivedHooks) ||
(hookName === "before_message_write" &&
(mockState.beforeMessageWriteBlock || mockState.beforeMessageWriteContent !== null));
return {
@@ -472,6 +503,9 @@ vi.mock("../../plugins/hook-runner-global.js", () => {
}
return undefined;
},
+ runMessageReceived: async (event: unknown, context: unknown) => {
+ mockState.messageReceivedCalls.push({ event, context });
+ },
}),
hasGlobalHooks: hasHooks,
};
@@ -1238,6 +1272,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
mockState.saveMediaWait = null;
mockState.activeSaveMediaCalls = 0;
mockState.maxActiveSaveMediaCalls = 0;
+ mockState.replyContextCalls = 0;
+ mockState.replyContextResult = null;
+ mockState.replyContextWait = null;
bindingMocks.resolveByConversation.mockReset();
bindingMocks.resolveByConversation.mockReturnValue(null);
mockState.sandboxWorkspace = null;
@@ -1246,6 +1283,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
mockState.unstagedSources = null;
mockState.deleteMediaBufferCalls = [];
mockState.hasBeforeAgentRunHooks = false;
+ mockState.hasMessageReceivedHooks = false;
+ mockState.messageReceivedCalls = [];
mockState.beforeMessageWriteBlock = false;
mockState.beforeMessageWriteContent = null;
mockState.beforeMessageWriteCalls = [];
@@ -1332,7 +1371,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect(context.addChatRun).toHaveBeenCalledTimes(1);
});
- it("rejects a stale explicit steer without an active owner", async () => {
+ it("rejects targetless steer when no leaf-bound owner exists", async () => {
await createGatewayUserTurnSqliteFixture("openclaw-chat-send-stale-steer-no-owner-");
await appendTranscriptMessage(transcriptScope(), {
eventId: "current-leaf",
@@ -1345,7 +1384,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
await send({
idempotencyKey: "idem-stale-steer-no-owner",
requestParams: {
- expectedLeafEntryId: "leaf-before-finished-run",
+ expectedLeafEntryId: "current-leaf",
queueMode: "steer",
},
waitFor: "none",
@@ -1359,7 +1398,214 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect(context.addChatRun).not.toHaveBeenCalled();
});
- it("allows an explicit steer during injectable non-streaming tool work", async () => {
+ it("rejects targetless steer without a supplied leaf before dispatch", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-targetless-no-leaf-");
+ const { context, respond, send } = createChatRequestFixture();
+ const queueMessage = vi.fn(async () => {});
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ });
+ operation.setPhase("running");
+ operation.attachBackend({
+ kind: "embedded",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage },
+ });
+
+ try {
+ await send({
+ idempotencyKey: "idem-targetless-no-leaf",
+ requestParams: { queueMode: "steer" },
+ waitFor: "none",
+ });
+ } finally {
+ operation.complete();
+ }
+
+ expect(lastRespondCall(respond)).toEqual([
+ false,
+ undefined,
+ expect.objectContaining({ details: { reason: "active-leaf-changed" } }),
+ ]);
+ expect(queueMessage).not.toHaveBeenCalled();
+ expect(context.addChatRun).not.toHaveBeenCalled();
+ expect(mockState.lastDispatchCtx).toBeUndefined();
+ });
+
+ it("injects a matching leaf-bound targetless steer through the legacy backend seam", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-targetless-steer-");
+ await appendTranscriptMessage(transcriptScope(), {
+ eventId: "current-leaf",
+ message: { role: "assistant", content: "working" },
+ now: 1,
+ parentId: null,
+ });
+ const { context, respond, send } = createChatRequestFixture();
+ const queueMessage = vi.fn(async () => {});
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: "current-leaf",
+ });
+ operation.setPhase("running");
+ operation.attachBackend({
+ kind: "embedded",
+ cancel: () => {},
+ isStopped: () => false,
+ queueMessage,
+ });
+
+ try {
+ await send({
+ idempotencyKey: "idem-targetless-steer",
+ requestParams: { expectedLeafEntryId: "current-leaf", queueMode: "steer" },
+ });
+ } finally {
+ operation.complete();
+ }
+
+ expect(respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({ status: "started" }),
+ undefined,
+ expect.any(Object),
+ );
+ expect(queueMessage).toHaveBeenCalledOnce();
+ expect(mockState.lastDispatchCtx).toBeUndefined();
+ expect(context.addChatRun).toHaveBeenCalledOnce();
+ });
+
+ it("rejects targetless steer when the owner immutable leaf differs", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-targetless-leaf-mismatch-");
+ await appendTranscriptMessage(transcriptScope(), {
+ eventId: "current-leaf",
+ message: { role: "assistant", content: "working" },
+ now: 1,
+ parentId: null,
+ });
+ const { context, respond, send } = createChatRequestFixture();
+ const queueMessage = vi.fn(async () => {});
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: "different-owner-leaf",
+ });
+ operation.setPhase("running");
+ operation.attachBackend({
+ kind: "embedded",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage },
+ });
+
+ try {
+ await send({
+ idempotencyKey: "idem-targetless-leaf-mismatch",
+ requestParams: { expectedLeafEntryId: "current-leaf", queueMode: "steer" },
+ waitFor: "none",
+ });
+ } finally {
+ operation.complete();
+ }
+
+ expect(lastRespondCall(respond)).toEqual([
+ false,
+ undefined,
+ expect.objectContaining({ details: { reason: "active-leaf-changed" } }),
+ ]);
+ expect(queueMessage).not.toHaveBeenCalled();
+ expect(context.addChatRun).not.toHaveBeenCalled();
+ expect(mockState.lastDispatchCtx).toBeUndefined();
+ });
+
+ it("rejects a captured targetless steer when a successor replaces its operation", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-targetless-operation-aba-");
+ await appendTranscriptMessage(transcriptScope(), {
+ eventId: "current-leaf",
+ message: { role: "assistant", content: "working" },
+ now: 1,
+ parentId: null,
+ });
+ const { context, respond } = createChatRequestFixture();
+ const originalQueue = vi.fn(async () => {});
+ const successorQueue = vi.fn(async () => {});
+ const original = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: "current-leaf",
+ });
+ original.setPhase("running");
+ original.attachBackend({
+ kind: "embedded",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage: originalQueue },
+ });
+ let successor: ReturnType | undefined;
+
+ try {
+ await handleChatSend(
+ {
+ params: {
+ sessionKey: "main",
+ message: "hello",
+ idempotencyKey: "idem-targetless-operation-aba",
+ expectedLeafEntryId: "current-leaf",
+ queueMode: "steer",
+ },
+ respond: respond as never,
+ req: {} as never,
+ client: {
+ connect: {
+ client: {
+ id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
+ mode: GATEWAY_CLIENT_MODES.WEBCHAT,
+ version: "dev",
+ platform: "web",
+ },
+ scopes: ["operator.admin"],
+ },
+ } as never,
+ isWebchatConnect: () => false,
+ context: context as GatewayRequestContext,
+ },
+ async () => {
+ original.complete();
+ successor = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: "current-leaf",
+ });
+ successor.setPhase("running");
+ successor.attachBackend({
+ kind: "embedded",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage: successorQueue },
+ });
+ return true;
+ },
+ );
+ } finally {
+ original.complete();
+ successor?.complete();
+ }
+
+ expect(lastRespondCall(respond)).toEqual([
+ false,
+ undefined,
+ expect.objectContaining({ details: { reason: "active-leaf-changed" } }),
+ ]);
+ expect(originalQueue).not.toHaveBeenCalled();
+ expect(successorQueue).not.toHaveBeenCalled();
+ expect(context.addChatRun).not.toHaveBeenCalled();
+ expect(mockState.lastDispatchCtx).toBeUndefined();
+ });
+
+ it("allows an exact-run steer after the active transcript leaf advances", async () => {
await createGatewayUserTurnSqliteFixture("openclaw-chat-send-steer-moving-leaf-");
await appendTranscriptMessage(transcriptScope(), {
eventId: "current-leaf",
@@ -1375,19 +1621,21 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
originatingLeafEntryId: "leaf-before-active-run-output",
});
operation.setPhase("running");
+ const queueMessage = vi.fn(async () => {});
operation.attachBackend({
kind: "embedded",
+ runId: "active-run",
+ supportsQueueMessageImages: true,
cancel: () => {},
- isStreaming: () => false,
- isStopped: () => false,
- queueMessage: async () => {},
+ messageInjection: { isAvailable: () => true, queueMessage },
});
try {
await send({
idempotencyKey: "idem-steer-moving-leaf",
requestParams: {
- expectedLeafEntryId: "leaf-before-active-run-output",
+ expectedLeafEntryId: "current-leaf",
+ expectedRunId: "active-run",
queueMode: "steer",
},
});
@@ -1402,7 +1650,464 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect.any(Object),
);
expect(context.addChatRun).toHaveBeenCalledTimes(1);
- expect(mockState.lastDispatchOriginatingLeafEntryId).toBe("leaf-before-active-run-output");
+ expect(queueMessage).toHaveBeenCalledOnce();
+ expect(queueMessage).toHaveBeenCalledWith(
+ "hello",
+ expect.objectContaining({
+ isInboundUserMessage: true,
+ waitForTranscriptCommit: true,
+ }),
+ );
+ expect(mockState.lastDispatchCtx).toBeUndefined();
+ });
+
+ it("starts exact-run injection before ACK and does not dispatch after the owner clears", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-steer-before-ack-");
+ const { context, respond, send } = createChatRequestFixture();
+ const delivery = createDeferred();
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: null,
+ });
+ operation.setPhase("running");
+ const queueMessage = vi.fn(() => {
+ expect(respond).not.toHaveBeenCalled();
+ return delivery.promise;
+ });
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "active-run",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage },
+ });
+
+ await send({
+ idempotencyKey: "idem-steer-before-ack",
+ requestParams: { expectedRunId: "active-run", queueMode: "steer" },
+ waitFor: "none",
+ });
+
+ expect(queueMessage).toHaveBeenCalledOnce();
+ expect(respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({ status: "started" }),
+ undefined,
+ expect.any(Object),
+ );
+ operation.complete();
+ delivery.resolve();
+ await waitForAssertion(() => {
+ expect(context.dedupe.get("chat:idem-steer-before-ack")?.payload).toEqual({
+ runId: "idem-steer-before-ack",
+ status: "ok",
+ });
+ });
+ expect(mockState.lastDispatchCtx).toBeUndefined();
+ expect(context.broadcast).toHaveBeenCalledOnce();
+ });
+
+ it("records accepted steering once across transcript, hooks, audit, and finalization", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-steer-accounting-");
+ mockState.hasMessageReceivedHooks = true;
+ mockState.savedMediaResults = [{ path: "/tmp/steer.png", contentType: "image/png" }];
+ const auditEvents: Array<{ reasonCode?: unknown; runId?: unknown }> = [];
+ const disposeAudit = onTrustedMessageAuditEvent((event) => auditEvents.push(event));
+ const { context, send } = createChatRequestFixture();
+ const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length;
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: null,
+ });
+ operation.setPhase("running");
+ const queueMessage = vi.fn(async (_text: string, options?: ReplyBackendQueueMessageOptions) => {
+ expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(dispatchCallsBefore);
+ await options?.userTurnTranscriptRecorder?.persistApproved();
+ });
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "active-run",
+ supportsQueueMessageImages: true,
+ taskSuggestionDeliveryMode: "gateway",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage },
+ });
+
+ try {
+ await send({
+ idempotencyKey: "idem-steer-accounting",
+ requestParams: {
+ expectedRunId: "active-run",
+ queueMode: "steer",
+ attachments: [{ mimeType: "image/png", content: TINY_PNG_BASE64 }],
+ },
+ client: {
+ connect: {
+ client: {
+ id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
+ mode: GATEWAY_CLIENT_MODES.WEBCHAT,
+ version: "dev",
+ platform: "web",
+ },
+ caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS],
+ scopes: ["operator.admin"],
+ },
+ },
+ });
+ } finally {
+ operation.complete();
+ disposeAudit();
+ }
+
+ await waitForAssertion(() => expect(mockState.messageReceivedCalls).toHaveLength(1));
+ expect(readPersistedUserMessages()).toHaveLength(1);
+ expect(readPersistedUserMessages()[0]?.content).toBe("hello");
+ expect(queueMessage).toHaveBeenCalledWith(
+ "hello",
+ expect.objectContaining({
+ images: [expect.objectContaining({ mimeType: "image/png" })],
+ imageOrder: ["inline"],
+ taskSuggestionDeliveryMode: "gateway",
+ userTurnTranscriptRecorder: expect.any(Object),
+ }),
+ );
+ expect(auditEvents).toContainEqual(
+ expect.objectContaining({
+ reasonCode: "active_run_injected",
+ runId: "idem-steer-accounting",
+ }),
+ );
+ expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(dispatchCallsBefore);
+ expect(context.broadcast).toHaveBeenCalledOnce();
+ });
+
+ it("hydrates reply context before injecting into the captured exact run", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-reply-steer-");
+ mockState.hasMessageReceivedHooks = true;
+ mockState.replyContextResult = {
+ ReplyToId: "prior-message",
+ ReplyToBody: "quoted deployment status",
+ ReplyToSender: "Alice",
+ };
+ const auditEvents: Array<{ reasonCode?: unknown }> = [];
+ const disposeAudit = onTrustedMessageAuditEvent((event) => auditEvents.push(event));
+ const { context, send } = createChatRequestFixture();
+ const queueMessage = vi.fn(async (_text: string, options?: ReplyBackendQueueMessageOptions) => {
+ await options?.userTurnTranscriptRecorder?.persistApproved();
+ });
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: "current-leaf",
+ });
+ operation.setPhase("running");
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage },
+ });
+
+ try {
+ await send({
+ idempotencyKey: "idem-reply-steer",
+ requestParams: {
+ expectedRunId: "run-a",
+ queueMode: "steer",
+ replyToId: "prior-message",
+ },
+ });
+ } finally {
+ operation.complete();
+ disposeAudit();
+ }
+
+ expect(queueMessage).toHaveBeenCalledOnce();
+ expect(queueMessage.mock.calls[0]?.[0]).toContain("Reply target of current user message:");
+ expect(queueMessage.mock.calls[0]?.[0]).toContain("quoted deployment status");
+ expect(queueMessage.mock.calls[0]?.[0]).toContain("hello");
+ expect(mockState.messageReceivedCalls).toHaveLength(1);
+ expect(readPersistedUserMessages()).toHaveLength(1);
+ expect(auditEvents.filter((event) => event.reasonCode === "active_run_injected")).toHaveLength(
+ 1,
+ );
+ expect(mockState.lastDispatchCtx).toBeUndefined();
+ expect(context.broadcast).toHaveBeenCalledOnce();
+ });
+
+ it("rejects reply steering when hydration outlives its captured run", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-reply-steer-race-");
+ const hydration = createDeferred();
+ mockState.replyContextWait = hydration.promise;
+ mockState.replyContextResult = {
+ ReplyToId: "prior-message",
+ ReplyToBody: "quoted deployment status",
+ ReplyToSender: "Alice",
+ };
+ const { context, send } = createChatRequestFixture();
+ const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length;
+ const originalQueue = vi.fn(async () => {});
+ const successorQueue = vi.fn(async () => {});
+ const successorCancel = vi.fn();
+ const original = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: "current-leaf",
+ });
+ original.setPhase("running");
+ original.attachBackend({
+ kind: "embedded",
+ runId: "run-a",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage: originalQueue },
+ });
+ let successor: ReturnType | undefined;
+
+ try {
+ await send({
+ idempotencyKey: "idem-reply-steer-race",
+ requestParams: {
+ expectedRunId: "run-a",
+ queueMode: "steer",
+ replyToId: "prior-message",
+ },
+ waitFor: "none",
+ });
+ await waitForAssertion(() => expect(mockState.replyContextCalls).toBe(1));
+ original.complete();
+ successor = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: "current-leaf",
+ });
+ successor.setPhase("running");
+ successor.attachBackend({
+ kind: "embedded",
+ runId: "run-b",
+ cancel: successorCancel,
+ messageInjection: { isAvailable: () => true, queueMessage: successorQueue },
+ });
+ hydration.resolve();
+ await waitForAssertion(() => {
+ expect(context.dedupe.get("chat:idem-reply-steer-race")?.payload).toMatchObject({
+ status: "error",
+ summary: "active run changed; review and retry",
+ });
+ });
+ } finally {
+ hydration.resolve();
+ original.complete();
+ successor?.complete();
+ }
+
+ expect(context.dedupe.get("chat:idem-reply-steer-race")?.error).toMatchObject({
+ code: "INVALID_REQUEST",
+ details: { reason: "active-run-changed" },
+ });
+ expect(originalQueue).not.toHaveBeenCalled();
+ expect(successorQueue).not.toHaveBeenCalled();
+ expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(dispatchCallsBefore);
+ expect(readPersistedUserMessages()).toHaveLength(1);
+ expect(successorCancel).not.toHaveBeenCalled();
+ const broadcasts = (context.broadcast as unknown as ReturnType).mock.calls.map(
+ ([, payload]) => payload as Record,
+ );
+ expect(broadcasts).toContainEqual(
+ expect.objectContaining({
+ state: "error",
+ errorMessage: "active run changed; review and retry",
+ }),
+ );
+ });
+
+ it("falls back to one normal dispatch when exact-run injection rejects after ACK", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-steer-reject-");
+ mockState.finalText = "fallback reply";
+ const { context, respond, send } = createChatRequestFixture();
+ const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length;
+ const delivery = createDeferred();
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: null,
+ });
+ operation.setPhase("running");
+ const queueMessage = vi.fn(() => delivery.promise);
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "active-run",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage },
+ });
+
+ await send({
+ idempotencyKey: "idem-steer-reject",
+ requestParams: { expectedRunId: "active-run", queueMode: "steer" },
+ waitFor: "none",
+ });
+ expect(respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({ status: "started" }),
+ undefined,
+ expect.any(Object),
+ );
+ operation.complete();
+ delivery.reject(new Error("native turn ended"));
+
+ await waitForAssertion(() => {
+ expect(context.dedupe.get("chat:idem-steer-reject")?.payload).toEqual({
+ runId: "idem-steer-reject",
+ status: "ok",
+ });
+ });
+ expect(queueMessage).toHaveBeenCalledOnce();
+ expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(dispatchCallsBefore + 1);
+ expect(mockState.lastDispatchCtx?.BodyForAgent).toBe("hello");
+ });
+
+ it("never aborts or replays onto a successor after unconfirmed acceptance", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-steer-unconfirmed-");
+ const { context, send } = createChatRequestFixture();
+ const delivery = createDeferred<{
+ transcriptCommit: "unconfirmed";
+ errorMessage: string;
+ }>();
+ const first = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: null,
+ });
+ first.setPhase("running");
+ first.attachBackend({
+ kind: "embedded",
+ runId: "active-run",
+ cancel: vi.fn(),
+ messageInjection: { isAvailable: () => true, queueMessage: () => delivery.promise },
+ });
+
+ await send({
+ idempotencyKey: "idem-steer-unconfirmed",
+ requestParams: { expectedRunId: "active-run", queueMode: "steer" },
+ waitFor: "none",
+ });
+ first.complete();
+ const successorCancel = vi.fn();
+ const successor = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: null,
+ });
+ successor.setPhase("running");
+ successor.attachBackend({
+ kind: "embedded",
+ runId: "successor-run",
+ cancel: successorCancel,
+ messageInjection: { isAvailable: () => true, queueMessage: vi.fn(async () => {}) },
+ });
+ delivery.resolve({
+ transcriptCommit: "unconfirmed",
+ errorMessage: "receipt timed out",
+ });
+
+ await waitForAssertion(() => {
+ expect(context.dedupe.get("chat:idem-steer-unconfirmed")?.payload).toEqual({
+ runId: "idem-steer-unconfirmed",
+ status: "ok",
+ });
+ });
+ expect(successor.result).toBeNull();
+ expect(successorCancel).not.toHaveBeenCalled();
+ expect(mockState.lastDispatchCtx).toBeUndefined();
+ successor.complete();
+ });
+
+ it("ACKs and dispatches once when exact-run injection throws synchronously", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-steer-sync-reject-");
+ const { respond, send } = createChatRequestFixture();
+ const dispatchCallsBefore = dispatchInboundMessageMock.mock.calls.length;
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: null,
+ });
+ operation.setPhase("running");
+ const queueMessage = vi.fn((): Promise => {
+ expect(respond).not.toHaveBeenCalled();
+ throw new Error("synchronous rejection");
+ });
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "active-run",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage },
+ });
+
+ try {
+ await send({
+ idempotencyKey: "idem-steer-sync-reject",
+ requestParams: { expectedRunId: "active-run", queueMode: "steer" },
+ });
+ } finally {
+ operation.complete();
+ }
+
+ expect(respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({ status: "started" }),
+ undefined,
+ expect.any(Object),
+ );
+ expect(queueMessage).toHaveBeenCalledOnce();
+ expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(dispatchCallsBefore + 1);
+ });
+
+ it("rejects a steer after the expected active run changes", async () => {
+ await createGatewayUserTurnSqliteFixture("openclaw-chat-send-steer-run-changed-");
+ const { context, respond, send } = createChatRequestFixture();
+ const operation = replyRunRegistry.begin({
+ sessionKey: "main",
+ sessionId: mockState.sessionId,
+ resetTriggered: false,
+ originatingLeafEntryId: "leaf-before-active-run-output",
+ });
+ operation.setPhase("running");
+ operation.attachBackend({
+ kind: "embedded",
+ runId: "successor-run",
+ cancel: () => {},
+ messageInjection: { isAvailable: () => true, queueMessage: async () => {} },
+ });
+
+ try {
+ await send({
+ idempotencyKey: "idem-steer-run-changed",
+ requestParams: {
+ expectedLeafEntryId: "leaf-before-active-run-output",
+ expectedRunId: "original-run",
+ queueMode: "steer",
+ },
+ waitFor: "none",
+ });
+ } finally {
+ operation.complete();
+ }
+
+ expect(lastRespondCall(respond)).toEqual([
+ false,
+ undefined,
+ expect.objectContaining({ details: { reason: "active-run-changed" } }),
+ ]);
+ expect(context.addChatRun).not.toHaveBeenCalled();
});
it("rejects a moved-leaf steer when the non-streaming owner evidence is stale", async () => {
@@ -1424,6 +2129,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
operation.setPhase("running");
operation.attachBackend({
kind: "embedded",
+ runId: "active-run",
cancel: () => {},
isStreaming: () => false,
isStopped: () => false,
@@ -1436,6 +2142,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
idempotencyKey: "idem-steer-stale-owner",
requestParams: {
expectedLeafEntryId: "leaf-before-stale-run-output",
+ expectedRunId: "active-run",
queueMode: "steer",
},
waitFor: "none",
@@ -1448,51 +2155,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect(lastRespondCall(respond)).toEqual([
false,
undefined,
- expect.objectContaining({ details: { reason: "active-leaf-changed" } }),
- ]);
- expect(context.addChatRun).not.toHaveBeenCalled();
- });
-
- it("rejects a stale explicit steer when a different leaf owns the active run", async () => {
- await createGatewayUserTurnSqliteFixture("openclaw-chat-send-steer-different-owner-");
- await appendTranscriptMessage(transcriptScope(), {
- eventId: "current-leaf",
- message: { role: "assistant", content: "working elsewhere" },
- now: 1,
- parentId: null,
- });
- const { context, respond, send } = createChatRequestFixture();
- const operation = replyRunRegistry.begin({
- sessionKey: "main",
- sessionId: mockState.sessionId,
- resetTriggered: false,
- originatingLeafEntryId: "different-branch-leaf",
- });
- operation.setPhase("running");
- operation.attachBackend({
- kind: "embedded",
- cancel: () => {},
- isStreaming: () => true,
- queueMessage: async () => {},
- });
-
- try {
- await send({
- idempotencyKey: "idem-steer-different-owner",
- requestParams: {
- expectedLeafEntryId: "stale-pane-leaf",
- queueMode: "steer",
- },
- waitFor: "none",
- });
- } finally {
- operation.complete();
- }
-
- expect(lastRespondCall(respond)).toEqual([
- false,
- undefined,
- expect.objectContaining({ details: { reason: "active-leaf-changed" } }),
+ expect.objectContaining({ details: { reason: "active-run-changed" } }),
]);
expect(context.addChatRun).not.toHaveBeenCalled();
});
diff --git a/src/gateway/server-methods/sessions-suggestions.test.ts b/src/gateway/server-methods/sessions-suggestions.test.ts
index 5c7576021eb7..661292e413dd 100644
--- a/src/gateway/server-methods/sessions-suggestions.test.ts
+++ b/src/gateway/server-methods/sessions-suggestions.test.ts
@@ -1,4 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ clearActiveEmbeddedRun,
+ setActiveEmbeddedRun,
+} from "../../agents/embedded-agent-runner/runs.js";
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
import { addSessionMember } from "../../config/sessions/session-sharing-store.js";
import {
@@ -66,6 +70,17 @@ vi.mock("../../config/sessions.js", async (importOriginal) => {
const sessionKey = "agent:main:main";
+const defaultSuggestionSession = {
+ sessionId: "session-main",
+ updatedAt: 1,
+ createdActor: { type: "human", id: "owner" },
+ visibility: "suggest",
+} as const;
+
+function upsertDefaultSuggestionSession() {
+ return upsertSessionEntry({ agentId: "main", sessionKey }, defaultSuggestionSession);
+}
+
function createDeferred() {
let resolve!: (value: T) => void;
const promise = new Promise((nextResolve) => {
@@ -159,15 +174,7 @@ afterEach(() => {
describe("session suggestion handlers", () => {
it("lets a suggest viewer add and list only their own suggestion", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const alice = client("alice", "Alice");
const add = await call(
"session.suggestions.add",
@@ -414,26 +421,27 @@ describe("session suggestion handlers", () => {
"dispatches %s through chat.send with suggested-by attribution",
async (resolution, queueMode) => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const added = await call(
"session.suggestions.add",
{ sessionKey, text: "Ship the focused change" },
client("alice", "Alice"),
);
const id = responseSuggestionId(added);
+ const requestContext = context();
+ if (resolution === "send") {
+ requestContext.chatAbortControllers.set("active-run", {
+ sessionKey,
+ sessionId: "session-main",
+ agentId: "main",
+ } as never);
+ }
const resolved = await call(
"session.suggestions.resolve",
{ sessionKey, id, resolution },
client("owner", "Owner"),
+ requestContext,
);
expect(resolved.responses[0]?.[0]).toBe(true);
expect(mocks.handleChatSend).toHaveBeenCalledWith(
@@ -441,6 +449,7 @@ describe("session suggestion handlers", () => {
params: expect.objectContaining({
message: "Ship the focused change",
queueMode,
+ ...(resolution === "send" ? { expectedRunId: "active-run" } : {}),
idempotencyKey: `session-suggestion:${id}`,
}),
client: expect.objectContaining({
@@ -459,17 +468,109 @@ describe("session suggestion handlers", () => {
},
);
+ it("sends immediately without a steer override when the session is idle", async () => {
+ await withOpenClawTestState({ scenario: "minimal" }, async () => {
+ await upsertDefaultSuggestionSession();
+ const added = await call(
+ "session.suggestions.add",
+ { sessionKey, text: "send while idle" },
+ client("alice", "Alice"),
+ );
+ const id = responseSuggestionId(added);
+
+ const resolved = await call(
+ "session.suggestions.resolve",
+ { sessionKey, id, resolution: "send" },
+ client("owner", "Owner"),
+ );
+
+ expect(resolved.responses[0]?.[0]).toBe(true);
+ const chatParams = mocks.handleChatSend.mock.calls[0]?.[0]?.params;
+ expect(chatParams).toMatchObject({
+ message: "send while idle",
+ idempotencyKey: `session-suggestion:${id}`,
+ });
+ expect(chatParams).not.toHaveProperty("queueMode");
+ expect(chatParams).not.toHaveProperty("expectedRunId");
+ });
+ });
+
+ it("keeps a suggestion pending when multiple active runs make send-now ambiguous", async () => {
+ await withOpenClawTestState({ scenario: "minimal" }, async () => {
+ await upsertDefaultSuggestionSession();
+ const added = await call(
+ "session.suggestions.add",
+ { sessionKey, text: "ambiguous send" },
+ client("alice", "Alice"),
+ );
+ const id = responseSuggestionId(added);
+ const requestContext = context();
+ for (const runId of ["run-a", "run-b"]) {
+ requestContext.chatAbortControllers.set(runId, {
+ sessionKey,
+ sessionId: "session-main",
+ agentId: "main",
+ } as never);
+ }
+
+ const resolved = await call(
+ "session.suggestions.resolve",
+ { sessionKey, id, resolution: "send" },
+ client("owner", "Owner"),
+ requestContext,
+ );
+
+ expect(resolved.responses[0]?.[0]).toBe(false);
+ expect(resolved.responses[0]?.[2]).toMatchObject({
+ message:
+ "session has multiple active runs; choose the target run before sending the suggestion",
+ details: { code: "SESSION_SUGGESTION_ACTIVE_RUN_AMBIGUOUS", sessionKey },
+ });
+ expect(mocks.handleChatSend).not.toHaveBeenCalled();
+ expect(listSessionSuggestions({ agentId: "main", sessionKey })).toEqual([
+ expect.objectContaining({ id, state: "pending" }),
+ ]);
+ });
+ });
+
+ it("rejects send-now when active work has no exact gateway run identity", async () => {
+ await withOpenClawTestState({ scenario: "minimal" }, async () => {
+ await upsertDefaultSuggestionSession();
+ const added = await call(
+ "session.suggestions.add",
+ { sessionKey, text: "hidden active run" },
+ client("alice", "Alice"),
+ );
+ const id = responseSuggestionId(added);
+ const hiddenHandle = {
+ runId: "embedded-only-run",
+ abort: () => {},
+ queueMessage: async () => {},
+ } as never;
+ setActiveEmbeddedRun("session-main", hiddenHandle, sessionKey);
+
+ try {
+ const resolved = await call(
+ "session.suggestions.resolve",
+ { sessionKey, id, resolution: "send" },
+ client("owner", "Owner"),
+ );
+
+ expect(resolved.responses[0]?.[0]).toBe(false);
+ expect(resolved.responses[0]?.[2]).toMatchObject({
+ message: "active session run has no exact dispatch identity; refresh and retry",
+ details: { code: "SESSION_SUGGESTION_ACTIVE_RUN_AMBIGUOUS", sessionKey },
+ });
+ expect(mocks.handleChatSend).not.toHaveBeenCalled();
+ } finally {
+ clearActiveEmbeddedRun("session-main", hiddenHandle, sessionKey);
+ }
+ });
+ });
+
it("allows only owners and admins to resolve suggestions", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const added = await call(
"session.suggestions.add",
{ sessionKey, text: "Edit me" },
@@ -511,15 +612,7 @@ describe("session suggestion handlers", () => {
it("publishes a fenced resolution before awaiting the transcript audit", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const added = await call(
"session.suggestions.add",
{ sessionKey, text: "resolve before audit" },
@@ -551,15 +644,7 @@ describe("session suggestion handlers", () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const broadcast = vi.fn();
const requestContext = context(broadcast);
mocks.presence = [{ user: { id: "alice" }, watchedSessions: [sessionKey] }];
@@ -655,15 +740,7 @@ describe("session suggestion handlers", () => {
it("returns structured errors for blank text and clientless dispatch", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const blank = await call(
"session.suggestions.add",
{ sessionKey, text: " " },
@@ -717,15 +794,7 @@ describe("session suggestion handlers", () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const added = await call(
"session.suggestions.add",
{ sessionKey, text: "retry me" },
@@ -775,15 +844,7 @@ describe("session suggestion handlers", () => {
it("claims a pending suggestion before dispatching it", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const added = await call(
"session.suggestions.add",
{ sessionKey, text: "only once" },
@@ -970,15 +1031,7 @@ describe("session suggestion handlers", () => {
it("releases a durable claim after a definite dispatch rejection", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
- await upsertSessionEntry(
- { agentId: "main", sessionKey },
- {
- sessionId: "session-main",
- updatedAt: 1,
- createdActor: { type: "human", id: "owner" },
- visibility: "suggest",
- },
- );
+ await upsertDefaultSuggestionSession();
const added = await call(
"session.suggestions.add",
{ sessionKey, text: "try again" },
diff --git a/src/gateway/server-methods/sessions-suggestions.ts b/src/gateway/server-methods/sessions-suggestions.ts
index 2c24a5ebfb90..79c9217948b9 100644
--- a/src/gateway/server-methods/sessions-suggestions.ts
+++ b/src/gateway/server-methods/sessions-suggestions.ts
@@ -32,6 +32,7 @@ import {
} from "../session-sharing.js";
import { handleChatSend } from "./chat-send-handler.js";
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
+import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js";
import { appendSessionAudit } from "./session-audit.js";
import {
broadcastTypingThrottled,
@@ -47,11 +48,7 @@ import type {
import { assertValidParams } from "./validation.js";
function suggestionScope(target: NonNullable>) {
- return {
- agentId: target.agentId,
- sessionKey: target.storeKey,
- storePath: target.storePath,
- };
+ return { agentId: target.agentId, sessionKey: target.storeKey, storePath: target.storePath };
}
function protocolSuggestion(
@@ -225,13 +222,44 @@ async function dispatchSuggestion(params: {
suggestion: StoredSessionSuggestion;
resolution: "send" | "queue";
}): Promise<{ ok: true } | { ok: false; error: Parameters[2] }> {
+ const activeRunState =
+ params.resolution === "send"
+ ? resolveVisibleActiveSessionRunState({
+ context: params.context,
+ requestedKey: params.target.canonicalKey,
+ canonicalKey: params.target.storeKey,
+ sessionId: params.target.entry.sessionId,
+ agentId: params.target.agentId,
+ })
+ : undefined;
+ if (activeRunState?.active && activeRunState.runIds.length !== 1) {
+ const message =
+ activeRunState.runIds.length === 0
+ ? "active session run has no exact dispatch identity; refresh and retry"
+ : "session has multiple active runs; choose the target run before sending the suggestion";
+ return {
+ ok: false,
+ error: errorShape(ErrorCodes.INVALID_REQUEST, message, {
+ retryable: false,
+ details: {
+ code: "SESSION_SUGGESTION_ACTIVE_RUN_AMBIGUOUS",
+ sessionKey: params.target.canonicalKey,
+ },
+ }),
+ };
+ }
+ const activeRunId = activeRunState?.active ? activeRunState.runIds[0] : undefined;
let response: Parameters | undefined;
const chatParams = {
sessionKey: params.target.canonicalKey,
agentId: params.target.agentId,
sessionId: params.target.entry.sessionId,
message: params.suggestion.text,
- queueMode: params.resolution === "send" ? "steer" : "followup",
+ ...(params.resolution === "queue"
+ ? { queueMode: "followup" as const }
+ : activeRunId
+ ? { queueMode: "steer" as const, expectedRunId: activeRunId }
+ : {}),
idempotencyKey: `session-suggestion:${params.suggestion.id}`,
};
await handleChatSend({
diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts
index 746ceeb8ff6e..0efa0d61e6ec 100644
--- a/src/gateway/server.chat.gateway-server-chat-b.test.ts
+++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts
@@ -4990,8 +4990,8 @@ describe("gateway server chat", () => {
ws,
"chat.send",
makeChatSendParams({
- message: "steer this turn",
- queueMode: "steer",
+ message: "follow up this turn",
+ queueMode: "followup",
idempotencyKey: "idem-queue-mode-override",
}),
);
@@ -5001,7 +5001,7 @@ describe("gateway server chat", () => {
expect(spy.mock.calls.length).toBeGreaterThan(0);
}, FAST_WAIT_OPTS);
- expect(capturedOpts).toMatchObject({ queueModeOverride: "steer" });
+ expect(capturedOpts).toMatchObject({ queueModeOverride: "followup" });
},
{
headers: { origin: `http://127.0.0.1:${harness.port}` },
diff --git a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts
index 1709590ace18..dea3b21072d7 100644
--- a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts
+++ b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts
@@ -355,7 +355,9 @@ suite.define(() => {
sessionKey: "main",
});
const queue = page.locator(".chat-queue");
- await queue.getByText("Steering").waitFor({ timeout: 10_000 });
+ await queue.locator(".chat-queue__badge--steered", { hasText: "Steering" }).waitFor({
+ timeout: 10_000,
+ });
await queue.getByText(followUp).waitFor({ timeout: 10_000 });
if (artifactDir) {
await page.screenshot({
@@ -544,7 +546,8 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}settings/appearance`);
await page.locator("[data-settings-follow-up-mode]").selectOption("queue");
- await page.goto(`${suite.server.baseUrl}chat`);
+ await page.goto(`${suite.server.baseUrl}chat?session=main`);
+ await expect.poll(() => new URL(page.url()).pathname).toMatch(/\/chat\/main$/);
await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active");
await page.getByRole("button", { name: "Send message" }).click();
@@ -560,6 +563,17 @@ suite.define(() => {
"sessions.list",
chatSessionListResponse([
{
+ activeLeafEntryId: "leaf-active",
+ activeRunIds: ["active-run"],
+ hasActiveRun: true,
+ key: "global",
+ kind: "global",
+ label: "Global",
+ updatedAt: Date.now(),
+ },
+ {
+ activeLeafEntryId: "leaf-active",
+ activeRunIds: ["active-run"],
hasActiveRun: true,
key: "main",
kind: "direct",
@@ -569,6 +583,7 @@ suite.define(() => {
]),
);
await page.reload();
+ await gateway.waitForRequest("sessions.list");
const queue = page.locator(".chat-queue");
await queue.getByText(queuedPrompt).waitFor({ timeout: 10_000 });
@@ -578,10 +593,15 @@ suite.define(() => {
const steerParams = requireRecord(steerRequest.params);
expect(steerParams).toMatchObject({
deliver: false,
+ expectedLeafEntryId: "leaf-active",
+ expectedRunId: "active-run",
message: queuedPrompt,
+ queueMode: "steer",
sessionKey: "main",
});
- await queue.getByText("Steering").waitFor({ timeout: 10_000 });
+ await queue.locator(".chat-queue__badge--steered", { hasText: "Steering" }).waitFor({
+ timeout: 10_000,
+ });
await gateway.emitChatFinal({
runId: requireString(steerParams.idempotencyKey, "restored steer idempotency key"),
text: "Restored steer completed.",
diff --git a/ui/src/e2e/chat-flow.messaging.e2e.test.ts b/ui/src/e2e/chat-flow.messaging.e2e.test.ts
index 920118144536..146b2c79a2c6 100644
--- a/ui/src/e2e/chat-flow.messaging.e2e.test.ts
+++ b/ui/src/e2e/chat-flow.messaging.e2e.test.ts
@@ -772,7 +772,7 @@ suite.define(() => {
});
});
- it("steers an active run when the session row only reports hasActiveRun", async () => {
+ it("steers the exact run with the current leaf reported by the session row", async () => {
await withChatPage(async (page) => {
const sessionKey = "main";
const gateway = await installMockGateway(page, {
@@ -787,6 +787,8 @@ suite.define(() => {
"sessions.list": chatSessionListResponse([
{
hasActiveRun: true,
+ activeRunIds: ["active-run"],
+ activeLeafEntryId: "leaf-before-steer",
key: "agent:main:main",
kind: "direct",
label: "Main",
@@ -811,6 +813,8 @@ suite.define(() => {
expect(params.sessionKey).toBe(sessionKey);
expect(params.message).toBe("use the smaller fix");
expect(params.deliver).toBe(false);
+ expect(params.expectedRunId).toBe("active-run");
+ expect(params.expectedLeafEntryId).toBe("leaf-before-steer");
await page.getByText("Steered.", { exact: true }).waitFor({ timeout: 10_000 });
expect(await page.getByText("No active run").count()).toBe(0);
diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts
index 75fbeba5d015..53c0906de8e3 100644
--- a/ui/src/lib/chat/chat-types.ts
+++ b/ui/src/lib/chat/chat-types.ts
@@ -37,6 +37,8 @@ export type ChatQueueItem = {
sendAttempts?: number;
sendError?: string;
sendRunId?: string;
+ /** Immutable active run selected when this row first became a steer. */
+ steerTargetRunId?: string;
sendState?:
| "waiting-model"
| "waiting-idle"
diff --git a/ui/src/lib/chat/outbox-store-codec.ts b/ui/src/lib/chat/outbox-store-codec.ts
index a663ae9fb76e..0006751b1358 100644
--- a/ui/src/lib/chat/outbox-store-codec.ts
+++ b/ui/src/lib/chat/outbox-store-codec.ts
@@ -111,6 +111,10 @@ export function normalizeStoredQueueItem(value: unknown): ChatQueueItem | null {
if (sendRunId) {
item.sendRunId = sendRunId;
}
+ const steerTargetRunId = normalizeOptionalString(entry.steerTargetRunId);
+ if (steerTargetRunId) {
+ item.steerTargetRunId = steerTargetRunId;
+ }
if (typeof entry.sendAttempts === "number" && Number.isFinite(entry.sendAttempts)) {
item.sendAttempts = entry.sendAttempts;
}
diff --git a/ui/src/pages/chat/chat-command-executor.test.ts b/ui/src/pages/chat/chat-command-executor.test.ts
index a4baf383f49f..ea412d9776b8 100644
--- a/ui/src/pages/chat/chat-command-executor.test.ts
+++ b/ui/src/pages/chat/chat-command-executor.test.ts
@@ -85,11 +85,19 @@ function restrictedSnapshot(
}
function row(key: string, overrides?: Partial): GatewaySessionRow {
+ const active = overrides?.status === "running" || overrides?.hasActiveRun === true;
return {
key,
spawnedBy: overrides?.spawnedBy,
kind: "direct",
updatedAt: null,
+ ...(active
+ ? {
+ hasActiveRun: true,
+ activeRunIds: ["active-run"],
+ activeLeafEntryId: "leaf-active",
+ }
+ : {}),
...overrides,
};
}
@@ -1602,10 +1610,18 @@ describe("executeSlashCommand /steer (soft inject)", () => {
expect(chatSend.payload.queueMode).toBe("steer");
});
- it("uses canonical active-run state when the session row only reports hasActiveRun", async () => {
+ it("uses a unique run id when a real session row omits active leaf context", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
- return { sessions: [row("agent:main:main", { hasActiveRun: true })] };
+ return {
+ sessions: [
+ row("agent:main:main", {
+ hasActiveRun: true,
+ activeRunIds: ["active-run"],
+ activeLeafEntryId: undefined,
+ }),
+ ],
+ };
}
if (method === "chat.send") {
return { status: "started", runId: "run-active-flag", messageSeq: 2 };
@@ -1627,7 +1643,39 @@ describe("executeSlashCommand /steer (soft inject)", () => {
sessionKey: "agent:main:main",
message: "continue with the smaller fix",
deliver: false,
+ expectedRunId: "active-run",
});
+ expect(chatSend.payload).not.toHaveProperty("expectedLeafEntryId");
+ });
+
+ it.each([
+ ["zero", []],
+ ["multiple", ["run-a", "run-b"]],
+ ] as const)("refuses %s authoritative active run ids", async (_label, activeRunIds) => {
+ const request = vi.fn(async (method: string) => {
+ if (method === "sessions.list") {
+ return {
+ sessions: [
+ row("agent:main:main", {
+ hasActiveRun: true,
+ activeRunIds: [...activeRunIds],
+ activeLeafEntryId: undefined,
+ }),
+ ],
+ };
+ }
+ throw new Error(`unexpected method: ${method}`);
+ });
+
+ const result = await executeSlashCommand(
+ { request } as unknown as GatewayBrowserClient,
+ "agent:main:main",
+ "steer",
+ "continue safely",
+ );
+
+ expect(result.content).toBe(t("chat.commandResults.steer.noActiveRun"));
+ expectNoRequestCall(request, "chat.send");
});
it("does not mark the current run pending when chat.send returns terminal ok", async () => {
diff --git a/ui/src/pages/chat/chat-command-executor.ts b/ui/src/pages/chat/chat-command-executor.ts
index e946505e19eb..f1a439ae0c42 100644
--- a/ui/src/pages/chat/chat-command-executor.ts
+++ b/ui/src/pages/chat/chat-command-executor.ts
@@ -818,8 +818,10 @@ async function resolveSteerTarget(
};
}
-function isActiveSteerSession(session: GatewaySessionRow | undefined): boolean {
- return Boolean(session && isSessionRunActive(session));
+function isActiveSteerSession(
+ session: GatewaySessionRow | undefined,
+): session is GatewaySessionRow & { activeRunIds: [string] } {
+ return Boolean(session && isSessionRunActive(session) && session.activeRunIds?.length === 1);
}
type SteerChatSendAckStatus = "started" | "in_flight" | "ok" | "timeout" | "error";
@@ -885,6 +887,10 @@ async function executeSteer(
message: resolved.message,
deliver: false,
queueMode: "steer",
+ expectedRunId: targetSession.activeRunIds[0],
+ ...(targetSession.activeLeafEntryId !== undefined
+ ? { expectedLeafEntryId: targetSession.activeLeafEntryId }
+ : {}),
idempotencyKey: generateUUID(),
}),
);
diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts
index 43b7c9a63093..b36914406922 100644
--- a/ui/src/pages/chat/chat-gateway.test.ts
+++ b/ui/src/pages/chat/chat-gateway.test.ts
@@ -878,6 +878,39 @@ describe("handleChatGatewayEvent", () => {
expectTextChatMessage(state.chatMessages[3], "assistant", "Final answer.");
});
+ it("retires a reply steer chip after an exact-target terminal rejection", () => {
+ const state = createState({
+ sessionKey: "main",
+ chatRunId: "reply-steer-request",
+ chatQueue: [
+ {
+ id: "reply-steer-chip",
+ text: "Reply with deployment context",
+ createdAt: 3,
+ kind: "steered",
+ pendingRunId: "reply-steer-request",
+ sendRunId: "reply-steer-request",
+ sessionKey: "main",
+ },
+ ],
+ });
+
+ expect(
+ handleChatGatewayEvent(state, {
+ runId: "reply-steer-request",
+ sessionKey: "main",
+ state: "error",
+ errorMessage: "active run changed; review and retry",
+ }),
+ ).toBe("error");
+
+ expect(state.chatQueue).toEqual([]);
+ expect(state.chatRunId).toBeNull();
+ expect(state.chatRunError).toEqual({
+ summary: "Error: active run changed; review and retry",
+ });
+ });
+
it("uses an already-persisted steer to recover the active stream boundary", () => {
const state = createState({
sessionKey: "main",
diff --git a/ui/src/pages/chat/chat-send-actions.ts b/ui/src/pages/chat/chat-send-actions.ts
index b520172c807a..74788966abed 100644
--- a/ui/src/pages/chat/chat-send-actions.ts
+++ b/ui/src/pages/chat/chat-send-actions.ts
@@ -71,7 +71,12 @@ export async function sendChatMessageWithGeneratedRunId(
message: msg,
attachments,
runId,
- ...(expectedLeafEntryId !== undefined ? { expectedLeafEntryId } : {}),
+ ...(options.expectedLeafEntryId !== undefined
+ ? { expectedLeafEntryId: options.expectedLeafEntryId }
+ : expectedLeafEntryId !== undefined
+ ? { expectedLeafEntryId }
+ : {}),
+ ...(options.expectedRunId ? { expectedRunId: options.expectedRunId } : {}),
...(options.queueMode ? { queueMode: options.queueMode } : {}),
});
} catch (err) {
diff --git a/ui/src/pages/chat/chat-send-request.ts b/ui/src/pages/chat/chat-send-request.ts
index 067a065ed165..25abfe4a404b 100644
--- a/ui/src/pages/chat/chat-send-request.ts
+++ b/ui/src/pages/chat/chat-send-request.ts
@@ -22,6 +22,7 @@ export async function requestChatSend(
queueMode?: QueueMode;
replyToId?: string;
expectedLeafEntryId?: string | null;
+ expectedRunId?: string;
},
): Promise {
const routing = resolveChatSendRouting(state, params);
@@ -42,6 +43,7 @@ export async function requestChatSend(
...(params.expectedLeafEntryId !== undefined
? { expectedLeafEntryId: params.expectedLeafEntryId }
: {}),
+ ...(params.expectedRunId ? { expectedRunId: params.expectedRunId } : {}),
idempotencyKey: params.runId,
attachments: buildChatApiAttachments(params.attachments),
});
diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts
index b8cfa3264649..af16a1eb6436 100644
--- a/ui/src/pages/chat/chat-send.test.ts
+++ b/ui/src/pages/chat/chat-send.test.ts
@@ -3480,6 +3480,7 @@ describe("handleSendChat", () => {
},
chatMessage: "tighten the plan",
chatRunId: "run-1",
+ chatDisplayedLeafEntryId: "leaf-active",
chatStream: "Working...",
sessionKey: "agent:main:main",
settings: { chatFollowUpMode: "steer" },
@@ -3539,6 +3540,7 @@ describe("handleSendChat", () => {
],
chatReplyTarget: replyTarget,
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatStream: "Working...",
settings: { chatFollowUpMode: "steer" },
});
@@ -3629,7 +3631,12 @@ describe("handleSendChat", () => {
chatRunId: null,
sessionKey: "agent:main:main",
sessionsResult: createSessionsResult([
- row("agent:main:main", { hasActiveRun: true, status: "running" }),
+ row("agent:main:main", {
+ hasActiveRun: true,
+ activeRunIds: ["active-run"],
+ activeLeafEntryId: "leaf-active",
+ status: "running",
+ }),
]),
});
@@ -3661,7 +3668,12 @@ describe("handleSendChat", () => {
chatRunId: null,
sessionKey: "agent:main:main",
sessionsResult: createSessionsResult([
- row("agent:main:main", { hasActiveRun: true, status: "running" }),
+ row("agent:main:main", {
+ hasActiveRun: true,
+ activeRunIds: ["active-run"],
+ activeLeafEntryId: "leaf-active",
+ status: "running",
+ }),
]),
settings: { chatFollowUpMode: "steer" },
});
@@ -3684,6 +3696,7 @@ describe("handleSendChat", () => {
connected: false,
chatMessage: "queued while offline",
chatRunId: "run-1",
+ chatDisplayedLeafEntryId: "leaf-active",
settings: { chatFollowUpMode: "steer" },
});
@@ -7626,7 +7639,14 @@ describe("handleSendChat", () => {
chatRunId: "run-1",
chatMessage: "/steer tighten the plan",
sessionKey: "agent:main:main",
- sessionsResult: createSessionsResult([row("agent:main:main", { status: "running" })]),
+ sessionsResult: createSessionsResult([
+ row("agent:main:main", {
+ activeLeafEntryId: "leaf-active",
+ activeRunIds: ["run-1"],
+ hasActiveRun: true,
+ status: "running",
+ }),
+ ]),
});
await handleSendChat(host);
@@ -7644,6 +7664,7 @@ describe("handleSendChat", () => {
"chat.send": { status: "started", runId: "steer-run" },
},
chatRunId: "run-1",
+ chatDisplayedLeafEntryId: "leaf-active",
chatStream: "Working...",
chatQueue: [original],
sessionKey: "agent:main:main",
@@ -7665,6 +7686,8 @@ describe("handleSendChat", () => {
message: "tighten the plan",
deliver: false,
queueMode: "steer",
+ expectedRunId: "run-1",
+ expectedLeafEntryId: "leaf-active",
idempotencyKey,
attachments: undefined,
});
@@ -7686,7 +7709,12 @@ describe("handleSendChat", () => {
chatQueue: [original],
sessionKey: "agent:main:main",
sessionsResult: createSessionsResult([
- row("agent:main:main", { hasActiveRun: true, status: "running" }),
+ row("agent:main:main", {
+ hasActiveRun: true,
+ activeRunIds: ["active-run"],
+ activeLeafEntryId: "leaf-active",
+ status: "running",
+ }),
]),
});
expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true);
@@ -7703,6 +7731,7 @@ describe("handleSendChat", () => {
message: "tighten the plan",
deliver: false,
queueMode: "steer",
+ expectedRunId: "active-run",
});
expect(host.chatRunId).toBeNull();
expect(host.chatQueue).toEqual([
@@ -7732,6 +7761,7 @@ describe("handleSendChat", () => {
},
chatQueue: [original],
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
sessionKey: original.sessionKey,
});
expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true);
@@ -7867,6 +7897,7 @@ describe("handleSendChat", () => {
const host = makeChatHost({
requestHandlers: {},
chatRunId: "run-1",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: "agent:main:main",
});
@@ -7896,6 +7927,7 @@ describe("handleSendChat", () => {
"chat.send": { status: "started", runId: "steer-run" },
},
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: "agent:main:main",
});
@@ -7944,6 +7976,7 @@ describe("handleSendChat", () => {
}),
},
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: "agent:main:main",
});
@@ -7991,6 +8024,7 @@ describe("handleSendChat", () => {
}),
},
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: original.sessionKey,
});
@@ -8255,6 +8289,7 @@ describe("handleSendChat", () => {
},
},
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: original.sessionKey,
});
@@ -8304,6 +8339,7 @@ describe("handleSendChat", () => {
},
},
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: original.sessionKey,
});
@@ -8379,6 +8415,7 @@ describe("handleSendChat", () => {
},
},
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: original.sessionKey,
});
@@ -8423,6 +8460,7 @@ describe("handleSendChat", () => {
}),
},
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: "agent:main:original",
});
@@ -8468,6 +8506,7 @@ describe("handleSendChat", () => {
},
chatError: null,
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: original.sessionKey,
});
@@ -8522,12 +8561,14 @@ describe("handleSendChat", () => {
const host = makeChatHost({
client,
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: "agent:main:main",
});
const peer = makeChatHost({
client,
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [{ ...original }],
sessionKey: host.sessionKey,
});
@@ -8630,6 +8671,7 @@ describe("handleSendChat", () => {
},
},
chatRunId: "active-run",
+ chatDisplayedLeafEntryId: "leaf-active",
chatQueue: [original],
sessionKey: original.sessionKey,
});
@@ -8656,6 +8698,7 @@ describe("handleSendChat", () => {
);
host.chatRunId = "active-run";
+ host.chatDisplayedLeafEntryId = "leaf-advanced-during-tool-work";
await retryQueuedChatMessage(host, original.id);
expect(payloads).toHaveLength(2);
@@ -8664,6 +8707,108 @@ describe("handleSendChat", () => {
original.sendRunId,
]);
expect(payloads.map((payload) => payload.queueMode)).toEqual(["steer", "steer"]);
+ expect(payloads.map((payload) => payload.expectedRunId)).toEqual(["active-run", "active-run"]);
+ expect(payloads.map((payload) => payload.expectedLeafEntryId)).toEqual([
+ "leaf-active",
+ "leaf-advanced-during-tool-work",
+ ]);
+ });
+
+ it("fails a restored steer that predates durable target identity", async () => {
+ const original = {
+ id: "legacy-targetless-steer",
+ text: "do not redirect this",
+ createdAt: 1,
+ kind: "steered" as const,
+ sendRunId: "stable-request",
+ sendState: "failed" as const,
+ sessionKey: "agent:main:main",
+ };
+ const host = makeChatHost({
+ requestHandlers: { "chat.send": { status: "started", runId: "successor" } },
+ chatRunId: "successor",
+ chatDisplayedLeafEntryId: "successor-leaf",
+ chatQueue: [original],
+ sessionKey: original.sessionKey,
+ });
+ expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true);
+
+ await retryQueuedChatMessage(host, original.id);
+
+ expect(host.request).not.toHaveBeenCalledWith("chat.send", expect.anything());
+ expect(host.chatQueue[0]).toMatchObject({
+ kind: "steered",
+ sendState: "failed",
+ sendError: "This restored steer has no original run target and cannot be retried safely.",
+ });
+ });
+
+ it("retries a restored steer against its run with the refreshed current leaf", async () => {
+ const payloads: Array> = [];
+ const original = {
+ id: "restored-run-bound-steer",
+ text: "continue the same turn",
+ createdAt: 1,
+ kind: "steered" as const,
+ sendRunId: "stable-steer-request",
+ sendState: "failed" as const,
+ steerTargetRunId: "active-run",
+ sessionKey: "agent:main:main",
+ };
+ const host = makeChatHost({
+ requestHandlers: {
+ "chat.send": (params: unknown) => {
+ const payload = requireRecord(params, "restored run-bound steer payload");
+ payloads.push(payload);
+ return { status: "started", runId: payload.idempotencyKey };
+ },
+ },
+ chatRunId: null,
+ chatQueue: [original],
+ sessionKey: original.sessionKey,
+ sessionsResult: createSessionsResult([
+ row(original.sessionKey, {
+ activeLeafEntryId: "leaf-advanced-during-tool-work",
+ activeRunIds: ["active-run"],
+ hasActiveRun: true,
+ status: "running",
+ }),
+ ]),
+ });
+ expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true);
+
+ await retryQueuedChatMessage(host, original.id);
+
+ expect(payloads).toHaveLength(1);
+ expect(payloads[0]).toMatchObject({
+ expectedLeafEntryId: "leaf-advanced-during-tool-work",
+ expectedRunId: "active-run",
+ idempotencyKey: original.sendRunId,
+ queueMode: "steer",
+ });
+ });
+
+ it("does not guess among multiple server-reported active runs", async () => {
+ const original = { id: "ambiguous-server-steer", text: "pick neither", createdAt: 1 };
+ const host = makeChatHost({
+ requestHandlers: {},
+ chatQueue: [original],
+ sessionKey: "agent:main:main",
+ sessionsResult: createSessionsResult([
+ row("agent:main:main", {
+ hasActiveRun: true,
+ activeRunIds: ["run-a", "run-b"],
+ activeLeafEntryId: "leaf-active",
+ status: "running",
+ }),
+ ]),
+ });
+ expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true);
+
+ await steerQueuedChatMessage(host, original.id);
+
+ expect(host.request).not.toHaveBeenCalledWith("chat.send", expect.anything());
+ expect(host.chatQueue[0]?.sendState).toBe("failed");
});
it("removes queued steer indicators when chat.send returns terminal ok", async () => {
@@ -8673,6 +8818,7 @@ describe("handleSendChat", () => {
"chat.send": { status: "ok", runId: "steer-ok" },
},
chatRunId: "run-1",
+ chatDisplayedLeafEntryId: "leaf-active",
chatStream: "Working...",
chatQueue: [original],
sessionKey: "agent:main:main",
@@ -8697,6 +8843,7 @@ describe("handleSendChat", () => {
"chat.send": { status: "error", runId: "steer-error" },
},
chatRunId: "run-1",
+ chatDisplayedLeafEntryId: "leaf-active",
chatStream: "Working...",
chatQueue: [original],
sessionKey: "agent:main:main",
diff --git a/ui/src/pages/chat/composer-persistence.test.ts b/ui/src/pages/chat/composer-persistence.test.ts
index b2c1af2d30df..f9e004ce3ada 100644
--- a/ui/src/pages/chat/composer-persistence.test.ts
+++ b/ui/src/pages/chat/composer-persistence.test.ts
@@ -63,6 +63,25 @@ afterEach(() => {
});
describe("chat composer persistence", () => {
+ it("round-trips only the immutable steer run identity", () => {
+ const state = createState();
+ const steer: ChatQueueItem = {
+ id: "steer-reload",
+ text: "keep the target",
+ createdAt: 1,
+ kind: "steered",
+ sendRunId: "steer-request",
+ sendState: "unconfirmed",
+ steerTargetRunId: "active-run",
+ };
+
+ expect(admitStoredChatComposerQueueItem(state, state.sessionKey, steer)).toBe(true);
+
+ expect(loadChatComposerSnapshot(state, state.sessionKey)?.queue[0]).toMatchObject({
+ steerTargetRunId: "active-run",
+ });
+ });
+
it("notifies durable outbox subscribers on writes until they unsubscribe", () => {
const state = createState();
const original = reconnectItem("notify", 1);
diff --git a/ui/src/pages/chat/steer-lifecycle.ts b/ui/src/pages/chat/steer-lifecycle.ts
index eba71bfe4f0c..67443035cc74 100644
--- a/ui/src/pages/chat/steer-lifecycle.ts
+++ b/ui/src/pages/chat/steer-lifecycle.ts
@@ -3,6 +3,7 @@ import type { SessionsListResult } from "../../api/types.ts";
import { setLastActiveSessionKey } from "../../app/settings.ts";
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { visibleSessionMatches } from "../../lib/sessions/index.ts";
+import { uiSessionRowMatchesSelectedChat } from "../../lib/sessions/session-key.ts";
import { generateUUID } from "../../lib/uuid.ts";
import {
getChatAttachmentDataUrl,
@@ -54,10 +55,52 @@ export type SteerSendDependencies = {
host: SteerLifecycleHost,
message: string,
attachments: ChatAttachment[] | undefined,
- options: { canApplyError: () => boolean; queueMode?: QueueMode; runId: string },
+ options: {
+ canApplyError: () => boolean;
+ queueMode?: QueueMode;
+ runId: string;
+ expectedRunId?: string;
+ expectedLeafEntryId?: string | null;
+ },
) => Promise;
};
+type SteerTarget = { runId: string; leafEntryId?: string | null };
+
+function resolveSteerTarget(host: SteerLifecycleHost, item: ChatQueueItem): SteerTarget | null {
+ const matchingRows =
+ host.sessionsResult?.sessions.filter((row) =>
+ uiSessionRowMatchesSelectedChat(host, row.key, item.sessionKey ?? host.sessionKey),
+ ) ?? [];
+ const serverRunIds = new Set(
+ matchingRows.flatMap((row) => (row.hasActiveRun ? (row.activeRunIds ?? []) : [])),
+ );
+ const durableRunId = item.kind === "steered" ? item.steerTargetRunId?.trim() : undefined;
+ if (item.kind === "steered" && !durableRunId) {
+ return null;
+ }
+ const runId =
+ durableRunId ||
+ host.chatRunId?.trim() ||
+ (serverRunIds.size === 1 ? [...serverRunIds][0] : undefined);
+ if (!runId) {
+ return null;
+ }
+ const activeRow = matchingRows.find((row) => row.activeRunIds?.includes(runId));
+ const displayedLeaf =
+ host.chatRunId?.trim() === runId ? host.chatDisplayedLeafEntryId : undefined;
+ const leafEntryId =
+ displayedLeaf === null ? null : displayedLeaf?.trim() || activeRow?.activeLeafEntryId;
+ return {
+ runId,
+ ...(leafEntryId === null
+ ? { leafEntryId: null }
+ : typeof leafEntryId === "string" && leafEntryId.trim()
+ ? { leafEntryId: leafEntryId.trim() }
+ : {}),
+ };
+}
+
type RejectedSteerChatSend = { kind: "rejected"; error: string };
type SteerChatSendResult = ChatSendAck | RejectedSteerChatSend | null;
@@ -244,7 +287,6 @@ export async function sendQueuedChatMessageWithQueueMode(
}
const isSteer = queueMode === "steer";
const unconfirmedError = isSteer ? UNCONFIRMED_STEER_ERROR : UNCONFIRMED_FOLLOW_UP_ERROR;
- const activeRunId = host.chatRunId;
const item = host.chatQueue.find(
(entry) =>
entry.id === id &&
@@ -255,6 +297,17 @@ export async function sendQueuedChatMessageWithQueueMode(
if (!item) {
return;
}
+ const steerTarget = isSteer ? resolveSteerTarget(host, item) : null;
+ if (isSteer && !steerTarget) {
+ const error =
+ item.kind === "steered"
+ ? "This restored steer has no original run target and cannot be retried safely."
+ : "The active run could not be identified uniquely. Review and retry.";
+ updateQueuedMessage(host, id, (entry) => ({ ...entry, sendError: error, sendState: "failed" }));
+ setChatError(host, error);
+ return;
+ }
+ const activeRunId = steerTarget?.runId ?? host.chatRunId;
const itemSessionKey = item.sessionKey ?? host.sessionKey;
const message = item.text.trim();
const attachments = item.attachments ?? [];
@@ -267,6 +320,11 @@ export async function sendQueuedChatMessageWithQueueMode(
const claimed = updateQueuedMessage(host, id, (entry) => ({
...entry,
...(isSteer ? { kind: "steered" as const } : {}),
+ ...(steerTarget
+ ? {
+ steerTargetRunId: steerTarget.runId,
+ }
+ : {}),
sendError: unconfirmedError,
sendRunId: entry.sendRunId ?? generateUUID(),
sendState: "unconfirmed",
@@ -312,6 +370,14 @@ export async function sendQueuedChatMessageWithQueueMode(
{
canApplyError: () => visibleSessionMatches(host, itemSessionKey, item.agentId),
...(queueMode ? { queueMode } : {}),
+ ...(steerTarget
+ ? {
+ expectedRunId: steerTarget.runId,
+ ...(steerTarget.leafEntryId !== undefined
+ ? { expectedLeafEntryId: steerTarget.leafEntryId }
+ : {}),
+ }
+ : {}),
runId: claimed.sendRunId,
},
);