fix(ui): preserve explicit steer queue mode (#108121)

This commit is contained in:
Peter Steinberger
2026-07-15 01:09:55 -07:00
committed by GitHub
parent a7456edf94
commit f3e6042119
14 changed files with 128 additions and 9 deletions
@@ -12998,6 +12998,7 @@ public struct ChatSendParams: Codable, Sendable {
public let fastmodevalue: AnyCodable?
public var fastmode: Bool? { fastmodevalue?.value as? Bool }
public let fastautoonseconds: Int?
public let queuemode: String?
public let deliver: Bool?
public let originatingchannel: String?
public let originatingto: String?
@@ -13019,6 +13020,7 @@ public struct ChatSendParams: Codable, Sendable {
thinking: String? = nil,
fastmodevalue: AnyCodable? = nil,
fastautoonseconds: Int? = nil,
queuemode: String? = nil,
deliver: Bool? = nil,
originatingchannel: String? = nil,
originatingto: String? = nil,
@@ -13039,6 +13041,7 @@ public struct ChatSendParams: Codable, Sendable {
self.thinking = thinking
self.fastmodevalue = fastmodevalue
self.fastautoonseconds = fastautoonseconds
self.queuemode = queuemode
self.deliver = deliver
self.originatingchannel = originatingchannel
self.originatingto = originatingto
@@ -13060,6 +13063,7 @@ public struct ChatSendParams: Codable, Sendable {
message: String,
thinking: String? = nil,
fastmode: Bool?,
queuemode: String? = nil,
deliver: Bool? = nil,
originatingchannel: String? = nil,
originatingto: String? = nil,
@@ -13081,6 +13085,7 @@ public struct ChatSendParams: Codable, Sendable {
thinking: thinking,
fastmodevalue: fastmode.map { AnyCodable($0) },
fastautoonseconds: nil,
queuemode: queuemode,
deliver: deliver,
originatingchannel: originatingchannel,
originatingto: originatingto,
@@ -13103,6 +13108,7 @@ public struct ChatSendParams: Codable, Sendable {
case thinking
case fastmodevalue = "fastMode"
case fastautoonseconds = "fastAutoOnSeconds"
case queuemode = "queueMode"
case deliver
case originatingchannel = "originatingChannel"
case originatingto = "originatingTo"
+1 -1
View File
@@ -539,7 +539,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.
- `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"`.
</Accordion>
@@ -952,6 +952,19 @@ describe("validateChatSendParams", () => {
expect(validateChatSendParams({ ...base, fastAutoOnSeconds: 2 })).toBe(true);
expect(validateChatSendParams({ ...base, fastAutoOnSeconds: 0 })).toBe(false);
});
it("accepts one-turn queue mode overrides", () => {
const base = {
sessionKey: "agent:main:main",
message: "hello",
idempotencyKey: "run-1",
};
for (const queueMode of ["steer", "followup", "collect", "interrupt"] as const) {
expect(validateChatSendParams({ ...base, queueMode })).toBe(true);
}
expect(validateChatSendParams({ ...base, queueMode: "invalid" })).toBe(false);
});
});
describe("validateModelsListParams", () => {
@@ -95,6 +95,8 @@ export const ChatSendParamsSchema = closedObject({
fastMode: Type.Optional(Type.Union([Type.Boolean(), Type.Literal("auto")])),
// One-turn override for auto fast-mode cutoff seconds.
fastAutoOnSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
// One-turn override for active-run queue admission.
queueMode: Type.Optional(Type.String({ enum: ["steer", "followup", "collect", "interrupt"] })),
deliver: Type.Optional(Type.Boolean()),
originatingChannel: Type.Optional(Type.String()),
originatingTo: Type.Optional(Type.String()),
@@ -796,6 +796,38 @@ describe("runPreparedReply media-only handling", () => {
expect(call?.followupRun.originatingChannel).toBe(channel);
});
it("prefers a one-turn queue override over the stored session mode", async () => {
const queueSettings = await import("./queue/settings-runtime.js");
const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js");
vi.mocked(queueSettings.resolveQueueSettings).mockImplementationOnce((params) => ({
mode: params.inlineMode ?? params.sessionEntry?.queueMode ?? "steer",
}));
vi.mocked(embeddedAgentRuntime.resolveActiveEmbeddedRunSessionId)
.mockReturnValueOnce("active-session")
.mockReturnValueOnce("active-session");
vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunActive).mockReturnValueOnce(true);
vi.mocked(embeddedAgentRuntime.isEmbeddedAgentRunStreaming).mockReturnValueOnce(true);
await runPreparedReply(
baseParams({
sessionEntry: {
sessionId: "active-session",
updatedAt: Date.now(),
queueMode: "followup",
},
opts: { queueModeOverride: "steer" },
}),
);
expect(queueSettings.resolveQueueSettings).toHaveBeenCalledWith(
expect.objectContaining({ inlineMode: "steer" }),
);
expect(requireLastRunReplyAgentCall()).toMatchObject({
shouldSteer: true,
resolvedQueue: { mode: "steer" },
});
});
it("keeps thread history context on follow-up turns", async () => {
const result = await runPreparedReply(
baseParams({
+3 -2
View File
@@ -538,11 +538,12 @@ export async function runPreparedReply(
const isHeartbeat = opts?.isHeartbeat === true;
const heartbeatRunScope = resolveHeartbeatRunScope(opts);
const explicitThinkingLevelOverride = normalizeThinkLevel(opts?.thinkingLevelOverride);
const effectiveQueueMode = opts?.queueModeOverride ?? perMessageQueueMode;
const traceAttributes = {
provider,
hasSessionKey: Boolean(sessionKey),
isHeartbeat,
queueMode: perMessageQueueMode ?? "configured",
queueMode: effectiveQueueMode ?? "configured",
};
const traceRunPhase = <T>(name: string, run: () => Promise<T> | T): Promise<T> =>
measureDiagnosticsTimelineSpan(name, run, {
@@ -1138,7 +1139,7 @@ export async function runPreparedReply(
cfg,
channel: sessionCtx.Provider,
sessionEntry,
inlineMode: perMessageQueueMode,
inlineMode: effectiveQueueMode,
inlineOptions: perMessageQueueOptions,
});
const embeddedAgentRuntime = useFastReplyRuntime
+3
View File
@@ -4,6 +4,7 @@ import type { ReplyOptionsWithHeartbeatRunScope } from "../../infra/heartbeat-ru
import type { GetReplyOptions } from "../get-reply-options.types.js";
import type { ReplyPayload } from "../reply-payload.js";
import type { MsgContext } from "../templating.js";
import type { QueueMode } from "./queue/types.js";
export type ReplySessionBinding = {
sessionKey?: string;
@@ -21,6 +22,8 @@ type InternalReplySessionOptions = {
sessionPromptSourceReplyDeliveryMode?: GetReplyOptions["sourceReplyDeliveryMode"];
/** Marks when this reply is waiting to own its session's reply lane. */
onReplyAdmissionWaitChange?: (waiting: boolean) => void;
/** Overrides persisted queue mode for this reply only. */
queueModeOverride?: QueueMode;
};
export type InternalGetReplyOptions = GetReplyOptions &
@@ -10,6 +10,7 @@ import {
validateChatSendParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { isBtwRequestText } from "../../auto-reply/reply/btw-command.js";
import type { QueueMode } from "../../auto-reply/reply/queue/types.js";
import type { InputProvenance } from "../../sessions/input-provenance.js";
import { normalizeInputProvenance } from "../../sessions/input-provenance.js";
import { isOperatorUiClient } from "../../utils/message-channel.js";
@@ -34,6 +35,7 @@ type ChatSendRequestParams = {
thinking?: string;
fastMode?: FastMode;
fastAutoOnSeconds?: number;
queueMode?: QueueMode;
deliver?: boolean;
originatingChannel?: string;
originatingTo?: string;
+1
View File
@@ -1317,6 +1317,7 @@ export const chatHandlers: GatewayRequestHandlers = {
imageOrder: imageOrder.length > 0 ? imageOrder : undefined,
thinkingLevelOverride: p.thinking,
fastModeOverride: p.fastMode,
queueModeOverride: p.queueMode,
userTurnTranscriptRecorder: userTurnRecorder,
...(restartSafeAdmission ? { suppressNextUserMessagePersistence: true } : {}),
fastModeAutoOnSecondsOverride: p.fastAutoOnSeconds,
@@ -5247,6 +5247,47 @@ describe("gateway server chat", () => {
);
});
test("chat.send forwards one-turn queue mode overrides internally", async () => {
await withGatewayChatHarness(
async ({ ws, createSessionDir }) => {
const spy = getReplyFromConfig;
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
await createSessionDir();
await writeMainSessionStore();
let capturedOpts: InternalGetReplyOptions | undefined;
mockGetReplyFromConfigOnce(async (_ctx, opts) => {
capturedOpts = opts;
return undefined;
});
const sendRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "steer this turn",
queueMode: "steer",
idempotencyKey: "idem-queue-mode-override",
});
expect(sendRes.ok).toBe(true);
await vi.waitFor(() => {
expect(spy.mock.calls.length).toBeGreaterThan(0);
}, FAST_WAIT_OPTS);
expect(capturedOpts).toMatchObject({ queueModeOverride: "steer" });
},
{
headers: { origin: `http://127.0.0.1:${harness.port}` },
},
);
});
test("chat.history hard-caps single oversized nested payloads", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
@@ -1273,6 +1273,7 @@ describe("executeSlashCommand /steer (soft inject)", () => {
expect(chatSend.payload.sessionKey).toBe("agent:main:main");
expect(chatSend.payload.message).toBe("try a different approach");
expect(chatSend.payload.deliver).toBe(false);
expect(chatSend.payload.queueMode).toBe("steer");
});
it("uses canonical active-run state when the session row only reports hasActiveRun", async () => {
@@ -738,6 +738,7 @@ async function executeSteer(
...selectedGlobalScope(resolved.key, context),
message: resolved.message,
deliver: false,
queueMode: "steer",
idempotencyKey: generateUUID(),
}),
);
+2
View File
@@ -3272,6 +3272,7 @@ describe("handleSendChat", () => {
sessionKey: "agent:main:main",
message: "tighten the plan",
deliver: false,
queueMode: "steer",
}),
),
);
@@ -6765,6 +6766,7 @@ describe("handleSendChat", () => {
sessionKey: "agent:main:main",
message: "tighten the plan",
deliver: false,
queueMode: "steer",
idempotencyKey,
attachments: undefined,
});
+20 -6
View File
@@ -254,6 +254,7 @@ async function requestChatSend(
runId: string;
sessionKey?: string;
agentId?: string;
queueMode?: "steer";
},
): Promise<ChatSendAck> {
const routing = resolveChatSendRouting(state, params);
@@ -269,6 +270,7 @@ async function requestChatSend(
...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}),
message: params.message,
deliver: false,
...(params.queueMode ? { queueMode: params.queueMode } : {}),
idempotencyKey: params.runId,
attachments: buildChatApiAttachments(params.attachments),
});
@@ -352,8 +354,11 @@ async function sendChatMessageWithGeneratedRunId(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
canApplyError: () => boolean = () => true,
runIdOverride?: string,
options: {
canApplyError?: () => boolean;
queueMode?: "steer";
runId?: string;
} = {},
): Promise<ChatSendAck | null> {
if (!state.client || !state.connected) {
return null;
@@ -363,12 +368,18 @@ async function sendChatMessageWithGeneratedRunId(
if (!msg && !hasAttachments) {
return null;
}
const canApplyError = options.canApplyError ?? (() => true);
if (canApplyError()) {
setChatError(state, null);
}
const runId = runIdOverride ?? generateUUID();
const runId = options.runId ?? generateUUID();
try {
return await requestChatSend(state, { message: msg, attachments, runId });
return await requestChatSend(state, {
message: msg,
attachments,
runId,
...(options.queueMode ? { queueMode: options.queueMode } : {}),
});
} catch (err) {
if (canApplyError()) {
setChatError(state, formatConnectError(err));
@@ -383,7 +394,7 @@ async function sendDetachedChatMessage(
attachments?: ChatAttachment[],
runId?: string,
): Promise<ChatSendAck | null> {
return sendChatMessageWithGeneratedRunId(state, message, attachments, () => true, runId);
return sendChatMessageWithGeneratedRunId(state, message, attachments, { runId });
}
function isChatResetCommand(text: string) {
@@ -1364,7 +1375,10 @@ export async function steerQueuedChatMessage(host: ChatHost, id: string) {
host as unknown as ChatState,
message,
hasAttachments ? attachments : undefined,
() => visibleSessionMatches(host, itemSessionKey, item.agentId),
{
canApplyError: () => visibleSessionMatches(host, itemSessionKey, item.agentId),
queueMode: "steer",
},
);
const pendingStillVisible = activeRunId
? host.chatQueue.some((entry) => entry.id === id && entry.pendingRunId === activeRunId)