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)
main
Gustavo Madeira Santana 2026-02-04 17:12:16 -05:00 committed by GitHub
parent a42e3cb78a
commit 38e6da1fe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1227 additions and 208 deletions

View File

@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### Fixes
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411. - Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard. - Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
- Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo. - Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.
- Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman. - Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.

View File

@ -108,10 +108,10 @@
"@larksuiteoapi/node-sdk": "^1.42.0", "@larksuiteoapi/node-sdk": "^1.42.0",
"@line/bot-sdk": "^10.6.0", "@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3", "@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.51.1", "@mariozechner/pi-agent-core": "0.51.3",
"@mariozechner/pi-ai": "0.51.1", "@mariozechner/pi-ai": "0.51.3",
"@mariozechner/pi-coding-agent": "0.51.1", "@mariozechner/pi-coding-agent": "0.51.3",
"@mariozechner/pi-tui": "0.51.1", "@mariozechner/pi-tui": "0.51.3",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@sinclair/typebox": "0.34.48", "@sinclair/typebox": "0.34.48",
"@slack/bolt": "^4.6.0", "@slack/bolt": "^4.6.0",

View File

@ -49,17 +49,17 @@ importers:
specifier: 1.2.0-beta.3 specifier: 1.2.0-beta.3
version: 1.2.0-beta.3 version: 1.2.0-beta.3
'@mariozechner/pi-agent-core': '@mariozechner/pi-agent-core':
specifier: 0.51.1 specifier: 0.51.3
version: 0.51.1(ws@8.19.0)(zod@4.3.6) version: 0.51.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': '@mariozechner/pi-ai':
specifier: 0.51.1 specifier: 0.51.3
version: 0.51.1(ws@8.19.0)(zod@4.3.6) version: 0.51.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': '@mariozechner/pi-coding-agent':
specifier: 0.51.1 specifier: 0.51.3
version: 0.51.1(ws@8.19.0)(zod@4.3.6) version: 0.51.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': '@mariozechner/pi-tui':
specifier: 0.51.1 specifier: 0.51.3
version: 0.51.1 version: 0.51.3
'@mozilla/readability': '@mozilla/readability':
specifier: ^0.6.0 specifier: ^0.6.0
version: 0.6.0 version: 0.6.0
@ -1457,22 +1457,22 @@ packages:
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
hasBin: true hasBin: true
'@mariozechner/pi-agent-core@0.51.1': '@mariozechner/pi-agent-core@0.51.3':
resolution: {integrity: sha512-Ssy7ipyYl2mg99T3W5maA1DKrFCYrWeM6kq5awyd+e34Bd6njK5bsi1keqtlbCIsTCtF9NngUwUJ2lWEi9kHhA==} resolution: {integrity: sha512-pO5ScRuf7F5GCqS02vuB3gIV/MHR2cskEEUnbVbkSf0RHJb3vTICy/ACQyeI+UYk7yjFmdvQgbSUtVrYJ3q8Ag==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.51.1': '@mariozechner/pi-ai@0.51.3':
resolution: {integrity: sha512-QJgiVwxvUJx6QECSqOQi1NNhOdzzFYDoX3C21aPgYH9DQQpvg4thzhSK9eZoxD+HsQGfcq8u/DkPdPyl0tl8Bg==} resolution: {integrity: sha512-NocfuwUPCGeNhWyfzSGKbsTqUvFmP+VihU8+xtzX9FoHvQQVJHQ49Sz8sfLK04BbEWYI9s/gZ7a9xnJ0O4cz8g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
hasBin: true hasBin: true
'@mariozechner/pi-coding-agent@0.51.1': '@mariozechner/pi-coding-agent@0.51.3':
resolution: {integrity: sha512-vZCQ1gOQKC5kJOUQLMZb55OySIG27NxcMTKbJUQ0f1Ncn5uvV/Z4I/U5Ok217tm60EDC4JRv5GC1YMwpVRQFyg==} resolution: {integrity: sha512-pu/4IxeMZMapYiSO3LWvNRztOXXKLlLNL+drjMvtgWbp9MJ8azP+5Zwsp3/vzrPvM54wCkaSa0voUEThm4Ba/Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
hasBin: true hasBin: true
'@mariozechner/pi-tui@0.51.1': '@mariozechner/pi-tui@0.51.3':
resolution: {integrity: sha512-1g6Z4WBvWcQf3bMM85fsHyQHv4mOcqKoH1AB8+G2lBHO49707gqHc3y6LbXuBSNn8uINGoAk2LpUoAUFnxLExg==} resolution: {integrity: sha512-1B9C3oVsAcBSO0rvk4qC3Iq655LveLQDSnlseypCo/KiR5eY39Hw1XRtvq5N05mtxNuo3mRw8FMcYCwIl1BbDg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
@ -3766,6 +3766,7 @@ packages:
glob@11.1.0: glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true hasBin: true
google-auth-library@10.5.0: google-auth-library@10.5.0:
@ -6783,10 +6784,9 @@ snapshots:
std-env: 3.10.0 std-env: 3.10.0
yoctocolors: 2.1.2 yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.51.1(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-agent-core@0.51.3(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/pi-ai': 0.51.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.51.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.51.1
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
- aws-crt - aws-crt
@ -6796,7 +6796,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-ai@0.51.1(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-ai@0.51.3(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6) '@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.981.0 '@aws-sdk/client-bedrock-runtime': 3.981.0
@ -6820,12 +6820,12 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-coding-agent@0.51.1(ws@8.19.0)(zod@4.3.6)': '@mariozechner/pi-coding-agent@0.51.3(ws@8.19.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/jiti': 2.6.5 '@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.51.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-agent-core': 0.51.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.51.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.51.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.51.1 '@mariozechner/pi-tui': 0.51.3
'@silvia-odwyer/photon-node': 0.3.4 '@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2 chalk: 5.6.2
cli-highlight: 2.1.11 cli-highlight: 2.1.11
@ -6848,7 +6848,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-tui@0.51.1': '@mariozechner/pi-tui@0.51.3':
dependencies: dependencies:
'@types/mime-types': 2.1.4 '@types/mime-types': 2.1.4
chalk: 5.6.2 chalk: 5.6.2

View File

@ -163,6 +163,7 @@ export function handleMessageUpdate(
mediaUrls: hasMedia ? mediaUrls : undefined, mediaUrls: hasMedia ? mediaUrls : undefined,
}, },
}); });
ctx.state.emittedAssistantUpdate = true;
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) { if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
void ctx.params.onPartialReply({ void ctx.params.onPartialReply({
text: cleanedText, text: cleanedText,
@ -215,6 +216,44 @@ export function handleMessageEnd(
? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText) ? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText)
: ""; : "";
const formattedReasoning = rawThinking ? formatReasoningMessage(rawThinking) : ""; 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 addedDuringMessage = ctx.state.assistantTexts.length > ctx.state.assistantTextBaseline;
const chunkerHasBuffered = ctx.blockChunker?.hasBuffered() ?? false; const chunkerHasBuffered = ctx.blockChunker?.hasBuffered() ?? false;

View File

@ -39,6 +39,7 @@ export type EmbeddedPiSubscribeState = {
partialBlockState: { thinking: boolean; final: boolean; inlineCode: InlineCodeState }; partialBlockState: { thinking: boolean; final: boolean; inlineCode: InlineCodeState };
lastStreamedAssistant?: string; lastStreamedAssistant?: string;
lastStreamedAssistantCleaned?: string; lastStreamedAssistantCleaned?: string;
emittedAssistantUpdate: boolean;
lastStreamedReasoning?: string; lastStreamedReasoning?: string;
lastBlockReplyText?: string; lastBlockReplyText?: string;
assistantMessageIndex: number; assistantMessageIndex: number;

View File

@ -62,6 +62,39 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onPartialReply).not.toHaveBeenCalled(); 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", () => { it("does not require <final> when enforcement is off", () => {
let handler: ((evt: unknown) => void) | undefined; let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = { const session: StubSession = {

View File

@ -185,6 +185,71 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payloads[1]?.delta).toBe(" world"); 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", () => { it("skips agent events when cleaned text rewinds mid-stream", () => {
let handler: ((evt: unknown) => void) | undefined; let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = { const session: StubSession = {

View File

@ -49,6 +49,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
partialBlockState: { thinking: false, final: false, inlineCode: createInlineCodeState() }, partialBlockState: { thinking: false, final: false, inlineCode: createInlineCodeState() },
lastStreamedAssistant: undefined, lastStreamedAssistant: undefined,
lastStreamedAssistantCleaned: undefined, lastStreamedAssistantCleaned: undefined,
emittedAssistantUpdate: false,
lastStreamedReasoning: undefined, lastStreamedReasoning: undefined,
lastBlockReplyText: undefined, lastBlockReplyText: undefined,
assistantMessageIndex: 0, assistantMessageIndex: 0,
@ -95,6 +96,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
state.partialBlockState.inlineCode = createInlineCodeState(); state.partialBlockState.inlineCode = createInlineCodeState();
state.lastStreamedAssistant = undefined; state.lastStreamedAssistant = undefined;
state.lastStreamedAssistantCleaned = undefined; state.lastStreamedAssistantCleaned = undefined;
state.emittedAssistantUpdate = false;
state.lastBlockReplyText = undefined; state.lastBlockReplyText = undefined;
state.lastStreamedReasoning = undefined; state.lastStreamedReasoning = undefined;
state.lastReasoningSent = undefined; state.lastReasoningSent = undefined;

View File

@ -42,6 +42,12 @@ export type GatewayClientInfo = {
instanceId?: string; 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_ID_SET = new Set<GatewayClientId>(Object.values(GATEWAY_CLIENT_IDS));
const GATEWAY_CLIENT_MODE_SET = new Set<GatewayClientMode>(Object.values(GATEWAY_CLIENT_MODES)); 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) ? (normalized as GatewayClientMode)
: undefined; : 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 AjvPkg, { type ErrorObject } from "ajv";
import type { SessionsPatchResult } from "../session-utils.types.js";
import { import {
type AgentEvent, type AgentEvent,
AgentEventSchema, AgentEventSchema,
@ -536,6 +537,7 @@ export type {
SessionsPreviewParams, SessionsPreviewParams,
SessionsResolveParams, SessionsResolveParams,
SessionsPatchParams, SessionsPatchParams,
SessionsPatchResult,
SessionsResetParams, SessionsResetParams,
SessionsDeleteParams, SessionsDeleteParams,
SessionsCompactParams, 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("exec.approval.requested", { id: "1" });
broadcast("device.pair.requested", { requestId: "r1" }); broadcast("device.pair.requested", { requestId: "r1" });
@ -52,5 +52,10 @@ describe("gateway broadcaster", () => {
expect(approvalsSocket.send).toHaveBeenCalledTimes(1); expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(1); expect(pairingSocket.send).toHaveBeenCalledTimes(1);
expect(readSocket.send).toHaveBeenCalledTimes(0); 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> }) { export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient> }) {
let seq = 0; let seq = 0;
const broadcast = (
const broadcastInternal = (
event: string, event: string,
payload: unknown, payload: unknown,
opts?: { opts?: {
dropIfSlow?: boolean; dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number }; stateVersion?: { presence?: number; health?: number };
}, },
targetConnIds?: ReadonlySet<string>,
) => { ) => {
const eventSeq = ++seq; const isTargeted = Boolean(targetConnIds);
const eventSeq = isTargeted ? undefined : ++seq;
const frame = JSON.stringify({ const frame = JSON.stringify({
type: "event", type: "event",
event, event,
@ -51,8 +54,9 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
}); });
const logMeta: Record<string, unknown> = { const logMeta: Record<string, unknown> = {
event, event,
seq: eventSeq, seq: eventSeq ?? "targeted",
clients: params.clients.size, clients: params.clients.size,
targets: targetConnIds ? targetConnIds.size : undefined,
dropIfSlow: opts?.dropIfSlow, dropIfSlow: opts?.dropIfSlow,
presenceVersion: opts?.stateVersion?.presence, presenceVersion: opts?.stateVersion?.presence,
healthVersion: opts?.stateVersion?.health, healthVersion: opts?.stateVersion?.health,
@ -62,6 +66,9 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
} }
logWs("out", "event", logMeta); logWs("out", "event", logMeta);
for (const c of params.clients) { for (const c of params.clients) {
if (targetConnIds && !targetConnIds.has(c.connId)) {
continue;
}
if (!hasEventScope(c, event)) { if (!hasEventScope(c, event)) {
continue; 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 { 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", () => { describe("agent event handler", () => {
it("emits chat delta for assistant text-only events", () => { it("emits chat delta for assistant text-only events", () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
const broadcast = vi.fn(); const broadcast = vi.fn();
const broadcastToConnIds = vi.fn();
const nodeSendToSession = vi.fn(); const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>(); const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState(); const chatRunState = createChatRunState();
const toolEventRecipients = createToolEventRecipientRegistry();
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" }); chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
const handler = createAgentEventHandler({ const handler = createAgentEventHandler({
broadcast, broadcast,
broadcastToConnIds,
nodeSendToSession, nodeSendToSession,
agentRunSeq, agentRunSeq,
chatRunState, chatRunState,
resolveSessionKeyForRun: () => undefined, resolveSessionKeyForRun: () => undefined,
clearAgentRunContext: vi.fn(), clearAgentRunContext: vi.fn(),
toolEventRecipients,
}); });
handler({ handler({
@ -39,4 +48,158 @@ describe("agent event handler", () => {
expect(sessionChatCalls).toHaveLength(1); expect(sessionChatCalls).toHaveLength(1);
nowSpy.mockRestore(); 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 = ( export type ChatEventBroadcast = (
event: string, event: string,
payload: unknown, payload: unknown,
@ -130,20 +203,29 @@ export type NodeSendToSession = (sessionKey: string, event: string, payload: unk
export type AgentEventHandlerOptions = { export type AgentEventHandlerOptions = {
broadcast: ChatEventBroadcast; broadcast: ChatEventBroadcast;
broadcastToConnIds: (
event: string,
payload: unknown,
connIds: ReadonlySet<string>,
opts?: { dropIfSlow?: boolean },
) => void;
nodeSendToSession: NodeSendToSession; nodeSendToSession: NodeSendToSession;
agentRunSeq: Map<string, number>; agentRunSeq: Map<string, number>;
chatRunState: ChatRunState; chatRunState: ChatRunState;
resolveSessionKeyForRun: (runId: string) => string | undefined; resolveSessionKeyForRun: (runId: string) => string | undefined;
clearAgentRunContext: (runId: string) => void; clearAgentRunContext: (runId: string) => void;
toolEventRecipients: ToolEventRecipientRegistry;
}; };
export function createAgentEventHandler({ export function createAgentEventHandler({
broadcast, broadcast,
broadcastToConnIds,
nodeSendToSession, nodeSendToSession,
agentRunSeq, agentRunSeq,
chatRunState, chatRunState,
resolveSessionKeyForRun, resolveSessionKeyForRun,
clearAgentRunContext, clearAgentRunContext,
toolEventRecipients,
}: AgentEventHandlerOptions) { }: AgentEventHandlerOptions) {
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => { const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
chatRunState.buffers.set(clientRunId, text); chatRunState.buffers.set(clientRunId, text);
@ -213,25 +295,25 @@ export function createAgentEventHandler({
nodeSendToSession(sessionKey, "chat", payload); nodeSendToSession(sessionKey, "chat", payload);
}; };
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => { const resolveToolVerboseLevel = (runId: string, sessionKey?: string) => {
const runContext = getAgentRunContext(runId); const runContext = getAgentRunContext(runId);
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel); const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
if (runVerbose) { if (runVerbose) {
return runVerbose === "on"; return runVerbose;
} }
if (!sessionKey) { if (!sessionKey) {
return false; return "off";
} }
try { try {
const { cfg, entry } = loadSessionEntry(sessionKey); const { cfg, entry } = loadSessionEntry(sessionKey);
const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel); const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel);
if (sessionVerbose) { if (sessionVerbose) {
return sessionVerbose === "on"; return sessionVerbose;
} }
const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault); const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault);
return defaultVerbose === "on"; return defaultVerbose ?? "off";
} catch { } catch {
return false; return "off";
} }
}; };
@ -244,10 +326,21 @@ export function createAgentEventHandler({
// Include sessionKey so Control UI can filter tool streams per session. // Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const last = agentRunSeq.get(evt.runId) ?? 0; 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); agentRunSeq.set(evt.runId, evt.seq);
return; 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) { if (evt.seq !== last + 1) {
broadcast("agent", { broadcast("agent", {
runId: evt.runId, runId: evt.runId,
@ -262,13 +355,20 @@ export function createAgentEventHandler({
}); });
} }
agentRunSeq.set(evt.runId, evt.seq); 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 = const lifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null; evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (sessionKey) { if (sessionKey) {
nodeSendToSession(sessionKey, "agent", agentPayload); nodeSendToSession(sessionKey, "agent", isToolEvent ? toolPayload : agentPayload);
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { } else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
@ -306,6 +406,7 @@ export function createAgentEventHandler({
} }
if (lifecyclePhase === "end" || lifecyclePhase === "error") { if (lifecyclePhase === "end" || lifecyclePhase === "error") {
toolEventRecipients.markFinal(evt.runId);
clearAgentRunContext(evt.runId); clearAgentRunContext(evt.runId);
} }
}; };

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
export type GatewayClient = { export type GatewayClient = {
connect: ConnectParams; connect: ConnectParams;
connId?: string;
}; };
export type RespondFn = ( export type RespondFn = (
@ -42,6 +43,15 @@ export type GatewayRequestContext = {
stateVersion?: { presence?: number; health?: number }; stateVersion?: { presence?: number; health?: number };
}, },
) => void; ) => 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; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToAllSubscribed: (event: string, payload: unknown) => void; nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
nodeSubscribe: (nodeId: string, sessionKey: string) => void; nodeSubscribe: (nodeId: string, sessionKey: string) => void;
@ -60,6 +70,7 @@ export type GatewayRequestContext = {
clientRunId: string, clientRunId: string,
sessionKey?: string, sessionKey?: string,
) => { sessionKey: string; clientRunId: string } | undefined; ) => { sessionKey: string; clientRunId: string } | undefined;
registerToolEventRecipient: (runId: string, connId: string) => void;
dedupe: Map<string, DedupeEntry>; dedupe: Map<string, DedupeEntry>;
wizardSessions: Map<string, WizardSession>; wizardSessions: Map<string, WizardSession>;
findRunningWizard: () => string | null; 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 { type CanvasHostHandler, createCanvasHostHandler } from "../canvas-host/server.js";
import { resolveGatewayListenHosts } from "./net.js"; import { resolveGatewayListenHosts } from "./net.js";
import { createGatewayBroadcaster } from "./server-broadcast.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 { MAX_PAYLOAD_BYTES } from "./server-constants.js";
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
import { createGatewayHooksRequestHandler } from "./server/hooks.js"; import { createGatewayHooksRequestHandler } from "./server/hooks.js";
@ -59,6 +63,15 @@ export async function createGatewayRuntimeState(params: {
stateVersion?: { presence?: number; health?: number }; stateVersion?: { presence?: number; health?: number };
}, },
) => void; ) => void;
broadcastToConnIds: (
event: string,
payload: unknown,
connIds: ReadonlySet<string>,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
agentRunSeq: Map<string, number>; agentRunSeq: Map<string, number>;
dedupe: Map<string, DedupeEntry>; dedupe: Map<string, DedupeEntry>;
chatRunState: ReturnType<typeof createChatRunState>; chatRunState: ReturnType<typeof createChatRunState>;
@ -71,6 +84,7 @@ export async function createGatewayRuntimeState(params: {
sessionKey?: string, sessionKey?: string,
) => ChatRunEntry | undefined; ) => ChatRunEntry | undefined;
chatAbortControllers: Map<string, ChatAbortControllerEntry>; chatAbortControllers: Map<string, ChatAbortControllerEntry>;
toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>;
}> { }> {
let canvasHost: CanvasHostHandler | null = null; let canvasHost: CanvasHostHandler | null = null;
if (params.canvasHostEnabled) { if (params.canvasHostEnabled) {
@ -154,7 +168,7 @@ export async function createGatewayRuntimeState(params: {
} }
const clients = new Set<GatewayWsClient>(); const clients = new Set<GatewayWsClient>();
const { broadcast } = createGatewayBroadcaster({ clients }); const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
const agentRunSeq = new Map<string, number>(); const agentRunSeq = new Map<string, number>();
const dedupe = new Map<string, DedupeEntry>(); const dedupe = new Map<string, DedupeEntry>();
const chatRunState = createChatRunState(); const chatRunState = createChatRunState();
@ -164,6 +178,7 @@ export async function createGatewayRuntimeState(params: {
const addChatRun = chatRunRegistry.add; const addChatRun = chatRunRegistry.add;
const removeChatRun = chatRunRegistry.remove; const removeChatRun = chatRunRegistry.remove;
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>(); const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
const toolEventRecipients = createToolEventRecipientRegistry();
return { return {
canvasHost, canvasHost,
@ -173,6 +188,7 @@ export async function createGatewayRuntimeState(params: {
wss, wss,
clients, clients,
broadcast, broadcast,
broadcastToConnIds,
agentRunSeq, agentRunSeq,
dedupe, dedupe,
chatRunState, chatRunState,
@ -181,5 +197,6 @@ export async function createGatewayRuntimeState(params: {
addChatRun, addChatRun,
removeChatRun, removeChatRun,
chatAbortControllers, chatAbortControllers,
toolEventRecipients,
}; };
} }

View File

@ -380,8 +380,8 @@ describe("gateway server chat", () => {
emitAgentEvent({ emitAgentEvent({
runId: "run-tool-1", runId: "run-tool-1",
stream: "tool", stream: "assistant",
data: { phase: "start", name: "read", toolCallId: "tool-1" }, data: { text: "hello" },
}); });
const evt = await agentEvtP; const evt = await agentEvtP;
@ -390,31 +390,6 @@ describe("gateway server chat", () => {
? (evt.payload as Record<string, unknown>) ? (evt.payload as Record<string, unknown>)
: {}; : {};
expect(payload.sessionKey).toBe("main"); 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"); expect(payload.stream).toBe("assistant");
} }

View File

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

View File

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

View File

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

View File

@ -1,10 +1,12 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { loadConfig, resolveGatewayPort } from "../config/config.js"; import { loadConfig, resolveGatewayPort } from "../config/config.js";
import { GatewayClient } from "../gateway/client.js"; import { GatewayClient } from "../gateway/client.js";
import { GATEWAY_CLIENT_CAPS } from "../gateway/protocol/client-info.js";
import { import {
type HelloOk, type HelloOk,
PROTOCOL_VERSION, PROTOCOL_VERSION,
type SessionsListParams, type SessionsListParams,
type SessionsPatchResult,
type SessionsPatchParams, type SessionsPatchParams,
} from "../gateway/protocol/index.js"; } from "../gateway/protocol/index.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@ -22,6 +24,7 @@ export type ChatSendOptions = {
thinking?: string; thinking?: string;
deliver?: boolean; deliver?: boolean;
timeoutMs?: number; timeoutMs?: number;
runId?: string;
}; };
export type GatewayEvent = { export type GatewayEvent = {
@ -116,6 +119,7 @@ export class GatewayChatClient {
clientVersion: VERSION, clientVersion: VERSION,
platform: process.platform, platform: process.platform,
mode: GATEWAY_CLIENT_MODES.UI, mode: GATEWAY_CLIENT_MODES.UI,
caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS],
instanceId: randomUUID(), instanceId: randomUUID(),
minProtocol: PROTOCOL_VERSION, minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION,
@ -153,7 +157,7 @@ export class GatewayChatClient {
} }
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> { async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> {
const runId = randomUUID(); const runId = opts.runId ?? randomUUID();
await this.client.request("chat.send", { await this.client.request("chat.send", {
sessionKey: opts.sessionKey, sessionKey: opts.sessionKey,
message: opts.message, message: opts.message,
@ -195,8 +199,8 @@ export class GatewayChatClient {
return await this.client.request<GatewayAgentsList>("agents.list", {}); return await this.client.request<GatewayAgentsList>("agents.list", {});
} }
async patchSession(opts: SessionsPatchParams) { async patchSession(opts: SessionsPatchParams): Promise<SessionsPatchResult> {
return await this.client.request("sessions.patch", opts); return await this.client.request<SessionsPatchResult>("sessions.patch", opts);
} }
async resetSession(key: string) { async resetSession(key: string) {

View File

@ -29,6 +29,8 @@ describe("tui command handlers", () => {
abortActive: vi.fn(), abortActive: vi.fn(),
setActivityStatus, setActivityStatus,
formatSessionKey: vi.fn(), formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
}); });
await handleCommand("/context"); await handleCommand("/context");

View File

@ -1,4 +1,6 @@
import type { Component, TUI } from "@mariozechner/pi-tui"; 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 { ChatLog } from "./components/chat-log.js";
import type { GatewayChatClient } from "./gateway-chat.js"; import type { GatewayChatClient } from "./gateway-chat.js";
import type { import type {
@ -38,6 +40,9 @@ type CommandHandlerContext = {
abortActive: () => Promise<void>; abortActive: () => Promise<void>;
setActivityStatus: (text: string) => void; setActivityStatus: (text: string) => void;
formatSessionKey: (key: string) => string; formatSessionKey: (key: string) => string;
applySessionInfoFromPatch: (result: SessionsPatchResult) => void;
noteLocalRunId: (runId: string) => void;
forgetLocalRunId?: (runId: string) => void;
}; };
export function createCommandHandlers(context: CommandHandlerContext) { export function createCommandHandlers(context: CommandHandlerContext) {
@ -57,6 +62,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
abortActive, abortActive,
setActivityStatus, setActivityStatus,
formatSessionKey, formatSessionKey,
applySessionInfoFromPatch,
noteLocalRunId,
forgetLocalRunId,
} = context; } = context;
const setAgent = async (id: string) => { const setAgent = async (id: string) => {
@ -81,11 +89,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
selector.onSelect = (item) => { selector.onSelect = (item) => {
void (async () => { void (async () => {
try { try {
await client.patchSession({ const result = await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
model: item.value, model: item.value,
}); });
chatLog.addSystem(`model set to ${item.value}`); chatLog.addSystem(`model set to ${item.value}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`); chatLog.addSystem(`model set failed: ${String(err)}`);
@ -284,11 +293,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
await openModelSelector(); await openModelSelector();
} else { } else {
try { try {
await client.patchSession({ const result = await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
model: args, model: args,
}); });
chatLog.addSystem(`model set to ${args}`); chatLog.addSystem(`model set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`); chatLog.addSystem(`model set failed: ${String(err)}`);
@ -309,11 +319,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break; break;
} }
try { try {
await client.patchSession({ const result = await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
thinkingLevel: args, thinkingLevel: args,
}); });
chatLog.addSystem(`thinking set to ${args}`); chatLog.addSystem(`thinking set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`think failed: ${String(err)}`); chatLog.addSystem(`think failed: ${String(err)}`);
@ -325,12 +336,13 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break; break;
} }
try { try {
await client.patchSession({ const result = await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
verboseLevel: args, verboseLevel: args,
}); });
chatLog.addSystem(`verbose set to ${args}`); chatLog.addSystem(`verbose set to ${args}`);
await refreshSessionInfo(); applySessionInfoFromPatch(result);
await loadHistory();
} catch (err) { } catch (err) {
chatLog.addSystem(`verbose failed: ${String(err)}`); chatLog.addSystem(`verbose failed: ${String(err)}`);
} }
@ -341,11 +353,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break; break;
} }
try { try {
await client.patchSession({ const result = await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
reasoningLevel: args, reasoningLevel: args,
}); });
chatLog.addSystem(`reasoning set to ${args}`); chatLog.addSystem(`reasoning set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`reasoning failed: ${String(err)}`); chatLog.addSystem(`reasoning failed: ${String(err)}`);
@ -362,11 +375,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
const next = const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
try { try {
await client.patchSession({ const result = await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
responseUsage: next === "off" ? null : next, responseUsage: next === "off" ? null : next,
}); });
chatLog.addSystem(`usage footer: ${next}`); chatLog.addSystem(`usage footer: ${next}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`usage failed: ${String(err)}`); chatLog.addSystem(`usage failed: ${String(err)}`);
@ -383,11 +397,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break; break;
} }
try { try {
await client.patchSession({ const result = await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
elevatedLevel: args, elevatedLevel: args,
}); });
chatLog.addSystem(`elevated set to ${args}`); chatLog.addSystem(`elevated set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`elevated failed: ${String(err)}`); chatLog.addSystem(`elevated failed: ${String(err)}`);
@ -399,11 +414,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break; break;
} }
try { try {
await client.patchSession({ const result = await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
groupActivation: args === "always" ? "always" : "mention", groupActivation: args === "always" ? "always" : "mention",
}); });
chatLog.addSystem(`activation set to ${args}`); chatLog.addSystem(`activation set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`activation failed: ${String(err)}`); chatLog.addSystem(`activation failed: ${String(err)}`);
@ -448,17 +464,24 @@ export function createCommandHandlers(context: CommandHandlerContext) {
try { try {
chatLog.addUser(text); chatLog.addUser(text);
tui.requestRender(); tui.requestRender();
const runId = randomUUID();
noteLocalRunId(runId);
state.activeChatRunId = runId;
setActivityStatus("sending"); setActivityStatus("sending");
const { runId } = await client.sendChat({ await client.sendChat({
sessionKey: state.currentSessionKey, sessionKey: state.currentSessionKey,
message: text, message: text,
thinking: opts.thinking, thinking: opts.thinking,
deliver: deliverDefault, deliver: deliverDefault,
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
runId,
}); });
state.activeChatRunId = runId;
setActivityStatus("waiting"); setActivityStatus("waiting");
} catch (err) { } catch (err) {
if (state.activeChatRunId) {
forgetLocalRunId?.(state.activeChatRunId);
}
state.activeChatRunId = null;
chatLog.addSystem(`send failed: ${String(err)}`); chatLog.addSystem(`send failed: ${String(err)}`);
setActivityStatus("error"); setActivityStatus("error");
} }

View File

@ -1,14 +1,14 @@
import type { TUI } from "@mariozechner/pi-tui";
import { describe, expect, it, vi } from "vitest"; 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 type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
import { createEventHandlers } from "./tui-event-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js";
type MockChatLog = { type MockChatLog = Pick<
startTool: ReturnType<typeof vi.fn>; ChatLog,
updateToolResult: ReturnType<typeof vi.fn>; "startTool" | "updateToolResult" | "addSystem" | "updateAssistant" | "finalizeAssistant"
addSystem: ReturnType<typeof vi.fn>; >;
updateAssistant: ReturnType<typeof vi.fn>; type MockTui = Pick<TUI, "requestRender">;
finalizeAssistant: ReturnType<typeof vi.fn>;
};
describe("tui-event-handlers: handleAgentEvent", () => { describe("tui-event-handlers: handleAgentEvent", () => {
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({ const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
@ -21,7 +21,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
currentSessionId: "session-1", currentSessionId: "session-1",
activeChatRunId: "run-1", activeChatRunId: "run-1",
historyLoaded: true, historyLoaded: true,
sessionInfo: {}, sessionInfo: { verboseLevel: "on" },
initialSessionApplied: true, initialSessionApplied: true,
isConnected: true, isConnected: true,
autoMessageSent: false, autoMessageSent: false,
@ -42,21 +42,40 @@ describe("tui-event-handlers: handleAgentEvent", () => {
updateAssistant: vi.fn(), updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(), finalizeAssistant: vi.fn(),
}; };
const tui = { requestRender: vi.fn() }; const tui: MockTui = { requestRender: vi.fn() };
const setActivityStatus = 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)", () => { it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" }); const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
const { chatLog, tui, setActivityStatus } = makeContext(state); const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({ const { handleAgentEvent } = createEventHandlers({
// Casts are fine here: TUI runtime shape is larger than we need in unit tests. chatLog,
// oxlint-disable-next-line typescript/no-explicit-any tui,
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
state, state,
setActivityStatus, setActivityStatus,
}); });
@ -82,10 +101,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: "run-1" }); const state = makeState({ activeChatRunId: "run-1" });
const { chatLog, tui, setActivityStatus } = makeContext(state); const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({ const { handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any chatLog,
chatLog: chatLog as any, tui,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
state, state,
setActivityStatus, setActivityStatus,
}); });
@ -107,10 +124,14 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: "run-9" }); const state = makeState({ activeChatRunId: "run-9" });
const { tui, setActivityStatus } = makeContext(state); const { tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({ const { handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any chatLog: {
chatLog: { startTool: vi.fn(), updateToolResult: vi.fn() } as any, startTool: vi.fn(),
// oxlint-disable-next-line typescript/no-explicit-any updateToolResult: vi.fn(),
tui: tui as any, addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
},
tui,
state, state,
setActivityStatus, setActivityStatus,
}); });
@ -131,10 +152,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: null }); const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state); const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({ const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any chatLog,
chatLog: chatLog as any, tui,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
state, state,
setActivityStatus, setActivityStatus,
}); });
@ -165,10 +184,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: null }); const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state); const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({ const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any chatLog,
chatLog: chatLog as any, tui,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
state, state,
setActivityStatus, setActivityStatus,
}); });
@ -194,14 +211,39 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(tui.requestRender).not.toHaveBeenCalled(); 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", () => { it("ignores lifecycle updates for non-active runs in the same session", () => {
const state = makeState({ activeChatRunId: "run-active" }); const state = makeState({ activeChatRunId: "run-active" });
const { chatLog, tui, setActivityStatus } = makeContext(state); const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({ const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any chatLog,
chatLog: chatLog as any, tui,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
state, state,
setActivityStatus, setActivityStatus,
}); });
@ -224,4 +266,95 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(setActivityStatus).not.toHaveBeenCalled(); expect(setActivityStatus).not.toHaveBeenCalled();
expect(tui.requestRender).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);
});
}); });

View File

@ -10,10 +10,24 @@ type EventHandlerContext = {
state: TuiStateAccess; state: TuiStateAccess;
setActivityStatus: (text: string) => void; setActivityStatus: (text: string) => void;
refreshSessionInfo?: () => Promise<void>; refreshSessionInfo?: () => Promise<void>;
loadHistory?: () => Promise<void>;
isLocalRunId?: (runId: string) => boolean;
forgetLocalRunId?: (runId: string) => void;
clearLocalRunIds?: () => void;
}; };
export function createEventHandlers(context: EventHandlerContext) { 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 finalizedRuns = new Map<string, number>();
const sessionRuns = new Map<string, number>(); const sessionRuns = new Map<string, number>();
let streamAssembler = new TuiStreamAssembler(); let streamAssembler = new TuiStreamAssembler();
@ -50,6 +64,7 @@ export function createEventHandlers(context: EventHandlerContext) {
finalizedRuns.clear(); finalizedRuns.clear();
sessionRuns.clear(); sessionRuns.clear();
streamAssembler = new TuiStreamAssembler(); streamAssembler = new TuiStreamAssembler();
clearLocalRunIds?.();
}; };
const noteSessionRun = (runId: string) => { const noteSessionRun = (runId: string) => {
@ -95,6 +110,11 @@ export function createEventHandlers(context: EventHandlerContext) {
} }
if (evt.state === "final") { if (evt.state === "final") {
if (isCommandMessage(evt.message)) { if (isCommandMessage(evt.message)) {
if (isLocalRunId?.(evt.runId)) {
forgetLocalRunId?.(evt.runId);
} else {
void loadHistory?.();
}
const text = extractTextFromMessage(evt.message); const text = extractTextFromMessage(evt.message);
if (text) { if (text) {
chatLog.addSystem(text); chatLog.addSystem(text);
@ -107,6 +127,11 @@ export function createEventHandlers(context: EventHandlerContext) {
tui.requestRender(); tui.requestRender();
return; return;
} }
if (isLocalRunId?.(evt.runId)) {
forgetLocalRunId?.(evt.runId);
} else {
void loadHistory?.();
}
const stopReason = const stopReason =
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message) evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
? typeof (evt.message as Record<string, unknown>).stopReason === "string" ? typeof (evt.message as Record<string, unknown>).stopReason === "string"
@ -129,6 +154,11 @@ export function createEventHandlers(context: EventHandlerContext) {
state.activeChatRunId = null; state.activeChatRunId = null;
setActivityStatus("aborted"); setActivityStatus("aborted");
void refreshSessionInfo?.(); void refreshSessionInfo?.();
if (isLocalRunId?.(evt.runId)) {
forgetLocalRunId?.(evt.runId);
} else {
void loadHistory?.();
}
} }
if (evt.state === "error") { if (evt.state === "error") {
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`); chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
@ -137,6 +167,11 @@ export function createEventHandlers(context: EventHandlerContext) {
state.activeChatRunId = null; state.activeChatRunId = null;
setActivityStatus("error"); setActivityStatus("error");
void refreshSessionInfo?.(); void refreshSessionInfo?.();
if (isLocalRunId?.(evt.runId)) {
forgetLocalRunId?.(evt.runId);
} else {
void loadHistory?.();
}
} }
tui.requestRender(); tui.requestRender();
}; };
@ -148,12 +183,20 @@ export function createEventHandlers(context: EventHandlerContext) {
const evt = payload as AgentEvent; const evt = payload as AgentEvent;
syncSessionKey(); syncSessionKey();
// Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the // 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; 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; return;
} }
if (evt.stream === "tool") { 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 data = evt.data ?? {};
const phase = asString(data.phase, ""); const phase = asString(data.phase, "");
const toolCallId = asString(data.toolCallId, ""); const toolCallId = asString(data.toolCallId, "");
@ -164,13 +207,20 @@ export function createEventHandlers(context: EventHandlerContext) {
if (phase === "start") { if (phase === "start") {
chatLog.startTool(toolCallId, toolName, data.args); chatLog.startTool(toolCallId, toolName, data.args);
} else if (phase === "update") { } else if (phase === "update") {
if (!allowToolOutput) {
return;
}
chatLog.updateToolResult(toolCallId, data.partialResult, { chatLog.updateToolResult(toolCallId, data.partialResult, {
partial: true, partial: true,
}); });
} else if (phase === "result") { } else if (phase === "result") {
chatLog.updateToolResult(toolCallId, data.result, { if (allowToolOutput) {
isError: Boolean(data.isError), chatLog.updateToolResult(toolCallId, data.result, {
}); isError: Boolean(data.isError),
});
} else {
chatLog.updateToolResult(toolCallId, { content: [] }, { isError: Boolean(data.isError) });
}
} }
tui.requestRender(); tui.requestRender();
return; return;

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);
});
});

View File

@ -1,4 +1,5 @@
import type { TUI } from "@mariozechner/pi-tui"; 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 { ChatLog } from "./components/chat-log.js";
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
@ -23,6 +24,30 @@ type SessionActionContext = {
updateFooter: () => void; updateFooter: () => void;
updateAutocompleteProvider: () => void; updateAutocompleteProvider: () => void;
setActivityStatus: (text: string) => 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) { export function createSessionActions(context: SessionActionContext) {
@ -40,8 +65,10 @@ export function createSessionActions(context: SessionActionContext) {
updateFooter, updateFooter,
updateAutocompleteProvider, updateAutocompleteProvider,
setActivityStatus, setActivityStatus,
clearLocalRunIds,
} = context; } = context;
let refreshSessionInfoPromise: Promise<void> | null = null; let refreshSessionInfoPromise: Promise<void> = Promise.resolve();
let lastSessionDefaults: SessionInfoDefaults | null = null;
const applyAgentsResult = (result: GatewayAgentsList) => { const applyAgentsResult = (result: GatewayAgentsList) => {
state.agentDefaultId = normalizeAgentId(result.defaultId); state.agentDefaultId = normalizeAgentId(result.defaultId);
@ -99,58 +126,173 @@ export function createSessionActions(context: SessionActionContext) {
} }
}; };
const refreshSessionInfo = async () => { const resolveModelSelection = (entry?: SessionInfoEntry) => {
if (refreshSessionInfoPromise) { if (entry?.modelProvider || entry?.model) {
return refreshSessionInfoPromise; return {
modelProvider: entry.modelProvider ?? state.sessionInfo.modelProvider,
model: entry.model ?? state.sessionInfo.model,
};
} }
refreshSessionInfoPromise = (async () => { const overrideModel = entry?.modelOverride?.trim();
try { if (overrideModel) {
const listAgentId = const overrideProvider = entry?.providerOverride?.trim() || state.sessionInfo.modelProvider;
state.currentSessionKey === "global" || state.currentSessionKey === "unknown" return { modelProvider: overrideProvider, model: overrideModel };
? undefined }
: state.currentAgentId; return {
const result = await client.listSessions({ modelProvider: state.sessionInfo.modelProvider,
includeGlobal: false, model: state.sessionInfo.model,
includeUnknown: false, };
agentId: listAgentId, };
});
const entry = result.sessions.find((row) => { const applySessionInfo = (params: {
// Exact match entry?: SessionInfoEntry | null;
if (row.key === state.currentSessionKey) { defaults?: SessionInfoDefaults | null;
return true; force?: boolean;
} }) => {
// Also match canonical keys like "agent:default:main" against "main" const entry = params.entry ?? undefined;
const parsed = parseAgentSessionKey(row.key); const defaults = params.defaults ?? lastSessionDefaults ?? undefined;
return parsed?.rest === state.currentSessionKey; const previousDefaults = lastSessionDefaults;
}); const defaultsChanged = params.defaults
state.sessionInfo = { ? previousDefaults?.model !== params.defaults.model ||
thinkingLevel: entry?.thinkingLevel, previousDefaults?.modelProvider !== params.defaults.modelProvider ||
verboseLevel: entry?.verboseLevel, previousDefaults?.contextTokens !== params.defaults.contextTokens
reasoningLevel: entry?.reasoningLevel, : false;
model: entry?.model ?? result.defaults?.model ?? undefined, if (params.defaults) {
modelProvider: entry?.modelProvider ?? result.defaults?.modelProvider ?? undefined, lastSessionDefaults = params.defaults;
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens, }
inputTokens: entry?.inputTokens ?? null,
outputTokens: entry?.outputTokens ?? null, const entryUpdatedAt = entry?.updatedAt ?? null;
totalTokens: entry?.totalTokens ?? null, const currentUpdatedAt = state.sessionInfo.updatedAt ?? null;
responseUsage: entry?.responseUsage, const modelChanged =
updatedAt: entry?.updatedAt ?? null, (entry?.modelProvider !== undefined &&
displayName: entry?.displayName, entry.modelProvider !== state.sessionInfo.modelProvider) ||
}; (entry?.model !== undefined && entry.model !== state.sessionInfo.model);
} catch (err) { if (
chatLog.addSystem(`sessions list failed: ${String(err)}`); !params.force &&
} entryUpdatedAt !== null &&
updateAutocompleteProvider(); currentUpdatedAt !== null &&
updateFooter(); entryUpdatedAt < currentUpdatedAt &&
tui.requestRender(); !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 { try {
await refreshSessionInfoPromise; const resolveListAgentId = () => {
} finally { if (state.currentSessionKey === "global" || state.currentSessionKey === "unknown") {
refreshSessionInfoPromise = null; 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 () => { const loadHistory = async () => {
try { try {
const history = await client.loadHistory({ const history = await client.loadHistory({
@ -161,9 +303,12 @@ export function createSessionActions(context: SessionActionContext) {
messages?: unknown[]; messages?: unknown[];
sessionId?: string; sessionId?: string;
thinkingLevel?: string; thinkingLevel?: string;
verboseLevel?: string;
}; };
state.currentSessionId = typeof record.sessionId === "string" ? record.sessionId : null; state.currentSessionId = typeof record.sessionId === "string" ? record.sessionId : null;
state.sessionInfo.thinkingLevel = record.thinkingLevel ?? state.sessionInfo.thinkingLevel; 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.clearAll();
chatLog.addSystem(`session ${state.currentSessionKey}`); chatLog.addSystem(`session ${state.currentSessionKey}`);
for (const entry of record.messages ?? []) { for (const entry of record.messages ?? []) {
@ -195,6 +340,9 @@ export function createSessionActions(context: SessionActionContext) {
continue; continue;
} }
if (message.role === "toolResult") { if (message.role === "toolResult") {
if (!showTools) {
continue;
}
const toolCallId = asString(message.toolCallId, ""); const toolCallId = asString(message.toolCallId, "");
const toolName = asString(message.toolName, "tool"); const toolName = asString(message.toolName, "tool");
const component = chatLog.startTool(toolCallId, toolName, {}); const component = chatLog.startTool(toolCallId, toolName, {});
@ -227,6 +375,7 @@ export function createSessionActions(context: SessionActionContext) {
state.activeChatRunId = null; state.activeChatRunId = null;
state.currentSessionId = null; state.currentSessionId = null;
state.historyLoaded = false; state.historyLoaded = false;
clearLocalRunIds?.();
updateHeader(); updateHeader();
updateFooter(); updateFooter();
await loadHistory(); await loadHistory();
@ -255,6 +404,7 @@ export function createSessionActions(context: SessionActionContext) {
applyAgentsResult, applyAgentsResult,
refreshAgents, refreshAgents,
refreshSessionInfo, refreshSessionInfo,
applySessionInfoFromPatch,
loadHistory, loadHistory,
setSession, setSession,
abortActive, abortActive,

View File

@ -95,6 +95,7 @@ export async function runTui(opts: TuiOptions) {
let wasDisconnected = false; let wasDisconnected = false;
let toolsExpanded = false; let toolsExpanded = false;
let showThinking = false; let showThinking = false;
const localRunIds = new Set<string>();
const deliverDefault = opts.deliver ?? false; const deliverDefault = opts.deliver ?? false;
const autoMessage = opts.message?.trim(); 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({ const client = new GatewayChatClient({
url: opts.url, url: opts.url,
token: opts.token, token: opts.token,
@ -522,9 +546,16 @@ export async function runTui(opts: TuiOptions) {
updateFooter, updateFooter,
updateAutocompleteProvider, updateAutocompleteProvider,
setActivityStatus, setActivityStatus,
clearLocalRunIds,
}); });
const { refreshAgents, refreshSessionInfo, loadHistory, setSession, abortActive } = const {
sessionActions; refreshAgents,
refreshSessionInfo,
applySessionInfoFromPatch,
loadHistory,
setSession,
abortActive,
} = sessionActions;
const { handleChatEvent, handleAgentEvent } = createEventHandlers({ const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog, chatLog,
@ -532,6 +563,10 @@ export async function runTui(opts: TuiOptions) {
state, state,
setActivityStatus, setActivityStatus,
refreshSessionInfo, refreshSessionInfo,
loadHistory,
isLocalRunId,
forgetLocalRunId,
clearLocalRunIds,
}); });
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } = const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } =
@ -545,12 +580,15 @@ export async function runTui(opts: TuiOptions) {
openOverlay, openOverlay,
closeOverlay, closeOverlay,
refreshSessionInfo, refreshSessionInfo,
applySessionInfoFromPatch,
loadHistory, loadHistory,
setSession, setSession,
refreshAgents, refreshAgents,
abortActive, abortActive,
setActivityStatus, setActivityStatus,
formatSessionKey, formatSessionKey,
noteLocalRunId,
forgetLocalRunId,
}); });
const { runLocalShellLine } = createLocalShellRunner({ const { runLocalShellLine } = createLocalShellRunner({