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