murashit_codex-panel/tests/features/chat/application/threads/thread-management-actions.test.ts
2026-06-26 21:15:38 +09:00

661 lines
25 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../../src/app-server/connection/client";
import type { ThreadRecord } from "../../../../../src/app-server/protocol/thread";
import { archiveThreadOnAppServer } from "../../../../../src/app-server/services/thread-archive";
import type { ArchiveExportDestination } from "../../../../../src/app-server/services/thread-archive-markdown";
import { normalizeExplicitThreadName } 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 { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
import { DEFAULT_SETTINGS } from "../../../../../src/settings/model";
import { deferred, waitForAsyncWork } from "../../../../support/async";
import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream";
import { chatStateFixture } from "../../support/state";
type MockArchiveExportDestination = ArchiveExportDestination & {
exists: ReturnType<typeof vi.fn<ArchiveExportDestination["exists"]>>;
createFolder: ReturnType<typeof vi.fn<ArchiveExportDestination["createFolder"]>>;
createMarkdownFile: ReturnType<typeof vi.fn<ArchiveExportDestination["createMarkdownFile"]>>;
};
describe("thread management actions", () => {
it("requests thread compaction and reports the shared status", async () => {
const client = clientMock();
const host = hostMock({ client, items: [] });
const controller = threadManagementActions(host);
await controller.compactThread("source");
expect(host.connectedClient).toHaveBeenCalledOnce();
expect(host.ensureConnected).toHaveBeenCalledOnce();
expect(client.compactThread).toHaveBeenCalledWith("source");
expect(host.addSystemMessage).toHaveBeenCalledWith("Compaction requested.");
expect(host.setStatus).toHaveBeenCalledWith("Compaction requested.");
});
it("reports compacting without an active thread", async () => {
const client = clientMock();
const host = hostMock({ client, items: [] });
const controller = threadManagementActions(host);
await controller.compactActiveThread();
expect(host.addSystemMessage).toHaveBeenCalledWith("No active thread to compact.");
expect(client.compactThread).not.toHaveBeenCalled();
});
it("does not report compaction completion after the panel switches threads", async () => {
const compact = deferred<undefined>();
const client = clientMock();
client.compactThread.mockReturnValue(compact.promise);
const host = hostMock({ client, items: [] });
host.stateStore.dispatch({
type: "active-thread/resumed",
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(client.compactThread).toHaveBeenCalledWith("source");
});
host.stateStore.dispatch({
type: "active-thread/resumed",
thread: panelThread("other"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
compact.resolve(undefined);
await pendingCompact;
expect(host.addSystemMessage).not.toHaveBeenCalledWith("Compaction requested.");
expect(host.setStatus).not.toHaveBeenCalledWith("Compaction requested.");
});
it("does not report compaction completion after the current client changes", async () => {
const compact = deferred<undefined>();
const firstClient = clientMock();
const secondClient = clientMock();
let currentClient = firstClient;
firstClient.compactThread.mockReturnValue(compact.promise);
const host = hostMock({ client: firstClient, currentClient: () => currentClient as unknown as AppServerClient, items: [] });
const controller = threadManagementActions(host);
const pendingCompact = controller.compactThread("source");
await waitForAsyncWork(() => {
expect(firstClient.compactThread).toHaveBeenCalledWith("source");
});
currentClient = secondClient;
compact.resolve(undefined);
await pendingCompact;
expect(host.addSystemMessage).not.toHaveBeenCalledWith("Compaction requested.");
expect(host.setStatus).not.toHaveBeenCalledWith("Compaction requested.");
});
it("saves archive markdown before archiving and notifying shared surfaces", async () => {
const client = clientMock();
const destination = archiveDestinationMock();
client.readThread.mockResolvedValue({ thread: archivedThread() });
const host = hostMock({
client,
items: [],
archiveDestination: destination,
settings: {
archiveExportEnabled: true,
archiveExportFolderTemplate: "Archive",
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = threadManagementActions(host);
await controller.archiveThread("source");
expect(host.ensureConnected).toHaveBeenCalledOnce();
expect(client.readThread).toHaveBeenCalledWith("source", true);
expect(destination.createMarkdownFile).toHaveBeenCalledWith(
"Archive/Archived Thread abcdef12.md",
expect.stringContaining('thread_id: "abcdef12-9999"'),
);
expect(client.archiveThread).toHaveBeenCalledWith("source");
expect(host.notifyThreadArchived).toHaveBeenCalledWith("source");
expect(host.showNotice).toHaveBeenCalledWith("Saved archived thread to Archive/Archived Thread abcdef12.md.");
expect(callOrder(destination.createMarkdownFile)).toBeLessThan(callOrder(client.archiveThread));
expect(callOrder(host.ensureConnected)).toBeLessThan(callOrder(client.readThread));
expect(callOrder(client.archiveThread)).toBeLessThan(callOrder(host.notifyThreadArchived));
});
it("does not archive or notify surfaces when archive markdown export fails", async () => {
const client = clientMock();
const destination = archiveDestinationMock({ createMarkdownFile: vi.fn().mockRejectedValue(new Error("disk full")) });
client.readThread.mockResolvedValue({ thread: archivedThread() });
const host = hostMock({
client,
items: [],
archiveDestination: destination,
settings: {
archiveExportEnabled: true,
archiveExportFolderTemplate: "Archive",
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = threadManagementActions(host);
await controller.archiveThread("source");
expect(client.readThread).toHaveBeenCalledWith("source", true);
expect(client.archiveThread).not.toHaveBeenCalled();
expect(host.notifyThreadArchived).not.toHaveBeenCalled();
expect(host.addSystemMessage).toHaveBeenCalledWith("disk full");
});
it("forks from a selected turn by dropping later turns on the fork", async () => {
const client = clientMock();
client.forkThread.mockResolvedValue({
thread: {
...archivedThread(),
id: "forked",
sessionId: "forked",
name: "Fork before rollback",
preview: "Pre-rollback",
updatedAt: 10,
},
});
client.rollbackThread.mockResolvedValue({
thread: { ...rollbackThread(), preview: "Post-rollback", updatedAt: 20 },
});
const host = hostMock({ client, items: turnItems() });
const controller = threadManagementActions(host);
await controller.forkThreadFromTurn("source", "turn-1", false);
expect(client.forkThread).toHaveBeenCalledWith("source", "/vault");
expect(client.rollbackThread).toHaveBeenCalledWith("forked", 2);
expect(host.applyThreadCatalogEvent).toHaveBeenCalledWith({
type: "thread-forked",
thread: expect.objectContaining({
id: "forked",
name: "Rolled Back Thread",
preview: "Post-rollback",
updatedAt: 20,
}),
});
expect(host.openThreadInNewView).toHaveBeenCalledWith("forked");
expect(client.archiveThread).not.toHaveBeenCalled();
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
});
it("saves the source before replacing the panel during fork and archive", async () => {
const client = clientMock();
const destination = archiveDestinationMock();
client.readThread.mockResolvedValue({ thread: archivedThread() });
const host = hostMock({
client,
items: turnItems(),
archiveDestination: destination,
settings: {
archiveExportEnabled: true,
archiveExportFolderTemplate: "Archive",
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = threadManagementActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
expect(client.forkThread).toHaveBeenCalledWith("source", "/vault");
expect(client.readThread).toHaveBeenCalledWith("source", true);
expect(destination.createMarkdownFile).toHaveBeenCalledWith("Archive/Archived Thread abcdef12.md", expect.any(String));
expect(client.archiveThread).toHaveBeenCalledWith("source");
expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked");
expect(host.notifyThreadArchived).toHaveBeenCalledWith("source");
expect(callOrder(destination.createMarkdownFile)).toBeLessThan(callOrder(client.archiveThread));
expect(callOrder(client.archiveThread)).toBeLessThan(callOrder(host.notifyThreadArchived));
expect(callOrder(host.notifyThreadArchived)).toBeLessThan(callOrder(host.openThreadInCurrentPanel));
});
it("keeps the source panel when fork and archive fails to archive", async () => {
const client = clientMock();
client.archiveThread.mockRejectedValue(new Error("archive failed"));
const host = hostMock({ client, items: turnItems() });
const controller = threadManagementActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
expect(client.rollbackThread).not.toHaveBeenCalled();
expect(client.archiveThread).toHaveBeenCalledWith("source");
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
expect(host.notifyThreadArchived).not.toHaveBeenCalled();
expect(host.addSystemMessage).toHaveBeenCalledWith("archive failed");
});
it("notifies surfaces when fork and archive succeeds but the fork cannot replace the source panel", async () => {
const client = clientMock();
const host = hostMock({ client, items: turnItems() });
host.openThreadInCurrentPanel.mockRejectedValue(new Error("resume failed"));
const controller = threadManagementActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
expect(client.archiveThread).toHaveBeenCalledWith("source");
expect(host.notifyThreadArchived).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: ThreadRecord }>();
const client = clientMock();
client.forkThread.mockReturnValue(fork.promise);
const host = hostMock({ client, items: turnItems() });
host.stateStore.dispatch({
type: "active-thread/resumed",
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(client.forkThread).toHaveBeenCalledWith("source", "/vault");
});
host.stateStore.dispatch({
type: "active-thread/resumed",
thread: panelThread("other"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
fork.resolve({ thread: { ...archivedThread(), id: "forked", sessionId: "forked", name: null } });
await pendingFork;
expect(client.archiveThread).not.toHaveBeenCalled();
expect(client.setThreadName).not.toHaveBeenCalled();
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
expect(host.notifyThreadRenamed).not.toHaveBeenCalled();
expect(host.notifyThreadArchived).not.toHaveBeenCalled();
});
it("does not open or record fork responses after the current client changes", async () => {
const fork = deferred<{ thread: ThreadRecord }>();
const firstClient = clientMock();
const secondClient = clientMock();
let currentClient = firstClient;
firstClient.forkThread.mockReturnValue(fork.promise);
const host = hostMock({
client: firstClient,
currentClient: () => currentClient as unknown as AppServerClient,
items: turnItems(),
});
const controller = threadManagementActions(host);
const pendingFork = controller.forkThreadFromTurn("source", null, false);
await waitForAsyncWork(() => {
expect(firstClient.forkThread).toHaveBeenCalledWith("source", "/vault");
});
currentClient = secondClient;
fork.resolve({ thread: archivedThread() });
await pendingFork;
expect(host.applyThreadCatalogEvent).not.toHaveBeenCalled();
expect(host.openThreadInNewView).not.toHaveBeenCalled();
expect(firstClient.archiveThread).not.toHaveBeenCalled();
});
it("renames a thread and notifies shared surfaces", async () => {
const client = clientMock();
const host = hostMock({ client, items: [] });
host.stateStore.dispatch({ type: "thread-list/applied", threads: [{ ...panelThread("thread"), name: "Old" }] });
const controller = threadManagementActions(host);
await expect(controller.renameThread("thread", " Slash command title ")).resolves.toBe(true);
expect(host.ensureConnected).toHaveBeenCalledOnce();
expect(client.setThreadName).toHaveBeenCalledWith("thread", "Slash command title");
expect(host.stateStore.getState().threadList.listedThreads[0]?.name).toBe("Old");
expect(host.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Slash command title");
});
it("ignores empty thread rename titles", async () => {
const client = clientMock();
const host = hostMock({ client, items: [] });
const controller = threadManagementActions(host);
await expect(controller.renameThread("thread", " ")).resolves.toBe(false);
expect(host.ensureConnected).not.toHaveBeenCalled();
expect(client.setThreadName).not.toHaveBeenCalled();
expect(host.notifyThreadRenamed).not.toHaveBeenCalled();
});
it("applies rollback response turns before refreshing shared thread state", async () => {
const client = clientMock();
const host = hostMock({ client, items: turnItems() });
host.stateStore.dispatch({
type: "active-thread/resumed",
thread: panelThread("source"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
host.stateStore.dispatch({
type: "message-stream/items-replaced",
items: turnItems(),
historyCursor: null,
loadingHistory: false,
});
const controller = threadManagementActions(host);
await controller.rollbackThread("source");
expect(client.rollbackThread).toHaveBeenCalledWith("source");
expect(chatStateMessageStreamItems(host.stateStore.getState()).slice(0, 2)).toMatchObject([
{ kind: "message", role: "user", text: "kept prompt", turnId: "kept-turn" },
{ kind: "message", 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("ignores stale rollback responses after the panel switches threads", async () => {
const rollback = deferred<{ thread: ThreadRecord }>();
const client = clientMock();
client.rollbackThread.mockReturnValue(rollback.promise);
const host = hostMock({ client, items: turnItems() });
host.stateStore.dispatch({
type: "active-thread/resumed",
thread: panelThread("source"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
host.stateStore.dispatch({
type: "message-stream/items-replaced",
items: turnItems(),
historyCursor: null,
loadingHistory: false,
});
const controller = threadManagementActions(host);
const pendingRollback = controller.rollbackThread("source");
await waitForAsyncWork(() => {
expect(client.rollbackThread).toHaveBeenCalledWith("source");
});
host.stateStore.dispatch({
type: "active-thread/resumed",
thread: panelThread("other"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
rollback.resolve({ thread: rollbackThread() });
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 responses after the current client changes", async () => {
const rollback = deferred<{ thread: ThreadRecord }>();
const firstClient = clientMock();
const secondClient = clientMock();
let currentClient = firstClient;
firstClient.rollbackThread.mockReturnValue(rollback.promise);
const host = hostMock({
client: firstClient,
currentClient: () => currentClient as unknown as AppServerClient,
items: turnItems(),
});
host.stateStore.dispatch({
type: "active-thread/resumed",
thread: panelThread("source"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
host.stateStore.dispatch({
type: "message-stream/items-replaced",
items: turnItems(),
historyCursor: null,
loadingHistory: false,
});
const controller = threadManagementActions(host);
const pendingRollback = controller.rollbackThread("source");
await waitForAsyncWork(() => {
expect(firstClient.rollbackThread).toHaveBeenCalledWith("source");
});
currentClient = secondClient;
rollback.resolve({ thread: rollbackThread() });
await pendingRollback;
expect(host.setComposerText).not.toHaveBeenCalled();
expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled();
expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled();
});
});
function turnItems(): MessageStreamItem[] {
return [
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "one", turnId: "turn-1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "one answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "two", turnId: "turn-2" },
{
id: "a2",
kind: "message",
role: "assistant",
text: "two answer",
turnId: "turn-2",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u3", kind: "message", messageKind: "user", role: "user", text: "three", turnId: "turn-3" },
{
id: "a3",
kind: "message",
role: "assistant",
text: "three answer",
turnId: "turn-3",
messageKind: "assistantResponse",
messageState: "completed",
},
];
}
function clientMock() {
return {
forkThread: vi.fn().mockResolvedValue({ thread: { ...archivedThread(), id: "forked", sessionId: "forked", name: null } }),
rollbackThread: vi.fn().mockResolvedValue({ thread: rollbackThread() }),
compactThread: vi.fn().mockResolvedValue({}),
archiveThread: vi.fn().mockResolvedValue({}),
readThread: vi.fn().mockResolvedValue({ thread: archivedThread() }),
setThreadName: vi.fn(),
};
}
function threadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
return createThreadManagementActions(host);
}
function hostMock({
client,
items,
archiveDestination = archiveDestinationMock(),
settings = {},
currentClient,
}: {
client: ReturnType<typeof clientMock>;
items: MessageStreamItem[];
archiveDestination?: ArchiveExportDestination;
settings?: Partial<typeof DEFAULT_SETTINGS>;
currentClient?: () => AppServerClient | null;
}) {
const state = withChatStateMessageStreamItems(chatStateFixture(), items);
const stateStore = createChatStateStore(state);
const notifyThreadArchived = vi.fn();
const notifyThreadRenamed = vi.fn();
const showNotice = vi.fn();
const ensureConnected = vi.fn().mockResolvedValue(undefined);
const connectedClient = vi.fn(async () => {
await ensureConnected();
return (currentClient ?? (() => client as unknown as AppServerClient))();
});
return {
stateStore,
vaultPath: "/vault",
ensureConnected,
connectedClient,
currentClient: currentClient ?? (() => client as unknown as AppServerClient),
addSystemMessage: vi.fn(),
setStatus: vi.fn(),
setComposerText: vi.fn(),
openThreadInNewView: vi.fn().mockResolvedValue(undefined),
openThreadInCurrentPanel: vi.fn().mockResolvedValue(undefined),
operations: {
archiveThread: vi.fn(async (threadId: string, options: { saveMarkdown?: boolean } = {}) => {
await ensureConnected();
const result = await archiveThreadOnAppServer(client as unknown as AppServerClient, threadId, {
settings: { ...DEFAULT_SETTINGS, ...settings },
vaultPath: "/vault",
vaultConfigDir: "vault-config",
archiveDestination: () => archiveDestination,
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled ?? DEFAULT_SETTINGS.archiveExportEnabled,
});
if (result.exportedPath) showNotice(`Saved archived thread to ${result.exportedPath}.`);
notifyThreadArchived(threadId);
return result;
}),
renameThread: vi.fn(async (threadId: string, value: string) => {
const name = normalizeExplicitThreadName(value);
if (!name) return false;
await ensureConnected();
await client.setThreadName(threadId, name);
notifyThreadRenamed(threadId, name);
return true;
}),
},
showNotice,
notifyThreadArchived,
notifyThreadRenamed,
notifyActiveThreadIdentityChanged: vi.fn(),
refreshAfterThreadMutation: vi.fn().mockResolvedValue(undefined),
applyThreadCatalogEvent: vi.fn(),
};
}
function archiveDestinationMock(overrides: Partial<MockArchiveExportDestination> = {}): MockArchiveExportDestination {
return {
normalizePath: (path) => path,
exists: vi.fn<ArchiveExportDestination["exists"]>().mockResolvedValue(false),
createFolder: vi.fn<ArchiveExportDestination["createFolder"]>().mockResolvedValue(undefined),
createMarkdownFile: vi.fn<ArchiveExportDestination["createMarkdownFile"]>().mockResolvedValue(undefined),
...overrides,
};
}
function archivedThread(): ThreadRecord {
return {
id: "abcdef12-9999",
sessionId: "abcdef12-9999",
forkedFromId: null,
parentThreadId: null,
preview: "Preview",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/vault",
cliVersion: "codex-cli 0.0.0",
source: "unknown",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name: "Archived Thread",
turns: [],
};
}
function rollbackThread(): ThreadRecord {
return {
...archivedThread(),
id: "forked",
sessionId: "forked",
name: "Rolled Back Thread",
turns: [
{
id: "kept-turn",
items: [
{ type: "userMessage", id: "kept-user", clientId: null, content: [{ type: "text", text: "kept prompt", text_elements: [] }] },
{ type: "agentMessage", id: "kept-agent", text: "kept answer", phase: "final_answer", memoryCitation: null },
],
itemsView: "full",
status: "completed",
error: null,
startedAt: 1,
completedAt: 2,
durationMs: 1000,
},
],
};
}
function panelThread(id: string) {
return {
id,
preview: "",
createdAt: 0,
updatedAt: 0,
name: null,
archived: false,
};
}
function callOrder(fn: ReturnType<typeof vi.fn>): number {
const order = fn.mock.invocationCallOrder[0];
if (order === undefined) throw new Error("Expected function to be called.");
return order;
}