mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(tui): route messages to the replacement agent session (#126820)
This commit is contained in:
committed by
GitHub
parent
a4901b6291
commit
97c0455add
@@ -216,6 +216,10 @@ describe("tui session actions", () => {
|
||||
agentNames,
|
||||
updateHeader,
|
||||
updateFooter,
|
||||
resolveSessionSelection: vi.fn((_raw?: string, agentId = state.currentAgentId) => ({
|
||||
key: `agent:${agentId}:${state.sessionMainKey}`,
|
||||
agentId,
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(refreshAgents()).resolves.toEqual({ ok: true, value: undefined });
|
||||
@@ -227,6 +231,7 @@ describe("tui session actions", () => {
|
||||
{ id: "system-agent", kind: "system", name: "System Agent" },
|
||||
]);
|
||||
expect(state.currentAgentId).toBe("team-lead");
|
||||
expect(state.currentSessionKey).toBe("agent:team-lead:primary");
|
||||
expect([...agentNames]).toEqual([
|
||||
["team-lead", "Lead Agent"],
|
||||
["system-agent", "System Agent"],
|
||||
@@ -235,6 +240,129 @@ describe("tui session actions", () => {
|
||||
expect(updateFooter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
scope: "per-sender" as const,
|
||||
previousKey: "agent:research:main",
|
||||
nextKey: "agent:ops:main",
|
||||
},
|
||||
{
|
||||
scope: "global" as const,
|
||||
previousKey: "global",
|
||||
nextKey: "global",
|
||||
},
|
||||
])(
|
||||
"retires the complete $scope session when its selected agent disappears",
|
||||
async ({ scope, previousKey, nextKey }) => {
|
||||
const state = createBaseState({
|
||||
agents: [{ id: "research" }],
|
||||
currentAgentId: "research",
|
||||
currentSessionKey: previousKey,
|
||||
currentSessionId: "old-session",
|
||||
sessionMainKey: "main",
|
||||
sessionScope: scope,
|
||||
activeChatRunId: "old-run",
|
||||
pendingSubmit: acceptedSubmit("pending-run"),
|
||||
historyLoaded: true,
|
||||
sessionInfo: { updatedAt: 100, thinkingLevel: "high", verboseLevel: "full" },
|
||||
});
|
||||
sendPendingUser(state, "pending-run", "stale prompt");
|
||||
const loadHistory = vi.fn();
|
||||
const invalidateRunOwnership = vi.fn();
|
||||
const clearLocalRunIds = vi.fn();
|
||||
const clearAll = vi.fn();
|
||||
const clearPendingUsers = vi.fn();
|
||||
const btw = createBtwPresenter();
|
||||
const { refreshAgents } = createTestSessionActions({
|
||||
client: makeTuiBackend({
|
||||
loadHistory,
|
||||
listAgents: vi.fn().mockResolvedValue({
|
||||
defaultId: "ops",
|
||||
mainKey: "main",
|
||||
scope,
|
||||
agents: [{ id: "ops" }],
|
||||
}),
|
||||
}),
|
||||
chatLog: makeChatLog({ clearAll, clearPendingUsers }),
|
||||
btw,
|
||||
state,
|
||||
invalidateRunOwnership,
|
||||
clearLocalRunIds,
|
||||
resolveSessionSelection: vi.fn((_raw?: string, agentId = state.currentAgentId) => ({
|
||||
key: scope === "global" ? "global" : `agent:${agentId}:main`,
|
||||
agentId,
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(refreshAgents()).resolves.toEqual({ ok: true, value: undefined });
|
||||
|
||||
expect(state).toMatchObject({
|
||||
currentAgentId: "ops",
|
||||
currentSessionKey: nextKey,
|
||||
currentSessionId: null,
|
||||
activeChatRunId: null,
|
||||
pendingSubmit: null,
|
||||
historyLoaded: false,
|
||||
sessionInfo: { updatedAt: null },
|
||||
});
|
||||
expect(state.sessionInfo.thinkingLevel).toBe("high");
|
||||
expect(state.sessionInfo.verboseLevel).toBeUndefined();
|
||||
expect(state.sessionProjection?.entries).toEqual([]);
|
||||
expect(invalidateRunOwnership).toHaveBeenCalledOnce();
|
||||
expect(clearLocalRunIds).toHaveBeenCalledOnce();
|
||||
expect(clearAll).toHaveBeenCalledOnce();
|
||||
expect(clearPendingUsers).toHaveBeenCalledOnce();
|
||||
expect(btw.clear).toHaveBeenCalledOnce();
|
||||
expect(loadHistory).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the complete selected session when its agent remains in the roster", async () => {
|
||||
const state = createBaseState({
|
||||
agents: [{ id: "research" }],
|
||||
currentAgentId: "research",
|
||||
currentSessionKey: "agent:research:incident",
|
||||
currentSessionId: "current-session",
|
||||
sessionScope: "per-sender",
|
||||
activeChatRunId: "current-run",
|
||||
pendingSubmit: acceptedSubmit("pending-run"),
|
||||
historyLoaded: true,
|
||||
sessionInfo: { updatedAt: 100, thinkingLevel: "high" },
|
||||
});
|
||||
sendPendingUser(state, "pending-run", "current prompt");
|
||||
const previousProjection = state.sessionProjection;
|
||||
const invalidateRunOwnership = vi.fn();
|
||||
const resolveSessionSelection = vi.fn();
|
||||
const { refreshAgents } = createTestSessionActions({
|
||||
client: makeTuiBackend({
|
||||
listAgents: vi.fn().mockResolvedValue({
|
||||
defaultId: "ops",
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
agents: [{ id: "ops" }, { id: "research" }],
|
||||
}),
|
||||
}),
|
||||
state,
|
||||
invalidateRunOwnership,
|
||||
resolveSessionSelection,
|
||||
});
|
||||
|
||||
await expect(refreshAgents()).resolves.toEqual({ ok: true, value: undefined });
|
||||
|
||||
expect(state).toMatchObject({
|
||||
currentAgentId: "research",
|
||||
currentSessionKey: "agent:research:incident",
|
||||
currentSessionId: "current-session",
|
||||
activeChatRunId: "current-run",
|
||||
historyLoaded: true,
|
||||
sessionInfo: { updatedAt: 100, thinkingLevel: "high" },
|
||||
});
|
||||
expect(state.pendingSubmit).toEqual(acceptedSubmit("pending-run"));
|
||||
expect(state.sessionProjection).toBe(previousProjection);
|
||||
expect(invalidateRunOwnership).not.toHaveBeenCalled();
|
||||
expect(resolveSessionSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues session refreshes and applies the latest result", async () => {
|
||||
let resolveFirst: ((value: unknown) => void) | undefined;
|
||||
let resolveSecond: ((value: unknown) => void) | undefined;
|
||||
|
||||
@@ -50,7 +50,7 @@ type SessionActionContext = {
|
||||
agentNames: Map<string, string>;
|
||||
initialSessionInput: string;
|
||||
initialSessionAgentId: string | null;
|
||||
resolveSessionSelection: (raw?: string) => { key: string; agentId: string };
|
||||
resolveSessionSelection: (raw?: string, agentId?: string) => { key: string; agentId: string };
|
||||
updateHeader: () => void;
|
||||
updateFooter: () => void;
|
||||
updateAutocompleteProvider: () => void;
|
||||
@@ -88,6 +88,45 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
agentId: state.currentAgentId,
|
||||
});
|
||||
|
||||
const applySessionSelection = (nextSelection: { key: string; agentId: string }) => {
|
||||
const previousSelection = captureSessionSelection();
|
||||
const selectionChanged = !(
|
||||
nextSelection.agentId === previousSelection.agentId &&
|
||||
agentSessionKeysMatchByRequestKey(nextSelection.key, previousSelection.sessionKey)
|
||||
);
|
||||
if (selectionChanged) {
|
||||
// Retire the previous session's runs before history can adopt a new
|
||||
// in-flight owner; otherwise its completion can promote an old run.
|
||||
invalidateRunOwnership?.();
|
||||
reduceTuiSessionProjection(state, {
|
||||
type: "sessionReset",
|
||||
scope: readTuiSessionProjectionScope(state),
|
||||
});
|
||||
}
|
||||
state.currentAgentId = nextSelection.agentId;
|
||||
state.currentSessionKey = nextSelection.key;
|
||||
state.activeChatRunId = null;
|
||||
submit.clearPendingSubmit(state);
|
||||
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.
|
||||
state.sessionInfo.updatedAt = null;
|
||||
state.historyLoaded = false;
|
||||
if (selectionChanged) {
|
||||
// Live prompt identities belong to the old selection, not its pending successor.
|
||||
chatLog.clearAll();
|
||||
}
|
||||
chatLog.clearPendingUsers();
|
||||
clearLocalRunIds?.();
|
||||
btw.clear();
|
||||
updateHeader();
|
||||
updateFooter();
|
||||
};
|
||||
|
||||
const isCurrentSessionSelection = (selection: { sessionKey: string; agentId: string }): boolean =>
|
||||
state.currentAgentId === selection.agentId &&
|
||||
agentSessionKeysMatchByRequestKey(state.currentSessionKey, selection.sessionKey);
|
||||
@@ -134,8 +173,12 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
}
|
||||
state.initialSessionApplied = true;
|
||||
} else if (!state.agents.some((agent) => agent.id === state.currentAgentId)) {
|
||||
state.currentAgentId =
|
||||
const nextAgentId =
|
||||
state.agents[0]?.id ?? normalizeAgentId(result.defaultId ?? state.currentAgentId);
|
||||
if (nextAgentId !== state.currentAgentId) {
|
||||
applySessionSelection(resolveSessionSelection(undefined, nextAgentId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateHeader();
|
||||
updateFooter();
|
||||
@@ -599,44 +642,7 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
};
|
||||
|
||||
const setSession = async (rawKey: string) => {
|
||||
const previousSelection = captureSessionSelection();
|
||||
const nextSelection = resolveSessionSelection(rawKey);
|
||||
const nextKey = nextSelection.key;
|
||||
const selectionChanged = !(
|
||||
nextSelection.agentId === previousSelection.agentId &&
|
||||
agentSessionKeysMatchByRequestKey(nextKey, previousSelection.sessionKey)
|
||||
);
|
||||
if (selectionChanged) {
|
||||
// Retire the previous session's runs before history can adopt a new
|
||||
// in-flight owner; otherwise its completion can promote an old run.
|
||||
invalidateRunOwnership?.();
|
||||
reduceTuiSessionProjection(state, {
|
||||
type: "sessionReset",
|
||||
scope: readTuiSessionProjectionScope(state),
|
||||
});
|
||||
}
|
||||
state.currentAgentId = nextSelection.agentId;
|
||||
state.currentSessionKey = nextKey;
|
||||
state.activeChatRunId = null;
|
||||
submit.clearPendingSubmit(state);
|
||||
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.
|
||||
state.sessionInfo.updatedAt = null;
|
||||
state.historyLoaded = false;
|
||||
if (selectionChanged) {
|
||||
// Live prompt identities belong to the old selection, not its pending successor.
|
||||
chatLog.clearAll();
|
||||
}
|
||||
chatLog.clearPendingUsers();
|
||||
clearLocalRunIds?.();
|
||||
btw.clear();
|
||||
updateHeader();
|
||||
updateFooter();
|
||||
applySessionSelection(resolveSessionSelection(rawKey));
|
||||
await loadHistory();
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -1037,12 +1037,12 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
|
||||
return name ? `${id} (${name})` : id;
|
||||
};
|
||||
|
||||
const resolveSessionSelection = (raw?: string) => {
|
||||
const resolveSessionSelection = (raw?: string, agentId = state.currentAgentId) => {
|
||||
return resolveTuiSessionSelection({
|
||||
raw,
|
||||
cfg: config,
|
||||
sessionScope: state.sessionScope,
|
||||
currentAgentId: state.currentAgentId,
|
||||
currentAgentId: agentId,
|
||||
sessionMainKey: state.sessionMainKey,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user