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