mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
29c81cfab5
commit
67dc60756a
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user