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
@@ -163,6 +163,7 @@ export function handleMessageUpdate(
mediaUrls: hasMedia ? mediaUrls : undefined,
},
});
ctx.state.emittedAssistantUpdate = true;
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
void ctx.params.onPartialReply({
text: cleanedText,
@@ -215,6 +216,44 @@ export function handleMessageEnd(
? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText)
: "";
const formattedReasoning = rawThinking ? formatReasoningMessage(rawThinking) : "";
const trimmedText = text.trim();
const parsedText = trimmedText ? parseReplyDirectives(stripTrailingDirective(trimmedText)) : null;
let cleanedText = parsedText?.text ?? "";
let mediaUrls = parsedText?.mediaUrls;
let hasMedia = Boolean(mediaUrls && mediaUrls.length > 0);
if (!cleanedText && !hasMedia) {
const rawTrimmed = rawText.trim();
const rawStrippedFinal = rawTrimmed.replace(/<\s*\/?\s*final\s*>/gi, "").trim();
const rawCandidate = rawStrippedFinal || rawTrimmed;
if (rawCandidate) {
const parsedFallback = parseReplyDirectives(stripTrailingDirective(rawCandidate));
cleanedText = parsedFallback.text ?? rawCandidate;
mediaUrls = parsedFallback.mediaUrls;
hasMedia = Boolean(mediaUrls && mediaUrls.length > 0);
}
}
if (!ctx.state.emittedAssistantUpdate && (cleanedText || hasMedia)) {
emitAgentEvent({
runId: ctx.params.runId,
stream: "assistant",
data: {
text: cleanedText,
delta: cleanedText,
mediaUrls: hasMedia ? mediaUrls : undefined,
},
});
void ctx.params.onAgentEvent?.({
stream: "assistant",
data: {
text: cleanedText,
delta: cleanedText,
mediaUrls: hasMedia ? mediaUrls : undefined,
},
});
ctx.state.emittedAssistantUpdate = true;
}
const addedDuringMessage = ctx.state.assistantTexts.length > ctx.state.assistantTextBaseline;
const chunkerHasBuffered = ctx.blockChunker?.hasBuffered() ?? false;
@@ -39,6 +39,7 @@ export type EmbeddedPiSubscribeState = {
partialBlockState: { thinking: boolean; final: boolean; inlineCode: InlineCodeState };
lastStreamedAssistant?: string;
lastStreamedAssistantCleaned?: string;
emittedAssistantUpdate: boolean;
lastStreamedReasoning?: string;
lastBlockReplyText?: string;
assistantMessageIndex: number;
@@ -62,6 +62,39 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onPartialReply).not.toHaveBeenCalled();
});
it("emits agent events on message_end even without <final> tags", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
enforceFinalTag: true,
onAgentEvent,
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
} as AssistantMessage;
handler?.({ type: "message_start", message: assistantMessage });
handler?.({ type: "message_end", message: assistantMessage });
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("Hello world");
expect(payloads[0]?.delta).toBe("Hello world");
});
it("does not require <final> when enforcement is off", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
@@ -185,6 +185,71 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payloads[1]?.delta).toBe(" world");
});
it("emits agent events on message_end for non-streaming assistant text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
} as AssistantMessage;
handler?.({ type: "message_start", message: assistantMessage });
handler?.({ type: "message_end", message: assistantMessage });
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("Hello world");
expect(payloads[0]?.delta).toBe("Hello world");
});
it("does not emit duplicate agent events when message_end repeats", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
} as AssistantMessage;
handler?.({ type: "message_start", message: assistantMessage });
handler?.({ type: "message_end", message: assistantMessage });
handler?.({ type: "message_end", message: assistantMessage });
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads).toHaveLength(1);
});
it("skips agent events when cleaned text rewinds mid-stream", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
+2
View File
@@ -49,6 +49,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
partialBlockState: { thinking: false, final: false, inlineCode: createInlineCodeState() },
lastStreamedAssistant: undefined,
lastStreamedAssistantCleaned: undefined,
emittedAssistantUpdate: false,
lastStreamedReasoning: undefined,
lastBlockReplyText: undefined,
assistantMessageIndex: 0,
@@ -95,6 +96,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
state.partialBlockState.inlineCode = createInlineCodeState();
state.lastStreamedAssistant = undefined;
state.lastStreamedAssistantCleaned = undefined;
state.emittedAssistantUpdate = false;
state.lastBlockReplyText = undefined;
state.lastStreamedReasoning = undefined;
state.lastReasoningSent = undefined;
+16
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);
}
+2
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,
+6 -1
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);
});
});
+36 -4
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 };
}
+164 -1
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();
});
});
+110 -9
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);
}
};
+10 -1
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 =
+14 -3
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,
},
+10
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);
},
+11
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;
+19 -2
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,
};
}
@@ -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");
}
+6
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,
+19 -8
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,
+4
View File
@@ -84,4 +84,8 @@ export type SessionsPatchResult = {
path: string;
key: string;
entry: SessionEntry;
resolved?: {
modelProvider?: string;
model?: string;
};
};
+10 -18
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)) {
+7 -3
View File
@@ -1,10 +1,12 @@
import { randomUUID } from "node:crypto";
import { loadConfig, resolveGatewayPort } from "../config/config.js";
import { GatewayClient } from "../gateway/client.js";
import { GATEWAY_CLIENT_CAPS } from "../gateway/protocol/client-info.js";
import {
type HelloOk,
PROTOCOL_VERSION,
type SessionsListParams,
type SessionsPatchResult,
type SessionsPatchParams,
} from "../gateway/protocol/index.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -22,6 +24,7 @@ export type ChatSendOptions = {
thinking?: string;
deliver?: boolean;
timeoutMs?: number;
runId?: string;
};
export type GatewayEvent = {
@@ -116,6 +119,7 @@ export class GatewayChatClient {
clientVersion: VERSION,
platform: process.platform,
mode: GATEWAY_CLIENT_MODES.UI,
caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS],
instanceId: randomUUID(),
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
@@ -153,7 +157,7 @@ export class GatewayChatClient {
}
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> {
const runId = randomUUID();
const runId = opts.runId ?? randomUUID();
await this.client.request("chat.send", {
sessionKey: opts.sessionKey,
message: opts.message,
@@ -195,8 +199,8 @@ export class GatewayChatClient {
return await this.client.request<GatewayAgentsList>("agents.list", {});
}
async patchSession(opts: SessionsPatchParams) {
return await this.client.request("sessions.patch", opts);
async patchSession(opts: SessionsPatchParams): Promise<SessionsPatchResult> {
return await this.client.request<SessionsPatchResult>("sessions.patch", opts);
}
async resetSession(key: string) {
+2
View File
@@ -29,6 +29,8 @@ describe("tui command handlers", () => {
abortActive: vi.fn(),
setActivityStatus,
formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
});
await handleCommand("/context");
+34 -11
View File
@@ -1,4 +1,6 @@
import type { Component, TUI } from "@mariozechner/pi-tui";
import { randomUUID } from "node:crypto";
import type { SessionsPatchResult } from "../gateway/protocol/index.js";
import type { ChatLog } from "./components/chat-log.js";
import type { GatewayChatClient } from "./gateway-chat.js";
import type {
@@ -38,6 +40,9 @@ type CommandHandlerContext = {
abortActive: () => Promise<void>;
setActivityStatus: (text: string) => void;
formatSessionKey: (key: string) => string;
applySessionInfoFromPatch: (result: SessionsPatchResult) => void;
noteLocalRunId: (runId: string) => void;
forgetLocalRunId?: (runId: string) => void;
};
export function createCommandHandlers(context: CommandHandlerContext) {
@@ -57,6 +62,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
abortActive,
setActivityStatus,
formatSessionKey,
applySessionInfoFromPatch,
noteLocalRunId,
forgetLocalRunId,
} = context;
const setAgent = async (id: string) => {
@@ -81,11 +89,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
selector.onSelect = (item) => {
void (async () => {
try {
await client.patchSession({
const result = await client.patchSession({
key: state.currentSessionKey,
model: item.value,
});
chatLog.addSystem(`model set to ${item.value}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`);
@@ -284,11 +293,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
await openModelSelector();
} else {
try {
await client.patchSession({
const result = await client.patchSession({
key: state.currentSessionKey,
model: args,
});
chatLog.addSystem(`model set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`);
@@ -309,11 +319,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
}
try {
await client.patchSession({
const result = await client.patchSession({
key: state.currentSessionKey,
thinkingLevel: args,
});
chatLog.addSystem(`thinking set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`think failed: ${String(err)}`);
@@ -325,12 +336,13 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
}
try {
await client.patchSession({
const result = await client.patchSession({
key: state.currentSessionKey,
verboseLevel: args,
});
chatLog.addSystem(`verbose set to ${args}`);
await refreshSessionInfo();
applySessionInfoFromPatch(result);
await loadHistory();
} catch (err) {
chatLog.addSystem(`verbose failed: ${String(err)}`);
}
@@ -341,11 +353,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
}
try {
await client.patchSession({
const result = await client.patchSession({
key: state.currentSessionKey,
reasoningLevel: args,
});
chatLog.addSystem(`reasoning set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`reasoning failed: ${String(err)}`);
@@ -362,11 +375,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
try {
await client.patchSession({
const result = await client.patchSession({
key: state.currentSessionKey,
responseUsage: next === "off" ? null : next,
});
chatLog.addSystem(`usage footer: ${next}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`usage failed: ${String(err)}`);
@@ -383,11 +397,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
}
try {
await client.patchSession({
const result = await client.patchSession({
key: state.currentSessionKey,
elevatedLevel: args,
});
chatLog.addSystem(`elevated set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`elevated failed: ${String(err)}`);
@@ -399,11 +414,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
}
try {
await client.patchSession({
const result = await client.patchSession({
key: state.currentSessionKey,
groupActivation: args === "always" ? "always" : "mention",
});
chatLog.addSystem(`activation set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`activation failed: ${String(err)}`);
@@ -448,17 +464,24 @@ export function createCommandHandlers(context: CommandHandlerContext) {
try {
chatLog.addUser(text);
tui.requestRender();
const runId = randomUUID();
noteLocalRunId(runId);
state.activeChatRunId = runId;
setActivityStatus("sending");
const { runId } = await client.sendChat({
await client.sendChat({
sessionKey: state.currentSessionKey,
message: text,
thinking: opts.thinking,
deliver: deliverDefault,
timeoutMs: opts.timeoutMs,
runId,
});
state.activeChatRunId = runId;
setActivityStatus("waiting");
} catch (err) {
if (state.activeChatRunId) {
forgetLocalRunId?.(state.activeChatRunId);
}
state.activeChatRunId = null;
chatLog.addSystem(`send failed: ${String(err)}`);
setActivityStatus("error");
}
+168 -35
View File
@@ -1,14 +1,14 @@
import type { TUI } from "@mariozechner/pi-tui";
import { describe, expect, it, vi } from "vitest";
import type { ChatLog } from "./components/chat-log.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
import { createEventHandlers } from "./tui-event-handlers.js";
type MockChatLog = {
startTool: ReturnType<typeof vi.fn>;
updateToolResult: ReturnType<typeof vi.fn>;
addSystem: ReturnType<typeof vi.fn>;
updateAssistant: ReturnType<typeof vi.fn>;
finalizeAssistant: ReturnType<typeof vi.fn>;
};
type MockChatLog = Pick<
ChatLog,
"startTool" | "updateToolResult" | "addSystem" | "updateAssistant" | "finalizeAssistant"
>;
type MockTui = Pick<TUI, "requestRender">;
describe("tui-event-handlers: handleAgentEvent", () => {
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
@@ -21,7 +21,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
currentSessionId: "session-1",
activeChatRunId: "run-1",
historyLoaded: true,
sessionInfo: {},
sessionInfo: { verboseLevel: "on" },
initialSessionApplied: true,
isConnected: true,
autoMessageSent: false,
@@ -42,21 +42,40 @@ describe("tui-event-handlers: handleAgentEvent", () => {
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
};
const tui = { requestRender: vi.fn() };
const tui: MockTui = { requestRender: vi.fn() };
const setActivityStatus = vi.fn();
const loadHistory = vi.fn();
const localRunIds = new Set<string>();
const noteLocalRunId = (runId: string) => {
localRunIds.add(runId);
};
const forgetLocalRunId = (runId: string) => {
localRunIds.delete(runId);
};
const isLocalRunId = (runId: string) => localRunIds.has(runId);
const clearLocalRunIds = () => {
localRunIds.clear();
};
return { chatLog, tui, state, setActivityStatus };
return {
chatLog,
tui,
state,
setActivityStatus,
loadHistory,
noteLocalRunId,
forgetLocalRunId,
isLocalRunId,
clearLocalRunIds,
};
};
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// Casts are fine here: TUI runtime shape is larger than we need in unit tests.
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -82,10 +101,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: "run-1" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -107,10 +124,14 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: "run-9" });
const { tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: { startTool: vi.fn(), updateToolResult: vi.fn() } as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog: {
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
},
tui,
state,
setActivityStatus,
});
@@ -131,10 +152,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -165,10 +184,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -194,14 +211,39 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("accepts tool events after chat final for the same run", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
});
handleChatEvent({
runId: "run-final",
sessionKey: state.currentSessionKey,
state: "final",
message: { content: [{ type: "text", text: "done" }] },
});
handleAgentEvent({
runId: "run-final",
stream: "tool",
data: { phase: "start", toolCallId: "tc-final", name: "session_status" },
});
expect(chatLog.startTool).toHaveBeenCalledWith("tc-final", "session_status", undefined);
expect(tui.requestRender).toHaveBeenCalled();
});
it("ignores lifecycle updates for non-active runs in the same session", () => {
const state = makeState({ activeChatRunId: "run-active" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -224,4 +266,95 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(setActivityStatus).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("suppresses tool events when verbose is off", () => {
const state = makeState({
activeChatRunId: "run-123",
sessionInfo: { verboseLevel: "off" },
});
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
});
handleAgentEvent({
runId: "run-123",
stream: "tool",
data: { phase: "start", toolCallId: "tc-off", name: "session_status" },
});
expect(chatLog.startTool).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("omits tool output when verbose is on (non-full)", () => {
const state = makeState({
activeChatRunId: "run-123",
sessionInfo: { verboseLevel: "on" },
});
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
});
handleAgentEvent({
runId: "run-123",
stream: "tool",
data: {
phase: "update",
toolCallId: "tc-on",
name: "session_status",
partialResult: { content: [{ type: "text", text: "secret" }] },
},
});
handleAgentEvent({
runId: "run-123",
stream: "tool",
data: {
phase: "result",
toolCallId: "tc-on",
name: "session_status",
result: { content: [{ type: "text", text: "secret" }] },
isError: false,
},
});
expect(chatLog.updateToolResult).toHaveBeenCalledTimes(1);
expect(chatLog.updateToolResult).toHaveBeenCalledWith(
"tc-on",
{ content: [] },
{ isError: false },
);
});
it("refreshes history after a non-local chat final", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus, loadHistory, isLocalRunId, forgetLocalRunId } =
makeContext(state);
const { handleChatEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
loadHistory,
isLocalRunId,
forgetLocalRunId,
});
handleChatEvent({
runId: "external-run",
sessionKey: state.currentSessionKey,
state: "final",
message: { content: [{ type: "text", text: "done" }] },
});
expect(loadHistory).toHaveBeenCalledTimes(1);
});
});
+56 -6
View File
@@ -10,10 +10,24 @@ type EventHandlerContext = {
state: TuiStateAccess;
setActivityStatus: (text: string) => void;
refreshSessionInfo?: () => Promise<void>;
loadHistory?: () => Promise<void>;
isLocalRunId?: (runId: string) => boolean;
forgetLocalRunId?: (runId: string) => void;
clearLocalRunIds?: () => void;
};
export function createEventHandlers(context: EventHandlerContext) {
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context;
const {
chatLog,
tui,
state,
setActivityStatus,
refreshSessionInfo,
loadHistory,
isLocalRunId,
forgetLocalRunId,
clearLocalRunIds,
} = context;
const finalizedRuns = new Map<string, number>();
const sessionRuns = new Map<string, number>();
let streamAssembler = new TuiStreamAssembler();
@@ -50,6 +64,7 @@ export function createEventHandlers(context: EventHandlerContext) {
finalizedRuns.clear();
sessionRuns.clear();
streamAssembler = new TuiStreamAssembler();
clearLocalRunIds?.();
};
const noteSessionRun = (runId: string) => {
@@ -95,6 +110,11 @@ export function createEventHandlers(context: EventHandlerContext) {
}
if (evt.state === "final") {
if (isCommandMessage(evt.message)) {
if (isLocalRunId?.(evt.runId)) {
forgetLocalRunId?.(evt.runId);
} else {
void loadHistory?.();
}
const text = extractTextFromMessage(evt.message);
if (text) {
chatLog.addSystem(text);
@@ -107,6 +127,11 @@ export function createEventHandlers(context: EventHandlerContext) {
tui.requestRender();
return;
}
if (isLocalRunId?.(evt.runId)) {
forgetLocalRunId?.(evt.runId);
} else {
void loadHistory?.();
}
const stopReason =
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
? typeof (evt.message as Record<string, unknown>).stopReason === "string"
@@ -129,6 +154,11 @@ export function createEventHandlers(context: EventHandlerContext) {
state.activeChatRunId = null;
setActivityStatus("aborted");
void refreshSessionInfo?.();
if (isLocalRunId?.(evt.runId)) {
forgetLocalRunId?.(evt.runId);
} else {
void loadHistory?.();
}
}
if (evt.state === "error") {
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
@@ -137,6 +167,11 @@ export function createEventHandlers(context: EventHandlerContext) {
state.activeChatRunId = null;
setActivityStatus("error");
void refreshSessionInfo?.();
if (isLocalRunId?.(evt.runId)) {
forgetLocalRunId?.(evt.runId);
} else {
void loadHistory?.();
}
}
tui.requestRender();
};
@@ -148,12 +183,20 @@ export function createEventHandlers(context: EventHandlerContext) {
const evt = payload as AgentEvent;
syncSessionKey();
// Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the
// active chat run id, not the session id.
// active chat run id, not the session id. Tool results can arrive after the chat
// final event, so accept finalized runs for tool updates.
const isActiveRun = evt.runId === state.activeChatRunId;
if (!isActiveRun && !sessionRuns.has(evt.runId)) {
const isKnownRun = isActiveRun || sessionRuns.has(evt.runId) || finalizedRuns.has(evt.runId);
if (!isKnownRun) {
return;
}
if (evt.stream === "tool") {
const verbose = state.sessionInfo.verboseLevel ?? "off";
const allowToolEvents = verbose !== "off";
const allowToolOutput = verbose === "full";
if (!allowToolEvents) {
return;
}
const data = evt.data ?? {};
const phase = asString(data.phase, "");
const toolCallId = asString(data.toolCallId, "");
@@ -164,13 +207,20 @@ export function createEventHandlers(context: EventHandlerContext) {
if (phase === "start") {
chatLog.startTool(toolCallId, toolName, data.args);
} else if (phase === "update") {
if (!allowToolOutput) {
return;
}
chatLog.updateToolResult(toolCallId, data.partialResult, {
partial: true,
});
} else if (phase === "result") {
chatLog.updateToolResult(toolCallId, data.result, {
isError: Boolean(data.isError),
});
if (allowToolOutput) {
chatLog.updateToolResult(toolCallId, data.result, {
isError: Boolean(data.isError),
});
} else {
chatLog.updateToolResult(toolCallId, { content: [] }, { isError: Boolean(data.isError) });
}
}
tui.requestRender();
return;
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import type { TuiStateAccess } from "./tui-types.js";
import { createSessionActions } from "./tui-session-actions.js";
describe("tui session actions", () => {
it("queues session refreshes and applies the latest result", async () => {
let resolveFirst: ((value: unknown) => void) | undefined;
let resolveSecond: ((value: unknown) => void) | undefined;
const listSessions = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve;
}),
);
const state: TuiStateAccess = {
agentDefaultId: "main",
sessionMainKey: "agent:main:main",
sessionScope: "global",
agents: [],
currentAgentId: "main",
currentSessionKey: "agent:main:main",
currentSessionId: null,
activeChatRunId: null,
historyLoaded: false,
sessionInfo: {},
initialSessionApplied: true,
isConnected: true,
autoMessageSent: false,
toolsExpanded: false,
showThinking: false,
connectionStatus: "connected",
activityStatus: "idle",
statusTimeout: null,
lastCtrlCAt: 0,
};
const updateFooter = vi.fn();
const updateAutocompleteProvider = vi.fn();
const requestRender = vi.fn();
const { refreshSessionInfo } = createSessionActions({
client: { listSessions } as { listSessions: typeof listSessions },
chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog,
tui: { requestRender } as unknown as import("@mariozechner/pi-tui").TUI,
opts: {},
state,
agentNames: new Map(),
initialSessionInput: "",
initialSessionAgentId: null,
resolveSessionKey: vi.fn(),
updateHeader: vi.fn(),
updateFooter,
updateAutocompleteProvider,
setActivityStatus: vi.fn(),
});
const first = refreshSessionInfo();
const second = refreshSessionInfo();
await Promise.resolve();
expect(listSessions).toHaveBeenCalledTimes(1);
resolveFirst?.({
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {},
sessions: [
{
key: "agent:main:main",
model: "old",
modelProvider: "anthropic",
},
],
});
await first;
await Promise.resolve();
expect(listSessions).toHaveBeenCalledTimes(2);
resolveSecond?.({
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {},
sessions: [
{
key: "agent:main:main",
model: "Minimax-M2.1",
modelProvider: "minimax",
},
],
});
await second;
expect(state.sessionInfo.model).toBe("Minimax-M2.1");
expect(updateAutocompleteProvider).toHaveBeenCalledTimes(2);
expect(updateFooter).toHaveBeenCalledTimes(2);
expect(requestRender).toHaveBeenCalledTimes(2);
});
});
+198 -48
View File
@@ -1,4 +1,5 @@
import type { TUI } from "@mariozechner/pi-tui";
import type { SessionsPatchResult } from "../gateway/protocol/index.js";
import type { ChatLog } from "./components/chat-log.js";
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
@@ -23,6 +24,30 @@ type SessionActionContext = {
updateFooter: () => void;
updateAutocompleteProvider: () => void;
setActivityStatus: (text: string) => void;
clearLocalRunIds?: () => void;
};
type SessionInfoDefaults = {
model?: string | null;
modelProvider?: string | null;
contextTokens?: number | null;
};
type SessionInfoEntry = {
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
model?: string;
modelProvider?: string;
modelOverride?: string;
providerOverride?: string;
contextTokens?: number | null;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
responseUsage?: "on" | "off" | "tokens" | "full";
updatedAt?: number | null;
displayName?: string;
};
export function createSessionActions(context: SessionActionContext) {
@@ -40,8 +65,10 @@ export function createSessionActions(context: SessionActionContext) {
updateFooter,
updateAutocompleteProvider,
setActivityStatus,
clearLocalRunIds,
} = context;
let refreshSessionInfoPromise: Promise<void> | null = null;
let refreshSessionInfoPromise: Promise<void> = Promise.resolve();
let lastSessionDefaults: SessionInfoDefaults | null = null;
const applyAgentsResult = (result: GatewayAgentsList) => {
state.agentDefaultId = normalizeAgentId(result.defaultId);
@@ -99,58 +126,173 @@ export function createSessionActions(context: SessionActionContext) {
}
};
const refreshSessionInfo = async () => {
if (refreshSessionInfoPromise) {
return refreshSessionInfoPromise;
const resolveModelSelection = (entry?: SessionInfoEntry) => {
if (entry?.modelProvider || entry?.model) {
return {
modelProvider: entry.modelProvider ?? state.sessionInfo.modelProvider,
model: entry.model ?? state.sessionInfo.model,
};
}
refreshSessionInfoPromise = (async () => {
try {
const listAgentId =
state.currentSessionKey === "global" || state.currentSessionKey === "unknown"
? undefined
: state.currentAgentId;
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
agentId: listAgentId,
});
const entry = result.sessions.find((row) => {
// Exact match
if (row.key === state.currentSessionKey) {
return true;
}
// Also match canonical keys like "agent:default:main" against "main"
const parsed = parseAgentSessionKey(row.key);
return parsed?.rest === state.currentSessionKey;
});
state.sessionInfo = {
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
model: entry?.model ?? result.defaults?.model ?? undefined,
modelProvider: entry?.modelProvider ?? result.defaults?.modelProvider ?? undefined,
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
inputTokens: entry?.inputTokens ?? null,
outputTokens: entry?.outputTokens ?? null,
totalTokens: entry?.totalTokens ?? null,
responseUsage: entry?.responseUsage,
updatedAt: entry?.updatedAt ?? null,
displayName: entry?.displayName,
};
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
}
updateAutocompleteProvider();
updateFooter();
tui.requestRender();
})();
const overrideModel = entry?.modelOverride?.trim();
if (overrideModel) {
const overrideProvider = entry?.providerOverride?.trim() || state.sessionInfo.modelProvider;
return { modelProvider: overrideProvider, model: overrideModel };
}
return {
modelProvider: state.sessionInfo.modelProvider,
model: state.sessionInfo.model,
};
};
const applySessionInfo = (params: {
entry?: SessionInfoEntry | null;
defaults?: SessionInfoDefaults | null;
force?: boolean;
}) => {
const entry = params.entry ?? undefined;
const defaults = params.defaults ?? lastSessionDefaults ?? undefined;
const previousDefaults = lastSessionDefaults;
const defaultsChanged = params.defaults
? previousDefaults?.model !== params.defaults.model ||
previousDefaults?.modelProvider !== params.defaults.modelProvider ||
previousDefaults?.contextTokens !== params.defaults.contextTokens
: false;
if (params.defaults) {
lastSessionDefaults = params.defaults;
}
const entryUpdatedAt = entry?.updatedAt ?? null;
const currentUpdatedAt = state.sessionInfo.updatedAt ?? null;
const modelChanged =
(entry?.modelProvider !== undefined &&
entry.modelProvider !== state.sessionInfo.modelProvider) ||
(entry?.model !== undefined && entry.model !== state.sessionInfo.model);
if (
!params.force &&
entryUpdatedAt !== null &&
currentUpdatedAt !== null &&
entryUpdatedAt < currentUpdatedAt &&
!defaultsChanged &&
!modelChanged
) {
return;
}
const next = { ...state.sessionInfo };
if (entry?.thinkingLevel !== undefined) {
next.thinkingLevel = entry.thinkingLevel;
}
if (entry?.verboseLevel !== undefined) {
next.verboseLevel = entry.verboseLevel;
}
if (entry?.reasoningLevel !== undefined) {
next.reasoningLevel = entry.reasoningLevel;
}
if (entry?.responseUsage !== undefined) {
next.responseUsage = entry.responseUsage;
}
if (entry?.inputTokens !== undefined) {
next.inputTokens = entry.inputTokens;
}
if (entry?.outputTokens !== undefined) {
next.outputTokens = entry.outputTokens;
}
if (entry?.totalTokens !== undefined) {
next.totalTokens = entry.totalTokens;
}
if (entry?.contextTokens !== undefined || defaults?.contextTokens !== undefined) {
next.contextTokens =
entry?.contextTokens ?? defaults?.contextTokens ?? state.sessionInfo.contextTokens;
}
if (entry?.displayName !== undefined) {
next.displayName = entry.displayName;
}
if (entry?.updatedAt !== undefined) {
next.updatedAt = entry.updatedAt;
}
const selection = resolveModelSelection(entry);
if (selection.modelProvider !== undefined) {
next.modelProvider = selection.modelProvider;
}
if (selection.model !== undefined) {
next.model = selection.model;
}
state.sessionInfo = next;
updateAutocompleteProvider();
updateFooter();
tui.requestRender();
};
const runRefreshSessionInfo = async () => {
try {
await refreshSessionInfoPromise;
} finally {
refreshSessionInfoPromise = null;
const resolveListAgentId = () => {
if (state.currentSessionKey === "global" || state.currentSessionKey === "unknown") {
return undefined;
}
const parsed = parseAgentSessionKey(state.currentSessionKey);
return parsed?.agentId ? normalizeAgentId(parsed.agentId) : state.currentAgentId;
};
const listAgentId = resolveListAgentId();
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
agentId: listAgentId,
});
const normalizeMatchKey = (key: string) => parseAgentSessionKey(key)?.rest ?? key;
const currentMatchKey = normalizeMatchKey(state.currentSessionKey);
const entry = result.sessions.find((row) => {
// Exact match
if (row.key === state.currentSessionKey) {
return true;
}
// Also match canonical keys like "agent:default:main" against "main"
return normalizeMatchKey(row.key) === currentMatchKey;
});
if (entry?.key && entry.key !== state.currentSessionKey) {
updateAgentFromSessionKey(entry.key);
state.currentSessionKey = entry.key;
updateHeader();
}
applySessionInfo({
entry,
defaults: result.defaults,
});
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
}
};
const refreshSessionInfo = async () => {
refreshSessionInfoPromise = refreshSessionInfoPromise.then(
runRefreshSessionInfo,
runRefreshSessionInfo,
);
await refreshSessionInfoPromise;
};
const applySessionInfoFromPatch = (result?: SessionsPatchResult | null) => {
if (!result?.entry) {
return;
}
if (result.key && result.key !== state.currentSessionKey) {
updateAgentFromSessionKey(result.key);
state.currentSessionKey = result.key;
updateHeader();
}
const resolved = result.resolved;
const entry =
resolved && (resolved.modelProvider || resolved.model)
? {
...result.entry,
modelProvider: resolved.modelProvider ?? result.entry.modelProvider,
model: resolved.model ?? result.entry.model,
}
: result.entry;
applySessionInfo({ entry, force: true });
};
const loadHistory = async () => {
try {
const history = await client.loadHistory({
@@ -161,9 +303,12 @@ export function createSessionActions(context: SessionActionContext) {
messages?: unknown[];
sessionId?: string;
thinkingLevel?: string;
verboseLevel?: string;
};
state.currentSessionId = typeof record.sessionId === "string" ? record.sessionId : null;
state.sessionInfo.thinkingLevel = record.thinkingLevel ?? state.sessionInfo.thinkingLevel;
state.sessionInfo.verboseLevel = record.verboseLevel ?? state.sessionInfo.verboseLevel;
const showTools = (state.sessionInfo.verboseLevel ?? "off") !== "off";
chatLog.clearAll();
chatLog.addSystem(`session ${state.currentSessionKey}`);
for (const entry of record.messages ?? []) {
@@ -195,6 +340,9 @@ export function createSessionActions(context: SessionActionContext) {
continue;
}
if (message.role === "toolResult") {
if (!showTools) {
continue;
}
const toolCallId = asString(message.toolCallId, "");
const toolName = asString(message.toolName, "tool");
const component = chatLog.startTool(toolCallId, toolName, {});
@@ -227,6 +375,7 @@ export function createSessionActions(context: SessionActionContext) {
state.activeChatRunId = null;
state.currentSessionId = null;
state.historyLoaded = false;
clearLocalRunIds?.();
updateHeader();
updateFooter();
await loadHistory();
@@ -255,6 +404,7 @@ export function createSessionActions(context: SessionActionContext) {
applyAgentsResult,
refreshAgents,
refreshSessionInfo,
applySessionInfoFromPatch,
loadHistory,
setSession,
abortActive,
+40 -2
View File
@@ -95,6 +95,7 @@ export async function runTui(opts: TuiOptions) {
let wasDisconnected = false;
let toolsExpanded = false;
let showThinking = false;
const localRunIds = new Set<string>();
const deliverDefault = opts.deliver ?? false;
const autoMessage = opts.message?.trim();
@@ -225,6 +226,29 @@ export async function runTui(opts: TuiOptions) {
},
};
const noteLocalRunId = (runId: string) => {
if (!runId) {
return;
}
localRunIds.add(runId);
if (localRunIds.size > 200) {
const [first] = localRunIds;
if (first) {
localRunIds.delete(first);
}
}
};
const forgetLocalRunId = (runId: string) => {
localRunIds.delete(runId);
};
const isLocalRunId = (runId: string) => localRunIds.has(runId);
const clearLocalRunIds = () => {
localRunIds.clear();
};
const client = new GatewayChatClient({
url: opts.url,
token: opts.token,
@@ -522,9 +546,16 @@ export async function runTui(opts: TuiOptions) {
updateFooter,
updateAutocompleteProvider,
setActivityStatus,
clearLocalRunIds,
});
const { refreshAgents, refreshSessionInfo, loadHistory, setSession, abortActive } =
sessionActions;
const {
refreshAgents,
refreshSessionInfo,
applySessionInfoFromPatch,
loadHistory,
setSession,
abortActive,
} = sessionActions;
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog,
@@ -532,6 +563,10 @@ export async function runTui(opts: TuiOptions) {
state,
setActivityStatus,
refreshSessionInfo,
loadHistory,
isLocalRunId,
forgetLocalRunId,
clearLocalRunIds,
});
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } =
@@ -545,12 +580,15 @@ export async function runTui(opts: TuiOptions) {
openOverlay,
closeOverlay,
refreshSessionInfo,
applySessionInfoFromPatch,
loadHistory,
setSession,
refreshAgents,
abortActive,
setActivityStatus,
formatSessionKey,
noteLocalRunId,
forgetLocalRunId,
});
const { runLocalShellLine } = createLocalShellRunner({