refactor(steering): make active-run admission exact and atomic (#120285)

* refactor(steering): make admission exact and atomic

* fix(steering): bind targets by run identity

* fix(steering): inject before reply dispatch

* fix(steering): preserve backend injection modes

* fix(steering): target session suggestions

* fix(steering): preserve sibling delivery contracts

* style(steering): satisfy exact-head lint

* fix(steering): preserve targetless protocol compatibility

* fix(steering): preserve exact targets through replies
This commit is contained in:
Peter Steinberger
2026-08-07 16:58:04 -07:00
committed by GitHub
parent b921139203
commit fa9626c4e1
65 changed files with 2646 additions and 824 deletions
@@ -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"
}
+1 -1
View File
@@ -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 (`<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, 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["<provider>/<model>"].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["<provider>/<model>"].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.
</Accordion>
@@ -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"),
};
@@ -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(
+47 -39
View File
@@ -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;
}
+2 -1
View File
@@ -114,7 +114,7 @@ export async function runCopilotExecution(context: {
now,
scope: input.agentHarnessTaskRuntimeScope,
});
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
let activeRunHandleRef: ReturnType<typeof registerCopilotActiveRun> | 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,
+21 -12
View File
@@ -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<void>;
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" } });
@@ -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";
@@ -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);
});
});
@@ -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,
});
+2 -7
View File
@@ -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();
});
+1 -4
View File
@@ -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();
});
@@ -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) {
@@ -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);
}
-3
View File
@@ -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);
}
@@ -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<void | EmbeddedAgentQueueMessageResult>;
messageInjection?: ReplyBackendMessageInjection;
isStreaming: () => boolean;
isStopped?: () => boolean;
/** True after this handle has accepted an abort, even while cleanup retains it. */
@@ -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,
@@ -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<number | undefined> = [];
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([]);
});
});
+22 -8
View File
@@ -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",
});
});
+26 -42
View File
@@ -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<EmbeddedAgentQueueMessageOutcome> {
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 {
+1
View File
@@ -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 =
@@ -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> | void;
@@ -529,7 +529,6 @@ export type RunReplyAgentParams = {
shouldFollowup: boolean;
isActive: boolean;
isRunActive?: () => boolean;
isStreaming: boolean;
opts?: InternalGetReplyOptions;
typing: TypingController;
sessionEntry?: SessionEntry;
@@ -186,7 +186,6 @@ function createDirectRuntimeReplyParams({
shouldSteer: false,
shouldFollowup,
isActive,
isStreaming: false,
typing: createMockTypingController(),
sessionCtx: createTelegramSessionCtx(),
defaultModel: "openai/gpt-5.4",
+1 -10
View File
@@ -65,7 +65,6 @@ type ExecutePreparedReplyAgentRunInput = Pick<
activeSessionStore: Record<string, SessionEntry> | undefined;
admitUserTurn: ReturnType<typeof createReplyRestartRecoveryClaimController>["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) {
+4 -88
View File
@@ -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,
@@ -146,7 +146,6 @@ function makeRunReplyAgentParams(
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing: createMockTypingController(),
sessionCtx: {
Provider: provider,
@@ -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,
}),
);
@@ -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,
@@ -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",
@@ -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<ReplyDispatchKind, number>;
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 {
@@ -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 })) {
@@ -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;
@@ -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,
@@ -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 =
@@ -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);
});
@@ -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<typeof deriveInboundMessageHookContext>;
/** Emit observation hooks once for an accepted inbound turn, independent of reply dispatch. */
export function emitMessageReceivedHooks(params: {
ctx: FinalizedMsgContext;
hookRunner: ReturnType<typeof getGlobalHookRunner>;
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",
);
}
}
+335 -47
View File
@@ -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<typeof beginReplyMessageInjectionTarget>[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<typeof beginReplyMessageInjectionTarget>
) {
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",
+192 -60
View File
@@ -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<void | ReplyBackendQueueMessageResult>;
};
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<ReplyMessageInjectionOutcome>;
};
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<void | ReplyBackendQueueMessageResult>;
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 {
@@ -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<ReturnType<typeof beginSessionWorkAdmission>> | undefined;
let admittedRunAbort: ReturnType<typeof registerChatAbortController> | undefined;
let restartSafeAdmission: ReturnType<typeof resolveRestartSafeChatAdmission>;
let messageInjectionTarget: ReturnType<typeof replyRunRegistry.resolveMessageInjectionTarget>;
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) => {
@@ -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<PreparedChatSendSession, "agentId" | "clientRunId" | "sessionKey">;
terminalizeRestartSafeAdmission: (state: {
retryable: boolean;
status: "failed" | "killed";
}) => Promise<boolean>;
}): Promise<void> {
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,
});
}
};
+98 -77
View File
@@ -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,
});
}
}
@@ -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<typeof buildChatSendReplyInjectionText>[0];
images: ReplyBackendQueueMessageOptions["images"];
imageOrder: ReplyBackendQueueMessageOptions["imageOrder"];
media: ReplyBackendQueueMessageOptions["media"];
queueSettings: Parameters<typeof resolveQueueSettings>[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<ReplyMessageInjectionOutcome, { status: "accepted" }>;
persistUserTurnTranscriptBestEffort: () => Promise<void>;
session: Pick<
PreparedChatSendSession,
"agentId" | "cfg" | "clientRunId" | "entry" | "sessionKey" | "storePath"
>;
startedAt: number;
target: ReplyMessageInjectionTarget;
targetRunId: string | undefined;
}): Promise<void> {
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 });
}
}
@@ -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;
@@ -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<MsgContext, "ReplyToId" | "ReplyToBody" | "ReplyToSender">
>;
/** Adds hydrated reply metadata to the direct-injection user prompt. */
export function buildChatSendReplyInjectionText(params: {
body: string;
cfg: OpenClawConfig;
ctx: MsgContext;
sessionEntry?: Parameters<typeof buildInboundUserContextPrefix>[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) {
@@ -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({
@@ -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: {
@@ -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,
};
}
@@ -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({
@@ -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<void> | null,
activeSaveMediaCalls: 0,
maxActiveSaveMediaCalls: 0,
replyContextCalls: 0,
replyContextResult: null as {
ReplyToId?: string;
ReplyToBody?: string;
ReplyToSender?: string;
} | null,
replyContextWait: null as Promise<void> | 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<typeof import("./chat-send-reply-context.js")>();
return {
...actual,
resolveChatSendReplyContext: async (
...args: Parameters<typeof actual.resolveChatSendReplyContext>
) => {
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<typeof replyRunRegistry.begin> | 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<typeof replyRunRegistry.begin> | 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<typeof vi.fn>).mock.calls.map(
([, payload]) => payload as Record<string, unknown>,
);
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<void> => {
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();
});
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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" },
@@ -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<ReturnType<typeof resolveSessionSharingTarget>>) {
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<RespondFn>[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<RespondFn> | 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({
@@ -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}` },
+23 -3
View File
@@ -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.",
+5 -1
View File
@@ -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);
+2
View File
@@ -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"
+4
View File
@@ -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;
}
@@ -85,11 +85,19 @@ function restrictedSnapshot(
}
function row(key: string, overrides?: Partial<GatewaySessionRow>): 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 () => {
+8 -2
View File
@@ -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(),
}),
);
+33
View File
@@ -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",
+6 -1
View File
@@ -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) {
+2
View File
@@ -22,6 +22,7 @@ export async function requestChatSend(
queueMode?: QueueMode;
replyToId?: string;
expectedLeafEntryId?: string | null;
expectedRunId?: string;
},
): Promise<ChatSendAck> {
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),
});
+151 -4
View File
@@ -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<Record<string, unknown>> = [];
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",
@@ -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);
+68 -2
View File
@@ -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<SteerChatSendResult>;
};
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,
},
);