Move chat app-server client access out of application

This commit is contained in:
murashit 2026-06-27 17:11:25 +09:00
parent 0f428292f8
commit 2f187d7e94
38 changed files with 1052 additions and 719 deletions

View file

@ -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/**"]

View file

@ -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.

View file

@ -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.

View file

@ -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")
}

View file

@ -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 {

View file

@ -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<AppServerClient | null>;
}
export function chatAppServerClientIsStale(host: CurrentChatAppServerClientHost, client: AppServerClient): boolean {
return host.currentClient() !== client;
}
export async function withConnectedChatAppServerClient<T>(
host: ConnectedChatAppServerClientHost,
operation: (client: AppServerClient) => Promise<T>,
): Promise<T | null> {
const client = await host.connectedClient();
if (!client) return null;
const result = await operation(client);
return chatAppServerClientIsStale(host, client) ? null : result;
}
export async function withCurrentChatAppServerClient<T>(
host: CurrentChatAppServerClientHost,
operation: (client: AppServerClient) => Promise<T>,
): Promise<T | null> {
const client = host.currentClient();
if (!client) return null;
const result = await operation(client);
return chatAppServerClientIsStale(host, client) ? null : result;
}

View file

@ -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;
},
};
}

View file

@ -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<ThreadReferenceInput | null>;
}
export function createThreadReferenceResolver(host: ThreadReferenceResolverHost): ThreadReferenceResolver {
return {
referThread: (thread, message) => referencedThreadInput(host, thread, message),
};
}
async function referencedThreadInput(
host: ThreadReferenceResolverHost,
thread: Thread,
message: string,
): Promise<ThreadReferenceInput | null> {
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;
}
}

View file

@ -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;
},
};
}

View file

@ -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<ThreadRollbackSnapshot> => {
const snapshot = await rollbackThread(client, threadId);
return {
thread: snapshot.thread,
cwd: snapshot.cwd,
items: messageStreamItemsFromTurns(snapshot.turns),
};
}),
};
}

View file

@ -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);
},
};
}

View file

@ -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<void>;
};
connection: {
connectedClient: () => Promise<AppServerClient | null>;
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<boolean>;
};
turnTransport: Pick<ChatTurnTransport, "interruptTurn">;
status: {
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
@ -54,7 +54,7 @@ async function sendMessage(host: ComposerSubmitActionsHost): Promise<void> {
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<void> {
async function interruptTurn(host: ComposerSubmitActionsHost): Promise<void> {
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));

View file

@ -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<AppServerClient | null>;
};
connectionAvailable: () => boolean;
turnTransport: ChatTurnTransport;
referThread: (thread: Thread, message: string) => Promise<ThreadReferenceInput | null>;
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,

View file

@ -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<AppServerClient | null>;
ensureConnected(): Promise<boolean>;
sendTurnText(text: string): Promise<void>;
requestDefaultCollaborationModeForNextTurn(): void;
}
@ -29,7 +28,7 @@ export function implementPlanTargetFromState(state: {
export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise<void> {
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 });

View file

@ -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<ThreadReferenceInput | null>;
setStatus: (status: string) => void;
}
@ -31,16 +27,12 @@ export async function executeSlashCommandWithState(
args: string,
): Promise<SlashCommandExecutionResult | undefined> {
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<ChatStateStore["getState"]>
);
return supportedEffortsForModelMetadata(model);
}
async function referencedThreadInput(
host: SlashCommandExecutorHost,
client: ThreadConversationSummaryClient,
thread: Thread,
message: string,
): Promise<ThreadReferenceInput | null> {
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;
}
}

View file

@ -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<AppServerClient | null>;
turnTransport: ChatTurnTransport;
ensureRestoredThreadLoaded: () => Promise<boolean>;
startThread: (preview?: string) => Promise<unknown>;
notifyActiveThreadIdentityChanged: () => void;
@ -57,8 +56,7 @@ async function sendTurnText(
codexInputOverride?: CodexInput,
referencedThread?: ReferencedThreadMetadata,
): Promise<void> {
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<TurnSubmissionPlan, { kind: "steer" }>,
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",

View file

@ -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<boolean>;
startTurn(request: ChatTurnStartRequest): Promise<ChatTurnStartResult | null>;
steerTurn(request: ChatTurnSteerRequest): Promise<boolean>;
interruptTurn(threadId: string, turnId: string): Promise<boolean>;
}

View file

@ -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<RuntimeSettingsCommitResult> {
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 };

View file

@ -0,0 +1,5 @@
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
export interface RuntimeSettingsTransport {
updateThreadSettings(threadId: string, update: RuntimeSettingsPatch): Promise<boolean>;
}

View file

@ -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<AppServerClient | null>;
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<void> {
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<boolean> {
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<boole
}
async function setGoal(host: GoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise<boolean> {
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<GoalObjectiveSavePlan, { kind: "start-thread-and-save" }>,
): Promise<boolean> {
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<void> {
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)}`);
}

View file

@ -0,0 +1,9 @@
import type { ThreadGoal, ThreadGoalUpdate } from "../../../../domain/threads/goal";
export interface ThreadGoalTransport {
readThreadGoal(threadId: string): Promise<ThreadGoal | null | undefined>;
setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoal | null | undefined>;
clearThreadGoal(threadId: string): Promise<boolean>;
recordThreadGoalUserMessage(threadId: string, objective: string): Promise<boolean>;
ensureConnected(): Promise<boolean>;
}

View file

@ -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<ThreadManagementClient | null>;
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<void>;
}
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<v
}
async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
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<ThreadManagementOperationScope | null> {
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;
}

View file

@ -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<boolean>;
forkThread(threadId: string): Promise<Thread | null>;
rollbackForkedThread(threadId: string, turnsToDrop: number): Promise<Thread | null>;
rollbackThread(threadId: string): Promise<ThreadRollbackSnapshot | null>;
}

View file

@ -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<T> =
| { readonly kind: "unchanged" }
| { readonly kind: "set"; readonly value: T }

View file

@ -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<T> = { readonly kind: "omit" } | { readonly kind: "clear" } | { readonly kind: "set"; readonly value: T };
type RuntimeSettingsPatchIntent<T> = { 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<T>(intent: PendingRuntimeIntent<T>): RuntimeTransportIntent<T> {
function runtimeSettingsPatchIntentFromPending<T>(intent: PendingRuntimeIntent<T>): RuntimeSettingsPatchIntent<T> {
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<string> {
): RuntimeSettingsPatchIntent<string> {
// 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<T>(intent: RuntimeTransportIntent<T>): T | null | undefined {
function runtimeSettingsPatchValue<T>(intent: RuntimeSettingsPatchIntent<T>): T | null | undefined {
if (intent.kind === "set") return intent.value;
if (intent.kind === "clear") return null;
return undefined;

View file

@ -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) => {

View file

@ -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);

View file

@ -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,

View file

@ -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<void>();
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> = {}): 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 };
}

View file

@ -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();

View file

@ -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();
});
});

View file

@ -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<SlashCommandExecutorHost>;
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> = {}): 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 };
}

View file

@ -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<TurnSubmissionActionsHost>;
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",

View file

@ -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<typeof createChatStateStore>,
client: Pick<AppServerClient, "updateThreadSettings">,
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<AppServerClient, "updateThreadSettings">> = {},
): Pick<AppServerClient, "updateThreadSettings"> {
function settingsTransportFixture(overrides: Partial<RuntimeSettingsTransport> = {}): RuntimeSettingsTransport {
return {
updateThreadSettings: vi.fn().mockResolvedValue({}),
updateThreadSettings: vi.fn().mockResolvedValue(true),
...overrides,
};
}

View file

@ -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<never>();
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<never>();
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> = {}): ThreadGoal {
...overrides,
};
}
function goalTransportFixture(overrides: Partial<ThreadGoalTransport> = {}): 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,
};
}

View file

@ -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<typeof vi.fn<ArchiveExportDestination["exists"]>>;
createFolder: ReturnType<typeof vi.fn<ArchiveExportDestination["createFolder"]>>;
createMarkdownFile: ReturnType<typeof vi.fn<ArchiveExportDestination["createMarkdownFile"]>>;
interface ThreadMutationTransportMock {
compactThread: Mock<ThreadMutationTransport["compactThread"]>;
forkThread: Mock<ThreadMutationTransport["forkThread"]>;
rollbackForkedThread: Mock<ThreadMutationTransport["rollbackForkedThread"]>;
rollbackThread: Mock<ThreadMutationTransport["rollbackThread"]>;
}
interface ThreadOperationsMock {
archiveThread: Mock<ThreadManagementActionsHost["operations"]["archiveThread"]>;
renameThread: Mock<ThreadManagementActionsHost["operations"]["renameThread"]>;
}
type ThreadManagementActionsHostMock = Omit<
ThreadManagementActionsHost,
| "addSystemMessage"
| "applyThreadCatalogEvent"
| "notifyActiveThreadIdentityChanged"
| "openThreadInCurrentPanel"
| "openThreadInNewView"
| "operations"
| "refreshAfterThreadMutation"
| "setComposerText"
| "setStatus"
| "threadTransport"
> & {
operations: ThreadOperationsMock;
threadTransport: ThreadMutationTransportMock;
addSystemMessage: Mock<ThreadManagementActionsHost["addSystemMessage"]>;
setStatus: Mock<ThreadManagementActionsHost["setStatus"]>;
setComposerText: Mock<ThreadManagementActionsHost["setComposerText"]>;
openThreadInNewView: Mock<ThreadManagementActionsHost["openThreadInNewView"]>;
openThreadInCurrentPanel: Mock<ThreadManagementActionsHost["openThreadInCurrentPanel"]>;
notifyActiveThreadIdentityChanged: Mock<ThreadManagementActionsHost["notifyActiveThreadIdentityChanged"]>;
refreshAfterThreadMutation: Mock<ThreadManagementActionsHost["refreshAfterThreadMutation"]>;
applyThreadCatalogEvent: Mock<ThreadManagementActionsHost["applyThreadCatalogEvent"]>;
};
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<undefined>();
const client = clientMock();
client.compactThread.mockReturnValue(compact.promise);
const host = hostMock({ client, items: [] });
const compact = deferred<boolean>();
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<undefined>();
const firstClient = clientMock();
const secondClient = clientMock();
let currentClient = firstClient;
firstClient.compactThread.mockReturnValue(compact.promise);
const host = hostMock({ client: firstClient, currentClient: () => currentClient as unknown as AppServerClient, items: [] });
it("does not report compaction completion when the transport rejects the mutation", async () => {
const host = hostMock({
items: [],
threadTransport: {
compactThread: vi.fn<ThreadMutationTransport["compactThread"]>().mockResolvedValue(false),
},
});
const controller = threadManagementActions(host);
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<ThreadManagementActionsHost["operations"]["archiveThread"]>().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<ThreadManagementActionsHost["operations"]["archiveThread"]>().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<Thread | null>();
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<ThreadMutationTransport["forkThread"]>().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<ThreadManagementActionsHost["operations"]["renameThread"]>().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<ThreadRollbackSnapshot | null>();
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<ThreadMutationTransport["rollbackThread"]>().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<typeof clientMock>;
items: MessageStreamItem[];
archiveDestination?: ArchiveExportDestination;
settings?: Partial<typeof DEFAULT_SETTINGS>;
currentClient?: () => AppServerClient | null;
}) {
operations?: Partial<ThreadOperationsMock>;
threadTransport?: Partial<ThreadMutationTransportMock>;
}): 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<ThreadMutationTransport["compactThread"]>().mockResolvedValue(true),
forkThread: vi.fn<ThreadMutationTransport["forkThread"]>().mockResolvedValue(panelThread("forked")),
rollbackForkedThread: vi.fn<ThreadMutationTransport["rollbackForkedThread"]>().mockResolvedValue(
panelThread("forked", {
name: "Rolled Back Thread",
preview: "Post-rollback",
updatedAt: 20,
}),
),
rollbackThread: vi.fn<ThreadMutationTransport["rollbackThread"]>().mockResolvedValue(rollbackSnapshot()),
...transportOverrides,
};
const operations: ThreadOperationsMock = {
archiveThread: vi.fn<ThreadManagementActionsHost["operations"]["archiveThread"]>().mockResolvedValue({ exportedPath: null }),
renameThread: vi.fn<ThreadManagementActionsHost["operations"]["renameThread"]>().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<ThreadManagementActionsHost["addSystemMessage"]>(),
setStatus: vi.fn<ThreadManagementActionsHost["setStatus"]>(),
setComposerText: vi.fn<ThreadManagementActionsHost["setComposerText"]>(),
openThreadInNewView: vi.fn<ThreadManagementActionsHost["openThreadInNewView"]>().mockResolvedValue(undefined),
openThreadInCurrentPanel: vi.fn<ThreadManagementActionsHost["openThreadInCurrentPanel"]>().mockResolvedValue(undefined),
notifyActiveThreadIdentityChanged: vi.fn<ThreadManagementActionsHost["notifyActiveThreadIdentityChanged"]>(),
refreshAfterThreadMutation: vi.fn<ThreadManagementActionsHost["refreshAfterThreadMutation"]>().mockResolvedValue(undefined),
applyThreadCatalogEvent: vi.fn<ThreadManagementActionsHost["applyThreadCatalogEvent"]>(),
};
}
function archiveDestinationMock(overrides: Partial<MockArchiveExportDestination> = {}): MockArchiveExportDestination {
function rollbackSnapshot(overrides: Partial<ThreadRollbackSnapshot> = {}): ThreadRollbackSnapshot {
return {
normalizePath: (path) => path,
exists: vi.fn<ArchiveExportDestination["exists"]>().mockResolvedValue(false),
createFolder: vi.fn<ArchiveExportDestination["createFolder"]>().mockResolvedValue(undefined),
createMarkdownFile: vi.fn<ArchiveExportDestination["createMarkdownFile"]>().mockResolvedValue(undefined),
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> = {}): Thread {
return {
id,
preview: "",
@ -651,10 +514,11 @@ function panelThread(id: string) {
updatedAt: 0,
name: null,
archived: false,
...overrides,
};
}
function callOrder(fn: ReturnType<typeof vi.fn>): 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;

View file

@ -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", () => {

View file

@ -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),
);