Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/controller/AgentGUIConversationRailQueryController.spec.ts
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

1732 lines
53 KiB
TypeScript

import {
AGENT_SESSION_ENGINE_LOCAL_ORIGIN,
normalizeAgentActivitySession,
selectEngineSession,
type AgentSessionEngine
} from "@tutti-os/agent-activity-core";
import { describe, expect, it, vi } from "vitest";
import { createTestAgentSessionEngine } from "../../../shared/testing/createTestAgentSessionEngine";
import { createWorkspaceQueryCache } from "../../../shared/query/workspaceQueryCache";
import {
AgentGUIConversationRailQueryController,
CONVERSATION_SEARCH_DEBOUNCE_MS,
type ConversationRailQueryScope,
type ConversationRailQueryRuntime
} from "./AgentGUIConversationRailQueryController";
import { resolveConversationRailQueryScope } from "./agentGuiConversationRailQueryTypes";
import { createConversationRailConversationsSelector } from "./agentGuiConversationRailQuerySnapshot";
import type { CachedConversationRailQuery } from "./agentGuiConversationRailQueryCache";
describe("AgentGUIConversationRailQueryController", () => {
it("does not ingest a first-page response after detach", async () => {
const engine = createTestAgentSessionEngine();
const session = createTestSession("detached-session", "conversations");
let resolveSections!: () => void;
let requestSignal: AbortSignal | undefined;
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: {
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
}),
listSessionSections: (input) => {
requestSignal = input.signal;
return new Promise<void>((resolve) => {
resolveSections = resolve;
}).then(() => ({
sections: [
{
hasMore: false,
kind: "conversations" as const,
sectionKey: "conversations",
sessions: [session],
totalCount: 1
}
],
workspaceId: input.workspaceId
}));
}
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const detach = controller.attach();
detach();
expect(requestSignal?.aborted).toBe(true);
resolveSections();
await Promise.resolve();
await Promise.resolve();
expect(
selectEngineSession(engine.getSnapshot(), session.agentSessionId)
).toBeNull();
engine.dispose();
});
it("does not write a stale scope response with another scope's query state", async () => {
const engine = createTestAgentSessionEngine();
const cache = createWorkspaceQueryCache<CachedConversationRailQuery>();
const pending: Array<{
agentTargetId: string;
resolve(): void;
signal?: AbortSignal;
}> = [];
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>((input) =>
new Promise<void>((resolve) => {
pending.push({
agentTargetId: input.agentTargetId ?? "",
resolve,
signal: input.signal
});
}).then(() => ({
sections: [
{
hasMore: false,
kind: "conversations" as const,
sectionKey: "conversations",
sessions: [
createTestSession(
`session-${input.agentTargetId}`,
"conversations"
)
],
totalCount: 1
}
],
workspaceId: input.workspaceId
}))
);
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
sessionSectionsQueryCache: cache,
runtime: {
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
}),
listSessionSections
},
workspaceId: "test-workspace"
});
const scopeA: ConversationRailQueryScope = {
conversationFilter: { agentTargetId: "agent-a", kind: "agentTarget" },
userProjects: []
};
const scopeB: ConversationRailQueryScope = {
conversationFilter: { agentTargetId: "agent-b", kind: "agentTarget" },
userProjects: []
};
const scopeAKey = resolveConversationRailQueryScope(
"test-workspace",
scopeA
).scopeKey;
controller.configure(scopeA);
const detach = controller.attach();
controller.configure(scopeB);
expect(pending).toHaveLength(2);
expect(pending[0]?.signal?.aborted).toBe(true);
pending[0]?.resolve();
await Promise.resolve();
await Promise.resolve();
expect(cache.read(scopeAKey)).toBeNull();
pending[1]?.resolve();
await vi.waitFor(() =>
expect(
controller.getSnapshot().runtimeRailMemberships?.[0]?.sessionIds
).toEqual(["session-agent-b"])
);
controller.configure(scopeA);
expect(listSessionSections).toHaveBeenCalledTimes(3);
pending[2]?.resolve();
await vi.waitFor(() =>
expect(
controller.getSnapshot().runtimeRailMemberships?.[0]?.sessionIds
).toEqual(["session-agent-a"])
);
detach();
engine.dispose();
});
it("does not treat workspace hydration as a rail membership mutation", async () => {
let resolveWorkspaceReconcile!: () => void;
const engine = createTestAgentSessionEngine("test-workspace", {
execute: async (command) => {
if (command.type !== "engine/reconcileWorkspace") return { ok: true };
await new Promise<void>((resolve) => {
resolveWorkspaceReconcile = resolve;
});
return { ok: true };
}
});
const session = normalizeAgentActivitySession({
activeTurnId: null,
agentSessionId: "historical-session",
agentTargetId: "local:codex",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey: "conversations",
title: "Historical session",
updatedAtUnixMs: 1,
workspaceId: "test-workspace"
});
let resolveFirstPages!: () => void;
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>((input) =>
new Promise<void>((resolve) => {
resolveFirstPages = resolve;
}).then(() => ({
sections: [
{
hasMore: false,
kind: "conversations" as const,
sectionKey: "conversations",
sessions: [session],
totalCount: 1
}
],
workspaceId: input.workspaceId
}))
);
const listSessionSectionPage = vi.fn(async (input) => ({
hasMore: false,
kind: "conversations" as const,
sectionKey: input.sectionKey,
sessions: [session],
totalCount: 1
}));
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: { listSessionSections, listSessionSectionPage },
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const detach = controller.attach();
expect(engine.getSnapshot().engineRuntime.workspaceReconcile.status).toBe(
"loading"
);
engine.dispatch({ sessions: [session], type: "session/snapshotReceived" });
expect(listSessionSectionPage).not.toHaveBeenCalled();
resolveWorkspaceReconcile();
await vi.waitFor(() =>
expect(engine.getSnapshot().engineRuntime.workspaceReconcile.status).toBe(
"ready"
)
);
resolveFirstPages();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
expect(controller.getSnapshot().runtimeRailMemberships).toEqual([
expect.objectContaining({
id: "conversations",
sessionIds: ["historical-session"]
})
]);
expect(listSessionSectionPage).not.toHaveBeenCalled();
detach();
engine.dispose();
});
it("refreshes a selected fork creation into its authoritative rail page", async () => {
const source = normalizeAgentActivitySession({
...createTestSession("source-session", "conversations"),
lifecycleCapabilities: { fork: true, forkThroughTurn: true }
});
const child = normalizeAgentActivitySession({
...source,
agentSessionId: "fork-child",
forkedFrom: {
forkedAtUnixMs: 2,
operationId: "fork-operation",
sourceAgentSessionId: source.agentSessionId,
sourceTurnId: "source-turn",
targetTurnId: "fork-target-turn"
},
title: "Fork child",
updatedAtUnixMs: 2
});
let resolveForkCommand: (value: unknown) => void = () => {};
const engine = createTestAgentSessionEngine("test-workspace", {
execute: async (command) => {
if (command.type === "session/forkThroughTurn") {
return new Promise((resolve) => {
resolveForkCommand = resolve;
});
}
return { ok: true };
}
});
let activeConversationId: string | null = null;
const listSessionSectionPage = vi.fn(async (input) => ({
hasMore: false,
kind: "conversations" as const,
sectionKey: input.sectionKey,
sessions: [child, source],
totalCount: 2
}));
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => activeConversationId,
runtime: {
listSessionSectionPage,
listSessionSections: async (input) => ({
sections: [
{
hasMore: false,
kind: "conversations",
sectionKey: "conversations",
sessions: [source],
totalCount: 1
}
],
workspaceId: input.workspaceId
})
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const detach = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
engine.dispatch({
live: true,
turn: {
agentSessionId: source.agentSessionId,
completedCommand: null,
error: null,
fileChanges: null,
origin: "user_prompt",
outcome: "completed",
phase: "settled",
providerForkBindingAvailable: true,
settledAtUnixMs: 2,
startedAtUnixMs: 1,
turnId: "source-turn",
updatedAtUnixMs: 2
},
type: "turn/upserted"
});
engine.dispatch({
requestId: "fork-request",
sourceAgentSessionId: source.agentSessionId,
targetAgentSessionId: child.agentSessionId,
turnId: "source-turn",
type: "session/forkThroughTurnRequested",
workspaceId: "test-workspace"
});
activeConversationId = child.agentSessionId;
engine.dispatch({ session: child, type: "session/upserted" });
await vi.waitFor(() =>
expect(listSessionSectionPage).toHaveBeenCalledWith(
expect.objectContaining({ sectionKey: "conversations" })
)
);
await vi.waitFor(() =>
expect(
controller.getSnapshot().runtimeRailMemberships?.[0]?.sessionIds
).toEqual([child.agentSessionId, source.agentSessionId])
);
resolveForkCommand({});
await Promise.resolve();
detach();
engine.dispose();
});
it("does not start a targeted page refresh while the rail scope is pending", async () => {
const engine = createTestAgentSessionEngine();
const session = normalizeAgentActivitySession({
activeTurnId: null,
agentSessionId: "session-during-first-pages",
agentTargetId: "local:codex",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey: "conversations",
title: "Session during first pages",
updatedAtUnixMs: 1,
workspaceId: "test-workspace"
});
let resolveFirstPages!: () => void;
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>((input) =>
new Promise<void>((resolve) => {
resolveFirstPages = resolve;
}).then(() => ({
sections: [
{
hasMore: false,
kind: "conversations" as const,
sectionKey: "conversations",
sessions: [session],
totalCount: 1
}
],
workspaceId: input.workspaceId
}))
);
const listSessionSectionPage = vi.fn(async (input) => ({
hasMore: false,
kind: "conversations" as const,
sectionKey: input.sectionKey,
sessions: [session],
totalCount: 1
}));
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: { listSessionSections, listSessionSectionPage },
workspaceId: "test-workspace"
});
const scope: ConversationRailQueryScope = {
conversationFilter: { kind: "all" },
userProjects: []
};
const scopeKey = resolveConversationRailQueryScope(
"test-workspace",
scope
).scopeKey;
controller.configure(scope);
const detach = controller.attach();
await vi.waitFor(() =>
expect(engine.getSnapshot().engineRuntime.workspaceReconcile.status).toBe(
"ready"
)
);
engine.dispatch({ session, type: "session/upserted" });
expect(listSessionSectionPage).not.toHaveBeenCalled();
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(true);
expect(controller.getSnapshot().runtimeRailResolvedScopeKey).not.toBe(
scopeKey
);
expect(controller.getSnapshot().runtimeRailMemberships).toBeNull();
expect(controller.isInteractionLocked()).toBe(true);
resolveFirstPages();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
expect(controller.getSnapshot().runtimeRailResolvedScopeKey).toBe(scopeKey);
expect(controller.getSnapshot().runtimeRailMemberships).toEqual([
expect.objectContaining({
id: "conversations",
sessionIds: [session.agentSessionId]
})
]);
expect(controller.isInteractionLocked()).toBe(false);
detach();
engine.dispose();
});
it("debounces conversation searches and immediately clears an active query", async () => {
vi.useFakeTimers();
try {
const engine = createTestAgentSessionEngine();
const listSessionsPage = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionsPage"]>
>(async (input) => ({
hasMore: false,
sessions: [],
workspaceId: input.workspaceId
}));
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: { listSessionsPage },
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const detach = controller.attach();
controller.setSearchQuery("first");
expect(controller.getSnapshot().railSearch.pending).toBe(true);
expect(listSessionsPage).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(CONVERSATION_SEARCH_DEBOUNCE_MS - 1);
expect(listSessionsPage).not.toHaveBeenCalled();
controller.setSearchQuery("second");
await vi.advanceTimersByTimeAsync(CONVERSATION_SEARCH_DEBOUNCE_MS);
expect(listSessionsPage).toHaveBeenCalledTimes(1);
expect(listSessionsPage).toHaveBeenCalledWith(
expect.objectContaining({ searchQuery: "second" })
);
expect(controller.getSnapshot().railSearch.pending).toBe(false);
controller.setSearchQuery("transient");
controller.setSearchQuery("second");
expect(controller.getSnapshot().railSearch.pending).toBe(true);
expect(listSessionsPage).toHaveBeenCalledTimes(1);
controller.setSearchQuery("");
expect(controller.getSnapshot().railSearch.pending).toBe(false);
detach();
engine.dispose();
} finally {
vi.useRealTimers();
}
});
it("publishes pin and delete snapshots only after affected pages resolve", async () => {
const engine = createTestAgentSessionEngine();
const session = normalizeAgentActivitySession({
activeTurnId: null,
agentSessionId: "session-1",
agentTargetId: "local:codex",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey: "conversations",
title: "Session",
updatedAtUnixMs: 1,
workspaceId: "test-workspace"
});
const sectionResolvers: Array<() => void> = [];
let pinnedRequestCount = 0;
const listPinnedSessionsPage = vi.fn(async () => {
pinnedRequestCount += 1;
return {
hasMore: false,
sessions:
pinnedRequestCount === 1
? [{ ...session, pinnedAtUnixMs: 100, updatedAtUnixMs: 2 }]
: [],
totalCount: pinnedRequestCount === 1 ? 1 : 0
};
});
const listSessionSectionPage = vi.fn(async (input) => ({
hasMore: false,
kind: "conversations" as const,
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
}));
const listSessionSections = vi.fn(
(input) =>
new Promise<
Awaited<
ReturnType<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>
>
>((resolve) => {
sectionResolvers.push(() =>
resolve({
sections: [
{
hasMore: false,
kind: "conversations",
sectionKey: "conversations",
sessions: [session],
totalCount: 1
}
],
workspaceId: input.workspaceId
})
);
})
);
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: {
listPinnedSessionsPage,
listSessionSections,
listSessionSectionPage,
listSessionsPage: async (input) => ({
hasMore: false,
sessions: [],
workspaceId: input.workspaceId
})
},
workspaceId: "test-workspace"
});
const initialScope: ConversationRailQueryScope = {
conversationFilter: { kind: "all" },
userProjects: []
};
const initialScopeKey = resolveConversationRailQueryScope(
"test-workspace",
initialScope
).scopeKey;
controller.configure(initialScope);
const detach = controller.attach();
expect(controller.isInteractionLocked()).toBe(true);
expect(controller.getSnapshot().runtimeRailResolvedScopeKey).not.toBe(
initialScopeKey
);
sectionResolvers.shift()?.();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
expect(controller.isInteractionLocked()).toBe(false);
expect(controller.getSnapshot().runtimeRailResolvedScopeKey).toBe(
initialScopeKey
);
const presentation = createRailConversationPresentation(controller, engine);
let visiblePinnedAt = presentation.getSnapshot()[0]?.pinnedAtUnixMs ?? null;
let visiblePinChanges = 0;
const unsubscribe = presentation.subscribe((conversations) => {
const nextPinnedAt = conversations[0]?.pinnedAtUnixMs ?? null;
if (nextPinnedAt !== visiblePinnedAt) {
visiblePinnedAt = nextPinnedAt;
visiblePinChanges += 1;
}
});
engine.dispatch({
type: "session/upserted",
session: {
...session,
pinnedAtUnixMs: 100,
updatedAtUnixMs: 2
}
});
expect(controller.isInteractionLocked()).toBe(true);
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false);
expect(controller.getSnapshot().runtimeRailMemberships).toHaveLength(1);
expect(presentation.getSnapshot()[0]?.pinnedAtUnixMs).toBeNull();
await vi.waitFor(() =>
expect(listPinnedSessionsPage).toHaveBeenCalledTimes(1)
);
expect(listSessionSectionPage).toHaveBeenCalledTimes(1);
expect(listSessionSections).toHaveBeenCalledTimes(1);
await vi.waitFor(() =>
expect(controller.isInteractionLocked()).toBe(false)
);
expect(presentation.getSnapshot()[0]?.pinnedAtUnixMs).toBe(100);
expect(
controller
.getSnapshot()
.runtimeRailMemberships?.some((section) => section.id === "pinned")
).toBe(true);
expect(visiblePinChanges).toBe(1);
let visibleConversationCount = presentation.getSnapshot().length;
let visibleDeleteChanges = 0;
const unsubscribeDelete = presentation.subscribe((conversations) => {
const nextCount = conversations.length;
if (nextCount !== visibleConversationCount) {
visibleConversationCount = nextCount;
visibleDeleteChanges += 1;
}
});
engine.dispatch({
type: "session/removed",
agentSessionId: session.agentSessionId
});
expect(controller.isInteractionLocked()).toBe(true);
expect(presentation.getSnapshot()).toHaveLength(1);
expect(
controller
.getSnapshot()
.runtimeRailMemberships?.some((section) => section.id === "pinned")
).toBe(true);
await vi.waitFor(() =>
expect(
controller
.getSnapshot()
.runtimeRailMemberships?.some((section) => section.id === "pinned")
).toBe(false)
);
expect(presentation.getSnapshot()).toHaveLength(0);
expect(controller.isInteractionLocked()).toBe(false);
expect(visibleDeleteChanges).toBe(1);
const nextScope: ConversationRailQueryScope = {
conversationFilter: {
agentTargetId: "local:claude-code",
kind: "agentTarget"
},
userProjects: []
};
controller.configure(nextScope);
expect(controller.isInteractionLocked()).toBe(true);
expect(controller.getSnapshot().runtimeRailResolvedScopeKey).not.toBe(
resolveConversationRailQueryScope("test-workspace", nextScope).scopeKey
);
unsubscribeDelete();
unsubscribe();
presentation.dispose();
detach();
engine.dispose();
});
it("reuses fresh first pages across reattach and equivalent scope changes", async () => {
const engine = createTestAgentSessionEngine();
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>(async (input) => ({
workspaceId: input.workspaceId,
sections: []
}));
const runtime: ConversationRailQueryRuntime = {
listSessionSections,
listSessionSectionPage: async (input) => ({
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
hasMore: false,
totalCount: 0
})
};
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime,
workspaceId: "test-workspace"
});
const regularScope = {
conversationFilter: { kind: "all" } as const,
userProjects: []
};
controller.configure(regularScope);
const detachFirst = controller.attach();
await vi.waitFor(() =>
expect(listSessionSections).toHaveBeenCalledTimes(1)
);
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
detachFirst();
const detachSecond = controller.attach();
expect(listSessionSections).toHaveBeenCalledTimes(1);
controller.configure({ ...regularScope });
expect(controller.getSnapshot().runtimeSectionsEnabled).toBe(true);
expect(listSessionSections).toHaveBeenCalledTimes(1);
detachSecond();
engine.dispose();
});
it("does not reload section pages when user projects only reorder", async () => {
const engine = createTestAgentSessionEngine();
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>(async (input) => ({
sections: [],
workspaceId: input.workspaceId
}));
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: {
listSessionSections,
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
})
},
workspaceId: "test-workspace"
});
const alpha = {
id: "alpha",
label: "Alpha",
path: "/alpha",
pinnedAtUnixMs: 0,
sectionKey: "project:/alpha"
};
const beta = {
id: "beta",
label: "Beta",
path: "/beta",
pinnedAtUnixMs: 0,
sectionKey: "project:/beta"
};
const scope = {
conversationFilter: { kind: "all" } as const,
userProjects: [alpha, beta]
};
controller.configure(scope);
const detach = controller.attach();
await vi.waitFor(() =>
expect(listSessionSections).toHaveBeenCalledTimes(1)
);
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
controller.configure({ ...scope, userProjects: [beta, alpha] });
expect(listSessionSections).toHaveBeenCalledTimes(1);
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false);
expect(controller.isInteractionLocked()).toBe(false);
controller.configure({
...scope,
userProjects: [
beta,
alpha,
{
id: "gamma",
label: "Gamma",
path: "/gamma",
pinnedAtUnixMs: 0,
sectionKey: "project:/gamma"
}
]
});
await vi.waitFor(() =>
expect(listSessionSections).toHaveBeenCalledTimes(2)
);
detach();
engine.dispose();
});
it("keeps existing rows interactive when a targeted membership refresh is rejected", async () => {
const engine = createTestAgentSessionEngine();
const session = normalizeAgentActivitySession({
activeTurnId: null,
agentSessionId: "session-1",
agentTargetId: "local:codex",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey: "conversations",
title: "Session",
updatedAtUnixMs: 1,
workspaceId: "test-workspace"
});
const listPinnedSessionsPage = vi.fn(async () => {
throw Object.assign(new Error("pinned page rejected"), {
statusCode: 403
});
});
const listSessionSectionPage = vi.fn(async () => {
throw Object.assign(new Error("section page rejected"), {
statusCode: 403
});
});
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: {
listPinnedSessionsPage,
listSessionSectionPage,
listSessionSections: async (input) => ({
sections: [
{
hasMore: false,
kind: "conversations",
sectionKey: "conversations",
sessions: [session],
totalCount: 1
}
],
workspaceId: input.workspaceId
})
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const detach = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
const presentation = createRailConversationPresentation(controller, engine);
engine.dispatch({
session: { ...session, pinnedAtUnixMs: 10, updatedAtUnixMs: 2 },
type: "session/upserted"
});
await vi.waitFor(() =>
expect(listPinnedSessionsPage).toHaveBeenCalledTimes(1)
);
expect(listSessionSectionPage).toHaveBeenCalledTimes(1);
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailFailed).toBe(true)
);
expect(controller.isInteractionLocked()).toBe(false);
expect(presentation.getSnapshot()[0]?.pinnedAtUnixMs).toBe(10);
expect(
controller.getSnapshot().runtimeRailMemberships?.[0]?.sessionIds
).toEqual(["session-1"]);
expect(listPinnedSessionsPage).toHaveBeenCalledTimes(1);
expect(listSessionSectionPage).toHaveBeenCalledTimes(1);
presentation.dispose();
detach();
engine.dispose();
});
it("clears a canceled section loading state before targeted refresh settles", async () => {
const engine = createTestAgentSessionEngine();
const sessionA = createTestSession("session-a", "project:a");
const sessionB = createTestSession("session-b", "project:b");
let paginationAborted = false;
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: {
listPinnedSessionsPage: async () => ({
hasMore: false,
sessions: [{ ...sessionA, pinnedAtUnixMs: 10, updatedAtUnixMs: 2 }],
totalCount: 1
}),
listSessionSectionPage: (input) => {
if (input.cursor) {
return new Promise((_resolve, reject) => {
input.signal?.addEventListener(
"abort",
() => {
paginationAborted = true;
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true }
);
});
}
return Promise.resolve({
hasMore: false,
kind: "project" as const,
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
});
},
listSessionSections: async (input) => ({
sections: [
{
hasMore: false,
kind: "project",
sectionKey: "project:a",
sessions: [sessionA],
totalCount: 1
},
{
hasMore: true,
kind: "project",
nextCursor: "b-cursor",
sectionKey: "project:b",
sessions: [sessionB],
totalCount: 2
}
],
workspaceId: input.workspaceId
})
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const detach = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
controller.loadMoreSectionConversations({ id: "project:b" });
expect(
controller.getSnapshot().sectionPageStates.get("project:b")?.isLoading
).toBe(true);
engine.dispatch({
session: { ...sessionA, pinnedAtUnixMs: 10, updatedAtUnixMs: 2 },
type: "session/upserted"
});
await vi.waitFor(() => expect(paginationAborted).toBe(true));
await vi.waitFor(() =>
expect(controller.isInteractionLocked()).toBe(false)
);
expect(
controller.getSnapshot().sectionPageStates.get("project:b")?.isLoading
).toBe(false);
detach();
engine.dispose();
});
it("retains resolved section pages when a same-scope refresh fails and recovers", async () => {
const engine = createTestAgentSessionEngine();
let requestCount = 0;
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>(async (input) => {
requestCount += 1;
if (requestCount === 2) {
throw Object.assign(new Error("request rejected"), {
statusCode: 403
});
}
return {
workspaceId: input.workspaceId,
sections: [
{
kind: "conversations",
sectionKey: "conversations",
sessions: [],
hasMore: true,
nextCursor: "cursor-1",
totalCount: 8
}
]
};
});
const controller = new AgentGUIConversationRailQueryController({
cacheFreshMs: -1,
engine,
getActiveConversationId: () => null,
runtime: {
listSessionSections,
listSessionSectionPage: async (input) => ({
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
hasMore: false,
totalCount: 0
})
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const detachFirst = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailMemberships).toHaveLength(1)
);
detachFirst();
const detachSecond = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
expect(controller.getSnapshot().runtimeRailMemberships).toEqual([
expect.objectContaining({ id: "conversations" })
]);
expect(
controller.getSnapshot().sectionPageStates.get("conversations")
).toEqual({
hasMore: true,
isLoading: false,
nextCursor: "cursor-1",
totalCount: 8
});
expect(controller.getSnapshot().runtimeRailFailed).toBe(true);
await controller.refresh();
expect(listSessionSections).toHaveBeenCalledTimes(3);
expect(controller.getSnapshot().runtimeRailFailed).toBe(false);
expect(controller.getSnapshot().runtimeRailMemberships).toEqual([
expect.objectContaining({ id: "conversations" })
]);
detachSecond();
engine.dispose();
});
it("retries a failed section page load when More is requested again", async () => {
const engine = createTestAgentSessionEngine();
const firstSession = createTestSession("session-1", "conversations");
const secondSession = createTestSession("session-2", "conversations");
let pageRequestCount = 0;
const listSessionSectionPage = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSectionPage"]>
>(async (input) => {
pageRequestCount += 1;
if (pageRequestCount === 1) throw new Error("transient page failure");
return {
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [secondSession],
totalCount: 2
};
});
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: {
listSessionSectionPage,
listSessionSections: async (input) => ({
sections: [
{
hasMore: true,
kind: "conversations",
nextCursor: "cursor-2",
sectionKey: "conversations",
sessions: [firstSession],
totalCount: 2
}
],
workspaceId: input.workspaceId
})
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const detach = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
controller.loadMoreSectionConversations({ id: "conversations" });
await vi.waitFor(() =>
expect(listSessionSectionPage).toHaveBeenCalledTimes(1)
);
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailFailed).toBe(true)
);
expect(
controller.getSnapshot().sectionPageStates.get("conversations")
).toEqual({
hasMore: true,
isLoading: false,
nextCursor: "cursor-2",
totalCount: 2
});
controller.loadMoreSectionConversations({ id: "conversations" });
await vi.waitFor(() =>
expect(listSessionSectionPage).toHaveBeenCalledTimes(2)
);
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailFailed).toBe(false)
);
expect(
controller.getSnapshot().sectionPageStates.get("conversations")
).toEqual({
hasMore: false,
isLoading: false,
nextCursor: null,
totalCount: 2
});
expect(
selectEngineSession(engine.getSnapshot(), secondSession.agentSessionId)
).not.toBeNull();
detach();
engine.dispose();
});
it("isolates subscriber failures from successful rail queries", async () => {
const engine = createTestAgentSessionEngine();
const controller = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime: {
listSessionSections: async (input) => ({
sections: [
{
hasMore: false,
kind: "conversations",
sectionKey: "conversations",
sessions: [],
totalCount: 0
}
],
workspaceId: input.workspaceId
}),
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
})
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
const unsubscribeThrowingListener = controller.subscribe(() => {
throw new Error("host projection failed");
});
const healthyListener = vi.fn();
const unsubscribeHealthyListener = controller.subscribe(healthyListener);
const detach = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
expect(healthyListener).toHaveBeenCalled();
expect(controller.getSnapshot().runtimeRailFailed).toBe(false);
expect(controller.getSnapshot().runtimeRailMemberships).toEqual([
expect.objectContaining({ id: "conversations" })
]);
unsubscribeHealthyListener();
unsubscribeThrowingListener();
detach();
engine.dispose();
});
it("logs only slow successful first-page rail queries", async () => {
const engine = createTestAgentSessionEngine();
const reportDiagnostic = vi.fn();
const diagnosticTimes = [0, 300, 325];
const controller = new AgentGUIConversationRailQueryController({
cacheFreshMs: -1,
diagnosticNow: () => diagnosticTimes.shift() ?? 325,
diagnosticSlowThresholdMs: 250,
engine,
getActiveConversationId: () => null,
runtime: {
listSessionSections: async (input) => ({
pinned: {
hasMore: false,
sessions: [],
totalCount: 0
},
sections: [
{
hasMore: false,
kind: "conversations",
sectionKey: "conversations",
sessions: [],
totalCount: 0
}
],
workspaceId: input.workspaceId
}),
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
}),
reportDiagnostic
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: {
kind: "agentTarget",
agentTargetId: "local:codex"
},
userProjects: []
});
const detach = controller.attach();
await vi.waitFor(() => expect(reportDiagnostic).toHaveBeenCalledTimes(1));
expect(reportDiagnostic).toHaveBeenCalledWith({
details: {
agentTargetId: "local:codex",
controllerApplyMs: 25,
durationMs: 325,
event: "agent_gui.conversation_rail.first_pages_slow",
nodeId: null,
requestId: 2,
requestMs: 300,
refreshReason: "attach",
returnedSessionCount: 0,
returnedSessionIds: [],
runtimeOrigin: AGENT_SESSION_ENGINE_LOCAL_ORIGIN,
sectionCount: 2,
status: "ready",
workspaceId: "test-workspace"
},
event: "agent_gui.conversation_rail.first_pages_slow",
level: "info",
source: "agent-gui",
workspaceId: "test-workspace"
});
detach();
engine.dispose();
});
it("suppresses fast success diagnostics but records real failures", async () => {
const engine = createTestAgentSessionEngine();
const diagnosticLogger = vi.fn();
let requestCount = 0;
const diagnosticTimes = [0, 100, 110, 110, 130];
const controller = new AgentGUIConversationRailQueryController({
cacheFreshMs: -1,
diagnosticLogger,
diagnosticNow: () => diagnosticTimes.shift() ?? 130,
diagnosticSlowThresholdMs: 250,
engine,
getActiveConversationId: () => null,
runtime: {
listSessionSections: async (input) => {
requestCount += 1;
if (requestCount > 1) throw new TypeError("backend unavailable");
return { sections: [], workspaceId: input.workspaceId };
},
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
})
},
workspaceId: "test-workspace"
});
const scope = {
conversationFilter: { kind: "all" } as const,
userProjects: []
};
controller.configure(scope);
const detachFirst = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
expect(diagnosticLogger).not.toHaveBeenCalled();
detachFirst();
const detachSecond = controller.attach();
await vi.waitFor(() => expect(diagnosticLogger).toHaveBeenCalledTimes(1));
expect(diagnosticLogger).toHaveBeenCalledWith({
agentTargetId: null,
controllerApplyMs: 0,
durationMs: 20,
errorKind: "TypeError",
event: "agent_gui.conversation_rail.first_pages_failed",
nodeId: null,
requestId: 4,
requestMs: 20,
refreshReason: "attach",
returnedSessionCount: 0,
returnedSessionIds: [],
runtimeOrigin: AGENT_SESSION_ENGINE_LOCAL_ORIGIN,
sectionCount: 0,
status: "error",
workspaceId: "test-workspace"
});
detachSecond();
engine.dispose();
});
it("isolates in-flight controller requests and restores the resolved cache after remount", async () => {
const engine = createTestAgentSessionEngine();
const cache = createWorkspaceQueryCache<CachedConversationRailQuery>();
const sectionResolvers: Array<() => void> = [];
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>((input) =>
new Promise<void>((resolve) => {
sectionResolvers.push(resolve);
}).then(() => ({
sections: [
{
hasMore: false,
kind: "conversations" as const,
sectionKey: "conversations",
sessions: [],
totalCount: 0
}
],
workspaceId: input.workspaceId
}))
);
const runtime: ConversationRailQueryRuntime = {
listSessionSections,
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
})
};
const scope = {
conversationFilter: {
agentTargetId: "local:codex",
kind: "agentTarget"
} as const,
userProjects: []
};
const first = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime,
sessionSectionsQueryCache: cache,
workspaceId: "test-workspace"
});
const second = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime,
sessionSectionsQueryCache: cache,
workspaceId: "test-workspace"
});
first.configure(scope);
second.configure(scope);
const detachFirst = first.attach();
const detachSecond = second.attach();
expect(listSessionSections).toHaveBeenCalledTimes(2);
for (const resolve of sectionResolvers) resolve();
await vi.waitFor(() =>
expect(first.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
await vi.waitFor(() =>
expect(second.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
detachFirst();
detachSecond();
const remounted = new AgentGUIConversationRailQueryController({
engine,
getActiveConversationId: () => null,
runtime,
sessionSectionsQueryCache: cache,
workspaceId: "test-workspace"
});
remounted.configure(scope);
const detachRemounted = remounted.attach();
expect(remounted.getSnapshot().runtimeRailMemberships).toHaveLength(1);
expect(remounted.getSnapshot().runtimeRailSectionsPending).toBe(false);
expect(listSessionSections).toHaveBeenCalledTimes(2);
detachRemounted();
engine.dispose();
});
it("restores a fresh target scope without refetching on A to B to A", async () => {
const engine = createTestAgentSessionEngine();
const diagnosticLogger = vi.fn();
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>(async (input) => ({
sections: [
{
hasMore: false,
kind: "conversations",
sectionKey: "conversations",
sessions: [],
totalCount: 0
}
],
workspaceId: input.workspaceId
}));
const controller = new AgentGUIConversationRailQueryController({
diagnosticLogger,
engine,
getActiveConversationId: () => null,
runtime: {
listSessionSections,
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
})
},
workspaceId: "test-workspace"
});
const scope = (agentTargetId: string) => ({
conversationFilter: { agentTargetId, kind: "agentTarget" as const },
userProjects: []
});
controller.configure(scope("local:codex"));
const detach = controller.attach();
await vi.waitFor(() =>
expect(listSessionSections).toHaveBeenCalledTimes(1)
);
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
controller.configure(scope("local:claude-code"));
await vi.waitFor(() =>
expect(listSessionSections).toHaveBeenCalledTimes(2)
);
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
expect(diagnosticLogger).toHaveBeenLastCalledWith(
expect.objectContaining({
cacheStatus: "miss",
event: "agent_gui.provider_switch.completed",
fromAgentTargetId: "local:codex",
status: "ready",
toAgentTargetId: "local:claude-code"
})
);
controller.configure(scope("local:codex"));
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false);
expect(listSessionSections).toHaveBeenCalledTimes(2);
expect(diagnosticLogger).toHaveBeenLastCalledWith(
expect.objectContaining({
cacheStatus: "fresh",
event: "agent_gui.provider_switch.completed",
fromAgentTargetId: "local:claude-code",
requestMs: 0,
status: "ready",
toAgentTargetId: "local:codex"
})
);
detach();
engine.dispose();
});
it("logs retained sessions when switching from a target filter to all", async () => {
const engine = createTestAgentSessionEngine();
const diagnosticLogger = vi.fn();
const previousSession = normalizeAgentActivitySession({
activeTurnId: null,
agentSessionId: "session-previous",
agentTargetId: "shared:one",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey: "conversations",
title: "Previous conversation",
updatedAtUnixMs: 1,
workspaceId: "test-workspace"
});
const allSession = normalizeAgentActivitySession({
...previousSession,
agentSessionId: "session-from-all",
title: "Conversation returned by all"
});
let resolveAllSections!: () => void;
let requestCount = 0;
const listSessionSections = vi.fn<
NonNullable<ConversationRailQueryRuntime["listSessionSections"]>
>((input) => {
requestCount += 1;
if (requestCount === 1) {
return Promise.resolve({
sections: [
{
hasMore: false,
kind: "conversations" as const,
sectionKey: "conversations",
sessions: [previousSession],
totalCount: 1
}
],
workspaceId: input.workspaceId
});
}
return new Promise<void>((resolve) => {
resolveAllSections = resolve;
}).then(() => ({
sections: [
{
hasMore: false,
kind: "conversations" as const,
sectionKey: "conversations",
sessions: [previousSession, allSession],
totalCount: 2
}
],
workspaceId: input.workspaceId
}));
});
const controller = new AgentGUIConversationRailQueryController({
diagnosticLogger,
engine,
getActiveConversationId: () => "session-previous",
nodeId: "shared-node-1",
runtime: {
listSessionSections,
listSessionSectionPage: async (input) => ({
hasMore: false,
kind: "conversations",
sectionKey: input.sectionKey,
sessions: [],
totalCount: 0
})
},
workspaceId: "test-workspace"
});
controller.configure({
conversationFilter: {
agentTargetId: "shared:one",
kind: "agentTarget"
},
userProjects: []
});
const detach = controller.attach();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
diagnosticLogger.mockClear();
controller.configure({
conversationFilter: { kind: "all" },
userProjects: []
});
expect(listSessionSections).toHaveBeenLastCalledWith(
expect.objectContaining({ agentTargetId: undefined })
);
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(true);
expect(controller.getSnapshot().runtimeRailMemberships).toEqual([
expect.objectContaining({ sessionIds: ["session-previous"] })
]);
expect(diagnosticLogger).toHaveBeenCalledWith(
expect.objectContaining({
activeConversationId: "session-previous",
event: "agent_gui.conversation_rail.scope_change.started",
fromAgentTargetId: "shared:one",
fromFilterKind: "agentTarget",
nodeId: "shared-node-1",
preservedSectionCount: 1,
preservedSessionIds: ["session-previous"],
retainedPreviousSections: true,
runtimeOrigin: AGENT_SESSION_ENGINE_LOCAL_ORIGIN,
status: "pending",
toAgentTargetId: null,
toFilterKind: "all"
})
);
resolveAllSections();
await vi.waitFor(() =>
expect(controller.getSnapshot().runtimeRailSectionsPending).toBe(false)
);
expect(diagnosticLogger).toHaveBeenCalledWith(
expect.objectContaining({
cacheStatus: "miss",
event: "agent_gui.conversation_rail.scope_change.completed",
nodeId: "shared-node-1",
returnedSessionCount: 2,
returnedSessionIds: ["session-previous", "session-from-all"],
status: "ready"
})
);
expect(diagnosticLogger).toHaveBeenCalledWith(
expect.objectContaining({
event: "agent_gui.provider_switch.completed",
fromAgentTargetId: "shared:one",
toAgentTargetId: null
})
);
detach();
engine.dispose();
});
});
function createTestSession(agentSessionId: string, railSectionKey: string) {
return normalizeAgentActivitySession({
activeTurnId: null,
agentSessionId,
agentTargetId: "local:codex",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey,
title: agentSessionId,
updatedAtUnixMs: 1,
workspaceId: "test-workspace"
});
}
function createRailConversationPresentation(
controller: AgentGUIConversationRailQueryController,
engine: AgentSessionEngine
) {
const select = createConversationRailConversationsSelector();
let current = select({
engineState: engine.getSnapshot(),
interactionLocked: controller.isInteractionLocked(),
querySnapshot: controller.getSnapshot()
});
const listeners = new Set<(value: typeof current) => void>();
const update = () => {
const next = select(
{
engineState: engine.getSnapshot(),
interactionLocked: controller.isInteractionLocked(),
querySnapshot: controller.getSnapshot()
},
current
);
if (next === current) return;
current = next;
for (const listener of listeners) listener(current);
};
const unsubscribeEngine = engine.subscribe(update);
const unsubscribeController = controller.subscribe(update);
return {
dispose() {
unsubscribeController();
unsubscribeEngine();
listeners.clear();
},
getSnapshot: () => current,
subscribe(listener: (value: typeof current) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
}