From 2f187d7e9412305f01140a056732190700fa4ac4 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 27 Jun 2026 17:11:25 +0900 Subject: [PATCH] Move chat app-server client access out of application --- biome.jsonc | 4 + docs/design.md | 2 + docs/development.md | 2 + ...t-application-root-app-server-imports.grit | 16 + .../chat/app-server/actions/threads.ts | 2 +- src/features/chat/app-server/client-scope.ts | 33 ++ .../chat/app-server/goals/transport.ts | 36 ++ .../references/thread-reference-resolver.ts | 51 ++ .../runtime/thread-settings-transport.ts | 16 + .../chat/app-server/threads/transport.ts | 33 ++ .../chat/app-server/turns/transport.ts | 36 ++ .../conversation/composer-submit-actions.ts | 13 +- .../application/conversation/composition.ts | 27 +- .../conversation/plan-implementation.ts | 5 +- .../conversation/slash-command-executor.ts | 41 +- .../conversation/turn-submission-actions.ts | 39 +- .../conversation/turn-transport.ts | 25 + .../application/runtime/settings-actions.ts | 19 +- .../application/runtime/settings-transport.ts | 5 + .../chat/application/threads/goal-actions.ts | 29 +- .../application/threads/goal-transport.ts | 9 + .../threads/thread-management-actions.ts | 64 +-- .../threads/thread-mutation-transport.ts | 15 + src/features/chat/domain/runtime/intent.ts | 4 +- .../runtime/thread-settings-patch.ts} | 32 +- src/features/chat/host/runtime-bundle.ts | 5 +- src/features/chat/host/thread-bundle.ts | 21 +- src/features/chat/host/turn-bundle.ts | 21 +- .../chat/app-server/transports.test.ts | 230 ++++++++ .../composer-submit-actions.test.ts | 36 +- .../conversation/plan-implementation.test.ts | 17 +- .../slash-command-executor.test.ts | 60 +-- .../turn-submission-actions.test.ts | 32 +- .../runtime/settings-actions.test.ts | 138 ++--- .../application/threads/goal-actions.test.ts | 129 ++--- .../threads/thread-management-actions.test.ts | 500 +++++++----------- tests/runtime/runtime-settings.test.ts | 8 +- tests/scripts/grit-policy.test.mjs | 16 +- 38 files changed, 1052 insertions(+), 719 deletions(-) create mode 100644 scripts/lint/no-chat-application-root-app-server-imports.grit create mode 100644 src/features/chat/app-server/client-scope.ts create mode 100644 src/features/chat/app-server/goals/transport.ts create mode 100644 src/features/chat/app-server/references/thread-reference-resolver.ts create mode 100644 src/features/chat/app-server/runtime/thread-settings-transport.ts create mode 100644 src/features/chat/app-server/threads/transport.ts create mode 100644 src/features/chat/app-server/turns/transport.ts create mode 100644 src/features/chat/application/conversation/turn-transport.ts create mode 100644 src/features/chat/application/runtime/settings-transport.ts create mode 100644 src/features/chat/application/threads/goal-transport.ts create mode 100644 src/features/chat/application/threads/thread-mutation-transport.ts rename src/features/chat/{app-server/runtime/thread-settings-update.ts => domain/runtime/thread-settings-patch.ts} (76%) create mode 100644 tests/features/chat/app-server/transports.test.ts diff --git a/biome.jsonc b/biome.jsonc index 94bd0f79..ca81e800 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -11,6 +11,10 @@ "path": "./scripts/lint/no-app-server-projection-rpcs.grit", "includes": ["**/src/features/chat/application/**/*.ts"] }, + { + "path": "./scripts/lint/no-chat-application-root-app-server-imports.grit", + "includes": ["**/src/features/chat/application/**/*.ts"] + }, { "path": "./scripts/lint/no-app-server-protocol-boundary-imports.grit", "includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/app-server/**"] diff --git a/docs/design.md b/docs/design.md index d435f760..2b9ab1e8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -28,6 +28,8 @@ Fast mode is a user-facing runtime intent, not a general service-tier editor. Co Generated app-server protocol types should stay behind `src/app-server/`. Services at that boundary adapt protocol payloads into panel-owned domain models or small projections before payloads reach features, workspace coordination, settings, or UI. +Chat application workflows should express turn, thread, goal, runtime-setting, and reference-thread needs as chat-owned contracts. Root app-server clients, RPC method names, connection freshness checks, vault-path injection, and protocol projection belong in chat app-server transports or host wiring, not in application state-transition code. + Turn stream conversion is the main exception: raw app-server stream payloads may be consumed at the conversion boundary because the event set is broad and changes with Codex. The converter should still reduce them into panel-owned display and diagnostic models before they reach chat state or UI. Server request adapters should normalize method-specific app-server requests into coarse panel models before they reach pending request state. The UI should handle user-facing intent; app-server-specific decisions and response payloads should remain boundary-owned. diff --git a/docs/development.md b/docs/development.md index 17a5f68a..cd57d084 100644 --- a/docs/development.md +++ b/docs/development.md @@ -61,6 +61,8 @@ Keep new code near the state or API it owns. A feature may import another featur Generated app-server types should stay behind app-server connection and protocol adapter modules. Chat-local app-server integration modules may consume app-server protocol projections, but not raw generated bindings. If a domain, shared, settings, workspace, or UI module needs app-server payloads, add or reuse a panel-owned projection at the boundary instead of importing generated payload types directly. +Chat application workflows should not import root `src/app-server/` modules or receive `AppServerClient` access directly. Keep app-server client access, connection freshness checks, vault-path injection, and payload projection in `src/features/chat/app-server/` transports or host-owned wiring, then pass chat-owned workflow contracts into application modules. + Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the shell-state adapter. Use Preact Signals only in `src/features/chat/panel/shell-state.tsx`; lint enforces this boundary. When a surface needs fewer dependencies, add or reuse a named shell-state projection instead of importing `@preact/signals` elsewhere. Chat feature folders should keep dependencies flowing toward owned adapters and render surfaces: `application/` must not import host, panel, presentation, or UI layers; `app-server/` must not import host, panel, presentation, or UI layers; `panel/` must not import app-server adapters or host internals; `presentation/` must not import application, app-server, host, panel, or UI layers; and `ui/` must not import application, app-server, host, or panel layers. Biome enforces these folder-scoped import boundaries with per-folder plugin includes. diff --git a/scripts/lint/no-chat-application-root-app-server-imports.grit b/scripts/lint/no-chat-application-root-app-server-imports.grit new file mode 100644 index 00000000..9ab9efd1 --- /dev/null +++ b/scripts/lint/no-chat-application-root-app-server-imports.grit @@ -0,0 +1,16 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { $source <: r"^[\"'](?:(?:\.\./){3,}app-server|src/app-server)(?:/.*)?[\"']$" }, + register_diagnostic(span=$stmt, message="Chat application modules must not import root app-server modules; use chat app-server transports or application ports instead.", severity="error") +} diff --git a/src/features/chat/app-server/actions/threads.ts b/src/features/chat/app-server/actions/threads.ts index 4f6761cb..120dc880 100644 --- a/src/features/chat/app-server/actions/threads.ts +++ b/src/features/chat/app-server/actions/threads.ts @@ -5,7 +5,7 @@ import type { ThreadCatalogEvent } from "../../../../workspace/thread-catalog"; import { resumedThreadAction } from "../../application/state/actions"; import type { ChatState } from "../../application/state/root-reducer"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; -import { serviceTierRequestForThreadStart } from "../runtime/thread-settings-update"; +import { serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch"; import { type ChatServerActionsHost, captureChatServerClientScope } from "./host"; interface StartedThreadSummary { diff --git a/src/features/chat/app-server/client-scope.ts b/src/features/chat/app-server/client-scope.ts new file mode 100644 index 00000000..ab60692a --- /dev/null +++ b/src/features/chat/app-server/client-scope.ts @@ -0,0 +1,33 @@ +import type { AppServerClient } from "../../../app-server/connection/client"; + +export interface CurrentChatAppServerClientHost { + currentClient(): AppServerClient | null; +} + +export interface ConnectedChatAppServerClientHost extends CurrentChatAppServerClientHost { + connectedClient(): Promise; +} + +export function chatAppServerClientIsStale(host: CurrentChatAppServerClientHost, client: AppServerClient): boolean { + return host.currentClient() !== client; +} + +export async function withConnectedChatAppServerClient( + host: ConnectedChatAppServerClientHost, + operation: (client: AppServerClient) => Promise, +): Promise { + const client = await host.connectedClient(); + if (!client) return null; + const result = await operation(client); + return chatAppServerClientIsStale(host, client) ? null : result; +} + +export async function withCurrentChatAppServerClient( + host: CurrentChatAppServerClientHost, + operation: (client: AppServerClient) => Promise, +): Promise { + const client = host.currentClient(); + if (!client) return null; + const result = await operation(client); + return chatAppServerClientIsStale(host, client) ? null : result; +} diff --git a/src/features/chat/app-server/goals/transport.ts b/src/features/chat/app-server/goals/transport.ts new file mode 100644 index 00000000..9cd99c03 --- /dev/null +++ b/src/features/chat/app-server/goals/transport.ts @@ -0,0 +1,36 @@ +import { readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/threads"; +import type { ThreadGoalTransport } from "../../application/threads/goal-transport"; +import type { ConnectedChatAppServerClientHost } from "../client-scope"; +import { chatAppServerClientIsStale, withConnectedChatAppServerClient, withCurrentChatAppServerClient } from "../client-scope"; + +export function createChatThreadGoalTransport(host: ConnectedChatAppServerClientHost): ThreadGoalTransport { + return { + ensureConnected: async () => (await host.connectedClient()) !== null, + readThreadGoal: async (threadId) => { + const client = host.currentClient(); + if (!client) return undefined; + const goal = await readThreadGoal(client, threadId); + return chatAppServerClientIsStale(host, client) ? undefined : goal; + }, + setThreadGoal: async (threadId, params) => { + const client = await host.connectedClient(); + if (!client) return undefined; + const goal = await setThreadGoal(client, threadId, params); + return chatAppServerClientIsStale(host, client) ? undefined : goal; + }, + clearThreadGoal: async (threadId) => { + const result = await withConnectedChatAppServerClient(host, async (client) => { + await client.clearThreadGoal(threadId); + return true; + }); + return result ?? false; + }, + recordThreadGoalUserMessage: async (threadId, objective) => { + const result = await withCurrentChatAppServerClient(host, async (client) => { + await recordThreadGoalUserMessage(client, threadId, objective); + return true; + }); + return result ?? false; + }, + }; +} diff --git a/src/features/chat/app-server/references/thread-reference-resolver.ts b/src/features/chat/app-server/references/thread-reference-resolver.ts new file mode 100644 index 00000000..72a7636c --- /dev/null +++ b/src/features/chat/app-server/references/thread-reference-resolver.ts @@ -0,0 +1,51 @@ +import { readReferencedThreadConversationSummaries, type ThreadConversationSummaryClient } from "../../../../app-server/threads"; +import { type CodexInput, codexTextInputWithAttachments } from "../../../../domain/chat/input"; +import type { Thread } from "../../../../domain/threads/model"; +import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadPromptBundle } from "../../../../domain/threads/reference"; +import { shortThreadId } from "../../../../shared/id/thread-id"; +import type { ThreadReferenceInput } from "../../application/conversation/slash-command-execution"; + +interface ThreadReferenceResolverHost { + currentClient(): ThreadConversationSummaryClient | null; + codexInput(text: string): CodexInput; + addSystemMessage(text: string): void; + setStatus(status: string): void; +} + +export interface ThreadReferenceResolver { + referThread(thread: Thread, message: string): Promise; +} + +export function createThreadReferenceResolver(host: ThreadReferenceResolverHost): ThreadReferenceResolver { + return { + referThread: (thread, message) => referencedThreadInput(host, thread, message), + }; +} + +async function referencedThreadInput( + host: ThreadReferenceResolverHost, + thread: Thread, + message: string, +): Promise { + const client = host.currentClient(); + if (!client) return null; + try { + const turns = await readReferencedThreadConversationSummaries(client, thread.id, REFERENCED_THREAD_TURN_LIMIT); + if (host.currentClient() !== client) return null; + if (turns.length === 0) { + host.addSystemMessage("Referenced thread has no readable conversation turns."); + return null; + } + const reference = referencedThreadPromptBundle(thread, turns, message); + const messageInput = host.codexInput(message); + host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`); + return { + input: codexTextInputWithAttachments(reference.prompt, messageInput), + referencedThread: reference.referencedThread, + }; + } catch (error) { + if (host.currentClient() !== client) return null; + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + return null; + } +} diff --git a/src/features/chat/app-server/runtime/thread-settings-transport.ts b/src/features/chat/app-server/runtime/thread-settings-transport.ts new file mode 100644 index 00000000..52f8d3c4 --- /dev/null +++ b/src/features/chat/app-server/runtime/thread-settings-transport.ts @@ -0,0 +1,16 @@ +import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; +import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport"; +import type { CurrentChatAppServerClientHost } from "../client-scope"; +import { withCurrentChatAppServerClient } from "../client-scope"; + +export function createChatRuntimeSettingsTransport(host: CurrentChatAppServerClientHost): RuntimeSettingsTransport { + return { + updateThreadSettings: async (threadId: string, update: RuntimeSettingsPatch) => { + const result = await withCurrentChatAppServerClient(host, async (client) => { + await client.updateThreadSettings(threadId, update); + return true; + }); + return result ?? false; + }, + }; +} diff --git a/src/features/chat/app-server/threads/transport.ts b/src/features/chat/app-server/threads/transport.ts new file mode 100644 index 00000000..5dbe9795 --- /dev/null +++ b/src/features/chat/app-server/threads/transport.ts @@ -0,0 +1,33 @@ +import { compactThread, forkThread, rollbackThread } from "../../../../app-server/threads"; +import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport"; +import type { ConnectedChatAppServerClientHost } from "../client-scope"; +import { withConnectedChatAppServerClient } from "../client-scope"; +import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items"; + +interface ChatThreadMutationTransportHost extends ConnectedChatAppServerClientHost { + vaultPath: string; +} + +export function createChatThreadMutationTransport(host: ChatThreadMutationTransportHost): ThreadMutationTransport { + return { + compactThread: async (threadId) => { + const result = await withConnectedChatAppServerClient(host, async (client) => { + await compactThread(client, threadId); + return true; + }); + return result ?? false; + }, + forkThread: (threadId) => withConnectedChatAppServerClient(host, (client) => forkThread(client, threadId, host.vaultPath)), + rollbackForkedThread: (threadId, turnsToDrop) => + withConnectedChatAppServerClient(host, async (client) => (await rollbackThread(client, threadId, turnsToDrop)).thread), + rollbackThread: (threadId) => + withConnectedChatAppServerClient(host, async (client): Promise => { + const snapshot = await rollbackThread(client, threadId); + return { + thread: snapshot.thread, + cwd: snapshot.cwd, + items: messageStreamItemsFromTurns(snapshot.turns), + }; + }), + }; +} diff --git a/src/features/chat/app-server/turns/transport.ts b/src/features/chat/app-server/turns/transport.ts new file mode 100644 index 00000000..35d68bce --- /dev/null +++ b/src/features/chat/app-server/turns/transport.ts @@ -0,0 +1,36 @@ +import type { ChatTurnTransport } from "../../application/conversation/turn-transport"; +import type { ConnectedChatAppServerClientHost } from "../client-scope"; +import { chatAppServerClientIsStale } from "../client-scope"; + +interface ChatTurnTransportHost extends ConnectedChatAppServerClientHost { + vaultPath: string; +} + +export function createChatTurnTransport(host: ChatTurnTransportHost): ChatTurnTransport { + return { + ensureConnected: async () => (await host.connectedClient()) !== null, + startTurn: async (request) => { + const client = host.currentClient(); + if (!client) return null; + const response = await client.startTurn({ + threadId: request.threadId, + cwd: host.vaultPath, + input: request.input, + clientUserMessageId: request.clientUserMessageId, + }); + return chatAppServerClientIsStale(host, client) ? null : { turnId: response.turn.id }; + }, + steerTurn: async (request) => { + const client = host.currentClient(); + if (!client) return false; + await client.steerTurn(request.threadId, request.turnId, request.input, request.clientUserMessageId); + return !chatAppServerClientIsStale(host, client); + }, + interruptTurn: async (threadId, turnId) => { + const client = host.currentClient(); + if (!client) return false; + await client.interruptTurn(threadId, turnId); + return !chatAppServerClientIsStale(host, client); + }, + }; +} diff --git a/src/features/chat/application/conversation/composer-submit-actions.ts b/src/features/chat/application/conversation/composer-submit-actions.ts index 56c34ccd..3b744a81 100644 --- a/src/features/chat/application/conversation/composer-submit-actions.ts +++ b/src/features/chat/application/conversation/composer-submit-actions.ts @@ -1,4 +1,3 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; import type { CodexInput } from "../../../../domain/chat/input"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; import { type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands"; @@ -6,6 +5,7 @@ import { parseSlashCommand } from "../composer/suggestions"; import type { ChatStateStore } from "../state/store"; import type { SlashCommandExecutionResult } from "./slash-command-execution"; import { submissionStateSnapshot } from "./submission-state"; +import type { ChatTurnTransport } from "./turn-transport"; const STATUS_INTERRUPT_REQUESTED = "Interrupt requested."; @@ -22,9 +22,9 @@ export interface ComposerSubmitActionsHost { sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata): Promise; }; connection: { - connectedClient: () => Promise; - currentClient: () => AppServerClient | null; + ensureConnected: () => Promise; }; + turnTransport: Pick; status: { setStatus: (status: string) => void; addSystemMessage: (text: string) => void; @@ -54,7 +54,7 @@ async function sendMessage(host: ComposerSubmitActionsHost): Promise { const slashCommand = parseSlashCommand(text); if (slashCommand) { - if (slashCommandRequiresConnection(slashCommand.command) && !(await host.connection.connectedClient())) return; + if (slashCommandRequiresConnection(slashCommand.command) && !(await host.connection.ensureConnected())) return; host.composer.setDraft("", { clearSuggestions: true }); const result = await host.slashCommandExecutor.execute(slashCommand.command, slashCommand.args); if (result?.composerDraft !== undefined) { @@ -74,10 +74,9 @@ async function sendMessage(host: ComposerSubmitActionsHost): Promise { async function interruptTurn(host: ComposerSubmitActionsHost): Promise { const state = submissionStateSnapshot(host.stateStore.getState()); const turnId = state.activeTurnId; - const client = host.connection.currentClient(); - if (!client || !state.activeThreadId || !turnId) return; + if (!state.activeThreadId || !turnId) return; try { - await client.interruptTurn(state.activeThreadId, turnId); + if (!(await host.turnTransport.interruptTurn(state.activeThreadId, turnId))) return; host.status.setStatus(STATUS_INTERRUPT_REQUESTED); } catch (error) { host.status.addSystemMessage(error instanceof Error ? error.message : String(error)); diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/conversation/composition.ts index 69376faa..6eeb2f32 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/conversation/composition.ts @@ -1,5 +1,5 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; import type { CodexInput } from "../../../../domain/chat/input"; +import type { Thread } from "../../../../domain/threads/model"; import type { LocalIdSource } from "../../../../shared/id/local-id"; import type { MessageStreamNoticeSection } from "../../domain/message-stream/items"; import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; @@ -8,17 +8,17 @@ import type { GoalActions } from "../threads/goal-actions"; import type { ThreadManagementActions } from "../threads/thread-management-actions"; import { type ComposerSubmitActions, type ComposerSubmitActionsHost, submitComposer } from "./composer-submit-actions"; import { implementPlan, type PlanImplementationHost } from "./plan-implementation"; +import type { ThreadReferenceInput } from "./slash-command-execution"; import { executeSlashCommandWithState, type SlashCommandExecutorHost } from "./slash-command-executor"; import { createTurnSubmissionActions } from "./turn-submission-actions"; +import type { ChatTurnTransport } from "./turn-transport"; export interface ConversationTurnActionsContext { - vaultPath: string; stateStore: ChatStateStore; localItemIds: LocalIdSource; - client: { - currentClient: () => AppServerClient | null; - connectedClient: () => Promise; - }; + connectionAvailable: () => boolean; + turnTransport: ChatTurnTransport; + referThread: (thread: Thread, message: string) => Promise; status: { set: (status: string) => void; addSystemMessage: (text: string) => void; @@ -73,12 +73,11 @@ export function createConversationTurnActions( context: ConversationTurnActionsContext, refs: ConversationTurnActionsRefs, ): ConversationTurnActions { - const { vaultPath, stateStore, localItemIds, client, status, runtime, thread, composer, scroll } = context; + const { stateStore, localItemIds, connectionAvailable, turnTransport, referThread, status, runtime, thread, composer, scroll } = context; const turnSubmission = createTurnSubmissionActions({ stateStore, - vaultPath, localItemIds, - connectedClient: client.connectedClient, + turnTransport, ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded, startThread: (preview) => refs.threadStarter.startThread(preview), notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, @@ -91,8 +90,8 @@ export function createConversationTurnActions( }); const slashCommandExecutorHost: SlashCommandExecutorHost = { stateStore, - currentClient: client.currentClient, - codexInput: composer.codexInput, + connectionAvailable, + referThread, startNewThread: thread.startNewThread, startThreadForGoal: (objective) => startThreadForGoal(refs.threadStarter, objective), resumeThread: thread.selectThread, @@ -111,7 +110,7 @@ export function createConversationTurnActions( }; const planImplementationHost: PlanImplementationHost = { stateStore, - connectedClient: client.connectedClient, + ensureConnected: () => turnTransport.ensureConnected(), sendTurnText: (text) => turnSubmission.sendTurnText(text), requestDefaultCollaborationModeForNextTurn: () => { refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn(); @@ -130,9 +129,9 @@ export function createConversationTurnActions( }, turnSubmission, connection: { - connectedClient: client.connectedClient, - currentClient: client.currentClient, + ensureConnected: () => turnTransport.ensureConnected(), }, + turnTransport, status: { setStatus: status.set, addSystemMessage: status.addSystemMessage, diff --git a/src/features/chat/application/conversation/plan-implementation.ts b/src/features/chat/application/conversation/plan-implementation.ts index a4b93ede..bbf4a695 100644 --- a/src/features/chat/application/conversation/plan-implementation.ts +++ b/src/features/chat/application/conversation/plan-implementation.ts @@ -1,4 +1,3 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/message-stream/selectors"; import type { ChatRuntimeState } from "../../domain/runtime/state"; import { type ChatMessageStreamState, messageStreamItems } from "../state/message-stream"; @@ -10,7 +9,7 @@ const IMPLEMENT_PLAN_PROMPT = "Please implement this plan."; export interface PlanImplementationHost { stateStore: ChatStateStore; - connectedClient(): Promise; + ensureConnected(): Promise; sendTurnText(text: string): Promise; requestDefaultCollaborationModeForNextTurn(): void; } @@ -29,7 +28,7 @@ export function implementPlanTargetFromState(state: { export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise { if (itemId !== implementPlanTargetFromState(host.stateStore.getState())?.itemId) return; - if (!(await host.connectedClient()) || !host.stateStore.getState().activeThread.id) return; + if (!(await host.ensureConnected()) || !host.stateStore.getState().activeThread.id) return; host.requestDefaultCollaborationModeForNextTurn(); host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); diff --git a/src/features/chat/application/conversation/slash-command-executor.ts b/src/features/chat/application/conversation/slash-command-executor.ts index e1a40028..56f6d653 100644 --- a/src/features/chat/application/conversation/slash-command-executor.ts +++ b/src/features/chat/application/conversation/slash-command-executor.ts @@ -1,11 +1,7 @@ -import { readReferencedThreadConversationSummaries, type ThreadConversationSummaryClient } from "../../../../app-server/threads"; import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { findModelMetadataByIdOrName, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata"; -import { type CodexInput, codexTextInputWithAttachments } from "../../../../domain/chat/input"; import { runtimeConfigOrDefault } from "../../../../domain/runtime/config"; import type { Thread } from "../../../../domain/threads/model"; -import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadPromptBundle } from "../../../../domain/threads/reference"; -import { shortThreadId } from "../../../../shared/id/thread-id"; import { resolveRuntimeControls } from "../../domain/runtime/resolution"; import type { SlashCommandName } from "../composer/slash-commands"; import { runtimeSnapshotForChatState } from "../runtime/snapshot"; @@ -20,8 +16,8 @@ import { submissionStateSnapshot } from "./submission-state"; export interface SlashCommandExecutorHost extends SlashCommandExecutionPorts { stateStore: ChatStateStore; - currentClient: () => ThreadConversationSummaryClient | null; - codexInput: (text: string) => CodexInput; + connectionAvailable: () => boolean; + referThread: (thread: Thread, message: string) => Promise; setStatus: (status: string) => void; } @@ -31,16 +27,12 @@ export async function executeSlashCommandWithState( args: string, ): Promise { const state = submissionStateSnapshot(host.stateStore.getState()); - const client = host.currentClient(); - if (!client && command !== "reconnect" && command !== "compact") return; + if (!host.connectionAvailable() && command !== "reconnect" && command !== "compact") return; return runSlashCommand(command, args, { ...host, activeThreadId: state.activeThreadId, listedThreads: state.listedThreads, - referThread: (thread, message) => { - if (!client) return Promise.resolve(null); - return referencedThreadInput(host, client, thread, message); - }, + referThread: host.referThread, supportedReasoningEfforts: () => supportedReasoningEfforts(host.stateStore.getState()), }); } @@ -53,28 +45,3 @@ function supportedReasoningEfforts(state: ReturnType ); return supportedEffortsForModelMetadata(model); } - -async function referencedThreadInput( - host: SlashCommandExecutorHost, - client: ThreadConversationSummaryClient, - thread: Thread, - message: string, -): Promise { - try { - const turns = await readReferencedThreadConversationSummaries(client, thread.id, REFERENCED_THREAD_TURN_LIMIT); - if (turns.length === 0) { - host.addSystemMessage("Referenced thread has no readable conversation turns."); - return null; - } - const reference = referencedThreadPromptBundle(thread, turns, message); - const messageInput = host.codexInput(message); - host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`); - return { - input: codexTextInputWithAttachments(reference.prompt, messageInput), - referencedThread: reference.referencedThread, - }; - } catch (error) { - host.addSystemMessage(error instanceof Error ? error.message : String(error)); - return null; - } -} diff --git a/src/features/chat/application/conversation/turn-submission-actions.ts b/src/features/chat/application/conversation/turn-submission-actions.ts index da037096..99494ca7 100644 --- a/src/features/chat/application/conversation/turn-submission-actions.ts +++ b/src/features/chat/application/conversation/turn-submission-actions.ts @@ -1,4 +1,3 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; import type { CodexInput } from "../../../../domain/chat/input"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; import type { LocalIdSource } from "../../../../shared/id/local-id"; @@ -12,14 +11,14 @@ import { } from "./optimistic-turn-start"; import { submissionStateSnapshot } from "./submission-state"; import { STATUS_TURN_RUNNING } from "./turn-state"; +import type { ChatTurnTransport } from "./turn-transport"; const STATUS_STEERED_CURRENT_TURN = "Steered current turn."; export interface TurnSubmissionActionsHost { stateStore: ChatStateStore; - vaultPath: string; localItemIds: LocalIdSource; - connectedClient: () => Promise; + turnTransport: ChatTurnTransport; ensureRestoredThreadLoaded: () => Promise; startThread: (preview?: string) => Promise; notifyActiveThreadIdentityChanged: () => void; @@ -57,8 +56,7 @@ async function sendTurnText( codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata, ): Promise { - const client = await host.connectedClient(); - if (!client) return; + if (!(await host.turnTransport.ensureConnected())) return; if (!(await host.ensureRestoredThreadLoaded())) return; const initialState = submissionStateSnapshot(host.stateStore.getState()); @@ -71,7 +69,7 @@ async function sendTurnText( host.addSystemMessage(plan.message); return; case "steer": - await steerCurrentTurn(host, localItemIds, client, plan, text, codexInputOverride, referencedThread); + await steerCurrentTurn(host, localItemIds, plan, text, codexInputOverride, referencedThread); return; case "start-thread-then-turn": if (!(await startThreadForTurn(host, text))) return; @@ -98,12 +96,22 @@ async function sendTurnText( }); host.setDraft(""); - const response = await client.startTurn({ + const response = await host.turnTransport.startTurn({ threadId: activeThreadId, - cwd: host.vaultPath, input: codexInput, clientUserMessageId: optimisticUserId, }); + if (!response) { + const failedState = submissionStateSnapshot(host.stateStore.getState()); + const items = cleanupFailedTurnStart({ + items: failedState.items, + optimisticUserId, + pendingTurnStart: failedState.pendingTurnStart, + }); + host.stateStore.dispatch({ type: "turn/start-failed", items }); + host.setDraft(text); + return; + } const acknowledgedState = submissionStateSnapshot(host.stateStore.getState()); const pendingStart = acknowledgedState.pendingTurnStart; if ( @@ -113,16 +121,16 @@ async function sendTurnText( pendingTurnStart: pendingStart, activeTurnId: acknowledgedState.activeTurnId, optimisticUserId, - responseTurnId: response.turn.id, + responseTurnId: response.turnId, }) ) { const items = acknowledgeOptimisticTurnStart({ items: acknowledgedState.items, optimisticUserId, - turnId: response.turn.id, + turnId: response.turnId, pendingTurnStart: pendingStart, }); - host.stateStore.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, items }); + host.stateStore.dispatch({ type: "turn/start-acknowledged", turnId: response.turnId, items }); host.setStatus(STATUS_TURN_RUNNING); } } catch (error) { @@ -160,7 +168,6 @@ async function startThreadForTurn(host: TurnSubmissionActionsHost, text: string) async function steerCurrentTurn( host: TurnSubmissionActionsHost, localItemIds: LocalIdSource, - client: AppServerClient, plan: Extract, text: string, codexInputOverride?: CodexInput, @@ -171,7 +178,13 @@ async function steerCurrentTurn( host.setDraft("", { clearSuggestions: true }); try { - await client.steerTurn(plan.threadId, plan.turnId, codexInput, localSteerId); + const steered = await host.turnTransport.steerTurn({ + threadId: plan.threadId, + turnId: plan.turnId, + input: codexInput, + clientUserMessageId: localSteerId, + }); + if (!steered) return; if (!isCurrentTurn(host, plan.threadId, plan.turnId)) return; host.stateStore.dispatch({ type: "message-stream/item-added", diff --git a/src/features/chat/application/conversation/turn-transport.ts b/src/features/chat/application/conversation/turn-transport.ts new file mode 100644 index 00000000..d725c03e --- /dev/null +++ b/src/features/chat/application/conversation/turn-transport.ts @@ -0,0 +1,25 @@ +import type { CodexInput } from "../../../../domain/chat/input"; + +interface ChatTurnStartRequest { + threadId: string; + input: CodexInput; + clientUserMessageId: string; +} + +interface ChatTurnStartResult { + turnId: string; +} + +interface ChatTurnSteerRequest { + threadId: string; + turnId: string; + input: CodexInput; + clientUserMessageId: string; +} + +export interface ChatTurnTransport { + ensureConnected(): Promise; + startTurn(request: ChatTurnStartRequest): Promise; + steerTurn(request: ChatTurnSteerRequest): Promise; + interruptTurn(threadId: string, turnId: string): Promise; +} diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index 41d58646..d3871e68 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -1,17 +1,17 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { type RuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../../../domain/runtime/config"; import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; -import { - pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch, - type PendingRuntimeSettingsPatch, -} from "../../app-server/runtime/thread-settings-update"; import { type CollaborationModeSelection, nextCollaborationMode, type RequestedFastMode } from "../../domain/runtime/intent"; import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels"; import { resolveRuntimeControls } from "../../domain/runtime/resolution"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; +import { + pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch, + type PendingRuntimeSettingsPatch, +} from "../../domain/runtime/thread-settings-patch"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; +import type { RuntimeSettingsTransport } from "./settings-transport"; interface RuntimeSettingsCommitResult { ok: boolean; @@ -23,7 +23,7 @@ type FastModeState = "enabled" | "disabled"; export interface RuntimeSettingsActionsHost { stateStore: ChatStateStore; - currentClient: () => AppServerClient | null; + runtimeTransport: RuntimeSettingsTransport; runtimeSnapshotForState: (state: ChatState) => RuntimeSnapshot; collaborationModeLabel: () => string; addSystemMessage: (text: string) => void; @@ -78,9 +78,8 @@ async function applyPendingThreadSettings(host: RuntimeSettingsActionsHost): Pro } async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise { - const client = host.currentClient(); const threadId = state(host).activeThread.id; - if (!client || !threadId) return { ok: true, collaborationModeApplied: true }; + if (!threadId) return { ok: true, collaborationModeApplied: true }; const { update, collaborationModeWarning } = pendingRuntimeSettingsPatch(host); if (collaborationModeWarning) reportCollaborationModeWarning(host, collaborationModeWarning); @@ -88,7 +87,9 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr if (Object.keys(update).length === 0) return { ok: true, collaborationModeApplied }; try { - await client.updateThreadSettings(threadId, update); + if (!(await host.runtimeTransport.updateThreadSettings(threadId, update))) { + return { ok: false, collaborationModeApplied: false }; + } if (state(host).activeThread.id !== threadId) return { ok: false, collaborationModeApplied: false }; if (!runtimeSettingsPatchStillPending(currentPendingRuntimeSettingsPatch(host), update)) { return { ok: false, collaborationModeApplied: false }; diff --git a/src/features/chat/application/runtime/settings-transport.ts b/src/features/chat/application/runtime/settings-transport.ts new file mode 100644 index 00000000..2dc180b4 --- /dev/null +++ b/src/features/chat/application/runtime/settings-transport.ts @@ -0,0 +1,5 @@ +import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; + +export interface RuntimeSettingsTransport { + updateThreadSettings(threadId: string, update: RuntimeSettingsPatch): Promise; +} diff --git a/src/features/chat/application/threads/goal-actions.ts b/src/features/chat/application/threads/goal-actions.ts index 61ba872b..1da175ae 100644 --- a/src/features/chat/application/threads/goal-actions.ts +++ b/src/features/chat/application/threads/goal-actions.ts @@ -1,14 +1,13 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; -import { readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/threads"; import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../../domain/threads/goal"; import type { LocalIdSource } from "../../../../shared/id/local-id"; import { goalChangeItem } from "../../domain/message-stream/factories/goal-items"; import type { GoalMessageStreamItem } from "../../domain/message-stream/items"; import type { ChatStateStore } from "../state/store"; +import type { ThreadGoalTransport } from "./goal-transport"; export interface ThreadGoalSyncHost { stateStore: ChatStateStore; - currentClient: () => AppServerClient | null; + goalTransport: ThreadGoalTransport; localItemIds: LocalIdSource; addSystemMessage: (text: string) => void; addGoalEvent: (item: GoalMessageStreamItem) => void; @@ -16,7 +15,6 @@ export interface ThreadGoalSyncHost { } export interface GoalActionsHost extends ThreadGoalSyncHost { - connectedClient: () => Promise; startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>; } @@ -79,10 +77,10 @@ export function createGoalActions(host: GoalActionsHost): GoalActions { } async function syncThreadGoal(host: ThreadGoalSyncHost, threadId: string): Promise { - const client = host.currentClient(); - if (!client) return; try { - applyGoalIfActive(host, threadId, await readThreadGoal(client, threadId), { reportChange: false }); + const goal = await host.goalTransport.readThreadGoal(threadId); + if (goal === undefined) return; + applyGoalIfActive(host, threadId, goal, { reportChange: false }); } catch (error) { addThreadScopedSystemMessage(host, threadId, `Could not load thread goal: ${errorMessage(error)}`); } @@ -134,10 +132,8 @@ function setGoalStatus(host: GoalActionsHost, threadId: string, status: ThreadGo } async function clearGoal(host: GoalActionsHost, threadId: string): Promise { - const client = await host.connectedClient(); - if (!client) return false; try { - await client.clearThreadGoal(threadId); + if (!(await host.goalTransport.clearThreadGoal(threadId))) return false; applyGoalIfActive(host, threadId, null, { reportChange: true }); return true; } catch (error) { @@ -147,10 +143,10 @@ async function clearGoal(host: GoalActionsHost, threadId: string): Promise { - const client = await host.connectedClient(); - if (!client) return false; try { - return applyGoalIfActive(host, threadId, await setThreadGoal(client, threadId, params), { reportChange: true }); + const goal = await host.goalTransport.setThreadGoal(threadId, params); + if (goal === undefined) return false; + return applyGoalIfActive(host, threadId, goal, { reportChange: true }); } catch (error) { addThreadScopedSystemMessage(host, threadId, errorMessage(error)); return false; @@ -200,8 +196,7 @@ async function startThreadAndSaveObjective( plan: Extract, ): Promise { try { - const client = await host.connectedClient(); - if (!client) return false; + if (!(await host.goalTransport.ensureConnected())) return false; const response = await host.startThread(plan.objective, { syncGoal: false }); const threadId = response?.threadId ?? null; return threadId ? await setNormalizedObjective(host, threadId, plan.objective, plan.tokenBudget) : false; @@ -212,10 +207,8 @@ async function startThreadAndSaveObjective( } async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, objective: string): Promise { - const client = host.currentClient(); - if (!client) return; try { - await recordThreadGoalUserMessage(client, threadId, objective); + await host.goalTransport.recordThreadGoalUserMessage(threadId, objective); } catch (error) { addThreadScopedSystemMessage(host, threadId, `Could not record goal message: ${errorMessage(error)}`); } diff --git a/src/features/chat/application/threads/goal-transport.ts b/src/features/chat/application/threads/goal-transport.ts new file mode 100644 index 00000000..d196d531 --- /dev/null +++ b/src/features/chat/application/threads/goal-transport.ts @@ -0,0 +1,9 @@ +import type { ThreadGoal, ThreadGoalUpdate } from "../../../../domain/threads/goal"; + +export interface ThreadGoalTransport { + readThreadGoal(threadId: string): Promise; + setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise; + clearThreadGoal(threadId: string): Promise; + recordThreadGoalUserMessage(threadId: string, objective: string): Promise; + ensureConnected(): Promise; +} diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 6d533485..142d080c 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -1,21 +1,13 @@ -import { - compactThread as compactThreadOnAppServer, - forkThread as forkThreadOnAppServer, - rollbackThread as rollbackThreadOnAppServer, - type ThreadCompactionClient, - type ThreadForkClient, - type ThreadRollbackClient, -} from "../../../../app-server/threads"; import { inheritedForkThreadName } from "../../../../domain/threads/model"; import type { ThreadCatalogEvent } from "../../../../workspace/thread-catalog"; import type { ThreadOperations } from "../../../threads/thread-operations"; -import { messageStreamItemsFromTurns } from "../../app-server/mappers/message-stream/turn-items"; import { activeThreadRuntimeState } from "../../domain/runtime/state"; import { chatTurnBusy } from "../conversation/turn-state"; import { resumedThreadActionFromActiveRuntime } from "../state/actions"; import { messageStreamRollbackCandidate, messageStreamTurnsAfterTurnId } from "../state/message-stream"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; +import type { ThreadMutationTransport } from "./thread-mutation-transport"; const STATUS_COMPACTION_REQUESTED = "Compaction requested."; const STATUS_ROLLBACK_STARTING = "Rolling back latest turn..."; @@ -24,10 +16,8 @@ const STATUS_ROLLBACK_FAILED = "Rollback failed."; export interface ThreadManagementActionsHost { stateStore: ChatStateStore; - vaultPath: string; operations: ThreadOperations; - connectedClient: () => Promise; - currentClient: () => ThreadManagementClient | null; + threadTransport: ThreadMutationTransport; addSystemMessage: (text: string) => void; setStatus: (status: string) => void; setComposerText: (text: string) => void; @@ -48,14 +38,11 @@ export interface ThreadManagementActions { rollbackThread: (threadId: string) => Promise; } -interface ThreadManagementOperationScope { - client: ThreadManagementClient; +interface ThreadManagementPanelScope { targetThreadId: string; initialActiveThreadId: string | null; } -type ThreadManagementClient = ThreadCompactionClient & ThreadForkClient & ThreadRollbackClient; - export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions { return { compactActiveThread: () => compactActiveThread(host), @@ -78,10 +65,9 @@ async function compactActiveThread(host: ThreadManagementActionsHost): Promise { - const scope = await captureThreadManagementOperationScope(host, threadId); - if (!scope) return; + const scope = captureThreadManagementPanelScope(host, threadId); try { - await compactThreadOnAppServer(scope.client, threadId); + if (!(await host.threadTransport.compactThread(threadId))) return; if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; host.addSystemMessage(STATUS_COMPACTION_REQUESTED); host.setStatus(STATUS_COMPACTION_REQUESTED); @@ -122,8 +108,7 @@ async function forkThreadFromTurn( host.addSystemMessage("Finish or interrupt the current turn before forking threads."); return; } - const scope = await captureThreadManagementOperationScope(host, threadId); - if (!scope) return; + const scope = captureThreadManagementPanelScope(host, threadId); const turnsToDrop = turnId ? messageStreamTurnsAfterTurnId(threadManagementState(host).messageStream, turnId) : 0; if (turnsToDrop === null) { @@ -133,13 +118,13 @@ async function forkThreadFromTurn( try { const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads); - let forkedThread = await forkThreadOnAppServer(scope.client, threadId, host.vaultPath); - if (threadManagementScopeClientStale(host, scope)) return; + let forkedThread = await host.threadTransport.forkThread(threadId); + if (!forkedThread) return; const forkedThreadId = forkedThread.id; if (turnsToDrop > 0) { - const snapshot = await rollbackThreadOnAppServer(scope.client, forkedThreadId, turnsToDrop); - if (threadManagementScopeClientStale(host, scope)) return; - forkedThread = snapshot.thread; + const rolledBackThread = await host.threadTransport.rollbackForkedThread(forkedThreadId, turnsToDrop); + if (!rolledBackThread) return; + forkedThread = rolledBackThread; } host.applyThreadCatalogEvent({ type: "thread-forked", thread: forkedThread }); if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; @@ -188,8 +173,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin host.addSystemMessage("Interrupt the current turn before rolling back."); return; } - const scope = await captureThreadManagementOperationScope(host, threadId); - if (!scope) return; + const scope = captureThreadManagementPanelScope(host, threadId); const candidate = messageStreamRollbackCandidate(threadManagementState(host).messageStream); if (!candidate) { @@ -199,7 +183,8 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin try { host.setStatus(STATUS_ROLLBACK_STARTING); - const snapshot = await rollbackThreadOnAppServer(scope.client, threadId); + const snapshot = await host.threadTransport.rollbackThread(threadId); + if (!snapshot) return; if (!threadManagementScopeStillTargetsPanel(host, scope)) return; threadManagementDispatch( host, @@ -212,7 +197,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin ); threadManagementDispatch(host, { type: "message-stream/items-replaced", - items: messageStreamItemsFromTurns(snapshot.turns), + items: snapshot.items, historyCursor: null, loadingHistory: false, }); @@ -235,29 +220,18 @@ function threadManagementDispatch(host: ThreadManagementActionsHost, action: Cha host.stateStore.dispatch(action); } -async function captureThreadManagementOperationScope( - host: ThreadManagementActionsHost, - targetThreadId: string, -): Promise { - const client = await host.connectedClient(); - if (!client) return null; +function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope { return { - client, targetThreadId, initialActiveThreadId: threadManagementState(host).activeThread.id, }; } -function threadManagementScopeClientStale(host: ThreadManagementActionsHost, scope: ThreadManagementOperationScope): boolean { - return host.currentClient() !== scope.client; +function threadManagementScopeStillTargetsPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean { + return threadManagementState(host).activeThread.id === scope.targetThreadId; } -function threadManagementScopeStillTargetsPanel(host: ThreadManagementActionsHost, scope: ThreadManagementOperationScope): boolean { - return !threadManagementScopeClientStale(host, scope) && threadManagementState(host).activeThread.id === scope.targetThreadId; -} - -function threadManagementScopeStillTargetsOriginalPanel(host: ThreadManagementActionsHost, scope: ThreadManagementOperationScope): boolean { - if (threadManagementScopeClientStale(host, scope)) return false; +function threadManagementScopeStillTargetsOriginalPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean { if (!scope.initialActiveThreadId) return true; return scope.initialActiveThreadId === scope.targetThreadId && threadManagementState(host).activeThread.id === scope.targetThreadId; } diff --git a/src/features/chat/application/threads/thread-mutation-transport.ts b/src/features/chat/application/threads/thread-mutation-transport.ts new file mode 100644 index 00000000..f15c0095 --- /dev/null +++ b/src/features/chat/application/threads/thread-mutation-transport.ts @@ -0,0 +1,15 @@ +import type { Thread } from "../../../../domain/threads/model"; +import type { MessageStreamItem } from "../../domain/message-stream/items"; + +export interface ThreadRollbackSnapshot { + thread: Thread; + cwd: string; + items: MessageStreamItem[]; +} + +export interface ThreadMutationTransport { + compactThread(threadId: string): Promise; + forkThread(threadId: string): Promise; + rollbackForkedThread(threadId: string, turnsToDrop: number): Promise; + rollbackThread(threadId: string): Promise; +} diff --git a/src/features/chat/domain/runtime/intent.ts b/src/features/chat/domain/runtime/intent.ts index debced7d..4825577a 100644 --- a/src/features/chat/domain/runtime/intent.ts +++ b/src/features/chat/domain/runtime/intent.ts @@ -3,8 +3,8 @@ import type { ModeKind } from "../../../../domain/runtime/thread-settings"; export type CollaborationModeSelection = ModeKind; export type ActiveCollaborationMode = CollaborationModeSelection | null; -// Pending runtime intents are panel-side user requests, not app-server payload values. -// They are projected for display first and converted to transport values only at the app-server boundary. +// Pending runtime intents are panel-side user requests, not app-server protocol values. +// They are projected for display and reduced into panel-owned runtime settings patches before app-server protocol adaptation. export type PendingRuntimeIntent = | { readonly kind: "unchanged" } | { readonly kind: "set"; readonly value: T } diff --git a/src/features/chat/app-server/runtime/thread-settings-update.ts b/src/features/chat/domain/runtime/thread-settings-patch.ts similarity index 76% rename from src/features/chat/app-server/runtime/thread-settings-update.ts rename to src/features/chat/domain/runtime/thread-settings-patch.ts index e1b06749..70717cbd 100644 --- a/src/features/chat/app-server/runtime/thread-settings-update.ts +++ b/src/features/chat/domain/runtime/thread-settings-patch.ts @@ -5,9 +5,9 @@ import { type RuntimeSettingsPatch, runtimeCollaborationModeSettings, } from "../../../../domain/runtime/thread-settings"; -import type { PendingRuntimeIntent } from "../../domain/runtime/intent"; -import { type RuntimeControlsResolution, resolveRuntimeControls } from "../../domain/runtime/resolution"; -import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; +import type { PendingRuntimeIntent } from "./intent"; +import { type RuntimeControlsResolution, resolveRuntimeControls } from "./resolution"; +import type { RuntimeSnapshot } from "./snapshot"; type TurnCollaborationModeWarning = "missing-model"; @@ -21,11 +21,11 @@ type TurnCollaborationModeSettings = warning: TurnCollaborationModeWarning; }; -// Transport intent is the app-server boundary vocabulary: +// Patch intent is the runtime settings request vocabulary: // omit -> leave the field out, clear -> send null, set -> send a concrete value. // For service tiers, app-server treats null as clearing to its baseline/default tier, // not as "use the configured service_tier" for thread/start. -type RuntimeTransportIntent = { readonly kind: "omit" } | { readonly kind: "clear" } | { readonly kind: "set"; readonly value: T }; +type RuntimeSettingsPatchIntent = { readonly kind: "omit" } | { readonly kind: "clear" } | { readonly kind: "set"; readonly value: T }; export interface PendingRuntimeSettingsPatch { update: RuntimeSettingsPatch; @@ -33,7 +33,7 @@ export interface PendingRuntimeSettingsPatch { } export function serviceTierRequestForThreadStart(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): RuntimeServiceTierRequest { - return runtimeSettingsPatchValue(serviceTierTransportIntent(snapshot, resolveRuntimeControls(snapshot, config), "thread-start")); + return runtimeSettingsPatchValue(serviceTierPatchIntent(snapshot, resolveRuntimeControls(snapshot, config), "thread-start")); } export function pendingRuntimeSettingsPatch(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): PendingRuntimeSettingsPatch { @@ -42,25 +42,29 @@ export function pendingRuntimeSettingsPatch(snapshot: RuntimeSnapshot, config: R const runtimeCollaborationModeSettings = requestedTurnCollaborationModeSettings(resolution); if (snapshot.pending.model.kind !== "unchanged") { - applyRuntimeSettingsPatchValue(update, "model", runtimeSettingsPatchValue(runtimeTransportIntentFromPending(snapshot.pending.model))); + applyRuntimeSettingsPatchValue( + update, + "model", + runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.model)), + ); } if (snapshot.pending.reasoningEffort.kind !== "unchanged") { applyRuntimeSettingsPatchValue( update, "effort", - runtimeSettingsPatchValue(runtimeTransportIntentFromPending(snapshot.pending.reasoningEffort)), + runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.reasoningEffort)), ); } applyRuntimeSettingsPatchValue( update, "serviceTier", - runtimeSettingsPatchValue(serviceTierTransportIntent(snapshot, resolution, "thread-update")), + runtimeSettingsPatchValue(serviceTierPatchIntent(snapshot, resolution, "thread-update")), ); if (snapshot.pending.approvalsReviewer.kind !== "unchanged") { applyRuntimeSettingsPatchValue( update, "approvalsReviewer", - runtimeSettingsPatchValue(runtimeTransportIntentFromPending(snapshot.pending.approvalsReviewer)), + runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.approvalsReviewer)), ); } if (resolution.collaborationMode.dirty) { @@ -82,17 +86,17 @@ function requestedTurnCollaborationModeSettings(resolution: RuntimeControlsResol }; } -function runtimeTransportIntentFromPending(intent: PendingRuntimeIntent): RuntimeTransportIntent { +function runtimeSettingsPatchIntentFromPending(intent: PendingRuntimeIntent): RuntimeSettingsPatchIntent { if (intent.kind === "set") return { kind: "set", value: intent.value }; if (intent.kind === "resetToConfig") return { kind: "clear" }; return { kind: "omit" }; } -function serviceTierTransportIntent( +function serviceTierPatchIntent( snapshot: RuntimeSnapshot, resolution: RuntimeControlsResolution, target: "thread-start" | "thread-update", -): RuntimeTransportIntent { +): RuntimeSettingsPatchIntent { // app-server has no separate "reset to config" token for service tiers. // thread/start null falls back to app-server's baseline/default tier, so a reset // to configured service_tier must send the configured id explicitly. @@ -113,7 +117,7 @@ function serviceTierTransportIntent( return { kind: "omit" }; } -function runtimeSettingsPatchValue(intent: RuntimeTransportIntent): T | null | undefined { +function runtimeSettingsPatchValue(intent: RuntimeSettingsPatchIntent): T | null | undefined { if (intent.kind === "set") return intent.value; if (intent.kind === "clear") return null; return undefined; diff --git a/src/features/chat/host/runtime-bundle.ts b/src/features/chat/host/runtime-bundle.ts index d441fd1d..3b4a629f 100644 --- a/src/features/chat/host/runtime-bundle.ts +++ b/src/features/chat/host/runtime-bundle.ts @@ -1,4 +1,5 @@ import type { ConnectionManager } from "../../../app-server/connection/connection-manager"; +import { createChatRuntimeSettingsTransport } from "../app-server/runtime/thread-settings-transport"; import { connectionDiagnosticSectionsFromState } from "../application/connection/diagnostic-sections"; import { toolInventoryDiagnosticSections } from "../application/connection/tool-inventory-diagnostic-sections"; import { type ChatRuntimeSettingsActions, createChatRuntimeSettingsActions } from "../application/runtime/settings-actions"; @@ -60,7 +61,9 @@ function createSessionRuntimeSettingsActions( ): ChatPanelRuntimeSettingsActions { return createChatRuntimeSettingsActions({ stateStore: host.stateStore, - currentClient, + runtimeTransport: createChatRuntimeSettingsTransport({ + currentClient, + }), runtimeSnapshotForState: runtimeSnapshotForChatState, collaborationModeLabel: () => collaborationModeLabel(host.stateStore), addSystemMessage: (text) => { diff --git a/src/features/chat/host/thread-bundle.ts b/src/features/chat/host/thread-bundle.ts index 33b8fc4a..94ecef92 100644 --- a/src/features/chat/host/thread-bundle.ts +++ b/src/features/chat/host/thread-bundle.ts @@ -7,6 +7,8 @@ import type { LocalIdSource } from "../../../shared/id/local-id"; import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations"; import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service"; import type { ChatServerThreadActions } from "../app-server/actions/threads"; +import { createChatThreadGoalTransport } from "../app-server/goals/transport"; +import { createChatThreadMutationTransport } from "../app-server/threads/transport"; import type { ChatResumeWorkTracker } from "../application/lifecycle"; import { messageStreamItems } from "../application/state/message-stream"; import type { ChatStateStore } from "../application/state/store"; @@ -192,10 +194,12 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP const { environment, stateStore } = host; const threadManagementHost: ThreadManagementActionsHost = { stateStore, - vaultPath: environment.plugin.settingsRef.vaultPath, operations: foundation.threadOperations, - connectedClient, - currentClient, + threadTransport: createChatThreadMutationTransport({ + vaultPath: environment.plugin.settingsRef.vaultPath, + currentClient, + connectedClient, + }), addSystemMessage: status.addSystemMessage, setStatus: status.set, setComposerText: (text) => { @@ -299,7 +303,10 @@ function createSessionGoalSyncActions( ): ChatPanelGoalSyncActions { return createThreadGoalSyncActions({ stateStore: host.stateStore, - currentClient, + goalTransport: createChatThreadGoalTransport({ + currentClient, + connectedClient: async () => currentClient(), + }), localItemIds, addSystemMessage: (text) => { status.addSystemMessage(text); @@ -357,9 +364,11 @@ function createSessionGoalActions( ): ChatPanelGoalActions { return createGoalActions({ stateStore: host.stateStore, - currentClient, + goalTransport: createChatThreadGoalTransport({ + currentClient, + connectedClient, + }), localItemIds, - connectedClient, startThread: (preview, options) => serverThreads.startThread(preview, options), addSystemMessage: (text) => { status.addSystemMessage(text); diff --git a/src/features/chat/host/turn-bundle.ts b/src/features/chat/host/turn-bundle.ts index f355be55..04800f07 100644 --- a/src/features/chat/host/turn-bundle.ts +++ b/src/features/chat/host/turn-bundle.ts @@ -3,6 +3,8 @@ import type { LocalIdSource } from "../../../shared/id/local-id"; import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; import type { ChatServerThreadActions } from "../app-server/actions/threads"; import type { ChatInboundHandler } from "../app-server/inbound/handler"; +import { createThreadReferenceResolver } from "../app-server/references/thread-reference-resolver"; +import { createChatTurnTransport } from "../app-server/turns/transport"; import { type ChatReconnectActionsHost, reconnectPanel } from "../application/connection/reconnect-actions"; import { type ConversationTurnActions as ChatPanelConversationTurnActions, @@ -124,15 +126,24 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn }, }; const reconnect = () => reconnectPanel(reconnectHost); + const turnTransport = createChatTurnTransport({ + vaultPath: host.environment.plugin.settingsRef.vaultPath, + currentClient, + connectedClient, + }); + const threadReferenceResolver = createThreadReferenceResolver({ + currentClient, + codexInput: (text) => composerController.codexInput(text), + addSystemMessage: status.addSystemMessage, + setStatus: status.set, + }); const turnActions = createConversationTurnActions( { - vaultPath: host.environment.plugin.settingsRef.vaultPath, stateStore: host.stateStore, localItemIds, - client: { - currentClient, - connectedClient, - }, + connectionAvailable: () => currentClient() !== null, + turnTransport, + referThread: (thread, message) => threadReferenceResolver.referThread(thread, message), status, runtime: { connectionDiagnosticDetails: runtimeProjection.connectionDiagnosticDetails, diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts new file mode 100644 index 00000000..12a724c5 --- /dev/null +++ b/tests/features/chat/app-server/transports.test.ts @@ -0,0 +1,230 @@ +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 { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn"; +import type { CodexInput } from "../../../../src/domain/chat/input"; +import { createChatThreadGoalTransport } from "../../../../src/features/chat/app-server/goals/transport"; +import { createThreadReferenceResolver } from "../../../../src/features/chat/app-server/references/thread-reference-resolver"; +import { createChatRuntimeSettingsTransport } from "../../../../src/features/chat/app-server/runtime/thread-settings-transport"; +import { createChatThreadMutationTransport } from "../../../../src/features/chat/app-server/threads/transport"; +import { createChatTurnTransport } from "../../../../src/features/chat/app-server/turns/transport"; +import { deferred } from "../../../support/async"; + +const textInput = (text: string): CodexInput => [{ type: "text", text }]; + +describe("chat app-server transports", () => { + it("starts turns with the session vault path and returns chat-owned turn ids", async () => { + const startTurn = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }); + const client = { startTurn } as unknown as AppServerClient; + const transport = createChatTurnTransport({ + vaultPath: "/vault", + currentClient: () => client, + connectedClient: vi.fn().mockResolvedValue(client), + }); + + await expect( + transport.startTurn({ + threadId: "thread", + input: textInput("hello"), + clientUserMessageId: "local-user", + }), + ).resolves.toEqual({ turnId: "turn-1" }); + + expect(startTurn).toHaveBeenCalledWith({ + threadId: "thread", + cwd: "/vault", + input: textInput("hello"), + clientUserMessageId: "local-user", + }); + }); + + it("drops stale turn transport responses after the current client changes", async () => { + const start = deferred<{ turn: { id: string } }>(); + const firstClient = { startTurn: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient; + const secondClient = {} as unknown as AppServerClient; + let currentClient = firstClient; + const transport = createChatTurnTransport({ + vaultPath: "/vault", + currentClient: () => currentClient, + connectedClient: vi.fn().mockResolvedValue(firstClient), + }); + + const starting = transport.startTurn({ threadId: "thread", input: textInput("hello"), clientUserMessageId: "local-user" }); + currentClient = secondClient; + start.resolve({ turn: { id: "turn-1" } }); + + await expect(starting).resolves.toBeNull(); + }); + + it("compacts threads through a connected app-server client", async () => { + const compactThread = vi.fn().mockResolvedValue({}); + const client = { compactThread } as unknown as AppServerClient; + const transport = createChatThreadMutationTransport({ + vaultPath: "/vault", + currentClient: () => client, + connectedClient: vi.fn().mockResolvedValue(client), + }); + + await expect(transport.compactThread("thread")).resolves.toBe(true); + + expect(compactThread).toHaveBeenCalledWith("thread"); + }); + + it("forks threads with the session vault path and returns panel threads", async () => { + const forkThread = vi.fn().mockResolvedValue({ thread: threadRecord("forked") }); + const client = { forkThread } as unknown as AppServerClient; + const transport = createChatThreadMutationTransport({ + vaultPath: "/vault", + currentClient: () => client, + connectedClient: vi.fn().mockResolvedValue(client), + }); + + const thread = await transport.forkThread("source"); + + expect(forkThread).toHaveBeenCalledWith("source", "/vault"); + expect(thread).toMatchObject({ id: "forked", preview: "Preview", archived: false }); + }); + + it("drops stale fork transport responses after the current client changes", async () => { + const fork = deferred<{ thread: ThreadRecord }>(); + const firstClient = { forkThread: vi.fn().mockReturnValue(fork.promise) } as unknown as AppServerClient; + const secondClient = {} as unknown as AppServerClient; + let currentClient = firstClient; + const transport = createChatThreadMutationTransport({ + vaultPath: "/vault", + currentClient: () => currentClient, + connectedClient: vi.fn().mockResolvedValue(firstClient), + }); + + const forking = transport.forkThread("source"); + currentClient = secondClient; + fork.resolve({ thread: threadRecord("forked") }); + + await expect(forking).resolves.toBeNull(); + }); + + it("projects rollback turns into message stream items", async () => { + const rollbackThread = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) }); + const client = { rollbackThread } as unknown as AppServerClient; + const transport = createChatThreadMutationTransport({ + vaultPath: "/vault", + currentClient: () => client, + connectedClient: vi.fn().mockResolvedValue(client), + }); + + const snapshot = await transport.rollbackThread("thread"); + + expect(rollbackThread).toHaveBeenCalledWith("thread"); + expect(snapshot?.thread.id).toBe("thread"); + expect(snapshot?.cwd).toBe("/vault"); + expect(snapshot?.items).toEqual([expect.objectContaining({ kind: "message", role: "user", text: "prompt" })]); + }); + + it("distinguishes absent goals from unavailable goal clients", async () => { + const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient; + const transport = createChatThreadGoalTransport({ + currentClient: () => client, + connectedClient: vi.fn().mockResolvedValue(client), + }); + const unavailable = createChatThreadGoalTransport({ + currentClient: () => null, + connectedClient: vi.fn().mockResolvedValue(null), + }); + + await expect(transport.readThreadGoal("thread")).resolves.toBeNull(); + await expect(unavailable.readThreadGoal("thread")).resolves.toBeUndefined(); + }); + + it("drops stale runtime settings updates after the current client changes", async () => { + const update = deferred(); + const firstClient = { updateThreadSettings: vi.fn().mockReturnValue(update.promise) } as unknown as AppServerClient; + const secondClient = {} as unknown as AppServerClient; + let currentClient = firstClient; + const transport = createChatRuntimeSettingsTransport({ + currentClient: () => currentClient, + }); + + const updating = transport.updateThreadSettings("thread", { model: "gpt-5.5" }); + currentClient = secondClient; + update.resolve(undefined); + + await expect(updating).resolves.toBe(false); + expect(firstClient.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" }); + }); + + it("resolves referenced thread input at the app-server boundary", async () => { + const threadTurnsList = vi.fn().mockResolvedValue({ + data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])], + nextCursor: null, + }); + const client = { threadTurnsList } as unknown as AppServerClient; + const setStatus = vi.fn(); + const resolver = createThreadReferenceResolver({ + currentClient: () => client, + codexInput: (text) => textInput(text), + addSystemMessage: vi.fn(), + setStatus, + }); + + const result = await resolver.referThread( + { id: "019abcde-0000-7000-8000-000000000001", preview: "", name: "Other", createdAt: 1, updatedAt: 1, archived: false }, + "summarize", + ); + + expect(threadTurnsList).toHaveBeenCalledWith("019abcde-0000-7000-8000-000000000001", null, 20); + expect(result?.input[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("Reference thread history:"), + }); + expect(result?.referencedThread).toMatchObject({ title: "Other", includedTurns: 1, turnLimit: 20 }); + expect(setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns)."); + }); +}); + +function threadRecord(id: string, turns: readonly TurnRecord[] = []): ThreadRecord { + return { + id, + sessionId: id, + 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: null, + turns, + }; +} + +function turn(items: TurnRecord["items"], overrides: Partial = {}): TurnRecord { + return { + id: "turn", + items, + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + ...overrides, + }; +} + +function userMessage(id: string, text: string): TurnItem { + return { type: "userMessage", id, clientId: null, content: [{ type: "text", text, text_elements: [] }] }; +} + +function agentMessage(id: string, text: string): TurnItem { + return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null }; +} diff --git a/tests/features/chat/application/conversation/composer-submit-actions.test.ts b/tests/features/chat/application/conversation/composer-submit-actions.test.ts index 0b6a7978..05765815 100644 --- a/tests/features/chat/application/conversation/composer-submit-actions.test.ts +++ b/tests/features/chat/application/conversation/composer-submit-actions.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import type { Thread } from "../../../../../src/domain/threads/model"; import { submitComposer } from "../../../../../src/features/chat/application/conversation/composer-submit-actions"; import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; @@ -20,12 +19,11 @@ function thread(id: string): Thread { function createHost(draft: string) { const stateStore = createChatStateStore(createChatState()); const interruptTurn = vi.fn().mockResolvedValue({}); - const client = { interruptTurn } as unknown as AppServerClient; const setDraft = vi.fn(); const sendTurnText = vi.fn().mockResolvedValue(undefined); const execute = vi.fn().mockResolvedValue(undefined); const showLatest = vi.fn(); - const connectedClient = vi.fn().mockResolvedValue(client); + const ensureConnected = vi.fn().mockResolvedValue(true); const host = { stateStore, composer: { @@ -37,26 +35,26 @@ function createHost(draft: string) { slashCommandExecutor: { execute }, turnSubmission: { sendTurnText }, connection: { - connectedClient, - currentClient: () => client, + ensureConnected, }, + turnTransport: { interruptTurn }, status: { setStatus: vi.fn(), addSystemMessage: vi.fn(), }, scroll: { showLatest }, }; - return { host, connectedClient, execute, interruptTurn, sendTurnText, setDraft, showLatest, stateStore }; + return { host, ensureConnected, execute, interruptTurn, sendTurnText, setDraft, showLatest, stateStore }; } describe("submitComposer", () => { it("sends plain drafts as turn text", async () => { - const { host, connectedClient, sendTurnText, showLatest } = createHost("hello"); + const { host, ensureConnected, sendTurnText, showLatest } = createHost("hello"); await submitComposer(host); expect(showLatest).toHaveBeenCalledOnce(); - expect(connectedClient).not.toHaveBeenCalled(); + expect(ensureConnected).not.toHaveBeenCalled(); expect(sendTurnText).toHaveBeenCalledWith("hello"); const [showLatestOrder] = showLatest.mock.invocationCallOrder; const [sendTurnTextOrder] = sendTurnText.mock.invocationCallOrder; @@ -67,57 +65,57 @@ describe("submitComposer", () => { }); it("executes slash commands and forwards command send results", async () => { - const { host, connectedClient, execute, sendTurnText, setDraft, showLatest } = createHost("/clear hello"); + const { host, ensureConnected, execute, sendTurnText, setDraft, showLatest } = createHost("/clear hello"); execute.mockResolvedValue({ sendText: "hello" }); await submitComposer(host); expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true }); - expect(connectedClient).toHaveBeenCalledOnce(); + expect(ensureConnected).toHaveBeenCalledOnce(); expect(execute).toHaveBeenCalledWith("clear", "hello"); expect(showLatest).toHaveBeenCalledOnce(); expect(sendTurnText).toHaveBeenCalledWith("hello", undefined, undefined); }); it("does not execute connection-dependent slash commands when connection fails", async () => { - const { host, connectedClient, execute, setDraft } = createHost("/clear"); - connectedClient.mockResolvedValue(null); + const { host, ensureConnected, execute, setDraft } = createHost("/clear"); + ensureConnected.mockResolvedValue(false); await submitComposer(host); - expect(connectedClient).toHaveBeenCalledOnce(); + expect(ensureConnected).toHaveBeenCalledOnce(); expect(setDraft).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); }); it("executes reconnect without a connected client preflight", async () => { - const { host, connectedClient, execute, setDraft } = createHost("/reconnect"); + const { host, ensureConnected, execute, setDraft } = createHost("/reconnect"); await submitComposer(host); - expect(connectedClient).not.toHaveBeenCalled(); + expect(ensureConnected).not.toHaveBeenCalled(); expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true }); expect(execute).toHaveBeenCalledWith("reconnect", ""); }); it("executes compact without a connected client preflight", async () => { - const { host, connectedClient, execute, setDraft } = createHost("/compact"); + const { host, ensureConnected, execute, setDraft } = createHost("/compact"); await submitComposer(host); - expect(connectedClient).not.toHaveBeenCalled(); + expect(ensureConnected).not.toHaveBeenCalled(); expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true }); expect(execute).toHaveBeenCalledWith("compact", ""); }); it("restores slash command composer drafts from command results", async () => { - const { host, connectedClient, execute, sendTurnText, setDraft, showLatest } = createHost("/goal edit"); + const { host, ensureConnected, execute, sendTurnText, setDraft, showLatest } = createHost("/goal edit"); execute.mockResolvedValue({ composerDraft: "/goal set Current objective" }); await submitComposer(host); expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true }); - expect(connectedClient).toHaveBeenCalledOnce(); + expect(ensureConnected).toHaveBeenCalledOnce(); expect(setDraft).toHaveBeenCalledWith("/goal set Current objective", { focus: true, clearSuggestions: true }); expect(showLatest).not.toHaveBeenCalled(); expect(sendTurnText).not.toHaveBeenCalled(); diff --git a/tests/features/chat/application/conversation/plan-implementation.test.ts b/tests/features/chat/application/conversation/plan-implementation.test.ts index d4fd5086..cc8cecc1 100644 --- a/tests/features/chat/application/conversation/plan-implementation.test.ts +++ b/tests/features/chat/application/conversation/plan-implementation.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import { implementPlan, implementPlanTargetFromState, @@ -42,21 +41,21 @@ function resumeThread(stateStore: ChatStateStore, items: readonly MessageStreamI stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" }); } -function createController({ client = {} as AppServerClient } = {}) { +function createController() { const stateStore = createChatStateStore(createChatState()); - const connectedClient = vi.fn().mockResolvedValue(client); + const ensureConnected = vi.fn().mockResolvedValue(true); const sendTurnText = vi.fn().mockResolvedValue(undefined); const requestDefaultCollaborationModeForNextTurn = vi.fn(() => { stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" }); }); const host: PlanImplementationHost = { stateStore, - connectedClient, + ensureConnected, sendTurnText, requestDefaultCollaborationModeForNextTurn, }; return { - connectedClient, + ensureConnected, host, requestDefaultCollaborationModeForNextTurn, sendTurnText, @@ -88,14 +87,14 @@ describe("implementPlan", () => { }); it("switches out of plan mode and submits the implementation prompt", async () => { - const { host, connectedClient, requestDefaultCollaborationModeForNextTurn, sendTurnText, stateStore } = createController(); + const { host, ensureConnected, requestDefaultCollaborationModeForNextTurn, sendTurnText, stateStore } = createController(); const plan = planItem("plan"); resumeThread(stateStore, [plan]); stateStore.dispatch({ type: "ui/panel-set", panel: "status-panel" }); await implementPlan(host, plan.id); - expect(connectedClient).toHaveBeenCalledOnce(); + expect(ensureConnected).toHaveBeenCalledOnce(); expect(requestDefaultCollaborationModeForNextTurn).toHaveBeenCalledOnce(); expect(stateStore.getState().runtime.pending.collaborationMode).toBe("default"); expect(stateStore.getState().ui.toolbarPanel).toBeNull(); @@ -103,14 +102,14 @@ describe("implementPlan", () => { }); it("ignores stale plan items", async () => { - const { host, connectedClient, sendTurnText, stateStore } = createController(); + const { host, ensureConnected, sendTurnText, stateStore } = createController(); const first = planItem("first"); const latest = planItem("latest"); resumeThread(stateStore, [first, latest]); await implementPlan(host, first.id); - expect(connectedClient).not.toHaveBeenCalled(); + expect(ensureConnected).not.toHaveBeenCalled(); expect(sendTurnText).not.toHaveBeenCalled(); }); }); diff --git a/tests/features/chat/application/conversation/slash-command-executor.test.ts b/tests/features/chat/application/conversation/slash-command-executor.test.ts index 5eff26e1..695e7b11 100644 --- a/tests/features/chat/application/conversation/slash-command-executor.test.ts +++ b/tests/features/chat/application/conversation/slash-command-executor.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../../../../src/app-server/connection/client"; -import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protocol/turn"; import type { CodexInput } from "../../../../../src/domain/chat/input"; import type { Thread } from "../../../../../src/domain/threads/model"; import { @@ -28,13 +26,12 @@ type SlashCommandExecutorHostOverrides = Partial; function createHost(overrides: SlashCommandExecutorHostOverrides = {}) { const stateStore = createChatStateStore(createChatState()); - const threadTurnsList = vi.fn().mockResolvedValue({ data: [] }); - const client = { threadTurnsList } as unknown as AppServerClient; const compactThread = vi.fn().mockResolvedValue(undefined); + const referThread = vi.fn().mockResolvedValue(null); const host: SlashCommandExecutorHost = { stateStore, - currentClient: () => client, - codexInput: vi.fn((text: string) => textInput(text)), + connectionAvailable: () => true, + referThread, startNewThread: vi.fn().mockResolvedValue(undefined), startThreadForGoal: vi.fn().mockResolvedValue("thread-new"), resumeThread: vi.fn().mockResolvedValue(undefined), @@ -71,7 +68,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) { effortStatusLines: () => [], ...overrides, }; - return { compactThread, host, stateStore, threadTurnsList }; + return { compactThread, host, referThread, stateStore }; } describe("executeSlashCommandWithState", () => { @@ -106,7 +103,7 @@ describe("executeSlashCommandWithState", () => { }); it("routes compact through the shared thread action port before a client is connected", async () => { - const { compactThread, host, stateStore } = createHost({ currentClient: () => null }); + const { compactThread, host, stateStore } = createHost({ connectionAvailable: () => false }); stateStore.dispatch({ type: "active-thread/resumed", thread: thread("thread", "Thread"), @@ -132,7 +129,7 @@ describe("executeSlashCommandWithState", () => { }); it("runs reconnect even when there is no current app-server client", async () => { - const { host } = createHost({ currentClient: () => null }); + const { host } = createHost({ connectionAvailable: () => false }); await executeSlashCommandWithState(host, "reconnect", ""); @@ -140,7 +137,7 @@ describe("executeSlashCommandWithState", () => { }); it("reports unreadable referenced threads", async () => { - const { host, stateStore, threadTurnsList } = createHost(); + const { host, referThread, stateStore } = createHost(); stateStore.dispatch({ type: "thread-list/applied", threads: [thread("other", "Other")], @@ -148,47 +145,30 @@ describe("executeSlashCommandWithState", () => { const result = await executeSlashCommandWithState(host, "refer", "Other summarize"); - expect(threadTurnsList).toHaveBeenCalledWith("other", null, 20); + expect(referThread).toHaveBeenCalledWith(expect.objectContaining({ id: "other" }), "summarize"); expect(result).toBeUndefined(); - expect(host.addSystemMessage).toHaveBeenCalledWith("Referenced thread has no readable conversation turns."); }); - it("sets chat-owned status copy for readable referenced threads", async () => { - const { host, stateStore, threadTurnsList } = createHost(); + it("forwards readable referenced thread input to turn submission", async () => { + const { host, referThread, stateStore } = createHost(); stateStore.dispatch({ type: "thread-list/applied", threads: [thread("019abcde-0000-7000-8000-000000000001", "Other")], }); - threadTurnsList.mockResolvedValue({ - data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])], - nextCursor: null, + referThread.mockResolvedValue({ + input: textInput("referenced summarize"), + referencedThread: { + threadId: "019abcde-0000-7000-8000-000000000001", + title: "Other", + includedTurns: 1, + turnLimit: 20, + }, }); const result = await executeSlashCommandWithState(host, "refer", "Other summarize"); expect(result?.sendText).toBe("summarize"); - expect(host.setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns)."); + expect(result?.sendInput).toEqual(textInput("referenced summarize")); + expect(result?.referencedThread?.title).toBe("Other"); }); }); - -function turn(items: TurnRecord["items"], overrides: Partial = {}): TurnRecord { - return { - id: "turn", - items, - itemsView: "full", - status: "completed", - error: null, - startedAt: null, - completedAt: null, - durationMs: null, - ...overrides, - }; -} - -function userMessage(id: string, text: string): TurnItem { - return { type: "userMessage", id, clientId: null, content: [{ type: "text", text, text_elements: [] }] }; -} - -function agentMessage(id: string, text: string): TurnItem { - return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null }; -} diff --git a/tests/features/chat/application/conversation/turn-submission-actions.test.ts b/tests/features/chat/application/conversation/turn-submission-actions.test.ts index c68d4a94..803be32e 100644 --- a/tests/features/chat/application/conversation/turn-submission-actions.test.ts +++ b/tests/features/chat/application/conversation/turn-submission-actions.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import type { CodexInput } from "../../../../../src/domain/chat/input"; import type { Thread } from "../../../../../src/domain/threads/model"; import { optimisticTurnStart } from "../../../../../src/features/chat/application/conversation/optimistic-turn-start"; @@ -30,16 +29,16 @@ type TurnSubmissionHostOverrides = Partial; function createHost(overrides: TurnSubmissionHostOverrides = {}) { const stateStore = createChatStateStore(createChatState()); - const startTurn = vi.fn().mockResolvedValue({ turn: { id: "turn" } }); - const steerTurn = vi.fn().mockResolvedValue({}); - const client = { - startTurn, - steerTurn, - } as unknown as AppServerClient; + const startTurn = vi.fn().mockResolvedValue({ turnId: "turn" }); + const steerTurn = vi.fn().mockResolvedValue(true); const host: TurnSubmissionActionsHost = { stateStore, - vaultPath: "/vault", - connectedClient: vi.fn().mockResolvedValue(client), + turnTransport: { + ensureConnected: vi.fn().mockResolvedValue(true), + startTurn, + steerTurn, + interruptTurn: vi.fn().mockResolvedValue(true), + }, ensureRestoredThreadLoaded: vi.fn().mockResolvedValue(true), startThread: vi.fn().mockImplementation(async () => { resumeThread(stateStore); @@ -82,7 +81,6 @@ describe("TurnSubmissionActions", () => { expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); expect(startTurn).toHaveBeenCalledWith({ threadId: "thread", - cwd: "/vault", input: textInput("hello"), clientUserMessageId: expect.stringMatching(/^local-user-\d+-[A-Za-z0-9_-]+-[a-z0-9]+$/), }); @@ -133,15 +131,15 @@ describe("TurnSubmissionActions", () => { await actions.sendTurnText("follow up"); - expect(steerTurn).toHaveBeenCalledWith( - "thread", - "turn", - textInput("follow up"), - expect.stringMatching(/^local-steer-\d+-[A-Za-z0-9_-]+-[a-z0-9]+$/), - ); + expect(steerTurn).toHaveBeenCalledWith({ + threadId: "thread", + turnId: "turn", + input: textInput("follow up"), + clientUserMessageId: expect.stringMatching(/^local-steer-\d+-[A-Za-z0-9_-]+-[a-z0-9]+$/), + }); expect(startTurn).not.toHaveBeenCalled(); expect(host.setStatus).toHaveBeenCalledWith("Steered current turn."); - const localSteerId = steerTurn.mock.calls[0]?.[3]; + const localSteerId = steerTurn.mock.calls[0]?.[0].clientUserMessageId; expect( chatStateMessageStreamItems(stateStore.getState()).some( (item) => item.kind === "message" && item.id === localSteerId && item.text === "follow up", diff --git a/tests/features/chat/application/runtime/settings-actions.test.ts b/tests/features/chat/application/runtime/settings-actions.test.ts index 6b758758..2fd6b6c2 100644 --- a/tests/features/chat/application/runtime/settings-actions.test.ts +++ b/tests/features/chat/application/runtime/settings-actions.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import type { ModelMetadata } from "../../../../../src/domain/catalog/metadata"; import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config"; import { type ChatRuntimeSettingsActions, createChatRuntimeSettingsActions, } from "../../../../../src/features/chat/application/runtime/settings-actions"; +import type { RuntimeSettingsTransport } from "../../../../../src/features/chat/application/runtime/settings-transport"; import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot"; import type { ActiveThreadSettingsAppliedAction } from "../../../../../src/features/chat/application/state/actions"; import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer"; @@ -17,11 +17,11 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; const controller = createChatRuntimeSettingsActions({ stateStore: store, - currentClient: () => client as AppServerClient, + runtimeTransport: transport, runtimeSnapshotForState: runtimeSnapshotFixture, collaborationModeLabel: () => "Plan", addSystemMessage: (text) => messages.push(text), @@ -29,7 +29,7 @@ describe("createChatRuntimeSettingsActions", () => { await expect(controller.requestModel("gpt-5.5")).resolves.toBe(true); - expect(client.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" }); + expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" }); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); expect(store.getState().runtime.active.model).toBe("gpt-5.5"); expect(messages).toEqual([]); @@ -37,9 +37,9 @@ describe("createChatRuntimeSettingsActions", () => { it("reserves thread runtime settings when no thread is active", async () => { const store = createChatStateStore(chatStateFixture()); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await expect(controller.requestModel("gpt-5.5")).resolves.toBe(true); await expect(controller.requestReasoningEffort("high")).resolves.toBe(true); @@ -48,7 +48,7 @@ describe("createChatRuntimeSettingsActions", () => { await expect(controller.setCollaborationMode("plan")).resolves.toBe(true); controller.requestDefaultCollaborationModeForNextTurn(); - expect(client.updateThreadSettings).not.toHaveBeenCalled(); + expect(transport.updateThreadSettings).not.toHaveBeenCalled(); expect(store.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" }); expect(store.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); expect(store.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); @@ -66,13 +66,13 @@ describe("createChatRuntimeSettingsActions", () => { state = chatStateWith(state, { activeThread: { id: "thread" } }); state = chatStateWith(state, { ui: { toolbarPanel: "status-panel" } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await controller.toggleFastMode(); - expect(client.updateThreadSettings).toHaveBeenCalledWith("thread", { serviceTier: "fast" }); + expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { serviceTier: "fast" }); expect(store.getState().runtime.pending.fastMode).toEqual({ kind: "unchanged" }); expect(store.getState().runtime.active.serviceTier).toBe("fast"); expect(store.getState().ui.toolbarPanel).toBeNull(); @@ -83,16 +83,16 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await controller.enableFastMode(); store.dispatch({ type: "active-thread/settings-applied", ...threadSettings("fast") }); await controller.disableFastMode(); - expect(client.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { serviceTier: "fast" }); - expect(client.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { serviceTier: null }); + expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { serviceTier: "fast" }); + expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { serviceTier: null }); expect(messages).toEqual(["Fast mode on for subsequent turns.", "Fast mode off for subsequent turns."]); }); @@ -102,19 +102,19 @@ describe("createChatRuntimeSettingsActions", () => { state = chatStateWith(state, { connection: { runtimeConfig: { ...emptyRuntimeConfigSnapshot(), serviceTier: "fast" } } }); state = chatStateWith(state, { runtime: { active: { serviceTier: "fast" } } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await controller.disableFastMode(); - expect(client.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: null }); + expect(transport.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: null }); expect(store.getState().runtime.active.serviceTier).toBeNull(); expect(store.getState().runtime.active.serviceTierKnown).toBe(true); await controller.toggleFastMode(); - expect(client.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: "fast" }); + expect(transport.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: "fast" }); expect(messages).toEqual(["Fast mode off for subsequent turns.", "Fast mode on for subsequent turns."]); }); @@ -126,19 +126,19 @@ describe("createChatRuntimeSettingsActions", () => { // last verified against codex app-server 0.142.0. state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-5.5", "priority")] } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await controller.toggleFastMode(); - expect(client.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: "priority" }); + expect(transport.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: "priority" }); expect(store.getState().runtime.active.serviceTier).toBe("priority"); store.dispatch({ type: "active-thread/settings-applied", ...threadSettings("priority") }); await controller.toggleFastMode(); - expect(client.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: null }); + expect(transport.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: null }); expect(store.getState().runtime.active.serviceTier).toBeNull(); expect(messages).toEqual(["Fast mode on for subsequent turns.", "Fast mode off for subsequent turns."]); }); @@ -147,13 +147,13 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await expect(controller.setCollaborationMode("plan")).resolves.toBe(true); - expect(client.updateThreadSettings).not.toHaveBeenCalled(); + expect(transport.updateThreadSettings).not.toHaveBeenCalled(); expect(store.getState().runtime.pending.collaborationMode).toBe("plan"); expect(store.getState().runtime.active.collaborationMode).toBeNull(); expect(messages).toEqual(["Plan mode is selected, but No effective model is available. Sending without a mode override."]); @@ -165,13 +165,13 @@ describe("createChatRuntimeSettingsActions", () => { state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } }); state = chatStateWith(state, { runtime: { pending: { collaborationMode: "plan" } } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); controller.requestDefaultCollaborationModeForNextTurn(); - expect(client.updateThreadSettings).not.toHaveBeenCalled(); + expect(transport.updateThreadSettings).not.toHaveBeenCalled(); expect(store.getState().runtime.pending.collaborationMode).toBe("default"); expect(store.getState().runtime.active.collaborationMode).toBe("plan"); expect(messages).toEqual([]); @@ -190,11 +190,11 @@ describe("createChatRuntimeSettingsActions", () => { }, }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; const controller = createChatRuntimeSettingsActions({ stateStore: store, - currentClient: () => client as AppServerClient, + runtimeTransport: transport, runtimeSnapshotForState: (state) => ({ ...runtimeSnapshotFixture(state), runtimeConfig: null }), collaborationModeLabel: () => "Plan", addSystemMessage: (text) => messages.push(text), @@ -202,7 +202,7 @@ describe("createChatRuntimeSettingsActions", () => { await expect(controller.setCollaborationMode("plan")).resolves.toBe(true); - expect(client.updateThreadSettings).toHaveBeenCalledWith("thread", { + expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { collaborationMode: { mode: "plan", settings: { @@ -219,9 +219,9 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const store = createChatStateStore(state); - const client = clientFixture({ updateThreadSettings: vi.fn().mockRejectedValue(new Error("nope")) }); + const transport = settingsTransportFixture({ updateThreadSettings: vi.fn().mockRejectedValue(new Error("nope")) }); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await expect(controller.requestModel("gpt-5.5")).resolves.toBe(false); @@ -230,14 +230,30 @@ describe("createChatRuntimeSettingsActions", () => { expect(messages).toEqual(["nope"]); }); + it("leaves pending runtime intent in place when the runtime transport is unavailable", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { activeThread: { id: "thread" } }); + const store = createChatStateStore(state); + const transport = settingsTransportFixture({ updateThreadSettings: vi.fn().mockResolvedValue(false) }); + const messages: string[] = []; + const controller = runtimeControllerFixture(store, transport, messages); + + await expect(controller.requestModel("gpt-5.5")).resolves.toBe(false); + + expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" }); + expect(store.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" }); + expect(store.getState().runtime.active.model).toBeNull(); + expect(messages).toEqual([]); + }); + it("keeps the runtime panel open when a toolbar runtime update fails", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); state = chatStateWith(state, { ui: { toolbarPanel: "status-panel" } }); const store = createChatStateStore(state); - const client = clientFixture({ updateThreadSettings: vi.fn().mockRejectedValue(new Error("nope")) }); + const transport = settingsTransportFixture({ updateThreadSettings: vi.fn().mockRejectedValue(new Error("nope")) }); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await controller.enableFastMode(); @@ -250,18 +266,18 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const store = createChatStateStore(state); - const client = clientFixture({ + const transport = settingsTransportFixture({ updateThreadSettings: vi.fn().mockImplementation(async () => { store.dispatch({ type: "active-thread/cleared" }); - return {}; + return true; }), }); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await expect(controller.requestModel("gpt-5.5")).resolves.toBe(false); - expect(client.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" }); + expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" }); expect(store.getState().activeThread.id).toBeNull(); expect(store.getState().runtime.active.model).toBeNull(); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); @@ -272,14 +288,14 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const store = createChatStateStore(state); - const client = clientFixture({ + const transport = settingsTransportFixture({ updateThreadSettings: vi.fn().mockImplementation(async () => { store.dispatch({ type: "active-thread/cleared" }); throw new Error("nope"); }), }); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await expect(controller.requestModel("gpt-5.5")).resolves.toBe(false); @@ -293,29 +309,29 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const store = createChatStateStore(state); - const firstUpdate = deferred({}); - const secondUpdate = deferred({}); - const client = clientFixture({ + const firstUpdate = deferred(true); + const secondUpdate = deferred(true); + const transport = settingsTransportFixture({ updateThreadSettings: vi .fn() .mockImplementationOnce(() => firstUpdate.promise) .mockImplementationOnce(() => secondUpdate.promise), }); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); const firstRequest = controller.requestModel("gpt-old"); - expect(client.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { model: "gpt-old" }); + expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { model: "gpt-old" }); const secondRequest = controller.requestModel("gpt-new"); - expect(client.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { model: "gpt-new" }); + expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { model: "gpt-new" }); - secondUpdate.resolve({}); + secondUpdate.resolve(); await expect(secondRequest).resolves.toBe(true); expect(store.getState().runtime.active.model).toBe("gpt-new"); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); - firstUpdate.resolve({}); + firstUpdate.resolve(); await expect(firstRequest).resolves.toBe(false); expect(store.getState().runtime.active.model).toBe("gpt-new"); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); @@ -327,13 +343,13 @@ describe("createChatRuntimeSettingsActions", () => { state = chatStateWith(state, { activeThread: { id: "thread" } }); state = chatStateWith(state, { runtime: { active: { model: "gpt-5.5" } } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await expect(controller.resetModelToConfig()).resolves.toBe(true); - expect(client.updateThreadSettings).toHaveBeenCalledWith("thread", { model: null }); + expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { model: null }); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); expect(store.getState().runtime.active.model).toBeNull(); }); @@ -342,39 +358,37 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const store = createChatStateStore(state); - const client = clientFixture(); + const transport = settingsTransportFixture(); const messages: string[] = []; - const controller = runtimeControllerFixture(store, client, messages); + const controller = runtimeControllerFixture(store, transport, messages); await controller.enableAutoReview(); store.dispatch({ type: "active-thread/settings-applied", ...threadSettings(null, "auto_review") }); await controller.disableAutoReview(); - expect(client.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { approvalsReviewer: "auto_review" }); - expect(client.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { approvalsReviewer: "user" }); + expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { approvalsReviewer: "auto_review" }); + expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { approvalsReviewer: "user" }); expect(messages).toEqual(["Auto-review on for subsequent turns.", "Auto-review off for subsequent turns."]); }); }); function runtimeControllerFixture( store: ReturnType, - client: Pick, + transport: RuntimeSettingsTransport, messages: string[], ): ChatRuntimeSettingsActions { return createChatRuntimeSettingsActions({ stateStore: store, - currentClient: () => client as AppServerClient, + runtimeTransport: transport, runtimeSnapshotForState: runtimeSnapshotFixture, collaborationModeLabel: () => "Plan", addSystemMessage: (text) => messages.push(text), }); } -function clientFixture( - overrides: Partial> = {}, -): Pick { +function settingsTransportFixture(overrides: Partial = {}): RuntimeSettingsTransport { return { - updateThreadSettings: vi.fn().mockResolvedValue({}), + updateThreadSettings: vi.fn().mockResolvedValue(true), ...overrides, }; } diff --git a/tests/features/chat/application/threads/goal-actions.test.ts b/tests/features/chat/application/threads/goal-actions.test.ts index 4d0e8d31..d23203be 100644 --- a/tests/features/chat/application/threads/goal-actions.test.ts +++ b/tests/features/chat/application/threads/goal-actions.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { createGoalActions } from "../../../../../src/features/chat/application/threads/goal-actions"; +import type { ThreadGoalTransport } from "../../../../../src/features/chat/application/threads/goal-transport"; import { createLocalIdSource } from "../../../../../src/shared/id/local-id"; import { deferred } from "../../../../support/async"; import { chatStateFixture, chatStateWith } from "../../support/state"; @@ -14,13 +14,12 @@ describe("createGoalActions", () => { state = chatStateWith(state, { activeThread: { id: "thread" } }); const stateStore = createChatStateStore(state); const currentGoal = goal(); - const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: currentGoal }) } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ readThreadGoal: vi.fn().mockResolvedValue(currentGoal) }); const refreshLiveState = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), @@ -38,12 +37,11 @@ describe("createGoalActions", () => { state = chatStateWith(state, { activeThread: { id: "thread" } }); const stateStore = createChatStateStore(state); const addSystemMessage = vi.fn(); - const client = { getThreadGoal: vi.fn().mockRejectedValue(new Error("offline")) } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ readThreadGoal: vi.fn().mockRejectedValue(new Error("offline")) }); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), @@ -64,19 +62,17 @@ describe("createGoalActions", () => { const stateStore = createChatStateStore(state); const updated = goal({ objective: "Updated", tokenBudget: 250 }); const paused = goal({ objective: "Updated", status: "paused", tokenBudget: 250 }); - const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: updated }).mockResolvedValueOnce({ goal: paused }); - const clearThreadGoal = vi.fn().mockResolvedValue({ cleared: true }); - const client = { - setThreadGoal, - clearThreadGoal, - } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ + setThreadGoal: vi.fn().mockResolvedValueOnce(updated).mockResolvedValueOnce(paused), + clearThreadGoal: vi.fn().mockResolvedValue(true), + }); + const { setThreadGoal, clearThreadGoal } = goalTransport; const addSystemMessage = vi.fn(); const addGoalEvent = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent, @@ -103,13 +99,12 @@ describe("createGoalActions", () => { state = chatStateWith(state, { activeThread: { goal: goal() } }); const stateStore = createChatStateStore(state); const update = deferred(); - const client = { setThreadGoal: vi.fn().mockReturnValue(update.promise) } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ setThreadGoal: vi.fn().mockReturnValue(update.promise) }); const addSystemMessage = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), @@ -131,13 +126,12 @@ describe("createGoalActions", () => { state = chatStateWith(state, { activeThread: { goal: goal() } }); const stateStore = createChatStateStore(state); const clear = deferred(); - const client = { clearThreadGoal: vi.fn().mockReturnValue(clear.promise) } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ clearThreadGoal: vi.fn().mockReturnValue(clear.promise) }); const addSystemMessage = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), @@ -157,16 +151,13 @@ describe("createGoalActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const stateStore = createChatStateStore(state); - const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal() }); - const injectThreadItems = vi.fn().mockResolvedValue({}); - const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ setThreadGoal: vi.fn().mockResolvedValueOnce(goal()) }); const addSystemMessage = vi.fn(); const addGoalEvent = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent, @@ -185,21 +176,14 @@ describe("createGoalActions", () => { objective: "Finish", }), ); - expect(injectThreadItems).toHaveBeenCalledWith("thread", [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "Finish" }], - }, - ]); + expect(goalTransport.recordThreadGoalUserMessage).toHaveBeenCalledWith("thread", "Finish"); }); it("starts a thread before saving a new goal objective when no thread is active", async () => { const stateStore = createChatStateStore(chatStateFixture()); const savedGoal = goal({ threadId: "thread-new", objective: "Plan release" }); - const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: savedGoal }); - const injectThreadItems = vi.fn().mockResolvedValue({}); - const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ setThreadGoal: vi.fn().mockResolvedValueOnce(savedGoal) }); + const { setThreadGoal } = goalTransport; const startThread = vi.fn().mockImplementation(async () => { stateStore.dispatch({ type: "active-thread/resumed", @@ -214,9 +198,8 @@ describe("createGoalActions", () => { }); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread, addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), @@ -227,25 +210,18 @@ describe("createGoalActions", () => { expect(startThread).toHaveBeenCalledWith("Plan release", { syncGoal: false }); expect(setThreadGoal).toHaveBeenCalledWith("thread-new", { objective: "Plan release", status: "active", tokenBudget: null }); - expect(injectThreadItems).toHaveBeenCalledWith("thread-new", [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "Plan release" }], - }, - ]); + expect(goalTransport.recordThreadGoalUserMessage).toHaveBeenCalledWith("thread-new", "Plan release"); }); it("rejects empty goal objective saves before connecting or starting a thread", async () => { const stateStore = createChatStateStore(chatStateFixture()); - const connectedClient = vi.fn().mockResolvedValue({ setThreadGoal: vi.fn() } as unknown as AppServerClient); + const goalTransport = goalTransportFixture(); const startThread = vi.fn().mockResolvedValue({ threadId: "thread" }); const addSystemMessage = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => ({ setThreadGoal: vi.fn() }) as unknown as AppServerClient, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient, startThread, addSystemMessage, addGoalEvent: vi.fn(), @@ -255,7 +231,7 @@ describe("createGoalActions", () => { await expect(controller.saveObjective(" ", null)).resolves.toBe(false); expect(addSystemMessage).toHaveBeenCalledWith("Goal objective cannot be empty."); - expect(connectedClient).not.toHaveBeenCalled(); + expect(goalTransport.ensureConnected).not.toHaveBeenCalled(); expect(startThread).not.toHaveBeenCalled(); }); @@ -263,15 +239,15 @@ describe("createGoalActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const stateStore = createChatStateStore(state); - const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal() }); - const injectThreadItems = vi.fn().mockRejectedValue(new Error("offline")); - const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ + setThreadGoal: vi.fn().mockResolvedValueOnce(goal()), + recordThreadGoalUserMessage: vi.fn().mockRejectedValue(new Error("offline")), + }); const addSystemMessage = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), @@ -287,18 +263,18 @@ describe("createGoalActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); const stateStore = createChatStateStore(state); - const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal() }); - const injectThreadItems = vi.fn().mockImplementation(async () => { - stateStore.dispatch({ type: "active-thread/cleared" }); - throw new Error("offline"); + const goalTransport = goalTransportFixture({ + setThreadGoal: vi.fn().mockResolvedValueOnce(goal()), + recordThreadGoalUserMessage: vi.fn().mockImplementation(async () => { + stateStore.dispatch({ type: "active-thread/cleared" }); + throw new Error("offline"); + }), }); - const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient; const addSystemMessage = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), @@ -315,15 +291,12 @@ describe("createGoalActions", () => { state = chatStateWith(state, { activeThread: { id: "thread" } }); state = chatStateWith(state, { activeThread: { goal: goal() } }); const stateStore = createChatStateStore(state); - const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal({ objective: "Updated" }) }); - const injectThreadItems = vi.fn().mockResolvedValue({}); - const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ setThreadGoal: vi.fn().mockResolvedValueOnce(goal({ objective: "Updated" })) }); const addGoalEvent = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent, @@ -333,7 +306,7 @@ describe("createGoalActions", () => { await controller.setObjective("thread", "Updated", null); expect(addGoalEvent).toHaveBeenCalledWith(expect.objectContaining({ kind: "goal", text: "updated: Updated", objective: "Updated" })); - expect(injectThreadItems).not.toHaveBeenCalled(); + expect(goalTransport.recordThreadGoalUserMessage).not.toHaveBeenCalled(); }); it("reports goal resume as a user-visible state change", async () => { @@ -341,15 +314,13 @@ describe("createGoalActions", () => { state = chatStateWith(state, { activeThread: { id: "thread" } }); state = chatStateWith(state, { activeThread: { goal: goal({ status: "paused" }) } }); const stateStore = createChatStateStore(state); - const setThreadGoal = vi.fn().mockResolvedValueOnce({ goal: goal() }); - const client = { setThreadGoal } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ setThreadGoal: vi.fn().mockResolvedValueOnce(goal()) }); const addSystemMessage = vi.fn(); const addGoalEvent = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent, @@ -367,13 +338,12 @@ describe("createGoalActions", () => { state = chatStateWith(state, { activeThread: { id: "thread" } }); const stateStore = createChatStateStore(state); const currentGoal = goal(); - const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: currentGoal }) } as unknown as AppServerClient; + const goalTransport = goalTransportFixture({ readThreadGoal: vi.fn().mockResolvedValue(currentGoal) }); const addSystemMessage = vi.fn(); const controller = createGoalActions({ stateStore, - currentClient: () => client, + goalTransport, localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), - connectedClient: vi.fn().mockResolvedValue(client), startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), @@ -400,3 +370,14 @@ function goal(overrides: Partial = {}): ThreadGoal { ...overrides, }; } + +function goalTransportFixture(overrides: Partial = {}): ThreadGoalTransport { + return { + readThreadGoal: vi.fn().mockResolvedValue(null), + setThreadGoal: vi.fn().mockResolvedValue(goal()), + clearThreadGoal: vi.fn().mockResolvedValue(true), + recordThreadGoalUserMessage: vi.fn().mockResolvedValue(true), + ensureConnected: vi.fn().mockResolvedValue(true), + ...overrides, + }; +} diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index e0408d98..fd3bef92 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -1,59 +1,85 @@ +import type { Mock } from "vitest"; 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 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 { 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>; - createFolder: ReturnType>; - createMarkdownFile: ReturnType>; +interface ThreadMutationTransportMock { + compactThread: Mock; + forkThread: Mock; + rollbackForkedThread: Mock; + rollbackThread: Mock; +} + +interface ThreadOperationsMock { + archiveThread: Mock; + renameThread: Mock; +} + +type ThreadManagementActionsHostMock = Omit< + ThreadManagementActionsHost, + | "addSystemMessage" + | "applyThreadCatalogEvent" + | "notifyActiveThreadIdentityChanged" + | "openThreadInCurrentPanel" + | "openThreadInNewView" + | "operations" + | "refreshAfterThreadMutation" + | "setComposerText" + | "setStatus" + | "threadTransport" +> & { + operations: ThreadOperationsMock; + threadTransport: ThreadMutationTransportMock; + addSystemMessage: Mock; + setStatus: Mock; + setComposerText: Mock; + openThreadInNewView: Mock; + openThreadInCurrentPanel: Mock; + notifyActiveThreadIdentityChanged: Mock; + refreshAfterThreadMutation: Mock; + applyThreadCatalogEvent: Mock; }; describe("thread management actions", () => { it("requests thread compaction and reports the shared status", async () => { - const client = clientMock(); - const host = hostMock({ client, items: [] }); + const host = hostMock({ items: [] }); const controller = threadManagementActions(host); await controller.compactThread("source"); - expect(host.connectedClient).toHaveBeenCalledOnce(); - expect(host.ensureConnected).toHaveBeenCalledOnce(); - expect(client.compactThread).toHaveBeenCalledWith("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 client = clientMock(); - const host = hostMock({ client, items: [] }); + const host = hostMock({ items: [] }); const controller = threadManagementActions(host); await controller.compactActiveThread(); expect(host.addSystemMessage).toHaveBeenCalledWith("No active thread to compact."); - expect(client.compactThread).not.toHaveBeenCalled(); + expect(host.threadTransport.compactThread).not.toHaveBeenCalled(); }); it("does not report compaction completion after the panel switches threads", async () => { - const compact = deferred(); - const client = clientMock(); - client.compactThread.mockReturnValue(compact.promise); - const host = hostMock({ client, items: [] }); + const compact = deferred(); + const host = hostMock({ items: [] }); + host.threadTransport.compactThread.mockReturnValue(compact.promise); host.stateStore.dispatch({ type: "active-thread/resumed", thread: panelThread("source"), @@ -67,7 +93,7 @@ describe("thread management actions", () => { const pendingCompact = controller.compactThread("source"); await waitForAsyncWork(() => { - expect(client.compactThread).toHaveBeenCalledWith("source"); + expect(host.threadTransport.compactThread).toHaveBeenCalledWith("source"); }); host.stateStore.dispatch({ type: "active-thread/resumed", @@ -78,112 +104,62 @@ describe("thread management actions", () => { serviceTier: null, approvalsReviewer: null, }); - compact.resolve(undefined); + 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 after the current client changes", async () => { - const compact = deferred(); - 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: [] }); + it("does not report compaction completion when the transport rejects the mutation", async () => { + const host = hostMock({ + items: [], + threadTransport: { + compactThread: vi.fn().mockResolvedValue(false), + }, + }); const controller = threadManagementActions(host); - const pendingCompact = controller.compactThread("source"); - await waitForAsyncWork(() => { - expect(firstClient.compactThread).toHaveBeenCalledWith("source"); - }); - currentClient = secondClient; - compact.resolve(undefined); - await pendingCompact; + 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("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}}", - }, - }); + it("delegates archive requests to thread operations", async () => { + const host = hostMock({ items: [] }); const controller = threadManagementActions(host); - await controller.archiveThread("source"); + await controller.archiveThread("source", true); - 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)); + expect(host.operations.archiveThread).toHaveBeenCalledWith("source", { saveMarkdown: true }); + expect(host.addSystemMessage).not.toHaveBeenCalled(); }); - 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() }); + it("reports archive operation failures", async () => { const host = hostMock({ - client, items: [], - archiveDestination: destination, - settings: { - archiveExportEnabled: true, - archiveExportFolderTemplate: "Archive", - archiveExportFilenameTemplate: "{{title}} {{shortId}}", + operations: { + archiveThread: vi.fn().mockRejectedValue(new Error("disk full")), }, }); 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.operations.archiveThread).toHaveBeenCalledWith("source", {}); 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 host = hostMock({ 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.threadTransport.forkThread).toHaveBeenCalledWith("source"); + expect(host.threadTransport.rollbackForkedThread).toHaveBeenCalledWith("forked", 2); expect(host.applyThreadCatalogEvent).toHaveBeenCalledWith({ type: "thread-forked", thread: expect.objectContaining({ @@ -194,72 +170,54 @@ describe("thread management actions", () => { }), }); expect(host.openThreadInNewView).toHaveBeenCalledWith("forked"); - expect(client.archiveThread).not.toHaveBeenCalled(); + expect(host.operations.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() }); + 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"); + 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({ - client, items: turnItems(), - archiveDestination: destination, - settings: { - archiveExportEnabled: true, - archiveExportFolderTemplate: "Archive", - archiveExportFilenameTemplate: "{{title}} {{shortId}}", + operations: { + archiveThread: vi.fn().mockRejectedValue(new Error("archive failed")), }, }); 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.threadTransport.rollbackForkedThread).not.toHaveBeenCalled(); + expect(host.operations.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() }); + 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(client.archiveThread).toHaveBeenCalledWith("source"); - expect(host.notifyThreadArchived).toHaveBeenCalledWith("source"); + 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: ThreadRecord }>(); - const client = clientMock(); - client.forkThread.mockReturnValue(fork.promise); - const host = hostMock({ client, items: turnItems() }); + const fork = deferred(); + const host = hostMock({ items: turnItems() }); + host.threadTransport.forkThread.mockReturnValue(fork.promise); host.stateStore.dispatch({ type: "active-thread/resumed", thread: panelThread("source"), @@ -278,7 +236,7 @@ describe("thread management actions", () => { const pendingFork = controller.forkThreadFromTurn("source", null, true); await waitForAsyncWork(() => { - expect(client.forkThread).toHaveBeenCalledWith("source", "/vault"); + expect(host.threadTransport.forkThread).toHaveBeenCalledWith("source"); }); host.stateStore.dispatch({ type: "active-thread/resumed", @@ -289,71 +247,55 @@ describe("thread management actions", () => { serviceTier: null, approvalsReviewer: null, }); - fork.resolve({ thread: { ...archivedThread(), id: "forked", sessionId: "forked", name: null } }); + fork.resolve(panelThread("forked")); await pendingFork; - expect(client.archiveThread).not.toHaveBeenCalled(); - expect(client.setThreadName).not.toHaveBeenCalled(); + expect(host.operations.archiveThread).not.toHaveBeenCalled(); + expect(host.operations.renameThread).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); + it("does not open or record fork responses when the transport has no result", async () => { const host = hostMock({ - client: firstClient, - currentClient: () => currentClient as unknown as AppServerClient, items: turnItems(), + threadTransport: { + forkThread: vi.fn().mockResolvedValue(null), + }, }); 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; + await controller.forkThreadFromTurn("source", null, false); expect(host.applyThreadCatalogEvent).not.toHaveBeenCalled(); expect(host.openThreadInNewView).not.toHaveBeenCalled(); - expect(firstClient.archiveThread).not.toHaveBeenCalled(); + expect(host.operations.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" }] }); + 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.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"); + expect(host.operations.renameThread).toHaveBeenCalledWith("thread", " Slash command title "); }); - it("ignores empty thread rename titles", async () => { - const client = clientMock(); - const host = hostMock({ client, items: [] }); + it("returns false when thread operations reject a rename", async () => { + const host = hostMock({ + items: [], + operations: { + renameThread: vi.fn().mockResolvedValue(false), + }, + }); 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(); + expect(host.operations.renameThread).toHaveBeenCalledWith("thread", " "); }); - it("applies rollback response turns before refreshing shared thread state", async () => { - const client = clientMock(); - const host = hostMock({ client, items: turnItems() }); + it("applies rollback response items before refreshing shared thread state", async () => { + const host = hostMock({ items: turnItems() }); host.stateStore.dispatch({ type: "active-thread/resumed", thread: panelThread("source"), @@ -373,7 +315,7 @@ describe("thread management actions", () => { await controller.rollbackThread("source"); - expect(client.rollbackThread).toHaveBeenCalledWith("source"); + expect(host.threadTransport.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" }, @@ -383,10 +325,9 @@ describe("thread management actions", () => { }); 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() }); + const rollback = deferred(); + const host = hostMock({ items: turnItems() }); + host.threadTransport.rollbackThread.mockReturnValue(rollback.promise); host.stateStore.dispatch({ type: "active-thread/resumed", thread: panelThread("source"), @@ -406,7 +347,7 @@ describe("thread management actions", () => { const pendingRollback = controller.rollbackThread("source"); await waitForAsyncWork(() => { - expect(client.rollbackThread).toHaveBeenCalledWith("source"); + expect(host.threadTransport.rollbackThread).toHaveBeenCalledWith("source"); }); host.stateStore.dispatch({ type: "active-thread/resumed", @@ -417,7 +358,7 @@ describe("thread management actions", () => { serviceTier: null, approvalsReviewer: null, }); - rollback.resolve({ thread: rollbackThread() }); + rollback.resolve(rollbackSnapshot()); await pendingRollback; expect(host.stateStore.getState().activeThread.id).toBe("other"); @@ -426,16 +367,12 @@ describe("thread management actions", () => { 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); + it("ignores rollback when the transport has no result", async () => { const host = hostMock({ - client: firstClient, - currentClient: () => currentClient as unknown as AppServerClient, items: turnItems(), + threadTransport: { + rollbackThread: vi.fn().mockResolvedValue(null), + }, }); host.stateStore.dispatch({ type: "active-thread/resumed", @@ -454,13 +391,7 @@ describe("thread management actions", () => { }); const controller = threadManagementActions(host); - const pendingRollback = controller.rollbackThread("source"); - await waitForAsyncWork(() => { - expect(firstClient.rollbackThread).toHaveBeenCalledWith("source"); - }); - currentClient = secondClient; - rollback.resolve({ thread: rollbackThread() }); - await pendingRollback; + await controller.rollbackThread("source"); expect(host.setComposerText).not.toHaveBeenCalled(); expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled(); @@ -503,15 +434,19 @@ function turnItems(): MessageStreamItem[] { ]; } -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 rollbackItems(): MessageStreamItem[] { + return [ + { id: "kept-user", kind: "message", messageKind: "user", role: "user", text: "kept prompt", turnId: "kept-turn" }, + { + id: "kept-agent", + kind: "message", + role: "assistant", + text: "kept answer", + turnId: "kept-turn", + messageKind: "assistantResponse", + messageState: "completed", + }, + ]; } function threadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions { @@ -519,131 +454,59 @@ function threadManagementActions(host: ThreadManagementActionsHost): ThreadManag } function hostMock({ - client, items, - archiveDestination = archiveDestinationMock(), - settings = {}, - currentClient, + operations: operationOverrides = {}, + threadTransport: transportOverrides = {}, }: { - client: ReturnType; items: MessageStreamItem[]; - archiveDestination?: ArchiveExportDestination; - settings?: Partial; - currentClient?: () => AppServerClient | null; -}) { + operations?: Partial; + threadTransport?: Partial; +}): ThreadManagementActionsHostMock { 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))(); - }); + const threadTransport: ThreadMutationTransportMock = { + compactThread: vi.fn().mockResolvedValue(true), + forkThread: vi.fn().mockResolvedValue(panelThread("forked")), + rollbackForkedThread: vi.fn().mockResolvedValue( + panelThread("forked", { + name: "Rolled Back Thread", + preview: "Post-rollback", + updatedAt: 20, + }), + ), + rollbackThread: vi.fn().mockResolvedValue(rollbackSnapshot()), + ...transportOverrides, + }; + const operations: ThreadOperationsMock = { + archiveThread: vi.fn().mockResolvedValue({ exportedPath: null }), + renameThread: vi.fn().mockResolvedValue(true), + ...operationOverrides, + }; 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(), + threadTransport, + operations, + addSystemMessage: vi.fn(), + setStatus: vi.fn(), + setComposerText: vi.fn(), + openThreadInNewView: vi.fn().mockResolvedValue(undefined), + openThreadInCurrentPanel: vi.fn().mockResolvedValue(undefined), + notifyActiveThreadIdentityChanged: vi.fn(), + refreshAfterThreadMutation: vi.fn().mockResolvedValue(undefined), + applyThreadCatalogEvent: vi.fn(), }; } -function archiveDestinationMock(overrides: Partial = {}): MockArchiveExportDestination { +function rollbackSnapshot(overrides: Partial = {}): ThreadRollbackSnapshot { return { - normalizePath: (path) => path, - exists: vi.fn().mockResolvedValue(false), - createFolder: vi.fn().mockResolvedValue(undefined), - createMarkdownFile: vi.fn().mockResolvedValue(undefined), + thread: panelThread("source", { name: "Rolled Back Thread" }), + cwd: "/vault", + items: rollbackItems(), ...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) { +function panelThread(id: string, overrides: Partial = {}): Thread { return { id, preview: "", @@ -651,10 +514,11 @@ function panelThread(id: string) { updatedAt: 0, name: null, archived: false, + ...overrides, }; } -function callOrder(fn: ReturnType): number { +function callOrder(fn: Mock): number { const order = fn.mock.invocationCallOrder[0]; if (order === undefined) throw new Error("Expected function to be called."); return order; diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index acf6b274..6b82d56b 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -3,10 +3,6 @@ import { describe, expect, it } from "vitest"; import { type ConfigReadResult, runtimeConfigSnapshotFromAppServerConfig } from "../../src/app-server/protocol/runtime-config"; import type { ModelMetadata } from "../../src/domain/catalog/metadata"; import { type RuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../src/domain/runtime/config"; -import { - pendingRuntimeSettingsPatch, - serviceTierRequestForThreadStart, -} from "../../src/features/chat/app-server/runtime/thread-settings-update"; import { resetRuntimeIntentToConfig, setRuntimeIntentValue } from "../../src/features/chat/domain/runtime/intent"; import { compactReasoningEffortLabel, @@ -15,6 +11,10 @@ import { } from "../../src/features/chat/domain/runtime/labels"; import { resolveRuntimeControls } from "../../src/features/chat/domain/runtime/resolution"; import type { RuntimeSnapshot } from "../../src/features/chat/domain/runtime/snapshot"; +import { + pendingRuntimeSettingsPatch, + serviceTierRequestForThreadStart, +} from "../../src/features/chat/domain/runtime/thread-settings-patch"; import { contextSummary, rateLimitSummary } from "../../src/features/chat/presentation/runtime/status"; describe("runtime settings", () => { diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 89697399..7c82fbeb 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -16,6 +16,8 @@ const projectPluginByName = new Map( ); const APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE = "Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models."; +const CHAT_APPLICATION_ROOT_APP_SERVER_MESSAGE = + "Chat application modules must not import root app-server modules; use chat app-server transports or application ports instead."; const CHAT_APPLICATION_OUTER_LAYER_MESSAGE = "Chat application modules must not import host, panel, presentation, or UI layers; expose state and workflow contracts instead."; const CHAT_APP_SERVER_OUTER_LAYER_MESSAGE = "Chat app-server adapters must not import chat host, panel, presentation, or UI layers."; @@ -392,6 +394,7 @@ export function timestamp(): number { it("keeps chat folder ownership boundaries explicit without filename-scoped Grit checks", async () => { const cwd = await tempBiomeWorkspace([ "no-chat-application-outer-layer-imports.grit", + "no-chat-application-root-app-server-imports.grit", "no-chat-app-server-outer-layer-imports.grit", "no-chat-panel-runtime-boundary-imports.grit", "no-chat-presentation-outer-layer-imports.grit", @@ -413,8 +416,17 @@ export const values = [statusText, Toolbar] satisfies unknown[]; path.join(cwd, "src/features/chat/application/allowed.ts"), ` import type { MessageStreamItem } from "../domain/message-stream/items"; +import type { ChatThreadHistoryPage } from "../app-server/threads/projection"; -export type Item = MessageStreamItem; +export type Item = MessageStreamItem | ChatThreadHistoryPage; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/application/root-app-server.ts"), + ` +import type { AppServerClient } from "../../../app-server/connection/client"; + +export type Escape = AppServerClient; `.trimStart(), ); await writeFile( @@ -510,6 +522,7 @@ export const value = statusText; [ "src/features/chat/application/outer.ts", "src/features/chat/application/allowed.ts", + "src/features/chat/application/root-app-server.ts", "src/features/chat/app-server/outer.ts", "src/features/chat/app-server/allowed.ts", "src/features/chat/panel/outer.tsx", @@ -526,6 +539,7 @@ export const value = statusText; Array.from({ length: 4 }, () => CHAT_APPLICATION_OUTER_LAYER_MESSAGE), ); expect(pluginDiagnostics(report, "src/features/chat/application/allowed.ts")).toEqual([]); + expect(pluginMessages(report, "src/features/chat/application/root-app-server.ts")).toEqual([CHAT_APPLICATION_ROOT_APP_SERVER_MESSAGE]); expect(pluginMessages(report, "src/features/chat/app-server/outer.ts")).toEqual( Array.from({ length: 4 }, () => CHAT_APP_SERVER_OUTER_LAYER_MESSAGE), );