mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(tui): fence session state and delayed actions (#129810)
This commit is contained in:
committed by
GitHub
parent
5005661473
commit
b8c6996eed
@@ -770,6 +770,70 @@ describe("tui command handlers", () => {
|
||||
expect(refreshSessionInfo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "a different agent", replace: false, fails: false },
|
||||
{ name: "a replacement session", replace: true, fails: false },
|
||||
{ name: "a rejected old goal", replace: false, fails: true },
|
||||
])("keeps a delayed local goal from leaking into $name", async ({ replace, fails }) => {
|
||||
const deferred = createDeferred<{ text: string; continuationPrompt?: string }>();
|
||||
const harness = createHarness({
|
||||
opts: { local: true },
|
||||
currentAgentId: "research",
|
||||
currentSessionKey: "agent:research:private",
|
||||
currentSessionId: "research-session",
|
||||
sessionGeneration: 4,
|
||||
runGoalCommand: vi.fn(() => deferred.promise),
|
||||
});
|
||||
|
||||
const pending = harness.handleCommand("/goal start private objective");
|
||||
if (replace) {
|
||||
harness.state.currentSessionId = "replacement-session";
|
||||
harness.state.sessionGeneration += 1;
|
||||
} else {
|
||||
harness.state.currentAgentId = "ops";
|
||||
harness.state.currentSessionKey = "agent:ops:public";
|
||||
harness.state.currentSessionId = "ops-session";
|
||||
}
|
||||
if (fails) {
|
||||
deferred.reject(new Error("private research failure"));
|
||||
} else {
|
||||
deferred.resolve({
|
||||
text: "private research goal status",
|
||||
continuationPrompt: "PRIVATE_RESEARCH_OBJECTIVE",
|
||||
});
|
||||
}
|
||||
await pending;
|
||||
|
||||
expect(harness.sendChat).not.toHaveBeenCalled();
|
||||
expect(harness.addSystem).not.toHaveBeenCalled();
|
||||
expect(harness.refreshSessionInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send an old goal continuation after its session changes during refresh", async () => {
|
||||
const refresh = createDeferred();
|
||||
const harness = createHarness({
|
||||
opts: { local: true },
|
||||
currentAgentId: "research",
|
||||
currentSessionKey: "agent:research:private",
|
||||
currentSessionId: "research-session",
|
||||
runGoalCommand: vi.fn().mockResolvedValue({
|
||||
text: "private research goal status",
|
||||
continuationPrompt: "PRIVATE_RESEARCH_OBJECTIVE",
|
||||
}),
|
||||
refreshSessionInfo: vi.fn(() => refresh.promise),
|
||||
});
|
||||
|
||||
const pending = harness.handleCommand("/goal start private objective");
|
||||
await vi.waitFor(() => expect(harness.refreshSessionInfo).toHaveBeenCalledOnce());
|
||||
harness.state.currentAgentId = "ops";
|
||||
harness.state.currentSessionKey = "agent:ops:public";
|
||||
harness.state.currentSessionId = "ops-session";
|
||||
refresh.resolve();
|
||||
await pending;
|
||||
|
||||
expect(harness.sendChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wraps command-prefixed local goal objectives before sending", async () => {
|
||||
const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
|
||||
const slashRunGoalCommand = vi
|
||||
@@ -1587,6 +1651,87 @@ describe("tui command handlers", () => {
|
||||
expect(loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "a different agent", replace: false, fails: false },
|
||||
{ name: "a replacement session", replace: true, fails: false },
|
||||
{ name: "a rejected old session", replace: false, fails: true },
|
||||
])("does not let delayed /new hijack $name", async ({ replace, fails }) => {
|
||||
const deferred = createDeferred<{ ok: true; key: string }>();
|
||||
const harness = createHarness({
|
||||
currentAgentId: "research",
|
||||
currentSessionKey: "agent:research:private",
|
||||
currentSessionId: "research-session",
|
||||
sessionGeneration: 4,
|
||||
sessionInfo: { inputTokens: 11, outputTokens: 22, totalTokens: 33 },
|
||||
createSession: vi.fn(() => deferred.promise),
|
||||
});
|
||||
|
||||
const pending = harness.handleCommand("/new");
|
||||
if (replace) {
|
||||
harness.state.currentSessionId = "replacement-session";
|
||||
harness.state.sessionGeneration += 1;
|
||||
} else {
|
||||
harness.state.currentAgentId = "ops";
|
||||
harness.state.currentSessionKey = "agent:ops:public";
|
||||
harness.state.currentSessionId = "ops-session";
|
||||
}
|
||||
if (fails) {
|
||||
deferred.reject(new Error("private research session failure"));
|
||||
} else {
|
||||
deferred.resolve({ ok: true, key: "agent:research:private-child" });
|
||||
}
|
||||
await pending;
|
||||
|
||||
expect(harness.setSession).not.toHaveBeenCalled();
|
||||
expect(harness.addSystem).not.toHaveBeenCalled();
|
||||
expect(harness.state.sessionInfo).toMatchObject({
|
||||
inputTokens: 11,
|
||||
outputTokens: 22,
|
||||
totalTokens: 33,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "reports an adopted session failure", fails: true, switches: false },
|
||||
{ name: "does not leak an adopted session notice", fails: false, switches: true },
|
||||
])("$name after /new changes the selected session", async ({ fails, switches }) => {
|
||||
const adoption = createDeferred();
|
||||
const createdKey = "agent:research:private-child";
|
||||
const harness = createHarness({
|
||||
currentAgentId: "research",
|
||||
currentSessionKey: "agent:research:private",
|
||||
currentSessionId: "research-session",
|
||||
createSession: vi.fn().mockResolvedValue({ ok: true, key: createdKey }),
|
||||
setSession: vi.fn((key: string) => {
|
||||
harness.state.currentSessionKey = key;
|
||||
harness.state.currentSessionId = null;
|
||||
return adoption.promise;
|
||||
}) as SetSessionMock,
|
||||
});
|
||||
|
||||
const pending = harness.handleCommand("/new");
|
||||
await vi.waitFor(() => expect(harness.setSession).toHaveBeenCalledOnce());
|
||||
if (switches) {
|
||||
harness.state.currentAgentId = "ops";
|
||||
harness.state.currentSessionKey = "agent:ops:public";
|
||||
harness.state.currentSessionId = "ops-session";
|
||||
}
|
||||
if (fails) {
|
||||
adoption.reject(new Error("replacement history unavailable"));
|
||||
} else {
|
||||
adoption.resolve();
|
||||
}
|
||||
await pending;
|
||||
|
||||
if (fails) {
|
||||
expect(harness.addSystem).toHaveBeenCalledWith(
|
||||
"new session failed: replacement history unavailable",
|
||||
);
|
||||
} else {
|
||||
expect(harness.addSystem).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a reset after adopting the backend's replacement session key", async () => {
|
||||
const resetResult = {
|
||||
ok: true as const,
|
||||
@@ -3120,21 +3265,58 @@ describe("tui command handlers", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "session result", sessionKey: "agent:main:first", agentId: "main", fails: false },
|
||||
{ name: "global-agent result", sessionKey: "global", agentId: "main", fails: false },
|
||||
{ name: "session failure", sessionKey: "agent:main:first", agentId: "main", fails: true },
|
||||
])("suppresses a stale usage-cost $name", async ({ sessionKey, agentId, fails }) => {
|
||||
{
|
||||
name: "session result",
|
||||
sessionKey: "agent:main:first",
|
||||
agentId: "main",
|
||||
fails: false,
|
||||
replace: false,
|
||||
},
|
||||
{
|
||||
name: "global-agent result",
|
||||
sessionKey: "global",
|
||||
agentId: "main",
|
||||
fails: false,
|
||||
replace: false,
|
||||
},
|
||||
{
|
||||
name: "session failure",
|
||||
sessionKey: "agent:main:first",
|
||||
agentId: "main",
|
||||
fails: true,
|
||||
replace: false,
|
||||
},
|
||||
{
|
||||
name: "replacement-session result",
|
||||
sessionKey: "agent:main:first",
|
||||
agentId: "main",
|
||||
fails: false,
|
||||
replace: true,
|
||||
},
|
||||
{
|
||||
name: "replacement-session failure",
|
||||
sessionKey: "agent:main:first",
|
||||
agentId: "main",
|
||||
fails: true,
|
||||
replace: true,
|
||||
},
|
||||
])("suppresses a stale usage-cost $name", async ({ sessionKey, agentId, fails, replace }) => {
|
||||
const deferred = createDeferred<{ text: string }>();
|
||||
const runUsageCostCommand = vi.fn(() => deferred.promise);
|
||||
const harness = createHarness({
|
||||
opts: { local: true },
|
||||
currentSessionKey: sessionKey,
|
||||
currentAgentId: agentId,
|
||||
currentSessionId: "original-session",
|
||||
sessionGeneration: 4,
|
||||
runUsageCostCommand,
|
||||
});
|
||||
|
||||
const pending = harness.handleCommand("/usage cost");
|
||||
if (sessionKey === "global") {
|
||||
if (replace) {
|
||||
harness.state.currentSessionId = "replacement-session";
|
||||
harness.state.sessionGeneration += 1;
|
||||
} else if (sessionKey === "global") {
|
||||
harness.state.currentAgentId = "work";
|
||||
} else {
|
||||
harness.state.currentSessionKey = "agent:main:second";
|
||||
|
||||
@@ -560,19 +560,24 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
},
|
||||
goal: async (_args, raw) => {
|
||||
if (opts.local === true && client.runGoalCommand) {
|
||||
const { selection, isCurrent } = captureSessionIncarnation();
|
||||
try {
|
||||
const result = await client.runGoalCommand({
|
||||
sessionKey: state.currentSessionKey,
|
||||
agentId: state.currentAgentId,
|
||||
...selection,
|
||||
command: raw,
|
||||
});
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
chatLog.addSystem(result.text);
|
||||
await refreshSessionInfo();
|
||||
if (result.continuationPrompt) {
|
||||
if (result.continuationPrompt && isCurrent()) {
|
||||
await sendMessage(result.continuationPrompt);
|
||||
}
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`);
|
||||
if (isCurrent()) {
|
||||
chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await sendMessage(raw);
|
||||
@@ -703,14 +708,14 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
addUnsupportedLocalCommand("usage cost");
|
||||
return;
|
||||
}
|
||||
const selection = captureSessionSelection();
|
||||
const { selection, isCurrent } = captureSessionIncarnation();
|
||||
try {
|
||||
const result = await client.runUsageCostCommand(selection);
|
||||
if (isCurrentSessionSelection(selection)) {
|
||||
if (isCurrent()) {
|
||||
chatLog.addSystem(result.text);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isCurrentSessionSelection(selection)) {
|
||||
if (isCurrent()) {
|
||||
chatLog.addSystem(`usage cost failed: ${formatTuiErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -773,27 +778,31 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
if (rejectUnsafeSessionRollover("new")) {
|
||||
return;
|
||||
}
|
||||
let creationIncarnation = captureSessionIncarnation();
|
||||
const { selection, sessionId } = creationIncarnation;
|
||||
const finishSessionTransition = beginSessionTransition("new");
|
||||
try {
|
||||
const uniqueKey = `tui-${randomUUID()}`;
|
||||
const result = await client.createSession({
|
||||
key: uniqueKey,
|
||||
agentId: state.currentAgentId,
|
||||
...(state.currentSessionId
|
||||
? { parentSessionKey: state.currentSessionKey, succeedsParent: true }
|
||||
: {}),
|
||||
key: `tui-${randomUUID()}`,
|
||||
agentId: selection.agentId,
|
||||
...(sessionId ? { parentSessionKey: selection.sessionKey, succeedsParent: true } : {}),
|
||||
});
|
||||
if (!creationIncarnation.isCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (!result.key) {
|
||||
throw new Error("sessions.create returned no session key");
|
||||
}
|
||||
state.sessionInfo.inputTokens = null;
|
||||
state.sessionInfo.outputTokens = null;
|
||||
state.sessionInfo.totalTokens = null;
|
||||
tui.requestRender();
|
||||
await setSession(result.key);
|
||||
chatLog.addSystem(`new session: ${result.key}`);
|
||||
const adoption = setSession(result.key);
|
||||
creationIncarnation = captureSessionIncarnation();
|
||||
await adoption;
|
||||
if (creationIncarnation.isCurrent()) {
|
||||
chatLog.addSystem(`new session: ${result.key}`);
|
||||
}
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`);
|
||||
if (creationIncarnation.isCurrent()) {
|
||||
chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
finishSessionTransition();
|
||||
}
|
||||
|
||||
@@ -200,7 +200,19 @@ describe("tui session actions", () => {
|
||||
activeChatRunId: "research-run",
|
||||
pendingSubmit: acceptedSubmit("research-pending", "private draft"),
|
||||
historyLoaded: true,
|
||||
sessionInfo: { displayName: "Research secret", updatedAt: 100, verboseLevel: "full" },
|
||||
sessionInfo: {
|
||||
displayName: "Research secret",
|
||||
updatedAt: 100,
|
||||
modelProvider: "anthropic",
|
||||
model: "private-research-model",
|
||||
thinkingLevel: "high",
|
||||
thinkingLevels: [{ id: "high", label: "high" }],
|
||||
agentRuntime: { id: "private-runtime", source: "agent" },
|
||||
responseUsage: "full",
|
||||
effectiveResponseUsage: "full",
|
||||
contextTokens: 999_999,
|
||||
verboseLevel: "full",
|
||||
},
|
||||
});
|
||||
sendPendingUser(state, "research-pending", "private draft");
|
||||
const chatLog = new ChatLog();
|
||||
@@ -208,6 +220,7 @@ describe("tui session actions", () => {
|
||||
const history = createDeferred<{
|
||||
messages: unknown[];
|
||||
sessionInfo: { sessionId: string };
|
||||
defaults: { modelProvider: string; model: string; contextTokens: number };
|
||||
}>();
|
||||
const loadHistory = vi.fn(() => history.promise);
|
||||
const invalidateRunOwnership = vi.fn();
|
||||
@@ -229,7 +242,7 @@ describe("tui session actions", () => {
|
||||
expect(state.currentAgentId).toBe("ops");
|
||||
expect(state.currentSessionKey).toBe("global");
|
||||
expect(state.currentSessionId).toBeNull();
|
||||
expect(state.sessionInfo.displayName).toBeUndefined();
|
||||
expect(state.sessionInfo).toEqual({});
|
||||
expect(state.activeChatRunId).toBeNull();
|
||||
expect(state.pendingSubmit).toBeNull();
|
||||
expect(state.historyLoaded).toBe(false);
|
||||
@@ -239,9 +252,21 @@ describe("tui session actions", () => {
|
||||
expect(clearLocalRunIds).toHaveBeenCalledOnce();
|
||||
expect(loadHistory).toHaveBeenCalledWith({ sessionKey: "global", agentId: "ops", limit: 200 });
|
||||
|
||||
history.resolve({ messages: [], sessionInfo: { sessionId: "ops-session" } });
|
||||
history.resolve({
|
||||
messages: [],
|
||||
sessionInfo: { sessionId: "ops-session" },
|
||||
defaults: { modelProvider: "openai", model: "gpt-5.4", contextTokens: 128_000 },
|
||||
});
|
||||
await switching;
|
||||
expect(state.currentSessionId).toBe("ops-session");
|
||||
expect(state.sessionInfo).toMatchObject({
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
contextTokens: 128_000,
|
||||
});
|
||||
expect(state.sessionInfo.thinkingLevel).toBeUndefined();
|
||||
expect(state.sessionInfo.agentRuntime).toBeUndefined();
|
||||
expect(state.sessionInfo.responseUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns success after applying a normalized fresh agent roster", async () => {
|
||||
@@ -397,9 +422,9 @@ describe("tui session actions", () => {
|
||||
activeChatRunId: null,
|
||||
pendingSubmit: null,
|
||||
historyLoaded: false,
|
||||
sessionInfo: { updatedAt: null },
|
||||
sessionInfo: {},
|
||||
});
|
||||
expect(state.sessionInfo.thinkingLevel).toBe("high");
|
||||
expect(state.sessionInfo.thinkingLevel).toBeUndefined();
|
||||
expect(state.sessionInfo.verboseLevel).toBeUndefined();
|
||||
expect(state.sessionProjection?.entries).toEqual([]);
|
||||
expect(invalidateRunOwnership).toHaveBeenCalledOnce();
|
||||
@@ -1685,13 +1710,15 @@ describe("tui session actions", () => {
|
||||
|
||||
await setSession("agent:main:target");
|
||||
|
||||
expect(state.sessionInfo).toMatchObject({
|
||||
displayName: undefined,
|
||||
fastMode: undefined,
|
||||
verboseLevel: undefined,
|
||||
traceLevel: undefined,
|
||||
reasoningLevel: undefined,
|
||||
});
|
||||
for (const key of [
|
||||
"displayName",
|
||||
"fastMode",
|
||||
"verboseLevel",
|
||||
"traceLevel",
|
||||
"reasoningLevel",
|
||||
] as const) {
|
||||
expect(state.sessionInfo[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("merges a same-session mode patch without clearing untouched modes", () => {
|
||||
@@ -2198,6 +2225,40 @@ describe("tui session actions", () => {
|
||||
expect(clearAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("discards in-flight history when an external event replaces the same session", async () => {
|
||||
const history = createDeferred<unknown>();
|
||||
const { chatLog, addUser, clearAll } = createHistoryChatLog();
|
||||
const state = createBaseState({
|
||||
currentSessionId: "session-before-reset",
|
||||
sessionGeneration: 4,
|
||||
sessionInfo: { model: "model-before-reset" },
|
||||
});
|
||||
const { loadHistory } = createTestSessionActions({
|
||||
client: makeTuiBackend({ loadHistory: vi.fn(() => history.promise) }),
|
||||
chatLog,
|
||||
state,
|
||||
});
|
||||
|
||||
const staleHistory = loadHistory();
|
||||
state.sessionGeneration = 5;
|
||||
state.currentSessionId = "session-after-reset";
|
||||
state.sessionInfo = { model: "model-after-reset" };
|
||||
history.resolve({
|
||||
sessionInfo: {
|
||||
key: "agent:main:main",
|
||||
sessionId: "session-before-reset",
|
||||
model: "private-old-model",
|
||||
},
|
||||
messages: [{ role: "user", content: "PRIVATE OLD HISTORY" }],
|
||||
});
|
||||
|
||||
await expect(staleHistory).resolves.toEqual({ loaded: false });
|
||||
expect(state.currentSessionId).toBe("session-after-reset");
|
||||
expect(state.sessionInfo.model).toBe("model-after-reset");
|
||||
expect(addUser).not.toHaveBeenCalled();
|
||||
expect(clearAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not clear the selected session for another session's reset result", () => {
|
||||
const addSystem = vi.fn();
|
||||
const clearAll = vi.fn();
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "./tui-formatters.js";
|
||||
import { readTuiSessionUserMessage } from "./tui-session-events.js";
|
||||
import {
|
||||
clearTuiSessionModeOverrides,
|
||||
sessionInfoUiEquals,
|
||||
type SessionInfoDefaults,
|
||||
type SessionInfoEntry,
|
||||
@@ -111,11 +110,8 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
submit.clearPendingSubmit(state);
|
||||
setActivityStatus("idle");
|
||||
state.currentSessionId = null;
|
||||
state.sessionInfo.displayName = undefined;
|
||||
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.
|
||||
state.sessionInfo.updatedAt = null;
|
||||
state.sessionInfo = {};
|
||||
lastSessionDefaults = null;
|
||||
state.historyLoaded = false;
|
||||
// Live prompt identities belong to the old selection, not its pending successor.
|
||||
chatLog.clearAll();
|
||||
@@ -455,9 +451,12 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
// History rebuilds mutate shared UI state after multiple awaits. Only the
|
||||
// latest request may render, or a slow reload can replace a newer selection.
|
||||
const generation = ++historyLoadGeneration;
|
||||
const sessionGeneration = state.sessionGeneration ?? 0;
|
||||
const selection = captureSessionSelection();
|
||||
const isCurrentLoad = () =>
|
||||
generation === historyLoadGeneration && isCurrentSessionSelection(selection);
|
||||
generation === historyLoadGeneration &&
|
||||
(state.sessionGeneration ?? 0) === sessionGeneration &&
|
||||
isCurrentSessionSelection(selection);
|
||||
try {
|
||||
const history = await client.loadHistory({
|
||||
sessionKey: selection.sessionKey,
|
||||
|
||||
@@ -41,11 +41,3 @@ export function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): bool
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user