TUI/Gateway: fix pi streaming + tool routing + model display + msg updating (#8432)

* TUI/Gateway: fix pi streaming + tool routing

* Tests: clarify verbose tool output expectation

* fix: avoid seq gaps for targeted tool events (#8432) (thanks @gumadeiras)
This commit is contained in:
Gustavo Madeira Santana
2026-02-04 17:12:16 -05:00
committed by GitHub
parent a42e3cb78a
commit 38e6da1fe0
32 changed files with 1227 additions and 208 deletions

View File

@@ -42,6 +42,12 @@ export type GatewayClientInfo = {
instanceId?: string;
};
export const GATEWAY_CLIENT_CAPS = {
TOOL_EVENTS: "tool-events",
} as const;
export type GatewayClientCap = (typeof GATEWAY_CLIENT_CAPS)[keyof typeof GATEWAY_CLIENT_CAPS];
const GATEWAY_CLIENT_ID_SET = new Set<GatewayClientId>(Object.values(GATEWAY_CLIENT_IDS));
const GATEWAY_CLIENT_MODE_SET = new Set<GatewayClientMode>(Object.values(GATEWAY_CLIENT_MODES));
@@ -68,3 +74,13 @@ export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMo
? (normalized as GatewayClientMode)
: undefined;
}
export function hasGatewayClientCap(
caps: string[] | null | undefined,
cap: GatewayClientCap,
): boolean {
if (!Array.isArray(caps)) {
return false;
}
return caps.includes(cap);
}

View File

@@ -1,4 +1,5 @@
import AjvPkg, { type ErrorObject } from "ajv";
import type { SessionsPatchResult } from "../session-utils.types.js";
import {
type AgentEvent,
AgentEventSchema,
@@ -536,6 +537,7 @@ export type {
SessionsPreviewParams,
SessionsResolveParams,
SessionsPatchParams,
SessionsPatchResult,
SessionsResetParams,
SessionsDeleteParams,
SessionsCompactParams,

View File

@@ -44,7 +44,7 @@ describe("gateway broadcaster", () => {
},
]);
const { broadcast } = createGatewayBroadcaster({ clients });
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
broadcast("exec.approval.requested", { id: "1" });
broadcast("device.pair.requested", { requestId: "r1" });
@@ -52,5 +52,10 @@ describe("gateway broadcaster", () => {
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
expect(readSocket.send).toHaveBeenCalledTimes(0);
broadcastToConnIds("tick", { ts: 1 }, new Set(["c-read"]));
expect(readSocket.send).toHaveBeenCalledTimes(1);
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
});
});

View File

@@ -33,15 +33,18 @@ function hasEventScope(client: GatewayWsClient, event: string): boolean {
export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient> }) {
let seq = 0;
const broadcast = (
const broadcastInternal = (
event: string,
payload: unknown,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
targetConnIds?: ReadonlySet<string>,
) => {
const eventSeq = ++seq;
const isTargeted = Boolean(targetConnIds);
const eventSeq = isTargeted ? undefined : ++seq;
const frame = JSON.stringify({
type: "event",
event,
@@ -51,8 +54,9 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
});
const logMeta: Record<string, unknown> = {
event,
seq: eventSeq,
seq: eventSeq ?? "targeted",
clients: params.clients.size,
targets: targetConnIds ? targetConnIds.size : undefined,
dropIfSlow: opts?.dropIfSlow,
presenceVersion: opts?.stateVersion?.presence,
healthVersion: opts?.stateVersion?.health,
@@ -62,6 +66,9 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
}
logWs("out", "event", logMeta);
for (const c of params.clients) {
if (targetConnIds && !targetConnIds.has(c.connId)) {
continue;
}
if (!hasEventScope(c, event)) {
continue;
}
@@ -84,5 +91,30 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
}
}
};
return { broadcast };
const broadcast = (
event: string,
payload: unknown,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => broadcastInternal(event, payload, opts);
const broadcastToConnIds = (
event: string,
payload: unknown,
connIds: ReadonlySet<string>,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => {
if (connIds.size === 0) {
return;
}
broadcastInternal(event, payload, opts, connIds);
};
return { broadcast, broadcastToConnIds };
}

View File

@@ -1,22 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
import {
createAgentEventHandler,
createChatRunState,
createToolEventRecipientRegistry,
} from "./server-chat.js";
describe("agent event handler", () => {
it("emits chat delta for assistant text-only events", () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
const broadcast = vi.fn();
const broadcastToConnIds = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
const toolEventRecipients = createToolEventRecipientRegistry();
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
const handler = createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => undefined,
clearAgentRunContext: vi.fn(),
toolEventRecipients,
});
handler({
@@ -39,4 +48,158 @@ describe("agent event handler", () => {
expect(sessionChatCalls).toHaveLength(1);
nowSpy.mockRestore();
});
it("routes tool events only to registered recipients when verbose is enabled", () => {
const broadcast = vi.fn();
const broadcastToConnIds = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
const toolEventRecipients = createToolEventRecipientRegistry();
registerAgentRunContext("run-tool", { sessionKey: "session-1", verboseLevel: "on" });
toolEventRecipients.add("run-tool", "conn-1");
const handler = createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => "session-1",
clearAgentRunContext: vi.fn(),
toolEventRecipients,
});
handler({
runId: "run-tool",
seq: 1,
stream: "tool",
ts: Date.now(),
data: { phase: "start", name: "read", toolCallId: "t1" },
});
expect(broadcast).not.toHaveBeenCalled();
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
resetAgentRunContextForTest();
});
it("suppresses tool events when verbose is off", () => {
const broadcast = vi.fn();
const broadcastToConnIds = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
const toolEventRecipients = createToolEventRecipientRegistry();
registerAgentRunContext("run-tool-off", { sessionKey: "session-1", verboseLevel: "off" });
toolEventRecipients.add("run-tool-off", "conn-1");
const handler = createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => "session-1",
clearAgentRunContext: vi.fn(),
toolEventRecipients,
});
handler({
runId: "run-tool-off",
seq: 1,
stream: "tool",
ts: Date.now(),
data: { phase: "start", name: "read", toolCallId: "t2" },
});
expect(broadcastToConnIds).not.toHaveBeenCalled();
resetAgentRunContextForTest();
});
it("strips tool output when verbose is on", () => {
const broadcast = vi.fn();
const broadcastToConnIds = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
const toolEventRecipients = createToolEventRecipientRegistry();
registerAgentRunContext("run-tool-on", { sessionKey: "session-1", verboseLevel: "on" });
toolEventRecipients.add("run-tool-on", "conn-1");
const handler = createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => "session-1",
clearAgentRunContext: vi.fn(),
toolEventRecipients,
});
handler({
runId: "run-tool-on",
seq: 1,
stream: "tool",
ts: Date.now(),
data: {
phase: "result",
name: "exec",
toolCallId: "t3",
result: { content: [{ type: "text", text: "secret" }] },
partialResult: { content: [{ type: "text", text: "partial" }] },
},
});
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
const payload = broadcastToConnIds.mock.calls[0]?.[1] as { data?: Record<string, unknown> };
expect(payload.data?.result).toBeUndefined();
expect(payload.data?.partialResult).toBeUndefined();
resetAgentRunContextForTest();
});
it("keeps tool output when verbose is full", () => {
const broadcast = vi.fn();
const broadcastToConnIds = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
const toolEventRecipients = createToolEventRecipientRegistry();
registerAgentRunContext("run-tool-full", { sessionKey: "session-1", verboseLevel: "full" });
toolEventRecipients.add("run-tool-full", "conn-1");
const handler = createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => "session-1",
clearAgentRunContext: vi.fn(),
toolEventRecipients,
});
const result = { content: [{ type: "text", text: "secret" }] };
handler({
runId: "run-tool-full",
seq: 1,
stream: "tool",
ts: Date.now(),
data: {
phase: "result",
name: "exec",
toolCallId: "t4",
result,
},
});
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
const payload = broadcastToConnIds.mock.calls[0]?.[1] as { data?: Record<string, unknown> };
expect(payload.data?.result).toEqual(result);
resetAgentRunContextForTest();
});
});

View File

@@ -120,6 +120,79 @@ export function createChatRunState(): ChatRunState {
};
}
export type ToolEventRecipientRegistry = {
add: (runId: string, connId: string) => void;
get: (runId: string) => ReadonlySet<string> | undefined;
markFinal: (runId: string) => void;
};
type ToolRecipientEntry = {
connIds: Set<string>;
updatedAt: number;
finalizedAt?: number;
};
const TOOL_EVENT_RECIPIENT_TTL_MS = 10 * 60 * 1000;
const TOOL_EVENT_RECIPIENT_FINAL_GRACE_MS = 30 * 1000;
export function createToolEventRecipientRegistry(): ToolEventRecipientRegistry {
const recipients = new Map<string, ToolRecipientEntry>();
const prune = () => {
if (recipients.size === 0) {
return;
}
const now = Date.now();
for (const [runId, entry] of recipients) {
const cutoff = entry.finalizedAt
? entry.finalizedAt + TOOL_EVENT_RECIPIENT_FINAL_GRACE_MS
: entry.updatedAt + TOOL_EVENT_RECIPIENT_TTL_MS;
if (now >= cutoff) {
recipients.delete(runId);
}
}
};
const add = (runId: string, connId: string) => {
if (!runId || !connId) {
return;
}
const now = Date.now();
const existing = recipients.get(runId);
if (existing) {
existing.connIds.add(connId);
existing.updatedAt = now;
} else {
recipients.set(runId, {
connIds: new Set([connId]),
updatedAt: now,
});
}
prune();
};
const get = (runId: string) => {
const entry = recipients.get(runId);
if (!entry) {
return undefined;
}
entry.updatedAt = Date.now();
prune();
return entry.connIds;
};
const markFinal = (runId: string) => {
const entry = recipients.get(runId);
if (!entry) {
return;
}
entry.finalizedAt = Date.now();
prune();
};
return { add, get, markFinal };
}
export type ChatEventBroadcast = (
event: string,
payload: unknown,
@@ -130,20 +203,29 @@ export type NodeSendToSession = (sessionKey: string, event: string, payload: unk
export type AgentEventHandlerOptions = {
broadcast: ChatEventBroadcast;
broadcastToConnIds: (
event: string,
payload: unknown,
connIds: ReadonlySet<string>,
opts?: { dropIfSlow?: boolean },
) => void;
nodeSendToSession: NodeSendToSession;
agentRunSeq: Map<string, number>;
chatRunState: ChatRunState;
resolveSessionKeyForRun: (runId: string) => string | undefined;
clearAgentRunContext: (runId: string) => void;
toolEventRecipients: ToolEventRecipientRegistry;
};
export function createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun,
clearAgentRunContext,
toolEventRecipients,
}: AgentEventHandlerOptions) {
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
chatRunState.buffers.set(clientRunId, text);
@@ -213,25 +295,25 @@ export function createAgentEventHandler({
nodeSendToSession(sessionKey, "chat", payload);
};
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
const resolveToolVerboseLevel = (runId: string, sessionKey?: string) => {
const runContext = getAgentRunContext(runId);
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
if (runVerbose) {
return runVerbose === "on";
return runVerbose;
}
if (!sessionKey) {
return false;
return "off";
}
try {
const { cfg, entry } = loadSessionEntry(sessionKey);
const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel);
if (sessionVerbose) {
return sessionVerbose === "on";
return sessionVerbose;
}
const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault);
return defaultVerbose === "on";
return defaultVerbose ?? "off";
} catch {
return false;
return "off";
}
};
@@ -244,10 +326,21 @@ export function createAgentEventHandler({
// Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const last = agentRunSeq.get(evt.runId) ?? 0;
if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) {
const isToolEvent = evt.stream === "tool";
const toolVerbose = isToolEvent ? resolveToolVerboseLevel(evt.runId, sessionKey) : "off";
if (isToolEvent && toolVerbose === "off") {
agentRunSeq.set(evt.runId, evt.seq);
return;
}
const toolPayload =
isToolEvent && toolVerbose !== "full"
? (() => {
const data = evt.data ? { ...evt.data } : {};
delete data.result;
delete data.partialResult;
return sessionKey ? { ...evt, sessionKey, data } : { ...evt, data };
})()
: agentPayload;
if (evt.seq !== last + 1) {
broadcast("agent", {
runId: evt.runId,
@@ -262,13 +355,20 @@ export function createAgentEventHandler({
});
}
agentRunSeq.set(evt.runId, evt.seq);
broadcast("agent", agentPayload);
if (isToolEvent) {
const recipients = toolEventRecipients.get(evt.runId);
if (recipients && recipients.size > 0) {
broadcastToConnIds("agent", toolPayload, recipients);
}
} else {
broadcast("agent", agentPayload);
}
const lifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (sessionKey) {
nodeSendToSession(sessionKey, "agent", agentPayload);
nodeSendToSession(sessionKey, "agent", isToolEvent ? toolPayload : agentPayload);
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
@@ -306,6 +406,7 @@ export function createAgentEventHandler({
}
if (lifecyclePhase === "end" || lifecyclePhase === "error") {
toolEventRecipients.markFinal(evt.runId);
clearAgentRunContext(evt.runId);
}
};

View File

@@ -28,6 +28,7 @@ import {
import { resolveAssistantIdentity } from "../assistant-identity.js";
import { parseMessageWithAttachments } from "../chat-attachments.js";
import { resolveAssistantAvatarUrl } from "../control-ui-shared.js";
import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js";
import {
ErrorCodes,
errorShape,
@@ -42,7 +43,7 @@ import { waitForAgentJob } from "./agent-job.js";
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
export const agentHandlers: GatewayRequestHandlers = {
agent: async ({ params, respond, context }) => {
agent: async ({ params, respond, context, client }) => {
const p = params;
if (!validateAgentParams(p)) {
respond(
@@ -296,6 +297,14 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const runId = idem;
const connId = typeof client?.connId === "string" ? client.connId : undefined;
const wantsToolEvents = hasGatewayClientCap(
client?.connect?.caps,
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
);
if (connId && wantsToolEvents) {
context.registerToolEventRecipient(runId, connId);
}
const wantsDelivery = request.deliver === true;
const explicitTo =

View File

@@ -20,6 +20,7 @@ import {
} from "../chat-abort.js";
import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js";
import { stripEnvelopeFromMessages } from "../chat-sanitize.js";
import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js";
import {
ErrorCodes,
errorShape,
@@ -216,7 +217,8 @@ export const chatHandlers: GatewayRequestHandlers = {
if (configured) {
thinkingLevel = configured;
} else {
const { provider, model } = resolveSessionModelRef(cfg, entry);
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
const { provider, model } = resolveSessionModelRef(cfg, entry, sessionAgentId);
const catalog = await context.loadGatewayModelCatalog();
thinkingLevel = resolveThinkingDefault({
cfg,
@@ -226,11 +228,13 @@ export const chatHandlers: GatewayRequestHandlers = {
});
}
}
const verboseLevel = entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault;
respond(true, {
sessionKey,
sessionId,
messages: capped,
thinkingLevel,
verboseLevel,
});
},
"chat.abort": ({ params, respond, context }) => {
@@ -432,7 +436,6 @@ export const chatHandlers: GatewayRequestHandlers = {
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
});
const ackPayload = {
runId: clientRunId,
status: "started" as const,
@@ -506,8 +509,16 @@ export const chatHandlers: GatewayRequestHandlers = {
abortSignal: abortController.signal,
images: parsedImages.length > 0 ? parsedImages : undefined,
disableBlockStreaming: true,
onAgentRunStart: () => {
onAgentRunStart: (runId) => {
agentRunStarted = true;
const connId = typeof client?.connId === "string" ? client.connId : undefined;
const wantsToolEvents = hasGatewayClientCap(
client?.connect?.caps,
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
);
if (connId && wantsToolEvents) {
context.registerToolEventRecipient(runId, connId);
}
},
onModelSelected,
},

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import type { GatewayRequestHandlers } from "./types.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../../agents/pi-embedded.js";
import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
@@ -12,6 +13,7 @@ import {
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import {
ErrorCodes,
errorShape,
@@ -31,6 +33,7 @@ import {
loadSessionEntry,
readSessionPreviewItemsFromTranscript,
resolveGatewaySessionStoreTarget,
resolveSessionModelRef,
resolveSessionTranscriptCandidates,
type SessionsPatchResult,
type SessionsPreviewEntry,
@@ -194,11 +197,18 @@ export const sessionsHandlers: GatewayRequestHandlers = {
respond(false, undefined, applied.error);
return;
}
const parsed = parseAgentSessionKey(target.canonicalKey ?? key);
const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
const resolved = resolveSessionModelRef(cfg, applied.entry, agentId);
const result: SessionsPatchResult = {
ok: true,
path: storePath,
key: target.canonicalKey,
entry: applied.entry,
resolved: {
modelProvider: resolved.provider,
model: resolved.model,
},
};
respond(true, result, undefined);
},

View File

@@ -14,6 +14,7 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
export type GatewayClient = {
connect: ConnectParams;
connId?: string;
};
export type RespondFn = (
@@ -42,6 +43,15 @@ export type GatewayRequestContext = {
stateVersion?: { presence?: number; health?: number };
},
) => void;
broadcastToConnIds: (
event: string,
payload: unknown,
connIds: ReadonlySet<string>,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
@@ -60,6 +70,7 @@ export type GatewayRequestContext = {
clientRunId: string,
sessionKey?: string,
) => { sessionKey: string; clientRunId: string } | undefined;
registerToolEventRecipient: (runId: string, connId: string) => void;
dedupe: Map<string, DedupeEntry>;
wizardSessions: Map<string, WizardSession>;
findRunningWizard: () => string | null;

View File

@@ -15,7 +15,11 @@ import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
import { type CanvasHostHandler, createCanvasHostHandler } from "../canvas-host/server.js";
import { resolveGatewayListenHosts } from "./net.js";
import { createGatewayBroadcaster } from "./server-broadcast.js";
import { type ChatRunEntry, createChatRunState } from "./server-chat.js";
import {
type ChatRunEntry,
createChatRunState,
createToolEventRecipientRegistry,
} from "./server-chat.js";
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
@@ -59,6 +63,15 @@ export async function createGatewayRuntimeState(params: {
stateVersion?: { presence?: number; health?: number };
},
) => void;
broadcastToConnIds: (
event: string,
payload: unknown,
connIds: ReadonlySet<string>,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
agentRunSeq: Map<string, number>;
dedupe: Map<string, DedupeEntry>;
chatRunState: ReturnType<typeof createChatRunState>;
@@ -71,6 +84,7 @@ export async function createGatewayRuntimeState(params: {
sessionKey?: string,
) => ChatRunEntry | undefined;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>;
}> {
let canvasHost: CanvasHostHandler | null = null;
if (params.canvasHostEnabled) {
@@ -154,7 +168,7 @@ export async function createGatewayRuntimeState(params: {
}
const clients = new Set<GatewayWsClient>();
const { broadcast } = createGatewayBroadcaster({ clients });
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
const agentRunSeq = new Map<string, number>();
const dedupe = new Map<string, DedupeEntry>();
const chatRunState = createChatRunState();
@@ -164,6 +178,7 @@ export async function createGatewayRuntimeState(params: {
const addChatRun = chatRunRegistry.add;
const removeChatRun = chatRunRegistry.remove;
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
const toolEventRecipients = createToolEventRecipientRegistry();
return {
canvasHost,
@@ -173,6 +188,7 @@ export async function createGatewayRuntimeState(params: {
wss,
clients,
broadcast,
broadcastToConnIds,
agentRunSeq,
dedupe,
chatRunState,
@@ -181,5 +197,6 @@ export async function createGatewayRuntimeState(params: {
addChatRun,
removeChatRun,
chatAbortControllers,
toolEventRecipients,
};
}

View File

@@ -380,8 +380,8 @@ describe("gateway server chat", () => {
emitAgentEvent({
runId: "run-tool-1",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
stream: "assistant",
data: { text: "hello" },
});
const evt = await agentEvtP;
@@ -390,31 +390,6 @@ describe("gateway server chat", () => {
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
}
{
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
emitAgentEvent({
runId: "run-tool-off",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
emitAgentEvent({
runId: "run-tool-off",
stream: "assistant",
data: { text: "hello" },
});
const evt = await onceMessage(
webchatWs,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off",
8000,
);
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.stream).toBe("assistant");
}

View File

@@ -318,6 +318,7 @@ export async function startGatewayServer(
wss,
clients,
broadcast,
broadcastToConnIds,
agentRunSeq,
dedupe,
chatRunState,
@@ -326,6 +327,7 @@ export async function startGatewayServer(
addChatRun,
removeChatRun,
chatAbortControllers,
toolEventRecipients,
} = await createGatewayRuntimeState({
cfg: cfgAtStart,
bindHost,
@@ -441,11 +443,13 @@ export async function startGatewayServer(
const agentUnsub = onAgentEvent(
createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun,
clearAgentRunContext,
toolEventRecipients,
}),
);
@@ -495,6 +499,7 @@ export async function startGatewayServer(
incrementPresenceVersion,
getHealthVersion,
broadcast,
broadcastToConnIds,
nodeSendToSession,
nodeSendToAllSubscribed,
nodeSubscribe,
@@ -509,6 +514,7 @@ export async function startGatewayServer(
chatDeltaSentAt: chatRunState.deltaSentAt,
addChatRun,
removeChatRun,
registerToolEventRecipient: toolEventRecipients.add,
dedupe,
wizardSessions,
findRunningWizard,

View File

@@ -9,7 +9,10 @@ import type {
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
resolveConfiguredModelRef,
resolveDefaultModelForAgent,
} from "../agents/model-selection.js";
import { type OpenClawConfig, loadConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
@@ -522,12 +525,15 @@ export function getSessionDefaults(cfg: OpenClawConfig): GatewaySessionsDefaults
export function resolveSessionModelRef(
cfg: OpenClawConfig,
entry?: SessionEntry,
agentId?: string,
): { provider: string; model: string } {
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const resolved = agentId
? resolveDefaultModelForAgent({ cfg, agentId })
: resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
let provider = resolved.provider;
let model = resolved.model;
const storedModelOverride = entry?.modelOverride?.trim();
@@ -623,6 +629,11 @@ export function listSessionsFromStore(params: {
entry?.label ??
originLabel;
const deliveryFields = normalizeSessionDeliveryFields(entry);
const parsedAgent = parseAgentSessionKey(key);
const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg));
const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
const modelProvider = resolvedModel.provider ?? DEFAULT_PROVIDER;
const model = resolvedModel.model ?? DEFAULT_MODEL;
return {
key,
entry,
@@ -648,8 +659,8 @@ export function listSessionsFromStore(params: {
outputTokens: entry?.outputTokens,
totalTokens: total,
responseUsage: entry?.responseUsage,
modelProvider: entry?.modelProvider,
model: entry?.model,
modelProvider,
model,
contextTokens: entry?.contextTokens,
deliveryContext: deliveryFields.deliveryContext,
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,

View File

@@ -84,4 +84,8 @@ export type SessionsPatchResult = {
path: string;
key: string;
entry: SessionEntry;
resolved?: {
modelProvider?: string;
model?: string;
};
};

View File

@@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveAllowedModelRef, resolveConfiguredModelRef } from "../agents/model-selection.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveAllowedModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
formatThinkingLevels,
@@ -14,7 +14,11 @@ import {
normalizeUsageDisplay,
supportsXHighThinking,
} from "../auto-reply/thinking.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { applyVerboseOverride, parseVerboseOverride } from "../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { normalizeSendPolicy } from "../sessions/send-policy.js";
@@ -63,6 +67,9 @@ export async function applySessionsPatchToStore(params: {
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
const { cfg, store, storeKey, patch } = params;
const now = Date.now();
const parsedAgent = parseAgentSessionKey(storeKey);
const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg));
const resolvedDefault = resolveDefaultModelForAgent({ cfg, agentId: sessionAgentId });
const existing = store[storeKey];
const next: SessionEntry = existing
@@ -121,11 +128,6 @@ export async function applySessionsPatchToStore(params: {
} else if (raw !== undefined) {
const normalized = normalizeThinkLevel(String(raw));
if (!normalized) {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const hintProvider = existing?.providerOverride?.trim() || resolvedDefault.provider;
const hintModel = existing?.modelOverride?.trim() || resolvedDefault.model;
return invalid(
@@ -251,11 +253,6 @@ export async function applySessionsPatchToStore(params: {
if ("model" in patch) {
const raw = patch.model;
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
if (raw === null) {
applyModelOverrideToSessionEntry({
entry: next,
@@ -302,11 +299,6 @@ export async function applySessionsPatchToStore(params: {
}
if (next.thinkingLevel === "xhigh") {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {