mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
610 lines
22 KiB
TypeScript
610 lines
22 KiB
TypeScript
import type { Mock } from "vitest";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { Thread } from "../../../../../src/domain/threads/model";
|
|
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,
|
|
ThreadRollbackSnapshot,
|
|
} 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 { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream";
|
|
|
|
interface ThreadMutationTransportMock {
|
|
compactThread: Mock<ThreadMutationTransport["compactThread"]>;
|
|
forkThread: Mock<ThreadMutationTransport["forkThread"]>;
|
|
rollbackThread: Mock<ThreadMutationTransport["rollbackThread"]>;
|
|
}
|
|
|
|
interface ThreadOperationsMock {
|
|
archiveThread: Mock<ThreadManagementActionsHost["operations"]["archiveThread"]>;
|
|
renameThread: Mock<ThreadManagementActionsHost["operations"]["renameThread"]>;
|
|
}
|
|
|
|
type ThreadManagementActionsHostMock = Omit<
|
|
ThreadManagementActionsHost,
|
|
| "addSystemMessage"
|
|
| "notifyActiveThreadIdentityChanged"
|
|
| "openThreadInCurrentPanel"
|
|
| "openThreadInNewView"
|
|
| "operations"
|
|
| "recordForkedThread"
|
|
| "refreshAfterThreadMutation"
|
|
| "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"]>;
|
|
notifyActiveThreadIdentityChanged: Mock<ThreadManagementActionsHost["notifyActiveThreadIdentityChanged"]>;
|
|
refreshAfterThreadMutation: Mock<ThreadManagementActionsHost["refreshAfterThreadMutation"]>;
|
|
recordForkedThread: Mock<ThreadManagementActionsHost["recordForkedThread"]>;
|
|
};
|
|
|
|
describe("thread management actions", () => {
|
|
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("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<boolean>();
|
|
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"),
|
|
cwd: "/vault",
|
|
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"),
|
|
cwd: "/vault",
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
compact.resolve(true);
|
|
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(false),
|
|
},
|
|
});
|
|
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("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", "turn-1");
|
|
expect(host.recordForkedThread).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: "forked",
|
|
}),
|
|
);
|
|
expect(host.openThreadInNewView).toHaveBeenCalledWith("forked");
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("archives the source before replacing the panel 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", "turn-3");
|
|
expect(host.operations.archiveThread).toHaveBeenCalledWith("source", {});
|
|
expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked");
|
|
expect(callOrder(host.operations.archiveThread)).toBeLessThan(callOrder(host.openThreadInCurrentPanel));
|
|
});
|
|
|
|
it("keeps the source panel when fork and archive fails to archive", 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).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("archive failed");
|
|
});
|
|
|
|
it("keeps the source panel when fork and archive is declined by thread operations", 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(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).not.toHaveBeenCalledWith("archive failed");
|
|
});
|
|
|
|
it("reports when fork and archive succeeds but the fork cannot replace the source 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).toHaveBeenCalledWith("source", {});
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Archived thread source, but could not open forked thread forked: resume failed");
|
|
});
|
|
|
|
it("does not archive or replace the panel from stale fork responses", async () => {
|
|
const fork = deferred<Thread | null>();
|
|
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"),
|
|
cwd: "/vault",
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "thread-list/applied",
|
|
threads: [{ ...panelThread("source"), name: "Source name" }],
|
|
threadsLoaded: true,
|
|
});
|
|
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"),
|
|
cwd: "/vault",
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
fork.resolve(panelThread("forked"));
|
|
await pendingFork;
|
|
|
|
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
|
expect(host.operations.renameThread).not.toHaveBeenCalled();
|
|
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not open or record fork responses when the transport has no result", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
threadTransport: {
|
|
forkThread: vi.fn<ThreadMutationTransport["forkThread"]>().mockResolvedValue(null),
|
|
},
|
|
});
|
|
const controller = threadManagementActions(host);
|
|
|
|
await controller.forkThreadFromTurn("source", null, false);
|
|
|
|
expect(host.recordForkedThread).not.toHaveBeenCalled();
|
|
expect(host.openThreadInNewView).not.toHaveBeenCalled();
|
|
expect(host.operations.archiveThread).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("applies rollback response items before refreshing shared thread state", 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"),
|
|
cwd: "/vault",
|
|
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.rollbackThread).toHaveBeenCalledWith("source");
|
|
expect(chatStateThreadStreamItems(host.stateStore.getState()).slice(0, 2)).toMatchObject([
|
|
{ kind: "dialogue", role: "user", text: "kept prompt", turnId: "kept-turn" },
|
|
{ kind: "dialogue", role: "assistant", text: "kept answer", turnId: "kept-turn" },
|
|
]);
|
|
expect(callOrder(host.setComposerText)).toBeLessThan(callOrder(host.addSystemMessage));
|
|
expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(host.refreshAfterThreadMutation));
|
|
});
|
|
|
|
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.rollbackThread).not.toHaveBeenCalled();
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be rolled back.");
|
|
});
|
|
|
|
it("ignores stale rollback responses after the panel switches threads", async () => {
|
|
const rollback = deferred<ThreadRollbackSnapshot | null>();
|
|
const host = hostMock({ items: turnItems() });
|
|
host.threadTransport.rollbackThread.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"),
|
|
cwd: "/vault",
|
|
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.rollbackThread).toHaveBeenCalledWith("source");
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("other"),
|
|
cwd: "/vault",
|
|
model: null,
|
|
reasoningEffort: null,
|
|
serviceTier: null,
|
|
approvalsReviewer: null,
|
|
});
|
|
rollback.resolve(rollbackSnapshot());
|
|
await pendingRollback;
|
|
|
|
expect(host.stateStore.getState().activeThread.id).toBe("other");
|
|
expect(host.setComposerText).not.toHaveBeenCalled();
|
|
expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled();
|
|
expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores rollback when the transport has no result", async () => {
|
|
const host = hostMock({
|
|
items: turnItems(),
|
|
threadTransport: {
|
|
rollbackThread: vi.fn<ThreadMutationTransport["rollbackThread"]>().mockResolvedValue(null),
|
|
},
|
|
});
|
|
host.stateStore.dispatch({
|
|
type: "active-thread/resumed",
|
|
approvalPolicyKnown: true,
|
|
sandboxPolicyKnown: true,
|
|
permissionProfileKnown: true,
|
|
approvalPolicy: null,
|
|
sandboxPolicy: null,
|
|
activePermissionProfile: null,
|
|
thread: panelThread("source"),
|
|
cwd: "/vault",
|
|
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.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled();
|
|
expect(host.refreshAfterThreadMutation).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 rollbackItems(): ThreadStreamItem[] {
|
|
return [
|
|
{ id: "kept-user", kind: "dialogue", dialogueKind: "user", role: "user", text: "kept prompt", turnId: "kept-turn" },
|
|
{
|
|
id: "kept-agent",
|
|
kind: "dialogue",
|
|
role: "assistant",
|
|
text: "kept answer",
|
|
turnId: "kept-turn",
|
|
dialogueKind: "assistantResponse",
|
|
dialogueState: "completed",
|
|
},
|
|
];
|
|
}
|
|
|
|
function threadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
|
|
return createThreadManagementActions(host);
|
|
}
|
|
|
|
function hostMock({
|
|
items,
|
|
activeThread,
|
|
operations: operationOverrides = {},
|
|
threadTransport: transportOverrides = {},
|
|
}: {
|
|
items: ThreadStreamItem[];
|
|
activeThread?: Partial<ReturnType<typeof chatStateFixture>["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 = {
|
|
compactThread: vi.fn<ThreadMutationTransport["compactThread"]>().mockResolvedValue(true),
|
|
forkThread: vi.fn<ThreadMutationTransport["forkThread"]>().mockResolvedValue(panelThread("forked")),
|
|
rollbackThread: vi.fn<ThreadMutationTransport["rollbackThread"]>().mockResolvedValue(rollbackSnapshot()),
|
|
...transportOverrides,
|
|
};
|
|
const operations: ThreadOperationsMock = {
|
|
archiveThread: vi.fn<ThreadManagementActionsHost["operations"]["archiveThread"]>().mockResolvedValue(true),
|
|
renameThread: vi.fn<ThreadManagementActionsHost["operations"]["renameThread"]>().mockResolvedValue(true),
|
|
...operationOverrides,
|
|
};
|
|
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"]>().mockResolvedValue(undefined),
|
|
notifyActiveThreadIdentityChanged: vi.fn<ThreadManagementActionsHost["notifyActiveThreadIdentityChanged"]>(),
|
|
refreshAfterThreadMutation: vi.fn<ThreadManagementActionsHost["refreshAfterThreadMutation"]>().mockResolvedValue(undefined),
|
|
recordForkedThread: vi.fn<ThreadManagementActionsHost["recordForkedThread"]>(),
|
|
};
|
|
}
|
|
|
|
function rollbackSnapshot(overrides: Partial<ThreadRollbackSnapshot> = {}): ThreadRollbackSnapshot {
|
|
return {
|
|
thread: panelThread("source", { name: "Rolled Back Thread" }),
|
|
cwd: "/vault",
|
|
items: rollbackItems(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function panelThread(id: string, overrides: Partial<Thread> = {}): Thread {
|
|
return {
|
|
id,
|
|
preview: "",
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
name: null,
|
|
archived: false,
|
|
...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;
|
|
}
|