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

* TUI/Gateway: fix pi streaming + tool routing

* Tests: clarify verbose tool output expectation

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
import type { TUI } from "@mariozechner/pi-tui";
import { describe, expect, it, vi } from "vitest";
import type { ChatLog } from "./components/chat-log.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
import { createEventHandlers } from "./tui-event-handlers.js";
type MockChatLog = {
startTool: ReturnType<typeof vi.fn>;
updateToolResult: ReturnType<typeof vi.fn>;
addSystem: ReturnType<typeof vi.fn>;
updateAssistant: ReturnType<typeof vi.fn>;
finalizeAssistant: ReturnType<typeof vi.fn>;
};
type MockChatLog = Pick<
ChatLog,
"startTool" | "updateToolResult" | "addSystem" | "updateAssistant" | "finalizeAssistant"
>;
type MockTui = Pick<TUI, "requestRender">;
describe("tui-event-handlers: handleAgentEvent", () => {
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
@@ -21,7 +21,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
currentSessionId: "session-1",
activeChatRunId: "run-1",
historyLoaded: true,
sessionInfo: {},
sessionInfo: { verboseLevel: "on" },
initialSessionApplied: true,
isConnected: true,
autoMessageSent: false,
@@ -42,21 +42,40 @@ describe("tui-event-handlers: handleAgentEvent", () => {
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
};
const tui = { requestRender: vi.fn() };
const tui: MockTui = { requestRender: vi.fn() };
const setActivityStatus = vi.fn();
const loadHistory = vi.fn();
const localRunIds = new Set<string>();
const noteLocalRunId = (runId: string) => {
localRunIds.add(runId);
};
const forgetLocalRunId = (runId: string) => {
localRunIds.delete(runId);
};
const isLocalRunId = (runId: string) => localRunIds.has(runId);
const clearLocalRunIds = () => {
localRunIds.clear();
};
return { chatLog, tui, state, setActivityStatus };
return {
chatLog,
tui,
state,
setActivityStatus,
loadHistory,
noteLocalRunId,
forgetLocalRunId,
isLocalRunId,
clearLocalRunIds,
};
};
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// Casts are fine here: TUI runtime shape is larger than we need in unit tests.
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -82,10 +101,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: "run-1" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -107,10 +124,14 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: "run-9" });
const { tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: { startTool: vi.fn(), updateToolResult: vi.fn() } as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog: {
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
},
tui,
state,
setActivityStatus,
});
@@ -131,10 +152,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -165,10 +184,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -194,14 +211,39 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("accepts tool events after chat final for the same run", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
});
handleChatEvent({
runId: "run-final",
sessionKey: state.currentSessionKey,
state: "final",
message: { content: [{ type: "text", text: "done" }] },
});
handleAgentEvent({
runId: "run-final",
stream: "tool",
data: { phase: "start", toolCallId: "tc-final", name: "session_status" },
});
expect(chatLog.startTool).toHaveBeenCalledWith("tc-final", "session_status", undefined);
expect(tui.requestRender).toHaveBeenCalled();
});
it("ignores lifecycle updates for non-active runs in the same session", () => {
const state = makeState({ activeChatRunId: "run-active" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
// oxlint-disable-next-line typescript/no-explicit-any
chatLog: chatLog as any,
// oxlint-disable-next-line typescript/no-explicit-any
tui: tui as any,
chatLog,
tui,
state,
setActivityStatus,
});
@@ -224,4 +266,95 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(setActivityStatus).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("suppresses tool events when verbose is off", () => {
const state = makeState({
activeChatRunId: "run-123",
sessionInfo: { verboseLevel: "off" },
});
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
});
handleAgentEvent({
runId: "run-123",
stream: "tool",
data: { phase: "start", toolCallId: "tc-off", name: "session_status" },
});
expect(chatLog.startTool).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("omits tool output when verbose is on (non-full)", () => {
const state = makeState({
activeChatRunId: "run-123",
sessionInfo: { verboseLevel: "on" },
});
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
});
handleAgentEvent({
runId: "run-123",
stream: "tool",
data: {
phase: "update",
toolCallId: "tc-on",
name: "session_status",
partialResult: { content: [{ type: "text", text: "secret" }] },
},
});
handleAgentEvent({
runId: "run-123",
stream: "tool",
data: {
phase: "result",
toolCallId: "tc-on",
name: "session_status",
result: { content: [{ type: "text", text: "secret" }] },
isError: false,
},
});
expect(chatLog.updateToolResult).toHaveBeenCalledTimes(1);
expect(chatLog.updateToolResult).toHaveBeenCalledWith(
"tc-on",
{ content: [] },
{ isError: false },
);
});
it("refreshes history after a non-local chat final", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus, loadHistory, isLocalRunId, forgetLocalRunId } =
makeContext(state);
const { handleChatEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
loadHistory,
isLocalRunId,
forgetLocalRunId,
});
handleChatEvent({
runId: "external-run",
sessionKey: state.currentSessionKey,
state: "final",
message: { content: [{ type: "text", text: "done" }] },
});
expect(loadHistory).toHaveBeenCalledTimes(1);
});
});

View File

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

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

View File

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