fix(tui): preserve run and session ownership across async events (#130147)

* fix(tui): preserve run and session ownership across async events

* test: stabilize filesystem mocks and gateway restart completion
This commit is contained in:
Peter Steinberger
2026-08-26 07:24:55 -07:00
committed by GitHub
parent 29c81cfab5
commit 67dc60756a
5 changed files with 203 additions and 28 deletions
@@ -918,14 +918,12 @@ describe("openclaw.chat", () => {
} finally {
releaseApproval.resolve();
}
await vi.waitFor(() => {
expect(resolveOperatorApproval).toHaveBeenCalledWith("allow-once", proposalHash);
expect(runGatewayRestart).toHaveBeenCalledOnce();
expect(systemAgentLane().activeCount).toBe(0);
});
expect(resolveOperatorApproval).toHaveBeenCalledWith("allow-once", proposalHash);
expect(runGatewayRestart).toHaveBeenCalledOnce();
await expect(resolveOperatorApproval.mock.results[0]?.value).resolves.toMatchObject({
text: expect.stringContaining("[openclaw] done: gateway.restart"),
});
await vi.waitFor(() => expect(systemAgentLane().activeCount).toBe(0));
expect(readLastSystemAgentAuditEntry()).toMatchObject({
operation: "gateway.restart",
summary: "Scheduled Gateway restart",
+116
View File
@@ -1401,6 +1401,42 @@ describe("tui command handlers", () => {
expect(loadHistory).toHaveBeenCalledTimes(1);
});
it.each(["error", "timeout", "ok"])(
"ignores a terminal %s ACK after its history reload switches sessions",
async (status) => {
const history = createDeferred();
const harness = createHarness({
currentAgentId: "research",
currentSessionKey: "agent:research:private",
currentSessionId: "private-session",
sendChat: vi.fn(async ({ runId }: { runId: string }) => ({ runId, status })),
loadHistory: vi.fn(() => history.promise) as LoadHistoryMock,
});
const pending = harness.handleCommand("private provider request");
await vi.waitFor(() => expect(harness.loadHistory).toHaveBeenCalledTimes(1));
harness.addSystem.mockClear();
harness.setActivityStatus.mockClear();
harness.requestRender.mockClear();
harness.state.currentAgentId = "ops";
harness.state.currentSessionKey = "agent:ops:public";
harness.state.currentSessionId = "public-session";
harness.state.activeChatRunId = "public-run";
harness.state.pendingSubmit = {
phase: "accepted",
runId: "public-run",
draftText: null,
};
history.resolve();
await pending;
expect(harness.addSystem).not.toHaveBeenCalled();
expect(harness.setActivityStatus).not.toHaveBeenCalled();
expect(harness.state.activeChatRunId).toBe("public-run");
expect(harness.state.pendingSubmit?.runId).toBe("public-run");
},
);
it("removes the accepted canonical pending turn after a re-keyed terminal failure", async () => {
const sendChat = vi.fn().mockResolvedValue({
runId: "accepted-failed-run",
@@ -2212,6 +2248,86 @@ describe("tui command handlers", () => {
expect(harness.addSystem).not.toHaveBeenCalled();
});
it.each(["/model openai/gpt-5.6-luna", "/usage reset"])(
"does not apply a stale %s result after a nested patch handoff",
async (command) => {
const appliedAgents: string[] = [];
const displayedAgents: string[] = [];
const patchSession = vi.fn(() => {
queueMicrotask(() => {
queueMicrotask(() => {
harness.state.currentAgentId = "ops";
harness.state.currentSessionKey = "agent:ops:public";
harness.state.currentSessionId = "public-session";
harness.state.sessionGeneration += 1;
harness.state.sessionInfo = {
responseUsage: "tokens",
effectiveResponseUsage: "tokens",
};
});
});
return Promise.resolve({
ok: true as const,
path: "/sessions/patch",
key: "agent:research:private",
entry: { model: "private-sensitive-model" },
});
});
const harness = createHarness({
currentAgentId: "research",
currentSessionKey: "agent:research:private",
currentSessionId: "private-session",
sessionGeneration: 2,
sessionInfo: { responseUsage: "tokens", effectiveResponseUsage: "tokens" },
patchSession,
applySessionInfoFromPatch: vi.fn(() => appliedAgents.push(harness.state.currentAgentId)),
});
harness.addSystem.mockImplementation(() =>
displayedAgents.push(harness.state.currentAgentId),
);
await harness.handleCommand(command);
expect(harness.state.currentAgentId).toBe("ops");
expect(harness.state.sessionInfo.responseUsage).toBe("tokens");
expect(appliedAgents).toEqual(["research"]);
expect(displayedAgents).toEqual(["research"]);
},
);
it.each([
{ command: "/model openai/gpt-5.6-luna", hook: "refresh" },
{ command: "/think high", hook: "refresh" },
{ command: "/verbose full", hook: "history" },
{ command: "/usage reset", hook: "refresh" },
])(
"hides a stale $command failure after its post-patch $hook rejects",
async ({ command, hook }) => {
const followup = createDeferred();
const harness = createHarness({
currentAgentId: "research",
currentSessionKey: "agent:research:private",
currentSessionId: "private-session",
...(hook === "history"
? { loadHistory: vi.fn(() => followup.promise) as LoadHistoryMock }
: { refreshSessionInfo: vi.fn(() => followup.promise) }),
});
const pending = harness.handleCommand(command);
const followupCall = hook === "history" ? harness.loadHistory : harness.refreshSessionInfo;
await vi.waitFor(() => expect(followupCall).toHaveBeenCalledTimes(1));
harness.addSystem.mockClear();
harness.state.currentAgentId = "ops";
harness.state.currentSessionKey = "agent:ops:public";
harness.state.currentSessionId = "public-session";
followup.reject(new Error("private provider account rejected research tenant"));
await pending;
expect(harness.addSystem).not.toHaveBeenCalled();
expect(harness.state.currentAgentId).toBe("ops");
},
);
it.each(["/model openai/gpt-5.6-luna", "/usage reset"])(
"ignores a stale global-agent %s result",
async (command) => {
+11 -20
View File
@@ -281,9 +281,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
};
};
const patchCurrentSession = async (
const applySessionSetting = async (
patch: Omit<Parameters<TuiBackend["patchSession"]>[0], "key" | "agentId">,
): Promise<SessionsPatchResult | null> => {
success: string | ((result: SessionsPatchResult) => string),
failure: string,
after?: (result: SessionsPatchResult) => void | Promise<void>,
) => {
const { selection, isCurrent } = captureSessionIncarnation();
try {
const result = await client.patchSession({
@@ -291,24 +294,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
...(!parseAgentSessionKey(selection.sessionKey) ? { agentId: selection.agentId } : {}),
...patch,
});
return isCurrent() ? result : null;
} catch (err) {
if (!isCurrent()) {
return null;
}
throw err;
}
};
const applySessionSetting = async (
patch: Omit<Parameters<TuiBackend["patchSession"]>[0], "key" | "agentId">,
success: string | ((result: SessionsPatchResult) => string),
failure: string,
after?: (result: SessionsPatchResult) => void | Promise<void>,
) => {
try {
const result = await patchCurrentSession(patch);
if (!result) {
return;
}
chatLog.addSystem(typeof success === "function" ? success(result) : success);
@@ -319,7 +305,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
await refreshSessionInfo();
}
} catch (err) {
chatLog.addSystem(`${failure}: ${formatTuiErrorMessage(err)}`);
if (isCurrent()) {
chatLog.addSystem(`${failure}: ${formatTuiErrorMessage(err)}`);
}
}
};
@@ -1034,6 +1022,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
state.activeChatRunId = null;
}
await loadHistory();
if (!isCurrentSendViewport()) {
return;
}
if (terminalAckFailure) {
chatLog.addSystem(`send failed: ${TERMINAL_CHAT_SEND_FAILURE_MESSAGE}`);
setActivityStatus("error");
+67
View File
@@ -2723,6 +2723,73 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(chatLog.addSystem).toHaveBeenCalledWith(`run error: ${backendError}`);
});
it.each(["error", "final"] as const)(
"accepts an owned local %s event before its submit is acknowledged",
(terminalState) => {
const { state, chatLog, handleAgentEvent, handleChatEvent, noteLocalRunId } =
createHandlersHarness({
localMode: true,
state: { activeChatRunId: null, sessionInfo: { modelProvider: "xai" } },
});
handleAgentEvent({
runId: "completed-run",
sessionKey: state.currentSessionKey,
data: { phase: "start" },
});
handleChatEvent(makeFinalChatEvent(state, "completed-run"));
state.pendingSubmit = sendingSubmit("next-local-run");
noteLocalRunId("next-local-run");
handleChatEvent({
runId: "next-local-run",
state: terminalState,
...(terminalState === "error"
? { errorMessage: "monthly spending limit" }
: { message: { content: [{ type: "text", text: "early local reply" }] } }),
});
expect(state.pendingSubmit).toBeNull();
if (terminalState === "error") {
expect(chatLog.addSystem).toHaveBeenCalledWith("run error: monthly spending limit");
} else {
expect(chatLog.finalizeAssistant).toHaveBeenCalledWith(
"early local reply",
"next-local-run",
);
}
},
);
it.each([
{ label: "unowned local", localMode: true, owned: false, sessionKey: "agent:main:main" },
{ label: "remote", localMode: false, owned: true, sessionKey: "agent:main:main" },
{ label: "foreign session", localMode: true, owned: true, sessionKey: "agent:main:other" },
])("rejects an unsequenced $label provisional event", ({ localMode, owned, sessionKey }) => {
const { state, chatLog, handleAgentEvent, handleChatEvent, noteLocalRunId } =
createHandlersHarness({ localMode, state: { activeChatRunId: null } });
handleAgentEvent({
runId: "completed-run",
sessionKey: state.currentSessionKey,
data: { phase: "start" },
});
handleChatEvent(makeFinalChatEvent(state, "completed-run"));
state.pendingSubmit = sendingSubmit("untrusted-run");
if (owned) {
noteLocalRunId("untrusted-run");
}
handleChatEvent({
runId: "untrusted-run",
sessionKey,
state: "error",
errorMessage: "foreign private diagnostic",
});
expect(state.pendingSubmit).toEqual(sendingSubmit("untrusted-run"));
expect(chatLog.addSystem).not.toHaveBeenCalledWith("run error: foreign private diagnostic");
});
it("surfaces a late provider error without replaying a completed assistant reply", () => {
const { state, chatLog, handleChatEvent } = createHandlersHarness({
state: { activeChatRunId: "run-source-reply" },
+6 -3
View File
@@ -180,9 +180,12 @@ export function createEventHandlers(context: EventHandlerContext) {
return;
}
const isSequencedGatewayEvent = Number.isSafeInteger(evt.seq) && (evt.seq ?? -1) >= 0;
const isOwnedLocalPendingRun =
localMode && state.pendingSubmit?.runId === evt.runId && isLocalRunId?.(evt.runId) === true;
if (
runCoordinator.isRetiredOrphanRun(evt.runId) &&
!isSequencedGatewayEvent &&
!isOwnedLocalPendingRun &&
evt.runId !== getPendingSubmitAcceptedRunId(state)
) {
return;
@@ -247,10 +250,10 @@ export function createEventHandlers(context: EventHandlerContext) {
}
}
}
// Gateway chat envelopes require a non-negative sequence, even when a
// legacy peer omits agent lifecycle starts; orphan deltas have none.
// Gateway events are sequenced; early embedded events are trustworthy only
// when their exact provisional run belongs to the selected local viewport.
acknowledgeChatRun(evt.runId, {
protectStream: isSequencedGatewayEvent,
protectStream: isSequencedGatewayEvent || isOwnedLocalPendingRun,
});
const isPendingChatRun = getPendingSubmitAcceptedRunId(state) === evt.runId;
const isLocalChatRun = isLocalRunId?.(evt.runId) ?? false;