fix(tui): keep session modes scoped on switch (#117658)

* fix(tui): keep session footer modes truthful

* test(tui): record footer behavior evidence
This commit is contained in:
Peter Steinberger
2026-08-01 15:39:55 -07:00
committed by GitHub
parent ac532e141a
commit 08ea9cc31d
9 changed files with 342 additions and 121 deletions

View File

@@ -87,6 +87,7 @@ export type TuiSessionList = {
| "thinkingLevels"
| "fastMode"
| "verboseLevel"
| "traceLevel"
| "reasoningLevel"
| "model"
| "contextTokens"

View File

@@ -1,4 +1,5 @@
// Covers formatting helpers used by TUI status and message rendering.
import { Text, visibleWidth } from "@earendil-works/pi-tui";
import { describe, expect, it } from "vitest";
import { markInboundContextLabel } from "../auto-reply/reply/inbound-context-marker.js";
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js";
@@ -6,56 +7,106 @@ import {
extractContentFromMessage,
extractTextFromMessage,
extractThinkingFromMessage,
formatModelFooter,
formatGoalFooter,
formatTuiFooter,
formatTuiErrorMessage,
isCommandMessage,
sanitizeRenderableText,
} from "./tui-formatters.js";
describe("formatModelFooter", () => {
it("shows a compact model name and its active thinking level", () => {
describe("formatTuiFooter", () => {
it("shows session modes and the process delivery mode in one compact summary", () => {
expect(
formatModelFooter({
model: "gpt-5.6-sol@openai:setup-64cddea3-938c-431e-be3b-aa47090577c7",
formatTuiFooter({
agentLabel: "Main",
sessionLabel: "work",
sessionInfo: {
model: "gpt-5.6-sol@openai:setup-64cddea3-938c-431e-be3b-aa47090577c7",
fastMode: "auto",
verboseLevel: "full",
traceLevel: "raw",
reasoningLevel: "stream",
totalTokens: 1_200,
contextTokens: 128_000,
},
thinkingLevel: "high",
deliver: true,
}),
).toBe("gpt-5.6-sol high");
).toBe(
"agent Main | session work | gpt-5.6-sol high | fast:auto | verbose full | trace:raw | reasoning:stream | deliver:on | tokens 1.2k/128k (1%)",
);
});
});
describe("formatGoalFooter", () => {
it("renders active goal usage", () => {
it("keeps disabled session modes hidden while reporting disabled delivery", () => {
expect(
formatGoalFooter({
schemaVersion: 1,
id: "goal-1",
objective: "land PR",
status: "active",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokensUsed: 12_000,
tokenBudget: 30_000,
continuationTurns: 0,
formatTuiFooter({
agentLabel: "Main",
sessionLabel: "main",
sessionInfo: { model: "fixture-model" },
deliver: false,
}),
).toBe("Pursuing goal (12k/30k)");
).toBe("agent Main | session main | fixture-model | deliver:off | tokens ?");
});
it("wraps the compact summary within the terminal width", () => {
const summary = formatTuiFooter({
agentLabel: "Main",
sessionLabel: "a-long-session-name",
sessionInfo: {
model: "fixture-provider/a-long-model-name",
traceLevel: "raw",
reasoningLevel: "stream",
},
deliver: true,
});
expect(new Text(summary, 1, 0).render(48).every((line) => visibleWidth(line) <= 48)).toBe(true);
});
it("renders active goal usage", () => {
const footer = formatTuiFooter({
agentLabel: "Main",
sessionLabel: "main",
sessionInfo: {
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "land PR",
status: "active",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokensUsed: 12_000,
tokenBudget: 30_000,
continuationTurns: 0,
},
},
deliver: false,
});
expect(footer).toContain("Pursuing goal (12k/30k)");
});
it("renders resumable blocked goals", () => {
expect(
formatGoalFooter({
schemaVersion: 1,
id: "goal-1",
objective: "land PR",
status: "blocked",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokensUsed: 0,
continuationTurns: 0,
}),
).toBe("Goal blocked (/goal resume)");
const footer = formatTuiFooter({
agentLabel: "Main",
sessionLabel: "main",
sessionInfo: {
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "land PR",
status: "blocked",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokensUsed: 0,
continuationTurns: 0,
},
},
deliver: false,
});
expect(footer).toContain("Goal blocked (/goal resume)");
});
});

View File

@@ -10,6 +10,7 @@ import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.j
import { extractAssistantVisibleText } from "../shared/chat-message-content.js";
import { chunkTextByBreakResolver } from "../shared/text-chunking.js";
import { formatTokenCount } from "../utils/usage-format.js";
import type { SessionInfo } from "./tui-types.js";
const REPLACEMENT_CHAR_RE = /\uFFFD/g;
const MAX_TOKEN_CHARS = 32;
@@ -35,7 +36,7 @@ const FENCED_CODE_RE = /(```|~~~)[^\n]*\n[\s\S]*?\n\1[^\n]*/g;
const INLINE_CODE_RE = /(`+)(?:(?!\1).)+?\1/g;
/** Keep routing/provider/profile details in session state, not the compact footer. */
export function formatModelFooter(params: {
function formatModelFooter(params: {
model?: string | null;
thinkingLevel?: string | null;
}): string {
@@ -44,6 +45,39 @@ export function formatModelFooter(params: {
return thinkingLevel && thinkingLevel !== "off" ? `${model} ${thinkingLevel}` : model;
}
/** Format the compact TUI footer from authoritative session and process state. */
export function formatTuiFooter(params: {
agentLabel: string;
sessionLabel: string;
sessionInfo: SessionInfo;
thinkingLevel?: string | null;
deliver: boolean;
}): string {
const { sessionInfo } = params;
const fastLabel =
sessionInfo.fastMode === "auto" ? "fast:auto" : sessionInfo.fastMode === true ? "fast" : null;
const verbose = sessionInfo.verboseLevel ?? "off";
const trace = sessionInfo.traceLevel ?? "off";
const reasoning = sessionInfo.reasoningLevel ?? "off";
const traceLabel = trace === "raw" ? "trace:raw" : trace === "on" ? "trace" : null;
const reasoningLabel =
reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null;
return [
`agent ${params.agentLabel}`,
`session ${params.sessionLabel}`,
formatModelFooter({ model: sessionInfo.model, thinkingLevel: params.thinkingLevel }),
formatGoalFooter(sessionInfo.goal),
fastLabel,
verbose !== "off" ? `verbose ${verbose}` : null,
traceLabel,
reasoningLabel,
`deliver:${params.deliver ? "on" : "off"}`,
formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null),
]
.filter(Boolean)
.join(" | ");
}
function hasControlChars(text: string): boolean {
for (const char of text) {
const code = char.charCodeAt(0);
@@ -615,7 +649,7 @@ export function isCommandMessage(message: unknown): boolean {
return (message as Record<string, unknown>).command === true;
}
export function formatTokens(total?: number | null, context?: number | null) {
function formatTokens(total?: number | null, context?: number | null) {
if (total == null && context == null) {
return "tokens ?";
}
@@ -637,7 +671,7 @@ function formatGoalUsage(goal: SessionGoal): string | null {
return `${formatTokenCount(goal.tokensUsed)}/${formatTokenCount(goal.tokenBudget)}`;
}
export function formatGoalFooter(goal?: SessionGoal): string | null {
function formatGoalFooter(goal?: SessionGoal): string | null {
if (!goal) {
return null;
}

View File

@@ -37,6 +37,7 @@ export async function writeTuiPtyFixtureScript(dir: string) {
const footerModel = process.env.OPENCLAW_TUI_PTY_MODEL;
const footerThinkingLevel = process.env.OPENCLAW_TUI_PTY_THINKING_LEVEL;
let verboseLevel = process.env.OPENCLAW_TUI_PTY_VERBOSE_LEVEL;
let modeTargetTraceLevel: string | undefined;
const launchThinkingLevel = process.env.OPENCLAW_TUI_PTY_LAUNCH_THINKING;
const initialMessage = process.env.OPENCLAW_TUI_PTY_INITIAL_MESSAGE;
const enablePickerFixture = process.env.OPENCLAW_TUI_PTY_PICKER_FIXTURE === "1";
@@ -76,15 +77,23 @@ export async function writeTuiPtyFixtureScript(dir: string) {
}
function sessionEntry(key = "main") {
const isModeSource = key.endsWith(":mode-source");
const isModeTarget = key.endsWith(":mode-target");
const entryFastMode = isModeSource ? true : isModeTarget ? undefined : fastMode;
const entryVerboseLevel = isModeSource ? "full" : isModeTarget ? undefined : verboseLevel;
const entryTraceLevel = isModeSource ? "raw" : isModeTarget ? modeTargetTraceLevel : undefined;
const entryReasoningLevel = isModeSource ? "stream" : undefined;
return {
key,
displayName: "Main",
model: currentModel,
modelProvider: "fixture-provider",
contextTokens: 128,
fastMode,
...(entryFastMode !== undefined ? { fastMode: entryFastMode } : {}),
...(currentThinkingLevel ? { thinkingLevel: currentThinkingLevel } : {}),
...(verboseLevel ? { verboseLevel } : {}),
...(entryVerboseLevel ? { verboseLevel: entryVerboseLevel } : {}),
...(entryTraceLevel ? { traceLevel: entryTraceLevel } : {}),
...(entryReasoningLevel ? { reasoningLevel: entryReasoningLevel } : {}),
thinkingLevels: [],
};
}
@@ -399,10 +408,12 @@ export async function writeTuiPtyFixtureScript(dir: string) {
messages: [{ role: "user", content: rapidSwitchMarker + "_HISTORY_MARKER" }],
};
}
const includeSessionInfo =
Boolean(footerModel) || sessionKey.endsWith(":mode-source") || sessionKey.endsWith(":mode-target");
return {
messages: [],
fastMode,
...(footerModel
...(includeSessionInfo
? {
thinkingLevel: footerThinkingLevel,
sessionInfo: sessionEntry(sessionKey),
@@ -455,6 +466,9 @@ export async function writeTuiPtyFixtureScript(dir: string) {
if (typeof opts.verboseLevel === "string") {
verboseLevel = opts.verboseLevel;
}
if (typeof opts.traceLevel === "string" && opts.key.endsWith(":mode-target")) {
modeTargetTraceLevel = opts.traceLevel;
}
return {
ok: true,
path: "",
@@ -554,7 +568,7 @@ export async function writeTuiPtyFixtureScript(dir: string) {
},
session: { scope: "per-sender", mainKey: "main" },
},
deliver: false,
deliver: process.env.OPENCLAW_TUI_PTY_DELIVER === "1",
thinking: launchThinkingLevel,
message: initialMessage,
historyLimit: 5,

View File

@@ -131,10 +131,76 @@ describe.sequential("TUI PTY harness", () => {
STARTUP_TEST_TIMEOUT_MS,
);
it(
"keeps session modes scoped while trace changes and delivery stays process-owned",
async () => {
const modeFixture = await startTuiFixture({
env: {
OPENCLAW_TUI_PTY_DELIVER: "1",
OPENCLAW_TUI_PTY_MODEL: "fixture-model",
},
});
try {
await modeFixture.run.waitForOutput("deliver:on", STARTUP_TIMEOUT_MS);
await modeFixture.run.write("/session agent:main:mode-source\r", { delay: false });
await modeFixture.waitForLogEntry(
(entry) =>
entry.method === "loadHistory" &&
objectFieldEquals(entry, "sessionKey", "agent:main:mode-source"),
);
await modeFixture.run.waitForOutput(
"trace:raw | reasoning:stream | deliver:on",
STARTUP_TIMEOUT_MS,
);
const targetOutputOffset = modeFixture.run.visibleOutput().length;
await modeFixture.run.write("/session agent:main:mode-target\r", { delay: false });
await modeFixture.waitForLogEntry(
(entry) =>
entry.method === "loadHistory" &&
objectFieldEquals(entry, "sessionKey", "agent:main:mode-target"),
);
await modeFixture.run.waitForOutput("session mode-target", STARTUP_TIMEOUT_MS);
const targetOutput = modeFixture.run.visibleOutput().slice(targetOutputOffset);
expect(targetOutput).toContain("deliver:on");
expect(targetOutput).not.toContain("fast:auto");
expect(targetOutput).not.toContain("verbose full");
expect(targetOutput).not.toContain("trace:raw");
expect(targetOutput).not.toContain("reasoning:stream");
await modeFixture.run.write("/trace on\r", { delay: false });
await modeFixture.waitForLogEntry(
(entry) =>
entry.method === "patchSession" && objectFieldEquals(entry, "traceLevel", "on"),
);
await modeFixture.run.waitForOutput("trace | deliver:on", STARTUP_TIMEOUT_MS);
await modeFixture.run.write("delivery proof\r", { delay: false });
const sent = await modeFixture.waitForLogEntry(
(entry) =>
entry.method === "sendChat" && objectFieldEquals(entry, "message", "delivery proof"),
);
expect(sent.payload).toMatchObject({ deliver: true });
console.log(
`[behavior-evidence] tui-session-footer ${JSON.stringify({
terminal: "real PTY",
sourceModesVisible: true,
targetModesCleared: true,
traceTransitionVisible: true,
fixedDeliveryPropagated: true,
})}`,
);
} finally {
await modeFixture.cleanup();
}
},
STARTUP_TEST_TIMEOUT_MS,
);
it(
"keeps the launch thinking override active across session-level changes",
async () => {
const footerNeedle = "fixture-provider/fixture-model high | tokens";
const footerNeedle = "fixture-provider/fixture-model high | deliver:off | tokens";
await thinkingOverrideFixture.run.waitForOutput(footerNeedle, STARTUP_TIMEOUT_MS);
await thinkingOverrideFixture.run.waitForOutput(
"PTY_RESPONSE: thinking override proof",
@@ -167,9 +233,11 @@ describe.sequential("TUI PTY harness", () => {
.visibleOutput()
.slice(sessionChangeOutputOffset);
expect(outputAfterSessionChange).toContain(footerNeedle);
expect(outputAfterSessionChange).not.toContain("fixture-provider/fixture-model low | tokens");
expect(outputAfterSessionChange).not.toContain(
"fixture-provider/fixture-model medium | tokens",
"fixture-provider/fixture-model low | deliver:off | tokens",
);
expect(outputAfterSessionChange).not.toContain(
"fixture-provider/fixture-model medium | deliver:off | tokens",
);
},
STARTUP_TEST_TIMEOUT_MS,

View File

@@ -1264,6 +1264,64 @@ describe("tui session actions", () => {
expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
});
it("replaces session-scoped modes when switching to a session without overrides", async () => {
const state = createBaseState({
currentSessionKey: "agent:main:source",
sessionInfo: {
fastMode: true,
verboseLevel: "full",
traceLevel: "raw",
reasoningLevel: "stream",
},
});
const loadHistory = vi.fn().mockResolvedValue({
sessionInfo: {
key: "agent:main:target",
sessionId: "session-target",
},
messages: [],
});
const { setSession } = createTestSessionActions({
client: { listSessions: vi.fn(), loadHistory } as unknown as TuiBackend,
state,
});
await setSession("agent:main:target");
expect(state.sessionInfo).toMatchObject({
fastMode: undefined,
verboseLevel: undefined,
traceLevel: undefined,
reasoningLevel: undefined,
});
});
it("merges a same-session mode patch without clearing untouched modes", () => {
const state = createBaseState({
sessionInfo: {
fastMode: true,
verboseLevel: "full",
traceLevel: "off",
reasoningLevel: "stream",
},
});
const { applySessionInfoFromPatch } = createTestSessionActions({ state });
applySessionInfoFromPatch({
ok: true,
path: "/sessions/patch",
key: "agent:main:main",
entry: { traceLevel: "raw" },
});
expect(state.sessionInfo).toMatchObject({
fastMode: true,
verboseLevel: "full",
traceLevel: "raw",
reasoningLevel: "stream",
});
});
it("keeps the newer session when an earlier switch's history resolves last", async () => {
const historyA = createDeferred<unknown>();
const historyB = createDeferred<unknown>();

View File

@@ -19,6 +19,12 @@ import {
isCommandMessage,
} from "./tui-formatters.js";
import { readTuiSessionUserMessage } from "./tui-session-events.js";
import {
clearTuiSessionModeOverrides,
sessionInfoUiEquals,
type SessionInfoDefaults,
type SessionInfoEntry,
} from "./tui-session-info.js";
import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js";
import {
getTuiSessionProjection,
@@ -26,7 +32,7 @@ import {
reduceTuiSessionProjection,
} from "./tui-session-projection.js";
import * as submit from "./tui-submit-state.js";
import type { SessionInfo, TuiHistoryLoadResult, TuiOptions, TuiStateAccess } from "./tui-types.js";
import type { TuiHistoryLoadResult, TuiOptions, TuiStateAccess } from "./tui-types.js";
type SessionActionBtwPresenter = {
clear: () => void;
@@ -51,47 +57,6 @@ type SessionActionContext = {
rememberSessionKey?: (sessionKey: string) => void | Promise<void>;
};
type SessionInfoDefaults = {
model?: string | null;
modelProvider?: string | null;
contextTokens?: number | null;
thinkingLevels?: Array<{ id: string; label: string }>;
};
type SessionInfoEntry = SessionInfo & {
key?: string;
sessionId?: string;
modelOverride?: string;
providerOverride?: string;
};
function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean {
return (
left.thinkingLevel === right.thinkingLevel &&
(left.thinkingLevels === right.thinkingLevels ||
JSON.stringify(left.thinkingLevels ?? null) ===
JSON.stringify(right.thinkingLevels ?? null)) &&
left.fastMode === right.fastMode &&
left.verboseLevel === right.verboseLevel &&
left.traceLevel === right.traceLevel &&
left.reasoningLevel === right.reasoningLevel &&
left.model === right.model &&
left.modelProvider === right.modelProvider &&
left.agentRuntime?.id === right.agentRuntime?.id &&
left.agentRuntime?.source === right.agentRuntime?.source &&
left.agentRuntime?.fallback === right.agentRuntime?.fallback &&
left.contextTokens === right.contextTokens &&
left.inputTokens === right.inputTokens &&
left.outputTokens === right.outputTokens &&
left.totalTokens === right.totalTokens &&
left.responseUsage === right.responseUsage &&
left.effectiveResponseUsage === right.effectiveResponseUsage &&
left.displayName === right.displayName &&
(left.goal === right.goal ||
JSON.stringify(left.goal ?? null) === JSON.stringify(right.goal ?? null))
);
}
export function createSessionActions(context: SessionActionContext) {
const {
client,
@@ -661,6 +626,7 @@ export function createSessionActions(context: SessionActionContext) {
setActivityStatus("idle");
if (selectionChanged) {
state.currentSessionId = null;
clearTuiSessionModeOverrides(state.sessionInfo);
}
// Session keys can move backwards in updatedAt ordering; drop previous session freshness
// so refresh data for the newly selected session isn't rejected as stale.

View File

@@ -0,0 +1,51 @@
import type { SessionInfo } from "./tui-types.js";
export type SessionInfoDefaults = {
model?: string | null;
modelProvider?: string | null;
contextTokens?: number | null;
thinkingLevels?: Array<{ id: string; label: string }>;
};
export type SessionInfoEntry = SessionInfo & {
key?: string;
sessionId?: string;
modelOverride?: string;
providerOverride?: string;
};
/** Compare only session facts that change visible TUI behavior. */
export function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean {
return (
left.thinkingLevel === right.thinkingLevel &&
(left.thinkingLevels === right.thinkingLevels ||
JSON.stringify(left.thinkingLevels ?? null) ===
JSON.stringify(right.thinkingLevels ?? null)) &&
left.fastMode === right.fastMode &&
left.verboseLevel === right.verboseLevel &&
left.traceLevel === right.traceLevel &&
left.reasoningLevel === right.reasoningLevel &&
left.model === right.model &&
left.modelProvider === right.modelProvider &&
left.agentRuntime?.id === right.agentRuntime?.id &&
left.agentRuntime?.source === right.agentRuntime?.source &&
left.agentRuntime?.fallback === right.agentRuntime?.fallback &&
left.contextTokens === right.contextTokens &&
left.inputTokens === right.inputTokens &&
left.outputTokens === right.outputTokens &&
left.totalTokens === right.totalTokens &&
left.responseUsage === right.responseUsage &&
left.effectiveResponseUsage === right.effectiveResponseUsage &&
left.displayName === right.displayName &&
(left.goal === right.goal ||
JSON.stringify(left.goal ?? null) === JSON.stringify(right.goal ?? null))
);
}
/** Clear selection-owned modes so a switch cannot display its predecessor while loading. */
export function clearTuiSessionModeOverrides(sessionInfo: SessionInfo): void {
sessionInfo.fastMode = undefined;
sessionInfo.verboseLevel = undefined;
sessionInfo.traceLevel = undefined;
sessionInfo.reasoningLevel = undefined;
}

View File

@@ -44,12 +44,7 @@ import { editorTheme, theme } from "./theme/theme.js";
import type { TuiBackend } from "./tui-backend.js";
import { createCommandHandlers } from "./tui-command-handlers.js";
import { createEventHandlers } from "./tui-event-handlers.js";
import {
formatGoalFooter,
formatModelFooter,
formatTuiErrorMessage,
formatTokens,
} from "./tui-formatters.js";
import { formatTuiFooter, formatTuiErrorMessage } from "./tui-formatters.js";
import {
buildTuiLastSessionScopeKey,
readTuiLastSessionKey,
@@ -1229,35 +1224,18 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
? `${sessionKeyLabel} (${state.sessionInfo.displayName})`
: sessionKeyLabel;
const agentLabel = formatAgentLabel(state.currentAgentId);
const modelLabel = formatModelFooter({
model: state.sessionInfo.model,
thinkingLevel: thinkingLevelOverride ?? state.sessionInfo.thinkingLevel,
});
const tokens = formatTokens(
state.sessionInfo.totalTokens ?? null,
state.sessionInfo.contextTokens ?? null,
footer.setText(
theme.dim(
formatTuiFooter({
agentLabel,
sessionLabel,
sessionInfo: state.sessionInfo,
thinkingLevel: thinkingLevelOverride ?? state.sessionInfo.thinkingLevel,
// Delivery is fixed at launch; session switches and patches cannot change it.
deliver: deliverDefault,
}),
),
);
const fastLabel =
state.sessionInfo.fastMode === "auto"
? "fast:auto"
: state.sessionInfo.fastMode === true
? "fast"
: null;
const verbose = state.sessionInfo.verboseLevel ?? "off";
const reasoning = state.sessionInfo.reasoningLevel ?? "off";
const reasoningLabel =
reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null;
const footerParts = [
`agent ${agentLabel}`,
`session ${sessionLabel}`,
modelLabel,
formatGoalFooter(state.sessionInfo.goal),
fastLabel,
verbose !== "off" ? `verbose ${verbose}` : null,
reasoningLabel,
tokens,
].filter(Boolean);
footer.setText(theme.dim(footerParts.join(" | ")));
};
const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);