mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
991 lines
37 KiB
TypeScript
991 lines
37 KiB
TypeScript
import type { Mock } from "vitest";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { Thread } from "../../../../../src/domain/threads/model";
|
|
import type { EffectOutcome } from "../../../../../src/features/chat/application/effect-outcome";
|
|
import { activeThreadId } from "../../../../../src/features/chat/application/state/root-reducer";
|
|
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
|
import {
|
|
createThreadManagementActions,
|
|
type ThreadManagementActions,
|
|
type ThreadManagementActionsHost,
|
|
} from "../../../../../src/features/chat/application/threads/thread-management-actions";
|
|
import type { ThreadMutationTransport } from "../../../../../src/features/chat/application/threads/thread-mutation-transport";
|
|
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
|
|
import { deferred, waitForAsyncWork } from "../../../../support/async";
|
|
import { chatStateFixture, chatStateWith } from "../../support/state";
|
|
import { withChatStateThreadStreamItems } from "../../support/thread-stream";
|
|
|
|
interface ThreadMutationTransportMock {
|
|
ensureConnected: Mock<ThreadMutationTransport["ensureConnected"]>;
|
|
compactThread: Mock<ThreadMutationTransport["compactThread"]>;
|
|
forkThread: Mock<ThreadMutationTransport["forkThread"]>;
|
|
}
|
|
|
|
interface ThreadOperationsMock {
|
|
archiveThread: Mock<ThreadManagementActionsHost["operations"]["archiveThread"]>;
|
|
renameThread: Mock<ThreadManagementActionsHost["operations"]["renameThread"]>;
|
|
}
|
|
|
|
type ThreadManagementActionsHostMock = Omit<
|
|
ThreadManagementActionsHost,
|
|
| "addSystemMessage"
|
|
| "openThreadInCurrentPanel"
|
|
| "openThreadInNewView"
|
|
| "operations"
|
|
| "beginThreadForkPublication"
|
|
| "setComposerText"
|
|
| "setStatus"
|
|
| "threadTransport"
|
|
> & {
|
|
operations: ThreadOperationsMock;
|
|
threadTransport: ThreadMutationTransportMock;
|
|
addSystemMessage: Mock<ThreadManagementActionsHost["addSystemMessage"]>;
|
|
setStatus: Mock<ThreadManagementActionsHost["setStatus"]>;
|
|
setComposerText: Mock<ThreadManagementActionsHost["setComposerText"]>;
|
|
openThreadInNewView: Mock<ThreadManagementActionsHost["openThreadInNewView"]>;
|
|
openThreadInCurrentPanel: Mock<ThreadManagementActionsHost["openThreadInCurrentPanel"]>;
|
|
forkPublication: {
|
|
record: Mock<ReturnType<ThreadManagementActionsHost["beginThreadForkPublication"]>["record"]>;
|
|
finish: Mock<ReturnType<ThreadManagementActionsHost["beginThreadForkPublication"]>["finish"]>;
|
|
};
|
|
beginThreadForkPublication: Mock<ThreadManagementActionsHost["beginThreadForkPublication"]>;
|
|
};
|
|
|
|
describe("thread management actions", () => {
|
|
it("allows direct compaction of an active side chat", async () => {
|
|
const host = hostMock({
|
|
items: [],
|
|
activeThread: {
|
|
id: "side-thread",
|
|
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
|
},
|
|
});
|
|
|
|
await threadManagementActions(host).compactThread("side-thread");
|
|
|
|
expect(host.threadTransport.compactThread).toHaveBeenCalledWith("side-thread");
|
|
});
|
|
|
|
it("does not compact an old panel target after connection completes", async () => {
|
|
const connection = deferred<boolean>();
|
|
const host = hostMock({
|
|
items: [],
|
|
activeThread: { id: "source" },
|
|
threadTransport: { ensureConnected: vi.fn(() => connection.promise) },
|
|
});
|
|
const compacting = threadManagementActions(host).compactThread("source");
|
|
await waitForAsyncWork(() => expect(host.threadTransport.ensureConnected).toHaveBeenCalledOnce());
|
|
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("other"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
connection.resolve(true);
|
|
await compacting;
|
|
|
|
expect(host.threadTransport.compactThread).not.toHaveBeenCalled();
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("other");
|
|
});
|
|
|
|
it("does not fork an ephemeral side chat", async () => {
|
|
const host = hostMock({
|
|
items: [],
|
|
activeThread: {
|
|
id: "side-thread",
|
|
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThread("side-thread");
|
|
|
|
expect(host.threadTransport.forkThread).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be forked.");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "fork",
|
|
invoke: (actions: ThreadManagementActions) => actions.forkThread("agent-thread"),
|
|
transport: "forkThread" as const,
|
|
message: "Agent threads cannot be forked.",
|
|
},
|
|
{
|
|
name: "rollback",
|
|
invoke: (actions: ThreadManagementActions) => actions.rollbackThread("agent-thread"),
|
|
transport: "forkThread" as const,
|
|
message: "Agent threads cannot be rolled back.",
|
|
},
|
|
])("rejects $name mutations for subagent threads", async ({ invoke, transport, message }) => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
activeThread: {
|
|
id: "agent-thread",
|
|
provenance: {
|
|
kind: "subagent",
|
|
subagentKind: "thread-spawn",
|
|
parentThreadId: "parent",
|
|
sessionId: "session",
|
|
depth: 1,
|
|
agentNickname: "Scout",
|
|
agentRole: "explorer",
|
|
},
|
|
},
|
|
});
|
|
|
|
await invoke(threadManagementActions(host));
|
|
|
|
expect(host.threadTransport[transport]).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith(message);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "archive",
|
|
invoke: (actions: ThreadManagementActions) => actions.archiveThread("source"),
|
|
operation: "archiveThread" as const,
|
|
message: "Finish or interrupt the current turn before archiving threads.",
|
|
},
|
|
{
|
|
name: "fork",
|
|
invoke: (actions: ThreadManagementActions) => actions.forkThread("source"),
|
|
operation: "forkThread" as const,
|
|
message: "Finish or interrupt the current turn before forking threads.",
|
|
},
|
|
{
|
|
name: "rollback",
|
|
invoke: (actions: ThreadManagementActions) => actions.rollbackThread("source"),
|
|
operation: "forkThread" as const,
|
|
message: "Interrupt the current turn before rolling back.",
|
|
},
|
|
])("rejects $name while a turn is busy", async ({ invoke, operation, message }) => {
|
|
const host = hostMock({ items: turnItems(), activeThread: { id: "source" } });
|
|
host.stateStore.dispatch({ type: "turn/started", threadId: "source", turnId: "turn-running" });
|
|
|
|
await invoke(threadManagementActions(host));
|
|
|
|
if (operation === "archiveThread") expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
else expect(host.threadTransport[operation]).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith(message);
|
|
});
|
|
|
|
it("requests thread compaction and reports the shared status", async () => {
|
|
const host = hostMock({ items: [] });
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.compactThread("source");
|
|
|
|
expect(host.threadTransport.compactThread).toHaveBeenCalledWith("source");
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Compaction requested.");
|
|
expect(host.setStatus).toHaveBeenCalledWith("Compaction requested.");
|
|
});
|
|
|
|
it("reports compacting without an active thread", async () => {
|
|
const host = hostMock({ items: [] });
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.compactActiveThread();
|
|
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("No active thread to compact.");
|
|
expect(host.threadTransport.compactThread).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not report compaction completion after the panel switches threads", async () => {
|
|
const compact = deferred<EffectOutcome<void>>();
|
|
const host = hostMock({ items: [] });
|
|
host.threadTransport.compactThread.mockReturnValue(compact.promise);
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("source"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
const pendingCompact = controller.compactThread("source");
|
|
await waitForAsyncWork(() => {
|
|
expect(host.threadTransport.compactThread).toHaveBeenCalledWith("source");
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("other"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
compact.resolve(completedCurrent(undefined));
|
|
await pendingCompact;
|
|
|
|
expect(host.addSystemMessage).not.toHaveBeenCalledWith("Compaction requested.");
|
|
expect(host.setStatus).not.toHaveBeenCalledWith("Compaction requested.");
|
|
});
|
|
|
|
it("does not report compaction completion when the transport rejects the mutation", async () => {
|
|
const host = hostMock({
|
|
items: [],
|
|
threadTransport: {
|
|
compactThread: vi.fn<ThreadMutationTransport["compactThread"]>().mockResolvedValue({ kind: "not-started" }),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.compactThread("source");
|
|
|
|
expect(host.threadTransport.compactThread).toHaveBeenCalledWith("source");
|
|
expect(host.addSystemMessage).not.toHaveBeenCalledWith("Compaction requested.");
|
|
expect(host.setStatus).not.toHaveBeenCalledWith("Compaction requested.");
|
|
});
|
|
|
|
it("delegates archive requests to thread operations", async () => {
|
|
const host = hostMock({ items: [] });
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.archiveThread("source", true);
|
|
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", { saveMarkdown: true });
|
|
expect(host.addSystemMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects archiving a thread that is active in another panel", async () => {
|
|
const host = hostMock({ items: [] });
|
|
vi.mocked(host.threadHasPendingOrRunningPanel).mockReturnValue(true);
|
|
|
|
await threadManagementActions(host).archiveThread("source");
|
|
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Finish or interrupt the thread before archiving it.");
|
|
});
|
|
|
|
it("reports archive operation failures", async () => {
|
|
const host = hostMock({
|
|
items: [],
|
|
operations: {
|
|
archiveThread: vi.fn<ThreadManagementActionsHost["operations"]["archiveThread"]>().mockRejectedValue(new Error("disk full")),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.archiveThread("source");
|
|
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", {});
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("disk full");
|
|
});
|
|
|
|
it("forks through a selected turn", async () => {
|
|
const host = hostMock({ items: turnItems() });
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThreadFromTurn("source", "turn-1", false);
|
|
|
|
expect(host.threadTransport.forkThread).toHaveBeenCalledWith("source", {
|
|
position: { kind: "through-turn", turnId: "turn-1" },
|
|
});
|
|
expect(host.forkPublication.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: "forked",
|
|
}),
|
|
);
|
|
expect(host.openThreadInNewView).toHaveBeenCalledWith("forked");
|
|
expect(host.forkPublication.finish).toHaveBeenCalledWith();
|
|
expect(callOrder(host.openThreadInNewView)).toBeLessThan(callOrder(host.forkPublication.finish));
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("replaces the panel before archiving the source during fork and archive", async () => {
|
|
const host = hostMock({ items: turnItems() });
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThreadFromTurn("source", "turn-3", true);
|
|
|
|
expect(host.threadTransport.forkThread).toHaveBeenCalledWith("source", {
|
|
position: { kind: "through-turn", turnId: "turn-3" },
|
|
});
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", {});
|
|
expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked", expect.any(Function));
|
|
expect(callOrder(host.openThreadInCurrentPanel)).toBeLessThan(callOrder(host.operations.archiveThread));
|
|
expect(host.forkPublication.finish).toHaveBeenCalledWith({ sourceArchived: true });
|
|
expect(callOrder(host.operations.archiveThread)).toBeLessThan(callOrder(host.forkPublication.finish));
|
|
});
|
|
|
|
it("keeps the replacement panel when source archiving fails", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
operations: {
|
|
archiveThread: vi.fn<ThreadManagementActionsHost["operations"]["archiveThread"]>().mockRejectedValue(new Error("archive failed")),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThreadFromTurn("source", "turn-3", true);
|
|
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", {});
|
|
expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked", expect.any(Function));
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("forked");
|
|
expect(host.forkPublication.finish).toHaveBeenCalledWith({ sourceArchived: false });
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Forked the thread, but could not archive the previous version: archive failed");
|
|
});
|
|
|
|
it("reports when source archiving is not completed after replacement", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
operations: {
|
|
archiveThread: vi.fn<ThreadManagementActionsHost["operations"]["archiveThread"]>().mockResolvedValue(false),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThreadFromTurn("source", "turn-3", true);
|
|
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", {});
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("forked");
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith(
|
|
"Forked the thread, but could not archive the previous version: archive was not completed",
|
|
);
|
|
});
|
|
|
|
it("keeps the source when a fork cannot replace the current panel", async () => {
|
|
const host = hostMock({ items: turnItems() });
|
|
host.openThreadInCurrentPanel.mockRejectedValue(new Error("resume failed"));
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThreadFromTurn("source", "turn-3", true);
|
|
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Forked thread forked, but could not open it in the current panel: resume failed");
|
|
});
|
|
|
|
it("does not archive or replace the panel from stale fork responses", async () => {
|
|
const fork = deferred<EffectOutcome<Thread>>();
|
|
const host = hostMock({ items: turnItems() });
|
|
host.threadTransport.forkThread.mockReturnValue(fork.promise);
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("source"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "thread-list/applied",
|
|
threads: [{ ...panelThread("source"), name: "Source name" }],
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
const pendingFork = controller.forkThreadFromTurn("source", null, true);
|
|
await waitForAsyncWork(() => {
|
|
expect(host.threadTransport.forkThread).toHaveBeenCalledWith("source");
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("other"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
fork.resolve(completedCurrent(panelThread("forked")));
|
|
await pendingFork;
|
|
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.operations.renameThread).not.toHaveBeenCalled();
|
|
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not archive the source when the panel changes during fork naming", async () => {
|
|
const rename = deferred<boolean>();
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
activeThread: { id: "source" },
|
|
operations: { renameThread: vi.fn(() => rename.promise) },
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "thread-list/applied",
|
|
threads: [{ ...panelThread("source"), name: "Source name" }],
|
|
});
|
|
|
|
const pendingFork = threadManagementActions(host).forkThreadFromTurn("source", null, true);
|
|
await waitForAsyncWork(() => expect(host.operations.renameThread).toHaveBeenCalledWith("forked", "Source name"));
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("other"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
rename.resolve(true);
|
|
await pendingFork;
|
|
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not overwrite a newer panel while committed source cleanup completes", async () => {
|
|
const archive = deferred<boolean>();
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
activeThread: { id: "source" },
|
|
operations: { archiveThread: vi.fn(() => archive.promise) },
|
|
});
|
|
|
|
const pendingFork = threadManagementActions(host).forkThreadFromTurn("source", null, true);
|
|
await waitForAsyncWork(() => expect(host.operations.archiveThread).toHaveBeenCalledWith("source", {}));
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("other"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
archive.resolve(true);
|
|
await pendingFork;
|
|
|
|
expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked", expect.any(Function));
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("other");
|
|
});
|
|
|
|
it("does not open or record fork responses when the transport has no result", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
activeThread: { id: "source" },
|
|
threadTransport: {
|
|
forkThread: vi.fn<ThreadMutationTransport["forkThread"]>().mockResolvedValue({ kind: "not-started" }),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThreadFromTurn("source", null, false);
|
|
|
|
expect(host.forkPublication.record).not.toHaveBeenCalled();
|
|
expect(host.forkPublication.finish).toHaveBeenCalledWith();
|
|
expect(host.openThreadInNewView).not.toHaveBeenCalled();
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records a fork that completed before its app-server context became stale", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
threadTransport: {
|
|
forkThread: vi
|
|
.fn<ThreadMutationTransport["forkThread"]>()
|
|
.mockResolvedValue({ kind: "completed-stale", value: panelThread("forked") }),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThreadFromTurn("source", null, true);
|
|
|
|
expect(host.forkPublication.record).toHaveBeenCalledWith(panelThread("forked"));
|
|
expect(host.forkPublication.finish).toHaveBeenCalledWith();
|
|
expect(host.operations.renameThread).not.toHaveBeenCalled();
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delegates thread rename requests", async () => {
|
|
const host = hostMock({ items: [] });
|
|
const controller = threadManagementActions(host);
|
|
|
|
await expect(controller.renameThread("thread", " Slash command title ")).resolves.toBe(true);
|
|
|
|
expect(host.operations.renameThread).toHaveBeenCalledWith("thread", " Slash command title ");
|
|
});
|
|
|
|
it("returns false when thread operations reject a rename", async () => {
|
|
const host = hostMock({
|
|
items: [],
|
|
operations: {
|
|
renameThread: vi.fn<ThreadManagementActionsHost["operations"]["renameThread"]>().mockResolvedValue(false),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await expect(controller.renameThread("thread", " ")).resolves.toBe(false);
|
|
|
|
expect(host.operations.renameThread).toHaveBeenCalledWith("thread", " ");
|
|
});
|
|
|
|
it("forks before the latest turn, adopts it, restores the prompt, and archives the source", async () => {
|
|
const host = hostMock({ items: turnItems() });
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("source"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "thread-stream/items-replaced",
|
|
items: turnItems(),
|
|
historyCursor: null,
|
|
loadingHistory: false,
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.rollbackThread("source");
|
|
|
|
expect(host.threadTransport.forkThread).toHaveBeenCalledWith("source", {
|
|
position: { kind: "before-turn", turnId: "turn-3" },
|
|
deferGoalContinuation: true,
|
|
runtime: { reasoningEffort: null, serviceTier: null },
|
|
});
|
|
expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked", expect.any(Function));
|
|
expect(host.setComposerText).toHaveBeenCalledWith("three");
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", { saveMarkdown: false });
|
|
expect(callOrder(host.openThreadInCurrentPanel)).toBeLessThan(callOrder(host.operations.archiveThread));
|
|
expect(callOrder(host.setComposerText)).toBeLessThan(callOrder(host.addSystemMessage));
|
|
expect(host.forkPublication.record).toHaveBeenCalledWith(panelThread("forked"));
|
|
expect(host.forkPublication.finish).toHaveBeenCalledWith({ sourceArchived: true });
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("forked");
|
|
});
|
|
|
|
it("uses the first turn itself as the marker when rolling back a one-turn thread", async () => {
|
|
const host = hostMock({ items: turnItems().slice(0, 2), activeThread: { id: "source" } });
|
|
|
|
await threadManagementActions(host).rollbackThread("source");
|
|
|
|
expect(host.threadTransport.forkThread).toHaveBeenCalledWith("source", {
|
|
position: { kind: "before-turn", turnId: "turn-1" },
|
|
deferGoalContinuation: true,
|
|
runtime: { reasoningEffort: null },
|
|
});
|
|
expect(host.setComposerText).toHaveBeenCalledWith("one");
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", { saveMarkdown: false });
|
|
});
|
|
|
|
it("preserves the active thread runtime when creating a rollback fork", async () => {
|
|
const host = hostMock({ items: turnItems(), activeThread: { id: "source" } });
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: "never",
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: { id: ":workspace", extends: null },
|
|
thread: panelThread("source"),
|
|
model: "gpt-5.6",
|
|
reasoningEffort: "high",
|
|
serviceTier: "priority",
|
|
approvalsReviewer: "auto_review",
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "thread-stream/items-replaced",
|
|
items: turnItems(),
|
|
historyCursor: null,
|
|
loadingHistory: false,
|
|
});
|
|
|
|
await threadManagementActions(host).rollbackThread("source");
|
|
|
|
expect(host.threadTransport.forkThread).toHaveBeenCalledWith("source", {
|
|
position: { kind: "before-turn", turnId: "turn-3" },
|
|
deferGoalContinuation: true,
|
|
runtime: {
|
|
model: "gpt-5.6",
|
|
reasoningEffort: "high",
|
|
serviceTier: "priority",
|
|
approvalPolicy: "never",
|
|
approvalsReviewer: "auto_review",
|
|
permissions: ":workspace",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps the source when the rollback fork is not adopted", async () => {
|
|
const host = hostMock({ items: turnItems(), activeThread: { id: "source" } });
|
|
host.openThreadInCurrentPanel.mockResolvedValue({ adopted: false });
|
|
|
|
await threadManagementActions(host).rollbackThread("source");
|
|
|
|
expect(host.forkPublication.record).toHaveBeenCalledWith(panelThread("forked"));
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("source");
|
|
expect(host.setComposerText).not.toHaveBeenCalled();
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith(
|
|
"The rolled-back version was created but could not be opened in this panel. Open it from thread history.",
|
|
);
|
|
});
|
|
|
|
it("commits rollback cleanup from the adoption result", async () => {
|
|
const host = hostMock({ items: turnItems(), activeThread: { id: "source" } });
|
|
host.openThreadInCurrentPanel.mockImplementation(async (threadId, onAdopted) => {
|
|
adoptThread(host, threadId);
|
|
onAdopted();
|
|
return { adopted: true };
|
|
});
|
|
|
|
await threadManagementActions(host).rollbackThread("source");
|
|
|
|
expect(host.setComposerText).toHaveBeenCalledWith("three");
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", { saveMarkdown: false });
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("forked");
|
|
});
|
|
|
|
it("keeps the adopted rollback when archiving its source fails", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
activeThread: { id: "source" },
|
|
operations: {
|
|
archiveThread: vi.fn<ThreadManagementActionsHost["operations"]["archiveThread"]>().mockRejectedValue(new Error("archive failed")),
|
|
},
|
|
});
|
|
|
|
await threadManagementActions(host).rollbackThread("source");
|
|
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("forked");
|
|
expect(host.setComposerText).toHaveBeenCalledWith("three");
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith(
|
|
"Rolled back the latest turn, but could not archive the previous version: archive failed",
|
|
);
|
|
});
|
|
|
|
it("does not roll back an ephemeral side chat", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
activeThread: {
|
|
id: "side-thread",
|
|
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.rollbackThread("side-thread");
|
|
|
|
expect(host.threadTransport.forkThread).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be rolled back.");
|
|
});
|
|
|
|
it("does not adopt or archive a rollback fork after the panel switches threads", async () => {
|
|
const rollback = deferred<EffectOutcome<Thread>>();
|
|
const host = hostMock({ items: turnItems() });
|
|
host.threadTransport.forkThread.mockReturnValue(rollback.promise);
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("source"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "thread-stream/items-replaced",
|
|
items: turnItems(),
|
|
historyCursor: null,
|
|
loadingHistory: false,
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
const pendingRollback = controller.rollbackThread("source");
|
|
await waitForAsyncWork(() => {
|
|
expect(host.threadTransport.forkThread).toHaveBeenCalledWith("source", {
|
|
position: { kind: "before-turn", turnId: "turn-3" },
|
|
deferGoalContinuation: true,
|
|
runtime: { reasoningEffort: null, serviceTier: null },
|
|
});
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("other"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
rollback.resolve(completedCurrent(panelThread("forked")));
|
|
await pendingRollback;
|
|
|
|
expect(activeThreadId(host.stateStore.getState())).toBe("other");
|
|
expect(host.setComposerText).not.toHaveBeenCalled();
|
|
expect(host.forkPublication.record).toHaveBeenCalledWith(panelThread("forked"));
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not adopt or archive a rollback fork after a new turn starts in the source", async () => {
|
|
const rollback = deferred<EffectOutcome<Thread>>();
|
|
const host = hostMock({ items: turnItems() });
|
|
host.threadTransport.forkThread.mockReturnValue(rollback.promise);
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("source"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
host.stateStore.dispatch({ type: "thread-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false });
|
|
const controller = threadManagementActions(host);
|
|
|
|
const pendingRollback = controller.rollbackThread("source");
|
|
await waitForAsyncWork(() => expect(host.threadTransport.forkThread).toHaveBeenCalledOnce());
|
|
host.stateStore.dispatch({ type: "turn/started", threadId: "source", turnId: "new-turn" });
|
|
rollback.resolve(completedCurrent(panelThread("forked")));
|
|
await pendingRollback;
|
|
|
|
expect(host.stateStore.getState().turn.lifecycle).toEqual({ kind: "running", turnId: "new-turn" });
|
|
expect(host.setComposerText).not.toHaveBeenCalled();
|
|
expect(host.forkPublication.record).toHaveBeenCalledWith(panelThread("forked"));
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not publish or adopt a rollback fork from a stale app-server context", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
activeThread: { id: "source" },
|
|
threadTransport: {
|
|
forkThread: vi
|
|
.fn<ThreadMutationTransport["forkThread"]>()
|
|
.mockResolvedValue({ kind: "completed-stale", value: panelThread("forked") }),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.rollbackThread("source");
|
|
|
|
expect(host.forkPublication.record).not.toHaveBeenCalled();
|
|
expect(host.setComposerText).not.toHaveBeenCalled();
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the source when marker fork has no result", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
threadTransport: {
|
|
forkThread: vi.fn<ThreadMutationTransport["forkThread"]>().mockResolvedValue({ kind: "not-started" }),
|
|
},
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("source"),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "thread-stream/items-replaced",
|
|
items: turnItems(),
|
|
historyCursor: null,
|
|
loadingHistory: false,
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.rollbackThread("source");
|
|
|
|
expect(host.setComposerText).not.toHaveBeenCalled();
|
|
expect(host.forkPublication.record).not.toHaveBeenCalled();
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function turnItems(): ThreadStreamItem[] {
|
|
return [
|
|
{ id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "one", turnId: "turn-1" },
|
|
{
|
|
id: "a1",
|
|
kind: "dialogue",
|
|
role: "assistant",
|
|
text: "one answer",
|
|
turnId: "turn-1",
|
|
dialogueKind: "assistantResponse",
|
|
dialogueState: "completed",
|
|
},
|
|
{ id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "two", turnId: "turn-2" },
|
|
{
|
|
id: "a2",
|
|
kind: "dialogue",
|
|
role: "assistant",
|
|
text: "two answer",
|
|
turnId: "turn-2",
|
|
dialogueKind: "assistantResponse",
|
|
dialogueState: "completed",
|
|
},
|
|
{ id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "three", turnId: "turn-3" },
|
|
{
|
|
id: "a3",
|
|
kind: "dialogue",
|
|
role: "assistant",
|
|
text: "three answer",
|
|
turnId: "turn-3",
|
|
dialogueKind: "assistantResponse",
|
|
dialogueState: "completed",
|
|
},
|
|
];
|
|
}
|
|
|
|
function threadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
|
|
return createThreadManagementActions(host);
|
|
}
|
|
|
|
function hostMock({
|
|
items,
|
|
activeThread,
|
|
operations: operationOverrides = {},
|
|
threadTransport: transportOverrides = {},
|
|
}: {
|
|
items: ThreadStreamItem[];
|
|
activeThread?: NonNullable<Parameters<typeof chatStateWith>[1]["activeThread"]>;
|
|
operations?: Partial<ThreadOperationsMock>;
|
|
threadTransport?: Partial<ThreadMutationTransportMock>;
|
|
}): ThreadManagementActionsHostMock {
|
|
let state = withChatStateThreadStreamItems(chatStateFixture(), items);
|
|
if (activeThread) state = chatStateWith(state, { activeThread });
|
|
const stateStore = createChatStateStore(state);
|
|
const threadTransport: ThreadMutationTransportMock = {
|
|
ensureConnected: vi.fn<ThreadMutationTransport["ensureConnected"]>().mockResolvedValue(true),
|
|
compactThread: vi.fn<ThreadMutationTransport["compactThread"]>().mockResolvedValue(completedCurrent(undefined)),
|
|
forkThread: vi.fn<ThreadMutationTransport["forkThread"]>().mockResolvedValue(completedCurrent(panelThread("forked"))),
|
|
...transportOverrides,
|
|
};
|
|
const operations: ThreadOperationsMock = {
|
|
archiveThread: vi.fn<ThreadManagementActionsHost["operations"]["archiveThread"]>().mockResolvedValue(true),
|
|
renameThread: vi.fn<ThreadManagementActionsHost["operations"]["renameThread"]>().mockResolvedValue(true),
|
|
...operationOverrides,
|
|
};
|
|
const forkPublication = {
|
|
record: vi.fn(),
|
|
finish: vi.fn(),
|
|
};
|
|
return {
|
|
stateStore,
|
|
threadTransport,
|
|
operations,
|
|
addSystemMessage: vi.fn<ThreadManagementActionsHost["addSystemMessage"]>(),
|
|
setStatus: vi.fn<ThreadManagementActionsHost["setStatus"]>(),
|
|
setComposerText: vi.fn<ThreadManagementActionsHost["setComposerText"]>(),
|
|
openThreadInNewView: vi.fn<ThreadManagementActionsHost["openThreadInNewView"]>().mockResolvedValue(undefined),
|
|
openThreadInCurrentPanel: vi
|
|
.fn<ThreadManagementActionsHost["openThreadInCurrentPanel"]>()
|
|
.mockImplementation(async (threadId, onAdopted) => {
|
|
adoptThread({ stateStore }, threadId);
|
|
onAdopted();
|
|
return { adopted: true };
|
|
}),
|
|
forkPublication,
|
|
beginThreadForkPublication: vi.fn<ThreadManagementActionsHost["beginThreadForkPublication"]>().mockReturnValue(forkPublication),
|
|
threadHasPendingOrRunningPanel: vi.fn(() => false),
|
|
};
|
|
}
|
|
|
|
function adoptThread(host: Pick<ThreadManagementActionsHost, "stateStore">, threadId: string): void {
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread(threadId),
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
}
|
|
|
|
function panelThread(id: string, overrides: Partial<Thread> = {}): Thread {
|
|
return {
|
|
id,
|
|
preview: "",
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
name: null,
|
|
archived: false,
|
|
provenance: { kind: "interactive" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function callOrder(fn: Mock): number {
|
|
const order = fn.mock.invocationCallOrder[0];
|
|
if (order === undefined) throw new Error("Expected function to be called.");
|
|
return order;
|
|
}
|
|
|
|
function completedCurrent<T>(value: T): EffectOutcome<T> {
|
|
return { kind: "completed-current", value };
|
|
}
|