mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Move chat app-server client access out of application
This commit is contained in:
parent
0f428292f8
commit
2f187d7e94
38 changed files with 1052 additions and 719 deletions
|
|
@ -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/**"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
33
src/features/chat/app-server/client-scope.ts
Normal file
33
src/features/chat/app-server/client-scope.ts
Normal 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;
|
||||
}
|
||||
36
src/features/chat/app-server/goals/transport.ts
Normal file
36
src/features/chat/app-server/goals/transport.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
33
src/features/chat/app-server/threads/transport.ts
Normal file
33
src/features/chat/app-server/threads/transport.ts
Normal 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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
36
src/features/chat/app-server/turns/transport.ts
Normal file
36
src/features/chat/app-server/turns/transport.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
25
src/features/chat/application/conversation/turn-transport.ts
Normal file
25
src/features/chat/application/conversation/turn-transport.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
|
||||
|
||||
export interface RuntimeSettingsTransport {
|
||||
updateThreadSettings(threadId: string, update: RuntimeSettingsPatch): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
9
src/features/chat/application/threads/goal-transport.ts
Normal file
9
src/features/chat/application/threads/goal-transport.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
230
tests/features/chat/app-server/transports.test.ts
Normal file
230
tests/features/chat/app-server/transports.test.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue