fix(lifecycle): preserve identity across context changes

This commit is contained in:
murashit 2026-07-17 06:08:53 +09:00
parent d525d97f9e
commit 760bdf3016
18 changed files with 231 additions and 35 deletions

View file

@ -117,7 +117,12 @@ export interface ChatActiveThreadState {
type ChatPanelThreadState =
| { readonly kind: "empty" }
| { readonly kind: "awaiting-resume"; readonly threadId: string; readonly fallbackTitle: string | null }
| {
readonly kind: "awaiting-resume";
readonly threadId: string;
readonly fallbackTitle: string | null;
readonly provenance: Thread["provenance"] | null;
}
| { readonly kind: "active"; readonly thread: ChatActiveThreadState };
type ActiveThreadLifetime =
@ -292,6 +297,11 @@ export function awaitingResumeThreadState(state: ChatState): Extract<ChatState["
return state.panelThread.kind === "awaiting-resume" ? state.panelThread : null;
}
export function panelThreadProvenance(state: ChatState): ChatActiveThreadState["provenance"] {
if (state.panelThread.kind === "active") return state.panelThread.thread.provenance;
return state.panelThread.kind === "awaiting-resume" ? state.panelThread.provenance : null;
}
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) {
case "connection/scoped-cleared":
@ -667,7 +677,7 @@ function clearConnectionContextState(state: ChatState): ChatState {
function panelThreadAfterConnectionExit(panelThread: ChatPanelThreadState): ChatPanelThreadState {
if (panelThread.kind === "awaiting-resume") return panelThread;
if (panelThread.kind !== "active" || panelThread.thread.lifetime?.kind === "ephemeral") return initialPanelThreadState();
return createAwaitingResumeThreadState(panelThread.thread.id, panelThread.thread.title ?? null);
return createAwaitingResumeThreadState(panelThread.thread.id, panelThread.thread.title ?? null, panelThread.thread.provenance);
}
function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState {
@ -805,11 +815,16 @@ function initialPanelThreadState(): ChatPanelThreadState {
return { kind: "empty" };
}
function createAwaitingResumeThreadState(threadId: string, fallbackTitle: string | null): ChatPanelThreadState {
function createAwaitingResumeThreadState(
threadId: string,
fallbackTitle: string | null,
provenance: Thread["provenance"] | null = null,
): ChatPanelThreadState {
return {
kind: "awaiting-resume",
threadId,
fallbackTitle,
provenance,
};
}

View file

@ -1,6 +1,6 @@
import type { Thread } from "../../../../domain/threads/model";
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
import { activeThreadState, type ChatState } from "../state/root-reducer";
import { activeThreadState, type ChatState, panelThreadProvenance } from "../state/root-reducer";
import { threadStreamItems } from "../state/thread-stream";
import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } from "./turn-state";
@ -20,7 +20,7 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
return {
activeThreadId: activeThread?.id ?? null,
activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral",
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
activeTurnId: activeTurnId(state),
busy: chatTurnBusy(state),
listedThreads: state.threadList.listedThreads,

View file

@ -1,6 +1,6 @@
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
import type { PendingRequestActions } from "../../application/pending-requests/pending-request-actions";
import { activeThreadId, activeThreadState } from "../../application/state/root-reducer";
import { activeThreadId, panelThreadProvenance } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import type { HistoryController } from "../../application/threads/history-controller";
import type { ThreadRenameEditorActions } from "../../application/threads/rename-editor-actions";
@ -74,7 +74,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
void environment.plugin.workspace.openSideChat(threadId, thread?.name ?? thread?.preview ?? null);
},
activeThreadChatActionsDisabled: () => activeThreadState(stateStore.getState())?.provenance?.kind === "subagent",
activeThreadChatActionsDisabled: () => panelThreadProvenance(stateStore.getState())?.kind === "subagent",
});
const toolbarSurface: ChatPanelToolbarSurface = {
connection: {

View file

@ -1,5 +1,6 @@
import { Notice } from "obsidian";
import { appServerQueryContextIdentity, appServerQueryContextIdentityMatches } from "../../../../app-server/query/keys";
import { recoverRolloutTokenUsage } from "../../../../app-server/services/rollout-token-usage";
import { createThreadOperationsTransport, createThreadTitleTransport } from "../../../threads/app-server/workflow-transports";
import { createThreadOperations, type ThreadOperations } from "../../../threads/workflows/thread-operations";
@ -128,6 +129,19 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
const threadOperations = createThreadOperations({
transport: threadOperationsTransport,
nameMutations: environment.plugin.threadNameMutations,
resourceContext: {
capture: () => appServerQueryContextIdentity(environment.plugin.appServerQueries.contextLease()),
isCurrent: (context) => {
try {
return appServerQueryContextIdentityMatches(
context,
appServerQueryContextIdentity(environment.plugin.appServerQueries.contextLease()),
);
} catch {
return false;
}
},
},
archiveExport: {
settings: () => environment.plugin.settingsRef.settings.archiveExportSettings(),
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),

View file

@ -6,13 +6,7 @@ import {
} from "../../../app-server/query/keys";
import { pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate";
import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title";
import {
activeThreadId,
activeThreadState,
awaitingResumeThreadState,
type ChatState,
panelThreadId,
} from "../application/state/root-reducer";
import { activeThreadState, awaitingResumeThreadState, type ChatState, panelThreadId } from "../application/state/root-reducer";
import { type ChatStateStore, createChatStateStore } from "../application/state/store";
import { ChatResumeWorkTracker } from "../application/threads/resume-work";
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom";
@ -32,7 +26,7 @@ export class ChatPanelSession implements ChatPanelHandle {
private observedAppServerContext: AppServerQueryContextIdentity;
private appServerContextReconnectAttemptGeneration = 0;
private appServerContextReplacementGeneration = 0;
private pendingAppServerContextReplacement: { activeThreadId: string | null; generation: number } | null = null;
private pendingAppServerContextReplacement: { panelThreadId: string | null; generation: number } | null = null;
private opened = false;
private closing = false;
@ -90,7 +84,7 @@ export class ChatPanelSession implements ChatPanelHandle {
const nextContext = this.currentAppServerContext();
if (!appServerQueryContextIdentityMatches(this.observedAppServerContext, nextContext)) {
this.observedAppServerContext = nextContext;
const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(activeThreadId(this.state));
const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(panelThreadId(this.state));
void this.reconnectAfterAppServerContextChange(replacement);
this.runtime.runtime.sharedState.applyCached();
}
@ -98,25 +92,25 @@ export class ChatPanelSession implements ChatPanelHandle {
}
prepareAppServerContextChange(): void {
const threadId = this.pendingAppServerContextReplacement?.activeThreadId ?? activeThreadId(this.state);
const threadId = this.pendingAppServerContextReplacement?.panelThreadId ?? panelThreadId(this.state);
this.captureAppServerContextReplacement(threadId);
this.runtime.actions.prepareAppServerContextChange();
}
private captureAppServerContextReplacement(activeThreadId: string | null): {
activeThreadId: string | null;
private captureAppServerContextReplacement(panelThreadId: string | null): {
panelThreadId: string | null;
generation: number;
} {
const replacement = { activeThreadId, generation: ++this.appServerContextReplacementGeneration };
const replacement = { panelThreadId, generation: ++this.appServerContextReplacementGeneration };
this.pendingAppServerContextReplacement = replacement;
return replacement;
}
private async reconnectAfterAppServerContextChange(replacement: { activeThreadId: string | null; generation: number }): Promise<void> {
private async reconnectAfterAppServerContextChange(replacement: { panelThreadId: string | null; generation: number }): Promise<void> {
const attemptGeneration = ++this.appServerContextReconnectAttemptGeneration;
try {
const resumed = await this.runtime.actions.reconnectAfterAppServerContextChange(
replacement.activeThreadId,
replacement.panelThreadId,
() =>
this.pendingAppServerContextReplacement?.generation === replacement.generation &&
this.appServerContextReconnectAttemptGeneration === attemptGeneration,

View file

@ -27,7 +27,7 @@ import {
type PreparedComposerInput,
preparedUserInputWithWikiLinkMentionsSkillsAndContext,
} from "../application/composer/wikilink-context";
import { activeThreadState, type ChatAction, type ChatState } from "../application/state/root-reducer";
import { activeThreadState, type ChatAction, type ChatState, panelThreadProvenance } from "../application/state/root-reducer";
import type { ChatStateStore } from "../application/state/store";
import type { ComposerCallbacks, ComposerPendingSelection, ComposerShellProps } from "../ui/composer";
import { syncComposerHeight } from "../ui/composer.dom";
@ -298,7 +298,7 @@ export class ChatComposerController {
{
activeThreadId: activeThread?.id ?? null,
activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral",
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
contextReferences: this.contextReferences(),
dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()),
permissionProfiles: state.connection.availablePermissionProfiles,

View file

@ -1,6 +1,6 @@
import { explicitThreadName } from "../../../domain/threads/model";
import { threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot";
import { activeThreadState, type ChatActiveThreadState, type ChatState } from "../application/state/root-reducer";
import { activeThreadState, type ChatActiveThreadState, type ChatState, panelThreadProvenance } from "../application/state/root-reducer";
import { threadStreamItems } from "../application/state/thread-stream";
import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state";
@ -70,7 +70,7 @@ export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel
return {
threads: state.threadList.listedThreads,
activeThreadId: activeThread?.id ?? null,
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
turnBusy: chatTurnBusy(state),
connection: state.connection,
@ -95,7 +95,7 @@ export function selectChatPanelThreadStream(state: ChatState): ChatPanelThreadSt
activeThreadId: activeThread?.id ?? null,
activeThreadCwd: activeThread?.cwd ?? null,
activeThreadLifetime: activeThread?.lifetime ?? null,
activeThreadProvenance: activeThread?.provenance ?? null,
activeThreadProvenance: panelThreadProvenance(state),
turn: state.turn,
runtimeCollaborationMode: state.runtime.pending.collaborationMode,
threadStream: state.threadStream,
@ -127,7 +127,7 @@ export function selectChatPanelComposer(state: ChatState): ChatPanelComposerMode
selectedSuggestionIndex: state.composer.suggestSelected,
activeThreadId,
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent",
webSubmissionPending: state.pendingSubmission !== null,
webSubmissionCancellable: state.pendingSubmission?.phase === "cancellable",
turnBusy: chatTurnBusy(state),

View file

@ -1,6 +1,12 @@
import { Notice } from "obsidian";
import { type AppServerQueryContext, appServerQueryContextRawEquals } from "../../app-server/query/keys";
import {
type AppServerContextLease,
type AppServerQueryContext,
appServerQueryContextIdentity,
appServerQueryContextIdentityMatches,
appServerQueryContextRawEquals,
} from "../../app-server/query/keys";
import type { ObservedResult } from "../../app-server/query/observed-result";
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
import { isStaleAppServerResourceContextError } from "../../app-server/query/resource-store";
@ -21,6 +27,7 @@ import { type ThreadsRenameState, type ThreadsViewPanelActivity, threadRows, tra
export interface ThreadsViewHost {
readonly settings: ThreadsViewSettingsAccess;
readonly vaultPath: string;
appServerContextLease(): AppServerContextLease;
readonly threadCatalog: ThreadsViewThreadCatalog;
readonly threadNameMutations: ThreadNameMutationCoordinator;
readonly threadOperationsTransport: ThreadOperationsTransport;
@ -84,6 +91,16 @@ export class ThreadsViewSession {
this.operations = createThreadOperations({
transport: this.host.threadOperationsTransport,
nameMutations: this.host.threadNameMutations,
resourceContext: {
capture: () => appServerQueryContextIdentity(this.host.appServerContextLease()),
isCurrent: (context) => {
try {
return appServerQueryContextIdentityMatches(context, appServerQueryContextIdentity(this.host.appServerContextLease()));
} catch {
return false;
}
},
},
archiveExport: {
settings: () => this.host.settings.archiveExportSettings(),
enabled: () => this.host.settings.archiveExportEnabled(),

View file

@ -1,3 +1,4 @@
import type { AppServerQueryContextIdentity } from "../../../app-server/query/keys";
import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown";
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
import type { ThreadCatalogEventSink } from "../catalog/thread-catalog";
@ -8,6 +9,10 @@ import type { ThreadNameMutationCoordinator } from "./thread-name-mutation-coord
export interface ThreadOperationsHost {
transport: ThreadOperationsTransport;
nameMutations: ThreadNameMutationCoordinator;
resourceContext: {
capture(): AppServerQueryContextIdentity;
isCurrent(context: AppServerQueryContextIdentity): boolean;
};
archiveExport: {
settings(): ArchiveExportSettings;
enabled(): boolean;
@ -53,11 +58,12 @@ async function renameThread(
): Promise<boolean> {
const name = normalizeExplicitThreadName(value);
if (!name) return false;
const context = Object.freeze({ ...host.resourceContext.capture() });
return host.nameMutations.run(threadId, async () => {
if (!(options.shouldStart?.() ?? true)) return false;
if (!host.resourceContext.isCurrent(context) || !(options.shouldStart?.() ?? true)) return false;
await host.transport.renameThread(threadId, name);
if (options.shouldPublish?.() ?? true) {
if (host.resourceContext.isCurrent(context) && (options.shouldPublish?.() ?? true)) {
host.catalog.apply({ type: "thread-renamed", threadId, name });
}
return true;

View file

@ -176,6 +176,7 @@ export class CodexPanelRuntime implements AppServerClientAccess {
return {
settings: this.threadsSettings(),
vaultPath: this.options.settingsRef.vaultPath,
appServerContextLease: () => this.appServerResourceStore.contextLease(),
threadCatalog: this.threadCatalog,
threadNameMutations: this.threadNameMutations,
threadOperationsTransport: createThreadOperationsTransport(this),

View file

@ -141,6 +141,7 @@ describe("reconnectPanel", () => {
kind: "awaiting-resume",
threadId: thread.id,
fallbackTitle: thread.name,
provenance: thread.provenance,
});
await expect(reconnectPanel(host)).resolves.toBe(true);

View file

@ -201,6 +201,7 @@ describe("chatReducer", () => {
kind: "awaiting-resume",
threadId: "active-thread",
fallbackTitle: "Reconnect me",
provenance,
});
expect(threadStreamItems(disconnected.threadStream)).toEqual([dialogueItem("retained-item")]);
});
@ -234,6 +235,7 @@ describe("chatReducer", () => {
kind: "awaiting-resume",
threadId: "restored-thread",
fallbackTitle: "Restored title",
provenance: null,
});
expect(restored.connection.statusText).toBe("Thread ready to resume.");
expectThreadScopeReset(restored, { items: [] });

View file

@ -122,7 +122,12 @@ describe("createActiveThreadIdentitySync", () => {
sync.applyThreadRenameToActiveIdentity("thread", "New");
expect(stateStore.getState().panelThread).toEqual({ kind: "awaiting-resume", threadId: "thread", fallbackTitle: "New" });
expect(stateStore.getState().panelThread).toEqual({
kind: "awaiting-resume",
threadId: "thread",
fallbackTitle: "New",
provenance: null,
});
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
});

View file

@ -194,6 +194,10 @@ function coordinatorFixture(
withClient: async (operation) => operation(currentClient()),
}),
nameMutations: createThreadNameMutationCoordinator(),
resourceContext: {
capture: () => ({ codexPath: "codex", vaultPath: "/vault", generation: 1 }),
isCurrent: (context) => context.codexPath === "codex" && context.vaultPath === "/vault" && context.generation === 1,
},
archiveExport: {
settings: () => DEFAULT_SETTINGS,
enabled: () => false,

View file

@ -256,6 +256,103 @@ describe("CodexChatView connection lifecycle", () => {
});
});
it("keeps a disconnected panel's awaiting thread across an app-server context replacement", async () => {
connectionMock.state.client = connectedClient();
const host = chatHost();
const view = await chatView({ host });
await view.onOpen();
await view.surface.connect();
await view.surface.openThread("thread-1");
connectionMock.state.connected = false;
connectionMock.state.onExit?.();
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false, threadId: "thread-1" });
view.surface.prepareAppServerContextChange();
const nextClient = connectedClient();
connectionMock.state.client = nextClient;
host.settingsSource.codexPath = "codex-next";
view.surface.refreshSettings();
await waitForAsyncWork(() => {
expect(nextClient.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" });
});
});
it("does not run an inline rename queued before an app-server context replacement", async () => {
const oldClient = connectedClient();
connectionMock.state.client = oldClient;
const nameMutations = createThreadNameMutationCoordinator();
const mutationRun = vi.spyOn(nameMutations, "run");
const host = chatHost({ threadNameMutations: nameMutations });
const view = await chatView({ host });
await view.onOpen();
await view.surface.connect();
host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Original" })]);
await view.surface.openThread("thread-1");
const blocker = deferred<void>();
const blockerWork = nameMutations.run("thread-1", () => blocker.promise);
requiredButton(view.containerEl, '[aria-label="Show thread list"]').click();
await flushAsyncTicks();
requiredButton(view.containerEl, '[aria-label="Rename thread"]').click();
await flushAsyncTicks();
const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input");
if (!input) throw new Error("Missing inline rename input");
input.value = "Queued in A";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushAsyncTicks();
expect(mutationRun).toHaveBeenCalledTimes(2);
view.surface.prepareAppServerContextChange();
const nextClient = connectedClient();
connectionMock.state.client = nextClient;
host.settingsSource.codexPath = "codex-next";
view.surface.refreshSettings();
await waitForAsyncWork(() => expect(connectionMock.state.connectCalls).toBe(2));
blocker.resolve(undefined);
await blockerWork;
await flushAsyncTicks();
expectRequestTimes(oldClient, "thread/name/set", 0);
expectRequestTimes(nextClient, "thread/name/set", 0);
});
it("does not run a slash rename queued before an app-server context replacement", async () => {
const oldClient = connectedClient();
connectionMock.state.client = oldClient;
const nameMutations = createThreadNameMutationCoordinator();
const mutationRun = vi.spyOn(nameMutations, "run");
const host = chatHost({ threadNameMutations: nameMutations });
const view = await chatView({ host });
await view.onOpen();
await view.surface.connect();
host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Original" })]);
const blocker = deferred<void>();
const blockerWork = nameMutations.run("thread-1", () => blocker.promise);
view.surface.setComposerText("/rename thread-1 Queued in A");
await submitComposerByEnter(view);
expect(mutationRun).toHaveBeenCalledTimes(2);
view.surface.prepareAppServerContextChange();
const nextClient = connectedClient();
connectionMock.state.client = nextClient;
host.settingsSource.codexPath = "codex-next";
view.surface.refreshSettings();
await waitForAsyncWork(() => expect(connectionMock.state.connectCalls).toBe(2));
blocker.resolve(undefined);
await blockerWork;
await flushAsyncTicks();
expectRequestTimes(oldClient, "thread/name/set", 0);
expectRequestTimes(nextClient, "thread/name/set", 0);
});
it("resumes each panel's captured thread after a shared app-server context replacement", async () => {
const host = chatHost();
const resumeRequestedThread = vi.fn((params: unknown) => {
@ -1286,6 +1383,7 @@ interface ChatHostFixtureOverrides {
refreshAppServerMetadata?: CodexChatHost["appServerQueries"]["refreshAppServerMetadata"];
refreshSkills?: CodexChatHost["appServerQueries"]["refreshSkills"];
refreshRateLimits?: CodexChatHost["appServerQueries"]["refreshRateLimits"];
threadNameMutations?: CodexChatHost["threadNameMutations"];
}
function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
@ -1393,7 +1491,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
activeThreads = threads;
emitActiveThreads();
},
threadNameMutations: createThreadNameMutationCoordinator(),
threadNameMutations: overrides.threadNameMutations ?? createThreadNameMutationCoordinator(),
settingsRef: {
settings: chatPanelSettingsAccess(settings),
vaultPath,

View file

@ -233,7 +233,7 @@ describe("ChatComposerController", () => {
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).toContain("/fast");
});
it("omits subagent slash suggestions on input without waiting for keyup", () => {
it("keeps a disconnected subagent composer read-only without slash suggestions", () => {
const stateStore = createChatStateStore(
chatStateFixture({
activeThread: {
@ -250,13 +250,16 @@ describe("ChatComposerController", () => {
},
}),
);
stateStore.dispatch({ type: "connection/scoped-cleared" });
const { controller, parent } = composerControllerFixture({ stateStore });
renderComposerController(parent, controller, stateStore);
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
setTextAreaValue(composer(parent), "/");
composer(parent).setSelectionRange(1, 1);
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
expect(props.submissionDisabled).toBe(true);
expect(stateStore.getState().composer.suggestions).toEqual([]);
});

View file

@ -758,6 +758,7 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
}),
},
vaultPath: "/vault",
appServerContextLease: () => ({ context: { codexPath: "codex", vaultPath: "/vault" }, generation: 1 }),
threadNameMutations: createThreadNameMutationCoordinator(),
threadOperationsTransport: createThreadOperationsTransport(clientAccess),
threadTitleTransport: createThreadTitleTransport({

View file

@ -2,6 +2,7 @@ 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 type { AppServerQueryContextIdentity } from "../../../../src/app-server/query/keys";
import { createThreadOperationsTransport } from "../../../../src/features/threads/app-server/workflow-transports";
import type { ArchiveExportDestination } from "../../../../src/features/threads/workflows/archive-export";
import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator";
@ -55,6 +56,28 @@ describe("ThreadOperations", () => {
expect(client.request).toHaveBeenNthCalledWith(3, "thread/name/set", { threadId: "thread", name: "Latest manual title" });
});
it("does not start a queued rename after its resource context is replaced", async () => {
const firstSave = deferred<object>();
const client = clientMock();
client.request.mockImplementationOnce(async (method: string) => {
if (method !== "thread/name/set") throw new Error(`Unexpected app-server request: ${method}`);
return firstSave.promise;
});
let context: AppServerQueryContextIdentity = { codexPath: "codex-a", vaultPath: "/vault", generation: 1 };
const { operations, catalog } = operationsFixture({ client, currentContext: () => context });
const started = operations.renameThread("thread", "Started in A");
await Promise.resolve();
const queued = operations.renameThread("thread", "Queued in A");
context = { codexPath: "codex-b", vaultPath: "/vault", generation: 2 };
firstSave.resolve({});
await expect(started).resolves.toBe(true);
await expect(queued).resolves.toBe(false);
expect(client.request).toHaveBeenCalledOnce();
expect(catalog.apply).not.toHaveBeenCalled();
});
it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => {
const { operations, catalog, notice, client, archiveDestination } = operationsFixture();
@ -129,7 +152,9 @@ describe("ThreadOperations", () => {
});
});
function operationsFixture(options: { client?: MockClient | null | (() => MockClient | null) } = {}) {
function operationsFixture(
options: { client?: MockClient | null | (() => MockClient | null); currentContext?: () => AppServerQueryContextIdentity } = {},
) {
const configuredClient = options.client === undefined ? clientMock() : options.client;
const currentClient = typeof configuredClient === "function" ? configuredClient : () => configuredClient;
const archiveDestination = archiveDestinationMock();
@ -142,6 +167,7 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl
const catalog = {
apply: vi.fn(),
};
const currentContext = options.currentContext ?? (() => ({ codexPath: "codex", vaultPath: "/vault", generation: 1 }));
const notice = vi.fn();
const host: ThreadOperationsHost = {
transport: createThreadOperationsTransport({
@ -154,6 +180,15 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl
},
}),
nameMutations: createThreadNameMutationCoordinator(),
resourceContext: {
capture: currentContext,
isCurrent: (context) => {
const current = currentContext();
return (
context.codexPath === current.codexPath && context.vaultPath === current.vaultPath && context.generation === current.generation
);
},
},
archiveExport: {
settings: archiveExportSettings,
enabled: () => false,