diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 12bf79f0..7a5ff8ea 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -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 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: { diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 027b2b47..230f1284 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -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(), diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index c6d81d73..f6d5a950 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -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 { + private async reconnectAfterAppServerContextChange(replacement: { panelThreadId: string | null; generation: number }): Promise { const attemptGeneration = ++this.appServerContextReconnectAttemptGeneration; try { const resumed = await this.runtime.actions.reconnectAfterAppServerContextChange( - replacement.activeThreadId, + replacement.panelThreadId, () => this.pendingAppServerContextReplacement?.generation === replacement.generation && this.appServerContextReconnectAttemptGeneration === attemptGeneration, diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index 08209fd1..6d75a4ec 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -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, diff --git a/src/features/chat/panel/shell-selectors.ts b/src/features/chat/panel/shell-selectors.ts index ccc32c00..78fe0fc6 100644 --- a/src/features/chat/panel/shell-selectors.ts +++ b/src/features/chat/panel/shell-selectors.ts @@ -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), diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 22906fab..09e7372f 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -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(), diff --git a/src/features/threads/workflows/thread-operations.ts b/src/features/threads/workflows/thread-operations.ts index 3d690c4a..c97c4f2f 100644 --- a/src/features/threads/workflows/thread-operations.ts +++ b/src/features/threads/workflows/thread-operations.ts @@ -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 { 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; diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 9cdef7c3..3cf3e278 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -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), diff --git a/tests/features/chat/application/connection/reconnect-actions.test.ts b/tests/features/chat/application/connection/reconnect-actions.test.ts index 83c6349a..77fbd193 100644 --- a/tests/features/chat/application/connection/reconnect-actions.test.ts +++ b/tests/features/chat/application/connection/reconnect-actions.test.ts @@ -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); diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index 169c4366..cbe65de0 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -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: [] }); diff --git a/tests/features/chat/application/threads/active-thread-identity-sync.test.ts b/tests/features/chat/application/threads/active-thread-identity-sync.test.ts index c0ed2e0d..e853ce88 100644 --- a/tests/features/chat/application/threads/active-thread-identity-sync.test.ts +++ b/tests/features/chat/application/threads/active-thread-identity-sync.test.ts @@ -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(); }); diff --git a/tests/features/chat/application/threads/auto-title-coordinator.test.ts b/tests/features/chat/application/threads/auto-title-coordinator.test.ts index 1a1db722..0093adc7 100644 --- a/tests/features/chat/application/threads/auto-title-coordinator.test.ts +++ b/tests/features/chat/application/threads/auto-title-coordinator.test.ts @@ -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, diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index 2c3e2c55..88a023f7 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -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(); + 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(".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(); + 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, diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 9c1ab37a..5dbb8088 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -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([]); }); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 9688bd7d..a1015866 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -758,6 +758,7 @@ function threadsHost(overrides: Record = {}) { }), }, vaultPath: "/vault", + appServerContextLease: () => ({ context: { codexPath: "codex", vaultPath: "/vault" }, generation: 1 }), threadNameMutations: createThreadNameMutationCoordinator(), threadOperationsTransport: createThreadOperationsTransport(clientAccess), threadTitleTransport: createThreadTitleTransport({ diff --git a/tests/features/threads/workflows/thread-operations.test.ts b/tests/features/threads/workflows/thread-operations.test.ts index 7cb15886..a19d5923 100644 --- a/tests/features/threads/workflows/thread-operations.test.ts +++ b/tests/features/threads/workflows/thread-operations.test.ts @@ -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(); + 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,