fix(tui): route messages to the replacement agent session (#126820)

This commit is contained in:
Peter Steinberger
2026-08-20 13:04:25 -07:00
committed by GitHub
parent a4901b6291
commit 97c0455add
3 changed files with 176 additions and 42 deletions
+128
View File
@@ -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;
+46 -40
View File
@@ -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
View File
@@ -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,
});
};