diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index 1c0f0387..7eff9a81 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -119,7 +119,7 @@ export interface AppServerStartStructuredTurnOptions { runtime?: AppServerTurnRuntimeOverrides; } -export class AppServerRpcError extends Error { +class AppServerRpcError extends Error { readonly code?: number; readonly data?: unknown; readonly method: ClientRequestMethod; diff --git a/src/app-server/protocol/turn.ts b/src/app-server/protocol/turn.ts index 8632bbb4..74b8b722 100644 --- a/src/app-server/protocol/turn.ts +++ b/src/app-server/protocol/turn.ts @@ -11,7 +11,7 @@ import { export type TurnItem = GeneratedTurnItem; export type TurnRecord = GeneratedTurnRecord; -export function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] { +function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] { return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn)); } diff --git a/src/app-server/services/rollout-token-usage.ts b/src/app-server/services/rollout-token-usage.ts index 3e593a60..9733caf1 100644 --- a/src/app-server/services/rollout-token-usage.ts +++ b/src/app-server/services/rollout-token-usage.ts @@ -1,7 +1,7 @@ import type { ThreadTokenUsage, TokenUsageBreakdown } from "../../domain/runtime/metrics"; -export const ROLLOUT_TOKEN_USAGE_READ_TIMEOUT_MS = 2_000; -export const ROLLOUT_TOKEN_USAGE_MAX_BASE64_BYTES = 12 * 1024 * 1024; +const ROLLOUT_TOKEN_USAGE_READ_TIMEOUT_MS = 2_000; +const ROLLOUT_TOKEN_USAGE_MAX_BASE64_BYTES = 12 * 1024 * 1024; export type RolloutReadFileBase64 = (path: string, options: { timeoutMs: number }) => Promise; @@ -23,7 +23,7 @@ export async function recoverRolloutTokenUsage( return text ? parseRolloutTokenUsageJsonl(text) : null; } -export function parseRolloutTokenUsageJsonl(text: string): ThreadTokenUsage | null { +function parseRolloutTokenUsageJsonl(text: string): ThreadTokenUsage | null { const lines = text.split(/\r?\n/); for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index]?.trim(); diff --git a/src/domain/server/diagnostics.ts b/src/domain/server/diagnostics.ts index 7c42ad74..5a2190f6 100644 --- a/src/domain/server/diagnostics.ts +++ b/src/domain/server/diagnostics.ts @@ -146,7 +146,7 @@ export function mcpServerStatusSummariesFromStatuses(servers: readonly McpServer return servers.map(mcpServerStatusSummaryFromStatus); } -export function shortErrorMessage(error: unknown, maxLength = 160): string { +function shortErrorMessage(error: unknown, maxLength = 160): string { const message = error instanceof Error ? error.message : String(error); const compact = message.replace(/\s+/g, " ").trim() || "Codex app-server request failed."; return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact; diff --git a/src/domain/threads/reference.ts b/src/domain/threads/reference.ts index d77c0c11..2593e92a 100644 --- a/src/domain/threads/reference.ts +++ b/src/domain/threads/reference.ts @@ -21,7 +21,7 @@ export interface ReferencedThreadPromptBundle { referencedThread: ReferencedThreadMetadata; } -export function referencedThreadPrompt(thread: Thread, turns: readonly ThreadConversationSummary[], userRequest: string): string { +function referencedThreadPrompt(thread: Thread, turns: readonly ThreadConversationSummary[], userRequest: string): string { const reference = referencedThreadMetadata(thread, turns.length); const envelope = referencedThreadEnvelope(reference, userRequest); diff --git a/src/features/chat/app-server/inbound/routing.ts b/src/features/chat/app-server/inbound/routing.ts index e7984760..56e17ac5 100644 --- a/src/features/chat/app-server/inbound/routing.ts +++ b/src/features/chat/app-server/inbound/routing.ts @@ -235,7 +235,7 @@ export function routeServerNotification(notification: ServerNotification, scope: return { kind: "unhandled", notification }; } -export function isMessageInActiveScope(message: ServerNotification | ServerRequest, scope: ActiveRouteScope): boolean { +function isMessageInActiveScope(message: ServerNotification | ServerRequest, scope: ActiveRouteScope): boolean { const threadId = messageThreadId(message); // Scope identifiers are filters only when both the notification and the // active panel have one. Thread catalog and idle-thread notifications often @@ -248,13 +248,13 @@ export function isMessageInActiveScope(message: ServerNotification | ServerReque return true; } -export function messageThreadId(message: ServerNotification | ServerRequest): string | null { +function messageThreadId(message: ServerNotification | ServerRequest): string | null { if (isServerRequest(message)) return serverRequestScope(message).threadId; if (isServerNotification(message)) return serverNotificationScope(message).threadId; return fallbackMessageScope(message).threadId; } -export function messageTurnId(message: ServerNotification | ServerRequest): string | null { +function messageTurnId(message: ServerNotification | ServerRequest): string | null { if (isServerRequest(message)) return serverRequestScope(message).turnId; if (isServerNotification(message)) return serverNotificationScope(message).turnId; return fallbackMessageScope(message).turnId; diff --git a/src/features/chat/app-server/mappers/message-stream/review-result-items.ts b/src/features/chat/app-server/mappers/message-stream/review-result-items.ts index 49808409..94eaeaa8 100644 --- a/src/features/chat/app-server/mappers/message-stream/review-result-items.ts +++ b/src/features/chat/app-server/mappers/message-stream/review-result-items.ts @@ -186,7 +186,7 @@ function autoReviewActionLabel(action: AutoReviewAction): string { return action.reason ?? "permission request"; } -export function autoReviewExecutionState(status: string): ExecutionState { +function autoReviewExecutionState(status: string): ExecutionState { return executionStateFromStatus(status, AUTO_REVIEW_STATES); } diff --git a/src/features/chat/app-server/mappers/message-stream/turn-items.ts b/src/features/chat/app-server/mappers/message-stream/turn-items.ts index 10e86611..43e2e3ab 100644 --- a/src/features/chat/app-server/mappers/message-stream/turn-items.ts +++ b/src/features/chat/app-server/mappers/message-stream/turn-items.ts @@ -659,7 +659,7 @@ function assertNever(_item: never): null { return null; } -export function commandExecutionState(status: string, exitCode?: number): ExecutionState { +function commandExecutionState(status: string, exitCode?: number): ExecutionState { if (typeof exitCode === "number" && exitCode !== 0) return "failed"; const state = executionStateFromStatus(status, COMMAND_STATES); if (state) return state; @@ -667,15 +667,15 @@ export function commandExecutionState(status: string, exitCode?: number): Execut return null; } -export function patchApplyExecutionState(status: string): ExecutionState { +function patchApplyExecutionState(status: string): ExecutionState { return executionStateFromStatus(status, PATCH_STATES); } -export function mcpToolCallExecutionState(status: string): ExecutionState { +function mcpToolCallExecutionState(status: string): ExecutionState { return standardToolCallExecutionState(status); } -export function dynamicToolCallExecutionState(status: string, success?: boolean | null): ExecutionState { +function dynamicToolCallExecutionState(status: string, success?: boolean | null): ExecutionState { if (success === false) return "failed"; const state = standardToolCallExecutionState(status); if (state) return state; diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index 007e2b00..3d688422 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -187,10 +187,6 @@ export function slashCommandSubcommandDefinition(command: SlashCommandName, subc return slashCommandSubcommands(command).find((item) => item.subcommand === subcommand) ?? null; } -export function slashCommandHelpLines(): string[] { - return SLASH_COMMANDS.map((item) => `${item.usage} - ${item.detail}`); -} - export function slashCommandHelpSections(): { title: string; rows: { key: string; value: string }[] }[] { return (Object.keys(SLASH_COMMAND_SURFACE_LABELS) as SlashCommandSurface[]) .map((surface) => ({ diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index a8a5a257..03e75ecd 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -112,7 +112,7 @@ function activeWikiLinkSuggestions(beforeCursor: string, notes: NoteCandidate[]) return findWikiLinkSuggestions(queryText, start, notes); } -export function findWikiLinkSuggestions(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] { +function findWikiLinkSuggestions(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] { const headingCompletion = wikiLinkHeadingCompletion(queryText, start, notes); if (headingCompletion) return headingCompletion; diff --git a/src/features/chat/application/composer/wikilink-context.ts b/src/features/chat/application/composer/wikilink-context.ts index e9973055..6b6b7f47 100644 --- a/src/features/chat/application/composer/wikilink-context.ts +++ b/src/features/chat/application/composer/wikilink-context.ts @@ -14,7 +14,7 @@ export type WikiLinkMentionResolver = (target: string) => { name: string; path: const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g; const SKILL_REFERENCE_PATTERN = /(^|[\s([{])\$([^\s\])}.,;!?]{1,120})(?=$|[\s\])}.,;!?])/g; -export function parsedWikiLinks(text: string): ParsedWikiLink[] { +function parsedWikiLinks(text: string): ParsedWikiLink[] { const links: ParsedWikiLink[] = []; const seen = new Set(); for (const match of text.matchAll(WIKILINK_PATTERN)) { @@ -31,10 +31,6 @@ export function parsedWikiLinks(text: string): ParsedWikiLink[] { return links; } -export function userInputWithWikiLinkMentions(text: string, resolveMention: WikiLinkMentionResolver) { - return userInputWithWikiLinkMentionsAndSkills(text, resolveMention, []); -} - export function userInputWithWikiLinkMentionsAndSkills( text: string, resolveMention: WikiLinkMentionResolver, @@ -63,7 +59,7 @@ export function userInputWithWikiLinkMentionsAndSkills( return codexTextInputWithMentions(text, mentions, resolvedSkills); } -export function parsedSkillReferences(text: string): string[] { +function parsedSkillReferences(text: string): string[] { const references: string[] = []; const seen = new Set(); for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) { diff --git a/src/features/chat/application/connection/diagnostics-display.ts b/src/features/chat/application/connection/diagnostics-display.ts index 349c626e..c872027c 100644 --- a/src/features/chat/application/connection/diagnostics-display.ts +++ b/src/features/chat/application/connection/diagnostics-display.ts @@ -51,17 +51,6 @@ export function connectionDiagnosticSections(input: ConnectionDiagnosticsInput): ]; } -export function hasDiagnosticIssue(diagnostics: Diagnostics): boolean { - for (const probe of Object.values(diagnostics.probes)) { - if (probe.status === "failed") return true; - } - for (const server of diagnostics.mcpServers) { - if (server.startupStatus === "failed") return true; - if (server.authStatus === "notLoggedIn") return true; - } - return false; -} - function diagnosticProbeRow(probe: DiagnosticProbeResult): DiagnosticRow { const detail = probe.message ? ` - ${probe.message}` : probe.summary ? ` (${probe.summary})` : ""; return { diff --git a/src/features/chat/application/conversation/optimistic-turn-start.ts b/src/features/chat/application/conversation/optimistic-turn-start.ts index c7f7c1a8..178bf9f7 100644 --- a/src/features/chat/application/conversation/optimistic-turn-start.ts +++ b/src/features/chat/application/conversation/optimistic-turn-start.ts @@ -7,7 +7,7 @@ import { attachHookRunsToTurn } from "../../domain/message-stream/updates"; import { isLocalSteerMessageClientId } from "../../domain/local-id"; import type { CodexInput } from "../../../../domain/chat/input"; -export interface LocalUserMessageParams { +interface LocalUserMessageParams { id: string; text: string; copyText?: string; @@ -49,7 +49,7 @@ export interface FailedTurnStartCleanupParams { pendingTurnStart: PendingTurnStart | null; } -export function localUserMessageItem(params: LocalUserMessageParams): MessageStreamMessageItem { +function localUserMessageItem(params: LocalUserMessageParams): MessageStreamMessageItem { const mentionedFiles = params.mentionedFiles ?? []; return { id: params.id, diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/conversation/slash-command-execution.ts index cf17db35..50281f3c 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -269,14 +269,14 @@ function applyReasoningEffortOverride( return requested === null ? context.resetReasoningEffortToConfig() : context.requestReasoningEffort(requested); } -export function parseModelOverride(args: string): string | null | undefined { +function parseModelOverride(args: string): string | null | undefined { const model = args.trim(); if (!model) return undefined; if (DEFAULT_RUNTIME_SETTING_ALIASES.has(model.toLowerCase())) return null; return model; } -export function parseReasoningEffortOverride(args: string): ReasoningEffort | null | undefined { +function parseReasoningEffortOverride(args: string): ReasoningEffort | null | undefined { const effort = args.trim(); if (!effort) return undefined; if (DEFAULT_RUNTIME_SETTING_ALIASES.has(effort.toLowerCase())) return null; diff --git a/src/features/chat/application/lifecycle.ts b/src/features/chat/application/lifecycle.ts index d4d4d50c..5bc547c5 100644 --- a/src/features/chat/application/lifecycle.ts +++ b/src/features/chat/application/lifecycle.ts @@ -13,11 +13,11 @@ export interface RestoredThreadState { export type ChatResumeLifecycleState = { kind: "idle" } | { kind: "resuming"; threadId: string }; export type ActiveChatResume = Extract; -export type ChatResumeLifecycleEvent = { type: "started"; resume: ActiveChatResume } | { type: "invalidated" }; +type ChatResumeLifecycleEvent = { type: "started"; resume: ActiveChatResume } | { type: "invalidated" }; export type ChatConnectionLifecycleState = ConnectionWorkLifecycleState; export type ActiveChatConnection = ActiveConnectionWork; -export type ChatConnectionLifecycleEvent = ConnectionWorkLifecycleEvent; +type ChatConnectionLifecycleEvent = ConnectionWorkLifecycleEvent; export type RestoredThreadLifecycleState = | { kind: "idle" } @@ -84,14 +84,14 @@ export class ChatResumeWorkTracker { } } -export function transitionChatConnectionLifecycle( +function transitionChatConnectionLifecycle( state: ChatConnectionLifecycleState, event: ChatConnectionLifecycleEvent, ): ChatConnectionLifecycleState { return transitionConnectionWorkLifecycle(state, event); } -export function transitionChatResumeLifecycle(state: ChatResumeLifecycleState, event: ChatResumeLifecycleEvent): ChatResumeLifecycleState { +function transitionChatResumeLifecycle(state: ChatResumeLifecycleState, event: ChatResumeLifecycleEvent): ChatResumeLifecycleState { switch (event.type) { case "started": return event.resume; diff --git a/src/features/chat/application/runtime/thread-settings-update.ts b/src/features/chat/application/runtime/thread-settings-update.ts index 891773b4..64f22ba5 100644 --- a/src/features/chat/application/runtime/thread-settings-update.ts +++ b/src/features/chat/application/runtime/thread-settings-update.ts @@ -10,7 +10,7 @@ import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { effectiveCollaborationMode, type PendingRuntimeSetting } from "../../domain/runtime/pending-settings"; import type { TurnCollaborationModeWarning } from "../../domain/runtime/warnings"; -export type TurnCollaborationModeSettings = +type TurnCollaborationModeSettings = | { collaborationMode: NonNullable; warning: null; @@ -35,10 +35,7 @@ export function serviceTierRequestForThreadStart(snapshot: RuntimeSnapshot, conf return config.serviceTier ?? undefined; } -export function requestedTurnCollaborationModeSettings( - snapshot: RuntimeSnapshot, - config: RuntimeConfigSnapshot, -): TurnCollaborationModeSettings { +function requestedTurnCollaborationModeSettings(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): TurnCollaborationModeSettings { const model = currentModel(snapshot, config); const effort = currentReasoningEffort(snapshot, config); if (!model) return { collaborationMode: null, warning: "missing-model" }; diff --git a/src/features/chat/application/state/message-stream.ts b/src/features/chat/application/state/message-stream.ts index 6df366d7..cc192dd5 100644 --- a/src/features/chat/application/state/message-stream.ts +++ b/src/features/chat/application/state/message-stream.ts @@ -98,7 +98,7 @@ export function messageStreamIsEmpty(state: Pick): string[] { +function messageStreamTurnIds(state: Pick): string[] { return orderedTurnIds(messageStreamItems(state)); } diff --git a/src/features/chat/application/threads/resume.ts b/src/features/chat/application/threads/resume.ts index 15e66a76..feecae5e 100644 --- a/src/features/chat/application/threads/resume.ts +++ b/src/features/chat/application/threads/resume.ts @@ -8,7 +8,7 @@ import type { ChatRuntimeState } from "../../domain/runtime/state"; import type { MessageStreamItem } from "../../domain/message-stream/items"; import type { ActiveThreadResumedAction } from "../state/actions"; -export interface ResumedThreadActionParams { +interface ResumedThreadActionParams { response: ThreadActivationSnapshot; listedThreads?: readonly Thread[]; items?: readonly MessageStreamItem[]; @@ -64,7 +64,7 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc }); } -export function resumedThreadAction(params: ResumedThreadActionParams): ActiveThreadResumedAction { +function resumedThreadAction(params: ResumedThreadActionParams): ActiveThreadResumedAction { const { response } = params; return { type: "active-thread/resumed", diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 888ac449..e65efc62 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -131,7 +131,7 @@ export function createThreadManagementActions(context: ThreadManagementActionsCo }; } -export async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise { +async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise { await host.ensureConnected(); const client = host.currentClient(); if (!client) return; @@ -146,7 +146,7 @@ export async function compactThread(host: ThreadManagementActionsHost, threadId: } } -export async function archiveThread( +async function archiveThread( host: ThreadManagementActionsHost, threadId: string, saveMarkdown = host.settings().archiveExportEnabled, @@ -189,7 +189,7 @@ function forkThread(host: ThreadManagementActionsHost, threadId: string): Promis return forkThreadFromTurn(host, threadId, null, false); } -export async function forkThreadFromTurn( +async function forkThreadFromTurn( host: ThreadManagementActionsHost, threadId: string, turnId: string | null, @@ -251,7 +251,7 @@ export async function forkThreadFromTurn( } } -export async function renameThread(host: RenameThreadHost, threadId: string, value: string): Promise { +async function renameThread(host: RenameThreadHost, threadId: string, value: string): Promise { const rename = threadRenameFromValue(value); if (!rename) return false; @@ -278,7 +278,7 @@ export async function renameConnectedThread(host: ConnectedRenameThreadHost, thr } } -export async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise { +async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise { if (chatTurnBusy(threadManagementState(host))) { host.addSystemMessage(interruptBeforeRollbackMessage()); return; diff --git a/src/features/chat/domain/message-stream/factories/task-progress.ts b/src/features/chat/domain/message-stream/factories/task-progress.ts index 7bbed21d..6d7284a7 100644 --- a/src/features/chat/domain/message-stream/factories/task-progress.ts +++ b/src/features/chat/domain/message-stream/factories/task-progress.ts @@ -16,7 +16,7 @@ export interface TaskPlanStep { status: TaskStepStatus; } -export function taskProgressExecutionState(status: string): ExecutionState { +function taskProgressExecutionState(status: string): ExecutionState { return executionStateFromStatus(status, TASK_STATES); } diff --git a/src/features/chat/domain/message-stream/items.ts b/src/features/chat/domain/message-stream/items.ts index 0a71a924..d0c3a77a 100644 --- a/src/features/chat/domain/message-stream/items.ts +++ b/src/features/chat/domain/message-stream/items.ts @@ -143,9 +143,7 @@ interface ProposedPlanMessageStreamItem extends MessageStreamMessageBase { messageState: MessageState; } -export type AssistantAuthoredMessageStreamItem = AssistantResponseMessageStreamItem | ProposedPlanMessageStreamItem; - -export type MessageStreamMessageItem = UserMessageStreamItem | AssistantAuthoredMessageStreamItem; +export type MessageStreamMessageItem = UserMessageStreamItem | AssistantResponseMessageStreamItem | ProposedPlanMessageStreamItem; export interface MessageStreamFileMention { name: string; diff --git a/src/features/chat/domain/message-stream/selectors.ts b/src/features/chat/domain/message-stream/selectors.ts index 79a3e6bb..0e68e4f3 100644 --- a/src/features/chat/domain/message-stream/selectors.ts +++ b/src/features/chat/domain/message-stream/selectors.ts @@ -1,5 +1,5 @@ -import type { AssistantAuthoredMessageStreamItem, MessageStreamItem } from "./items"; -import { messageStreamIsProposedPlan, messageStreamSemanticClassifications } from "./semantics"; +import type { MessageStreamItem } from "./items"; +import { messageStreamSemanticClassifications } from "./semantics"; export interface ForkCandidate { itemId: string; @@ -20,18 +20,10 @@ export function forkCandidatesFromItems(items: readonly MessageStreamItem[]): re return [...turnOutcomeItemsByTurn.values()]; } -export function latestProposedPlanFromItems(items: readonly MessageStreamItem[]): MessageStreamItem | null { - return [...messageStreamSemanticClassifications(items)].reverse().find(messageStreamIsProposedPlan)?.item ?? null; -} - export function latestImplementablePlanFromItems(items: readonly MessageStreamItem[]): MessageStreamItem | null { return [...messageStreamSemanticClassifications(items)].reverse().find((item) => item.actions.canImplementPlan)?.item ?? null; } -export function isAssistantAuthoredMessage(item: MessageStreamItem): item is AssistantAuthoredMessageStreamItem { - return item.kind === "message" && (item.messageKind === "assistantResponse" || item.messageKind === "proposedPlan"); -} - export function isCompletedTurnOutcomeMessage(item: MessageStreamItem): boolean { return messageStreamSemanticClassifications([item])[0]?.actions.isTurnOutcome ?? false; } diff --git a/src/features/chat/domain/message-stream/updates.ts b/src/features/chat/domain/message-stream/updates.ts index 535e59e4..f6fa1332 100644 --- a/src/features/chat/domain/message-stream/updates.ts +++ b/src/features/chat/domain/message-stream/updates.ts @@ -1,12 +1,5 @@ -import { normalizeProposedPlanMarkdown } from "./format/proposed-plan"; -import { isAssistantAuthoredMessage } from "./selectors"; import { messageStreamIsTurnInitiator, messageStreamSemanticClassifications } from "./semantics"; -import { - streamedItemOutputMessageStreamItem, - streamedTextMessageStreamItem, - streamedToolOutputMessageStreamItem, -} from "./factories/streaming-items"; -import type { AssistantAuthoredMessageStreamItem, MessageStreamFileChange, MessageStreamItem, MessageStreamItemKind } from "./items"; +import type { MessageStreamFileChange, MessageStreamItem } from "./items"; export function upsertMessageStreamItemById(items: readonly MessageStreamItem[], next: MessageStreamItem): MessageStreamItem[] { const index = items.findIndex((item) => item.id === next.id); @@ -35,45 +28,6 @@ function mergeChanges(previous: MessageStreamItem, next: MessageStreamItem): Mes return nextChanges && nextChanges.length > 0 ? nextChanges : previousChanges; } -export function appendAssistantDelta( - items: readonly MessageStreamItem[], - sourceItemId: string, - turnId: string, - delta: string, -): MessageStreamItem[] { - const index = items.findIndex( - (item) => item.sourceItemId === sourceItemId && item.kind === "message" && item.messageKind === "assistantResponse", - ); - if (index !== -1) { - return items.map((item, itemIndex) => - itemIndex === index && item.kind === "message" && item.messageKind === "assistantResponse" - ? { - ...item, - text: `${item.text}${delta}`, - copyText: `${item.text}${delta}`, - turnId: item.turnId ?? turnId, - messageState: "streaming", - } - : item, - ); - } - return [ - ...items, - { - id: sourceItemId, - kind: "message", - messageKind: "assistantResponse", - role: "assistant", - text: delta, - copyText: delta, - turnId, - sourceItemId, - provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId }, - messageState: "streaming", - }, - ]; -} - export function completeReasoningItems(items: readonly MessageStreamItem[], turnId: string): MessageStreamItem[] { return items.map((item) => item.kind === "reasoning" && item.turnId === turnId @@ -86,103 +40,6 @@ export function completeReasoningItems(items: readonly MessageStreamItem[], turn ); } -export function appendPlanDelta( - items: readonly MessageStreamItem[], - sourceItemId: string, - turnId: string, - delta: string, -): MessageStreamItem[] { - const index = items.findIndex((item) => item.sourceItemId === sourceItemId && isAssistantAuthoredMessage(item)); - if (index !== -1) { - return items.map((item, itemIndex) => - itemIndex === index && isAssistantAuthoredMessage(item) ? appendPlanDeltaToMessage(item, turnId, delta) : item, - ); - } - const text = normalizeProposedPlanMarkdown(delta); - return [ - ...items, - { - id: sourceItemId, - kind: "message", - messageKind: "proposedPlan", - role: "assistant", - text, - copyText: text, - turnId, - sourceItemId, - provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId }, - messageState: "streaming", - }, - ]; -} - -function appendPlanDeltaToMessage(item: AssistantAuthoredMessageStreamItem, turnId: string, delta: string): MessageStreamItem { - const text = normalizeProposedPlanMarkdown(`${item.text}${delta}`); - return { - ...item, - messageKind: "proposedPlan", - text, - copyText: text, - turnId: item.turnId ?? turnId, - messageState: "streaming", - }; -} - -export function appendItemText( - items: readonly MessageStreamItem[], - sourceItemId: string, - turnId: string, - label: string, - delta: string, - kind: Extract = "tool", -): MessageStreamItem[] { - const index = items.findIndex((item) => item.sourceItemId === sourceItemId); - if (index !== -1) { - return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${"text" in item ? item.text : ""}${delta}` } : item)); - } - return [...items, streamedTextMessageStreamItem({ id: sourceItemId, kind, label, delta, turnId })]; -} - -export function appendToolOutput( - items: readonly MessageStreamItem[], - sourceItemId: string, - turnId: string, - delta: string, - fallbackLabel: string, -): MessageStreamItem[] { - const index = items.findIndex((item) => item.sourceItemId === sourceItemId); - if (index !== -1) { - return items.map((item, itemIndex) => - itemIndex === index && (item.kind === "tool" || item.kind === "hook" || item.kind === "reasoning") - ? { ...item, output: `${item.output ?? ""}${delta}` } - : item, - ); - } - return [...items, streamedToolOutputMessageStreamItem({ id: sourceItemId, turnId, output: delta, fallbackLabel })]; -} - -export function appendItemOutput( - items: readonly MessageStreamItem[], - sourceItemId: string, - turnId: string, - delta: string, - kind: "command" | "fileChange", - fallbackText: string, -): MessageStreamItem[] { - const index = items.findIndex((item) => item.sourceItemId === sourceItemId); - if (index !== -1) { - return items.map((item, itemIndex) => - itemIndex === index && (item.kind === "command" || item.kind === "fileChange") - ? { ...item, output: `${item.output ?? ""}${delta}` } - : item, - ); - } - return [ - ...items, - streamedItemOutputMessageStreamItem({ id: sourceItemId, kind, turnId, output: delta, fallbackText }), - ] as MessageStreamItem[]; -} - export function attachHookRunsToTurn( items: readonly MessageStreamItem[], turnId: string, diff --git a/src/features/chat/domain/runtime/effective.ts b/src/features/chat/domain/runtime/effective.ts index 0309b52b..d33a08bc 100644 --- a/src/features/chat/domain/runtime/effective.ts +++ b/src/features/chat/domain/runtime/effective.ts @@ -55,7 +55,7 @@ export function fastModeActive(snapshot: RuntimeSnapshot, config: RuntimeConfigS return isFastServiceTier(currentServiceTier(snapshot, config), currentModelServiceTiers(snapshot, config)); } -export function isFastServiceTier(value: string | null | undefined, serviceTiers: ModelMetadata["serviceTiers"] = []): boolean { +function isFastServiceTier(value: string | null | undefined, serviceTiers: ModelMetadata["serviceTiers"] = []): boolean { if (!value) return false; if (value === "fast") return true; if (serviceTiers.length === 0) return value === "priority"; diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index c4dbe86d..f7189087 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -155,7 +155,7 @@ function createChatPanelSessionDeferredRef(name: string): ChatPanelSessionDef }; } -export interface ChatPanelWarmupHost { +interface ChatPanelWarmupHost { deferredTasks: ChatViewDeferredTasks; opened: () => boolean; closing: () => boolean; @@ -163,7 +163,7 @@ export interface ChatPanelWarmupHost { ensureConnected: () => Promise; } -export function scheduleChatPanelWarmup(host: ChatPanelWarmupHost): void { +function scheduleChatPanelWarmup(host: ChatPanelWarmupHost): void { const shouldWarmup = (): boolean => host.opened() && !host.connected(); if (!shouldWarmup()) return; @@ -174,7 +174,7 @@ export function scheduleChatPanelWarmup(host: ChatPanelWarmupHost): void { }); } -export function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string { +function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string { if (!activeThreadId) return "Codex"; const thread = threads.find((item) => item.id === activeThreadId); diff --git a/src/features/chat/panel/snapshot.ts b/src/features/chat/panel/snapshot.ts index 6721d338..6cd990ba 100644 --- a/src/features/chat/panel/snapshot.ts +++ b/src/features/chat/panel/snapshot.ts @@ -1,7 +1,5 @@ import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot"; import type { ChatState } from "../application/state/root-reducer"; -import type { MessageStreamItem } from "../domain/message-stream/items"; -import { latestProposedPlanFromItems } from "../domain/message-stream/selectors"; import type { RestoredThreadState } from "../application/lifecycle"; export function openPanelTurnLifecycle(state: ChatState["turn"]["lifecycle"]): OpenCodexPanelSnapshot["turnLifecycle"] { @@ -10,10 +8,6 @@ export function openPanelTurnLifecycle(state: ChatState["turn"]["lifecycle"]): O return { kind: "idle" }; } -export function latestProposedPlanItem(items: readonly MessageStreamItem[]): MessageStreamItem | null { - return latestProposedPlanFromItems(items); -} - export function parseRestoredThreadState(state: unknown): RestoredThreadState | null { if (!state || typeof state !== "object") return null; const record = state as Record; diff --git a/src/features/chat/panel/surface/composer-projection.tsx b/src/features/chat/panel/surface/composer-projection.tsx index 93d02f14..2dd3659c 100644 --- a/src/features/chat/panel/surface/composer-projection.tsx +++ b/src/features/chat/panel/surface/composer-projection.tsx @@ -39,14 +39,14 @@ export interface ChatPanelComposerActions { submit: () => void; } -export interface RuntimeComposerChoicesInput { +interface RuntimeComposerChoicesInput { state: Pick; snapshot: RuntimeSnapshot; requestModel: (model: string) => void; requestReasoningEffort: (effort: ReasoningEffort) => void; } -export function composerPlaceholder(threadName: string | null): string { +function composerPlaceholder(threadName: string | null): string { return threadName ? `Ask Codex to work on “${threadName}”...` : "Ask Codex to work on this task..."; } @@ -80,7 +80,7 @@ export function chatPanelComposerProjection( }; } -export function composerMetaViewModel( +function composerMetaViewModel( state: ComposerMetaState, snapshot: RuntimeSnapshot, ): Omit { @@ -125,7 +125,7 @@ export function composerMetaViewModel( }; } -export function runtimeComposerChoices(input: RuntimeComposerChoicesInput): { +function runtimeComposerChoices(input: RuntimeComposerChoicesInput): { modelChoices: ChatPanelComposerRuntimeChoice[]; effortChoices: ChatPanelComposerRuntimeChoice[]; } { diff --git a/src/features/chat/panel/surface/goal-projection.tsx b/src/features/chat/panel/surface/goal-projection.tsx index 1604e063..216dd30d 100644 --- a/src/features/chat/panel/surface/goal-projection.tsx +++ b/src/features/chat/panel/surface/goal-projection.tsx @@ -6,7 +6,7 @@ import { GoalPanel } from "../../ui/goal"; import { goalStateFromShellState, useChatPanelShellState, type ChatPanelGoalShellState } from "../shell-state"; import type { ChatPanelGoalSurface } from "./model"; -export interface ChatPanelGoalProjection { +interface ChatPanelGoalProjection { goal: ChatPanelGoalShellState["activeThread"]["goal"]; goalThreadId: string | null; editor: GoalPanelEditorState; @@ -18,7 +18,7 @@ export function ChatPanelGoal({ surface }: { surface: ChatPanelGoalSurface }): U return h(GoalPanel, props); } -export function chatPanelGoalProjection(state: ChatPanelGoalShellState): ChatPanelGoalProjection { +function chatPanelGoalProjection(state: ChatPanelGoalShellState): ChatPanelGoalProjection { const goal = state.activeThread.goal; const goalThreadId = goal?.threadId ?? null; const goalEditor = state.ui.goalEditor; @@ -36,7 +36,7 @@ export function chatPanelGoalProjection(state: ChatPanelGoalShellState): ChatPan }; } -export function chatPanelGoalViewModel( +function chatPanelGoalViewModel( surface: ChatPanelGoalSurface, state: ChatPanelGoalShellState, ): { diff --git a/src/features/chat/panel/surface/message-stream-projection.ts b/src/features/chat/panel/surface/message-stream-projection.ts index 4191058d..5166e1f6 100644 --- a/src/features/chat/panel/surface/message-stream-projection.ts +++ b/src/features/chat/panel/surface/message-stream-projection.ts @@ -62,7 +62,7 @@ export interface MessageStreamSurfaceContextOptions { requests: ChatMessageStreamRequests; } -export interface MessageStreamStateProjection { +interface MessageStreamStateProjection { activeThreadId: string | null; turnLifecycle: ChatPanelMessageStreamShellState["turn"]["lifecycle"]; historyCursor: string | null; @@ -102,14 +102,6 @@ export function createMessageStreamSurfaceContext(options: MessageStreamSurfaceC }; } -export function messageStreamContextFromState( - state: ChatPanelMessageStreamShellState, - context: ChatMessageStreamSurfaceContext, -): MessageStreamContext { - const projection = messageStreamStateProjection(state, context.vaultPath); - return messageStreamContextFromProjection(projection, context); -} - export function messageStreamSurfaceProjectionFromState( state: ChatPanelMessageStreamShellState, context: ChatMessageStreamSurfaceContext, @@ -162,7 +154,7 @@ function messageStreamContextFromProjection( }; } -export function messageStreamStateProjection(state: ChatPanelMessageStreamShellState, vaultPath: string): MessageStreamStateProjection { +function messageStreamStateProjection(state: ChatPanelMessageStreamShellState, vaultPath: string): MessageStreamStateProjection { const busy = chatTurnBusy(state); const items = messageStreamItems(state.messageStream); const stableItems = messageStreamStableItems(state.messageStream); diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index 26a2b18a..6394c654 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -14,7 +14,7 @@ import { runtimeSnapshotForToolbarShellState } from "./runtime-snapshot"; type ToolbarState = Pick; -export interface ToolbarViewModelInput { +interface ToolbarViewModelInput { state: ToolbarState; snapshot: RuntimeSnapshot; connected: boolean; @@ -25,7 +25,7 @@ export interface ToolbarViewModelInput { archiveExportEnabled: boolean; } -export interface ToolbarStateProjection { +interface ToolbarStateProjection { newChatDisabled: boolean; chatActionsOpen: boolean; historyOpen: boolean; @@ -58,7 +58,7 @@ export function ChatPanelToolbar({ surface, actions }: { surface: ChatPanelToolb return h(Toolbar, { model: chatPanelToolbarViewModel(surface, state), actions }); } -export function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewModel { +function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewModel { const { state, snapshot } = input; const projection = toolbarStateProjection(input); const limit = rateLimitSummary(snapshot, input.nowMs); @@ -80,9 +80,7 @@ export function chatPanelToolbarProjection(input: ToolbarViewModelInput): Toolba }; } -export function toolbarStateProjection( - input: Pick, -): ToolbarStateProjection { +function toolbarStateProjection(input: Pick): ToolbarStateProjection { const historyOpen = input.state.ui.toolbarPanel === "history"; const chatActionsOpen = input.state.ui.toolbarPanel === "chat-actions"; const statusPanelOpen = input.state.ui.toolbarPanel === "status-panel"; diff --git a/src/features/chat/presentation/runtime/messages.ts b/src/features/chat/presentation/runtime/messages.ts index 462e2f1f..f5f8b5df 100644 --- a/src/features/chat/presentation/runtime/messages.ts +++ b/src/features/chat/presentation/runtime/messages.ts @@ -1,12 +1,6 @@ import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import type { CollaborationMode } from "../../domain/runtime/pending-settings"; -export function compactModelLabel(model: string | null): string { - if (!model) return "default"; - const match = /^gpt-(.+)$/.exec(model); - return match?.[1] ?? model; -} - export function compactReasoningEffortLabel(effort: ReasoningEffort | null): string { if (!effort) return "default"; if (effort === "minimal") return "min"; diff --git a/src/features/chat/presentation/runtime/status.ts b/src/features/chat/presentation/runtime/status.ts index e981a049..90d83b13 100644 --- a/src/features/chat/presentation/runtime/status.ts +++ b/src/features/chat/presentation/runtime/status.ts @@ -73,11 +73,11 @@ export interface EffortStatusLinesInput { const CODEX_DEFAULT_LABEL = "(Codex default)"; const NOT_REPORTED_LABEL = "(not reported)"; -export function serviceTierLabel(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { +function serviceTierLabel(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { return formatServiceTierLabel(currentServiceTier(snapshot, config)); } -export function fastModeLabel(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { +function fastModeLabel(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { return formatFastModeLabel({ requestedOff: snapshot.requestedServiceTier.kind === "set" && snapshot.requestedServiceTier.value === "off", active: fastModeActive(snapshot, config), diff --git a/src/features/chat/ui/message-stream/markdown-renderer.ts b/src/features/chat/ui/message-stream/markdown-renderer.ts index 53a32ba0..00172d89 100644 --- a/src/features/chat/ui/message-stream/markdown-renderer.ts +++ b/src/features/chat/ui/message-stream/markdown-renderer.ts @@ -29,12 +29,12 @@ export class MarkdownMessageRenderer { } } -export interface RenderedMarkdownLinkContext { +interface RenderedMarkdownLinkContext { app: App; vaultPath: string; } -export function bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string, context: RenderedMarkdownLinkContext): void { +function bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string, context: RenderedMarkdownLinkContext): void { parent.querySelectorAll("a.internal-link").forEach((link) => { link.addClass("codex-panel__wikilink"); link.onclick = (event) => { diff --git a/src/features/chat/ui/message-stream/viewport.tsx b/src/features/chat/ui/message-stream/viewport.tsx index dae92fa3..37087c44 100644 --- a/src/features/chat/ui/message-stream/viewport.tsx +++ b/src/features/chat/ui/message-stream/viewport.tsx @@ -51,7 +51,7 @@ export function MessageStreamViewport({ state, rootAttributes }: MessageStreamVi ); } -export function messageStreamVirtualItems( +function messageStreamVirtualItems( virtualItems: { index: number; key: unknown; start: number }[], blocks: readonly MessageStreamBlock[], scrollOffset = 0, diff --git a/src/features/selection-rewrite/output.ts b/src/features/selection-rewrite/output.ts index 4a9b6b7a..b7cf971b 100644 --- a/src/features/selection-rewrite/output.ts +++ b/src/features/selection-rewrite/output.ts @@ -17,7 +17,7 @@ export class SelectionRewriteOutputError extends Error { } } -export function parseSelectionRewriteOutput(text: string): SelectionRewriteOutput | null { +function parseSelectionRewriteOutput(text: string): SelectionRewriteOutput | null { try { const parsed = JSON.parse(text.trim()) as unknown; if (!parsed || typeof parsed !== "object") return null; @@ -29,10 +29,6 @@ export function parseSelectionRewriteOutput(text: string): SelectionRewriteOutpu } } -export function selectionRewriteOutputFromText(text: string | null): SelectionRewriteOutput | null { - return selectionRewriteOutputParseResultFromText(text).output; -} - export function selectionRewriteOutputParseResultFromText(text: string | null): SelectionRewriteOutputParseResult { if (!text) return { output: null, rawText: null }; return { output: parseSelectionRewriteOutput(text), rawText: text }; diff --git a/src/features/selection-rewrite/popover.tsx b/src/features/selection-rewrite/popover.tsx index 85def784..59906f29 100644 --- a/src/features/selection-rewrite/popover.tsx +++ b/src/features/selection-rewrite/popover.tsx @@ -17,7 +17,7 @@ import { SelectionRewriteSession, type SelectionRewriteSessionStatus } from "./s const POPOVER_MARGIN = 8; -export function isSelectionRewriteActionKey(event: { +function isSelectionRewriteActionKey(event: { key: string; shiftKey: boolean; metaKey?: boolean; diff --git a/src/features/selection-rewrite/runner.ts b/src/features/selection-rewrite/runner.ts index 9e0218e4..acf545d9 100644 --- a/src/features/selection-rewrite/runner.ts +++ b/src/features/selection-rewrite/runner.ts @@ -75,16 +75,16 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions): return output; } -export interface SelectionRewriteRuntimeOverride { +interface SelectionRewriteRuntimeOverride { model?: string; effort?: ReasoningEffort; } -export function selectionRewriteRuntimeOverride(settings: SelectionRewriteRuntimeSettings): SelectionRewriteRuntimeOverride { +function selectionRewriteRuntimeOverride(settings: SelectionRewriteRuntimeSettings): SelectionRewriteRuntimeOverride { return runtimeOverride({ model: settings.rewriteSelectionModel, effort: settings.rewriteSelectionEffort }); } -export function validatedSelectionRewriteRuntimeOverride( +function validatedSelectionRewriteRuntimeOverride( settings: SelectionRewriteRuntimeSettings, models: readonly ModelMetadata[], ): SelectionRewriteRuntimeOverride { diff --git a/src/features/thread-operations/archive-markdown.ts b/src/features/thread-operations/archive-markdown.ts index aaeb9604..b12b3438 100644 --- a/src/features/thread-operations/archive-markdown.ts +++ b/src/features/thread-operations/archive-markdown.ts @@ -55,7 +55,7 @@ export async function exportArchivedThreadMarkdown( return { path }; } -export function markdownFromThread(thread: ArchiveThreadInput, exportedAt = new Date(), settings?: Partial): string { +function markdownFromThread(thread: ArchiveThreadInput, exportedAt = new Date(), settings?: Partial): string { const title = exportThreadTitle(thread); const tags = normalizedArchiveTags(settings?.archiveExportTags ?? ""); const lines = [ @@ -74,7 +74,7 @@ export function markdownFromThread(thread: ArchiveThreadInput, exportedAt = new return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath) : markdown; } -export function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string): string { +function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string): string { const lines = markdown.split("\n"); let inFence = false; return lines @@ -105,7 +105,7 @@ function normalizeExportedMarkdownLinksInLine(line: string, vaultPath: string): return output; } -export function normalizedArchiveTags(value: string): string[] { +function normalizedArchiveTags(value: string): string[] { const seen = new Set(); const tags: string[] = []; for (const rawTag of value.split(",")) { diff --git a/src/features/thread-operations/title-generation.ts b/src/features/thread-operations/title-generation.ts index 230ccb4a..6d7e5fba 100644 --- a/src/features/thread-operations/title-generation.ts +++ b/src/features/thread-operations/title-generation.ts @@ -67,21 +67,21 @@ export async function generateThreadTitleWithCodex( return threadTitleFromGenerationTurn(turn); } -export interface ThreadTitleRuntimeOverride { +interface ThreadTitleRuntimeOverride { model?: string; effort?: ReasoningEffort; } -export function threadTitleFromGenerationTurn(turn: TurnRecord): string | null { +function threadTitleFromGenerationTurn(turn: TurnRecord): string | null { const response = conversationAssistantTextFromTurnRecord(turn); return response ? threadTitleFromGeneratedText(response) : null; } -export function threadTitleRuntimeOverride(settings: ThreadTitleRuntimeSettings): ThreadTitleRuntimeOverride { +function threadTitleRuntimeOverride(settings: ThreadTitleRuntimeSettings): ThreadTitleRuntimeOverride { return runtimeOverride({ model: settings.threadNamingModel, effort: settings.threadNamingEffort }); } -export function validatedThreadTitleRuntimeOverride( +function validatedThreadTitleRuntimeOverride( settings: ThreadTitleRuntimeSettings, models: readonly ModelMetadata[], ): ThreadTitleRuntimeOverride { diff --git a/src/features/thread-operations/title-model.ts b/src/features/thread-operations/title-model.ts index 6cb06048..8b89faff 100644 --- a/src/features/thread-operations/title-model.ts +++ b/src/features/thread-operations/title-model.ts @@ -58,7 +58,7 @@ export async function findThreadTitleContext(options: { return null; } -export function normalizeGeneratedThreadTitle(value: unknown): string | null { +function normalizeGeneratedThreadTitle(value: unknown): string | null { if (typeof value !== "string") return null; const title = value .trim() diff --git a/src/features/thread-picker/modal.ts b/src/features/thread-picker/modal.ts index 4aa7be58..edcf13b0 100644 --- a/src/features/thread-picker/modal.ts +++ b/src/features/thread-picker/modal.ts @@ -39,7 +39,7 @@ export async function openThreadPicker(host: ThreadPickerHost): Promise { } } -export function threadPickerSuggestions(threads: readonly Thread[], queryText: string): ThreadSuggestion[] { +function threadPickerSuggestions(threads: readonly Thread[], queryText: string): ThreadSuggestion[] { const query = queryText.trim().toLowerCase(); return [...threads] .sort((a, b) => b.updatedAt - a.updatedAt) @@ -70,7 +70,7 @@ export function threadPickerSuggestions(threads: readonly Thread[], queryText: s })); } -export function threadOpenModeFromEvent(evt: MouseEvent | KeyboardEvent): ThreadOpenMode { +function threadOpenModeFromEvent(evt: MouseEvent | KeyboardEvent): ThreadOpenMode { if (evt instanceof KeyboardEvent && (evt.metaKey || evt.ctrlKey)) return "available"; return "current"; } diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index d21b7b91..0aa4413a 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -4,7 +4,7 @@ import { explicitThreadName, getThreadTitle } from "../../domain/threads/model"; type ThreadsLiveStatus = "needs-input" | "approval" | "running" | "draft" | "offline" | "open"; -export interface ThreadsLiveState { +interface ThreadsLiveState { status: ThreadsLiveStatus; label: string; viewId: string; @@ -65,7 +65,7 @@ export function threadRows( }); } -export function liveStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): ThreadsLiveState | null { +function liveStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): ThreadsLiveState | null { const liveSnapshots = snapshots.filter((snapshot) => snapshot.threadId !== null); if (liveSnapshots.length === 0) return null; const winner = [...liveSnapshots].sort((a, b) => STATUS_PRIORITY[snapshotStatus(b)] - STATUS_PRIORITY[snapshotStatus(a)]).at(0); @@ -79,7 +79,7 @@ export function liveStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): Thre }; } -export function selectedStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): boolean { +function selectedStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): boolean { return snapshots.some((snapshot) => snapshot.threadId !== null && snapshot.lastFocused); } diff --git a/tests/app-server/app-server-client.test.ts b/tests/app-server/app-server-client.test.ts index 4e914509..e4370d6e 100644 --- a/tests/app-server/app-server-client.test.ts +++ b/tests/app-server/app-server-client.test.ts @@ -1,11 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppServerClient } from "../../src/app-server/connection/client"; -import type { - AppServerRpcError, - AppServerStartStructuredTurnOptions, - AppServerStartTurnOptions, -} from "../../src/app-server/connection/client"; +import type { AppServerStartStructuredTurnOptions, AppServerStartTurnOptions } from "../../src/app-server/connection/client"; import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport"; import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages"; import type { InitializeResponse } from "../../src/generated/app-server/InitializeResponse"; @@ -780,7 +776,7 @@ describe("AppServerClient", () => { code: -32601, method: "model/list", message: "Method not found", - } satisfies Partial); + }); }); it("resumes and forks threads without loading full turn history", async () => { diff --git a/tests/app-server/app-server-diagnostics.test.ts b/tests/app-server/app-server-diagnostics.test.ts index d236f8bd..1de7f749 100644 --- a/tests/app-server/app-server-diagnostics.test.ts +++ b/tests/app-server/app-server-diagnostics.test.ts @@ -7,7 +7,6 @@ import { diagnosticProbeError, diagnosticProbeOk, createServerDiagnostics, - shortErrorMessage, upsertMcpServerDiagnostic, } from "../../src/domain/server/diagnostics"; import type { InitializeDiagnostics } from "../../src/domain/server/diagnostics"; @@ -58,8 +57,8 @@ describe("app-server diagnostics", () => { }); it("shortens error messages and tracks MCP server diagnostics", () => { - expect(shortErrorMessage("a\n b\t c")).toBe("a b c"); - expect(shortErrorMessage("x".repeat(200))).toHaveLength(160); + expect(diagnosticProbeError("model/list", "a\n b\t c").message).toBe("a b c"); + expect(diagnosticProbeError("model/list", "x".repeat(200)).message).toHaveLength(160); let diagnostics = upsertMcpServerDiagnostic(createServerDiagnostics(), { name: "github", diff --git a/tests/app-server/rollout-token-usage.test.ts b/tests/app-server/rollout-token-usage.test.ts index c1b10897..51fd778b 100644 --- a/tests/app-server/rollout-token-usage.test.ts +++ b/tests/app-server/rollout-token-usage.test.ts @@ -1,18 +1,17 @@ import { describe, expect, it, vi } from "vitest"; -import { - parseRolloutTokenUsageJsonl, - recoverRolloutTokenUsage, - ROLLOUT_TOKEN_USAGE_MAX_BASE64_BYTES, - ROLLOUT_TOKEN_USAGE_READ_TIMEOUT_MS, -} from "../../src/app-server/services/rollout-token-usage"; +import { recoverRolloutTokenUsage } from "../../src/app-server/services/rollout-token-usage"; describe("rollout token usage recovery", () => { - it("parses the last valid token_count event", () => { + it("parses the last valid token_count event", async () => { const first = tokenCountLine({ input: 100, total: 120, context: 1000 }); const second = tokenCountLine({ input: 250, total: 300, context: 2000 }); - expect(parseRolloutTokenUsageJsonl(["not json", first, '{"type":"response_item","payload":{}}', second, ""].join("\n"))).toEqual({ + const readFileBase64 = vi + .fn() + .mockResolvedValue(btoa(["not json", first, '{"type":"response_item","payload":{}}', second, ""].join("\n"))); + + await expect(recoverRolloutTokenUsage("/tmp/rollout.jsonl", readFileBase64)).resolves.toEqual({ last: { inputTokens: 250, cachedInputTokens: 25, @@ -31,24 +30,27 @@ describe("rollout token usage recovery", () => { }); }); - it("returns null for missing or invalid token usage shapes", () => { - expect(parseRolloutTokenUsageJsonl("")).toBeNull(); - expect(parseRolloutTokenUsageJsonl('{"type":"event_msg","payload":{"type":"agent_message"}}')).toBeNull(); - expect( - parseRolloutTokenUsageJsonl( - JSON.stringify({ - type: "event_msg", - payload: { - type: "token_count", - info: { - last_token_usage: { input_tokens: -1 }, - total_token_usage: {}, - model_context_window: 1000, - }, - }, - }), + it("returns null for missing or invalid token usage shapes", async () => { + const invalidShape = JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { + last_token_usage: { input_tokens: -1 }, + total_token_usage: {}, + model_context_window: 1000, + }, + }, + }); + + await expect(recoverRolloutTokenUsage("/tmp/empty.jsonl", vi.fn().mockResolvedValue(btoa("")))).resolves.toBeNull(); + await expect( + recoverRolloutTokenUsage( + "/tmp/message.jsonl", + vi.fn().mockResolvedValue(btoa('{"type":"event_msg","payload":{"type":"agent_message"}}')), ), - ).toBeNull(); + ).resolves.toBeNull(); + await expect(recoverRolloutTokenUsage("/tmp/invalid.jsonl", vi.fn().mockResolvedValue(btoa(invalidShape)))).resolves.toBeNull(); }); it("recovers usage from an absolute rollout path through app-server file reads", async () => { @@ -58,7 +60,7 @@ describe("rollout token usage recovery", () => { last: { inputTokens: 42, totalTokens: 50 }, modelContextWindow: 1000, }); - expect(readFileBase64).toHaveBeenCalledWith("/tmp/rollout.jsonl", { timeoutMs: ROLLOUT_TOKEN_USAGE_READ_TIMEOUT_MS }); + expect(readFileBase64).toHaveBeenCalledWith("/tmp/rollout.jsonl", { timeoutMs: 2_000 }); }); it("skips relative paths, read failures, invalid base64, and oversized payloads", async () => { @@ -69,7 +71,7 @@ describe("rollout token usage recovery", () => { await expect(recoverRolloutTokenUsage("/tmp/rollout.jsonl", vi.fn().mockRejectedValue(new Error("missing")))).resolves.toBeNull(); await expect(recoverRolloutTokenUsage("/tmp/rollout.jsonl", vi.fn().mockResolvedValue("%%%"))).resolves.toBeNull(); await expect( - recoverRolloutTokenUsage("/tmp/rollout.jsonl", vi.fn().mockResolvedValue("a".repeat(ROLLOUT_TOKEN_USAGE_MAX_BASE64_BYTES + 1))), + recoverRolloutTokenUsage("/tmp/rollout.jsonl", vi.fn().mockResolvedValue("a".repeat(12 * 1024 * 1024 + 1))), ).resolves.toBeNull(); }); }); diff --git a/tests/app-server/turn.test.ts b/tests/app-server/turn.test.ts index bf4a5285..65f95723 100644 --- a/tests/app-server/turn.test.ts +++ b/tests/app-server/turn.test.ts @@ -7,7 +7,6 @@ import { conversationAssistantTextFromTurnRecord, lastAgentMessageTextFromTurnRecord, transcriptEntriesFromTurnRecords, - transcriptEntriesFromTurnRecord, type TurnItem, type TurnRecord, } from "../../src/app-server/protocol/turn"; @@ -15,12 +14,12 @@ import { describe("app-server turn records", () => { it("projects readable transcript entries without command log items", () => { expect( - transcriptEntriesFromTurnRecord( + transcriptEntriesFromTurnRecords([ turn([userMessage("u1", " 依頼です "), commandItem("cmd"), agentMessage("a1", "回答です"), planItem("p1", "計画です")], { startedAt: 10, completedAt: 12, }), - ), + ]), ).toEqual([ { kind: "user", text: "依頼です", timestamp: 10 }, { kind: "assistant", text: "回答です", timestamp: 12 }, diff --git a/tests/domain/threads/reference.test.ts b/tests/domain/threads/reference.test.ts index e59b83bc..f688c789 100644 --- a/tests/domain/threads/reference.test.ts +++ b/tests/domain/threads/reference.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Thread } from "../../../src/domain/threads/model"; -import { - referencedThreadMetadataFromPrompt, - referencedThreadPromptBundle, - referencedThreadPrompt, -} from "../../../src/domain/threads/reference"; +import { referencedThreadMetadataFromPrompt, referencedThreadPromptBundle } from "../../../src/domain/threads/reference"; function thread(overrides: Partial = {}): Thread { return { @@ -22,7 +18,7 @@ function thread(overrides: Partial = {}): Thread { describe("thread reference context", () => { it("builds an untruncated reference prompt with the 20 turn limit noted", () => { const longText = "x".repeat(5000); - const prompt = referencedThreadPrompt(thread(), [{ userText: longText, assistantText: "回答" }], "この続きです"); + const { prompt } = referencedThreadPromptBundle(thread(), [{ userText: longText, assistantText: "回答" }], "この続きです"); expect(prompt).toContain("[Codex Panel referenced thread v1]"); expect(prompt).toContain('"version":1'); @@ -33,7 +29,7 @@ describe("thread reference context", () => { }); it("extracts display text and metadata from a reference prompt", () => { - const prompt = referencedThreadPrompt(thread(), [{ userText: "元の依頼", assistantText: "回答" }], "この続きです"); + const { prompt } = referencedThreadPromptBundle(thread(), [{ userText: "元の依頼", assistantText: "回答" }], "この続きです"); expect(referencedThreadMetadataFromPrompt(prompt)).toEqual({ text: "この続きです", diff --git a/tests/features/chat/connection/app-server-warmup.test.ts b/tests/features/chat/connection/app-server-warmup.test.ts deleted file mode 100644 index b5f859e5..00000000 --- a/tests/features/chat/connection/app-server-warmup.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -// @vitest-environment jsdom - -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/lifecycle"; -import { scheduleChatPanelWarmup } from "../../../../src/features/chat/host/session"; - -function createWarmupHost({ - opened = true, - closing = false, - connected = false, -}: { opened?: boolean; closing?: boolean; connected?: boolean } = {}) { - const ensureConnected = vi.fn().mockResolvedValue(undefined); - const host = { - deferredTasks: createChatViewDeferredTasks(() => window), - opened: () => opened, - closing: () => closing, - connected: () => connected, - ensureConnected, - }; - return { host, ensureConnected }; -} - -describe("scheduleChatPanelWarmup", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - it("connects on the next tick when the opened view is disconnected", async () => { - const { host, ensureConnected } = createWarmupHost(); - - scheduleChatPanelWarmup(host); - await vi.advanceTimersByTimeAsync(0); - - expect(ensureConnected).toHaveBeenCalledOnce(); - }); - - it("does not schedule warmup when the view is closed or already connected", async () => { - const closed = createWarmupHost({ opened: false }); - const connected = createWarmupHost({ connected: true }); - - scheduleChatPanelWarmup(closed.host); - scheduleChatPanelWarmup(connected.host); - - await vi.advanceTimersByTimeAsync(0); - - expect(closed.ensureConnected).not.toHaveBeenCalled(); - expect(connected.ensureConnected).not.toHaveBeenCalled(); - }); - - it("skips a scheduled warmup if the view is closing", async () => { - const { host, ensureConnected } = createWarmupHost({ closing: true }); - - scheduleChatPanelWarmup(host); - await vi.advanceTimersByTimeAsync(0); - - expect(ensureConnected).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/features/chat/conversation/composer/composer-suggestions.test.ts b/tests/features/chat/conversation/composer/composer-suggestions.test.ts index 157cb0f3..6444cf2b 100644 --- a/tests/features/chat/conversation/composer/composer-suggestions.test.ts +++ b/tests/features/chat/conversation/composer/composer-suggestions.test.ts @@ -7,17 +7,20 @@ import { applyComposerSuggestionInsertion, composerSuggestionSignature, composerSuggestionNavigationDirection, - findWikiLinkSuggestions, nextComposerSuggestionIndex, parseSlashCommand, } from "../../../../../src/features/chat/application/composer/suggestions"; -import { userInputWithWikiLinkMentions } from "../../../../../src/features/chat/application/composer/wikilink-context"; +import { userInputWithWikiLinkMentionsAndSkills } from "../../../../../src/features/chat/application/composer/wikilink-context"; function expectPresent(value: T | null | undefined): T { if (value === null || value === undefined) throw new Error("Expected value to be present"); return value; } +function wikiLinkSuggestions(query: string, notes: Parameters[1]) { + return activeComposerSuggestions(`[[${query}`, notes, []); +} + function thread(overrides: Partial = {}): Thread { return { id: "019abcde-0000-7000-8000-000000000001", @@ -128,20 +131,20 @@ describe("composer suggestions", () => { }); it("ranks wikilinks with Obsidian fuzzy search and uses Obsidian linktext", () => { - const suggestions = findWikiLinkSuggestions("alp", 0, notes); + const suggestions = wikiLinkSuggestions("alp", notes); expect(suggestions[0]).toMatchObject({ display: "Alpha", detail: "projects/Alpha.md", replacement: "[[projects/Alpha]]", }); - expect(findWikiLinkSuggestions("btnt", 0, notes)[0]).toMatchObject({ + expect(wikiLinkSuggestions("btnt", notes)[0]).toMatchObject({ display: "Beta Note", replacement: "[[Beta Note]]", }); }); it("uses recent files only for empty wikilink suggestions", () => { - expect(findWikiLinkSuggestions("", 0, notes).map((suggestion) => suggestion.replacement)).toEqual([ + expect(wikiLinkSuggestions("", notes).map((suggestion) => suggestion.replacement)).toEqual([ "[[projects/Alpha]]", "[[thoughts/Alpha]]", "[[Assets/Diagram.png]]", @@ -149,12 +152,12 @@ describe("composer suggestions", () => { }); it("suggests non-markdown vault files when filtering wikilinks", () => { - expect(findWikiLinkSuggestions("projects", 0, notes)[0]).toMatchObject({ + expect(wikiLinkSuggestions("projects", notes)[0]).toMatchObject({ display: "Projects.base", detail: "Bases/Projects.base", replacement: "[[Bases/Projects.base]]", }); - expect(findWikiLinkSuggestions("paper", 0, notes)[0]).toMatchObject({ + expect(wikiLinkSuggestions("paper", notes)[0]).toMatchObject({ display: "Paper.pdf", detail: "References/Paper.pdf", replacement: "[[References/Paper.pdf]]", @@ -162,10 +165,12 @@ describe("composer suggestions", () => { }); it("keeps non-markdown wikilink completions compatible with mention parsing", () => { - const suggestion = expectPresent(findWikiLinkSuggestions("diagram", 0, notes)[0]); + const suggestion = expectPresent(wikiLinkSuggestions("diagram", notes)[0]); const text = `Please inspect ${suggestion.replacement}`; - const input = userInputWithWikiLinkMentions(text, (target) => - target === "Assets/Diagram.png" ? { name: "Diagram", path: "Assets/Diagram.png" } : null, + const input = userInputWithWikiLinkMentionsAndSkills( + text, + (target) => (target === "Assets/Diagram.png" ? { name: "Diagram", path: "Assets/Diagram.png" } : null), + [], ); expect(suggestion).toMatchObject({ diff --git a/tests/features/chat/conversation/composer/wikilink-context.test.ts b/tests/features/chat/conversation/composer/wikilink-context.test.ts index ea9b0e52..40bc8e82 100644 --- a/tests/features/chat/conversation/composer/wikilink-context.test.ts +++ b/tests/features/chat/conversation/composer/wikilink-context.test.ts @@ -1,25 +1,26 @@ import { describe, expect, it } from "vitest"; -import { - parsedSkillReferences, - parsedWikiLinks, - userInputWithWikiLinkMentions, - userInputWithWikiLinkMentionsAndSkills, -} from "../../../../../src/features/chat/application/composer/wikilink-context"; +import { userInputWithWikiLinkMentionsAndSkills } from "../../../../../src/features/chat/application/composer/wikilink-context"; describe("wikilink context", () => { it("parses aliases, subpaths, and duplicate links", () => { - expect(parsedWikiLinks("See [[Alpha|label]], [[Beta#Heading]], [[Gamma^block]], and [[Alpha]].")).toEqual([ - { raw: "Alpha|label", target: "Alpha", subpath: "", display: "label" }, - { raw: "Beta#Heading", target: "Beta", subpath: "#Heading", display: "" }, - { raw: "Gamma^block", target: "Gamma", subpath: "^block", display: "" }, + const text = "See [[Alpha|label]], [[Beta#Heading]], [[Gamma^block]], and [[Alpha]]."; + const input = userInputWithWikiLinkMentionsAndSkills(text, (target) => ({ name: target, path: `${target}.md` }), []); + + expect(input).toEqual([ + { type: "text", text }, + { type: "mention", name: "Alpha", path: "Alpha.md" }, + { type: "mention", name: "Beta", path: "Beta.md" }, + { type: "mention", name: "Gamma", path: "Gamma.md" }, ]); }); it("adds only resolved file mentions without changing the visible prompt body", () => { const text = "Please compare [[Alpha#Heading|A]] and [[Missing]]."; - const input = userInputWithWikiLinkMentions(text, (target) => - target === "Alpha" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null, + const input = userInputWithWikiLinkMentionsAndSkills( + text, + (target) => (target === "Alpha" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null), + [], ); expect(input).toEqual([ @@ -31,20 +32,18 @@ describe("wikilink context", () => { it("resolves aliases and subpaths from non-markdown wikilinks by target", () => { const text = "Open [[Bases/Projects.base|Projects]], [[References/Paper.pdf]], and [[Assets/Diagram.png#crop|Diagram]]."; - const input = userInputWithWikiLinkMentions(text, (target) => { - const mentions = new Map([ - ["Bases/Projects.base", { name: "Projects", path: "Bases/Projects.base" }], - ["References/Paper.pdf", { name: "Paper", path: "References/Paper.pdf" }], - ["Assets/Diagram.png", { name: "Diagram", path: "Assets/Diagram.png" }], - ]); - return mentions.get(target) ?? null; - }); - - expect(parsedWikiLinks(text)).toEqual([ - { raw: "Bases/Projects.base|Projects", target: "Bases/Projects.base", subpath: "", display: "Projects" }, - { raw: "References/Paper.pdf", target: "References/Paper.pdf", subpath: "", display: "" }, - { raw: "Assets/Diagram.png#crop|Diagram", target: "Assets/Diagram.png", subpath: "#crop", display: "Diagram" }, - ]); + const input = userInputWithWikiLinkMentionsAndSkills( + text, + (target) => { + const mentions = new Map([ + ["Bases/Projects.base", { name: "Projects", path: "Bases/Projects.base" }], + ["References/Paper.pdf", { name: "Paper", path: "References/Paper.pdf" }], + ["Assets/Diagram.png", { name: "Diagram", path: "Assets/Diagram.png" }], + ]); + return mentions.get(target) ?? null; + }, + [], + ); expect(input).toEqual([ { type: "text", text }, { type: "mention", name: "Projects", path: "Bases/Projects.base" }, @@ -55,8 +54,10 @@ describe("wikilink context", () => { it("deduplicates mentions by resolved path", () => { const text = "Read [[Alpha]], [[Alpha#Heading]], and [[Alias|A]]."; - const input = userInputWithWikiLinkMentions(text, (target) => - target === "Alpha" || target === "Alias" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null, + const input = userInputWithWikiLinkMentionsAndSkills( + text, + (target) => (target === "Alpha" || target === "Alias" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null), + [], ); expect(input).toEqual([ @@ -65,14 +66,6 @@ describe("wikilink context", () => { ]); }); - it("parses complete skill references", () => { - expect(parsedSkillReferences("Use $obsidian-codex-panel-maintain and ($github:yeet), but not $missing.")).toEqual([ - "obsidian-codex-panel-maintain", - "github:yeet", - "missing", - ]); - }); - it("adds resolved skill input without changing the visible prompt body", () => { const text = "Please use $obsidian-codex-panel-maintain with [[Alpha]]."; const input = userInputWithWikiLinkMentionsAndSkills( diff --git a/tests/features/chat/conversation/turns/optimistic-turn-start.test.ts b/tests/features/chat/conversation/turns/optimistic-turn-start.test.ts index 833fcb23..7d315533 100644 --- a/tests/features/chat/conversation/turns/optimistic-turn-start.test.ts +++ b/tests/features/chat/conversation/turns/optimistic-turn-start.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { acknowledgeOptimisticTurnStart, cleanupFailedTurnStart, - localUserMessageItem, localUserMessageItemFromInput, optimisticTurnStart, shouldAcknowledgeTurnStart, @@ -11,16 +10,6 @@ import { import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; describe("optimistic turn start helpers", () => { - it("builds local user messages without sharing mentioned file arrays", () => { - const mentionedFiles = [{ name: "Note", path: "Note.md" }]; - const item = localUserMessageItem({ id: "local", text: "hello", mentionedFiles }); - - mentionedFiles.push({ name: "Other", path: "Other.md" }); - - expect(item).toMatchObject({ id: "local", kind: "message", messageKind: "user", role: "user", text: "hello", copyText: "hello" }); - expect(item.mentionedFiles).toEqual([{ name: "Note", path: "Note.md" }]); - }); - it("builds optimistic turn starts from immutable input snapshots", () => { const input = [ { type: "text" as const, text: "hello [[Note]]" }, @@ -108,7 +97,7 @@ describe("optimistic turn start helpers", () => { it("attaches acknowledged turn ids and pending hook runs immutably", () => { const items: MessageStreamItem[] = [ - localUserMessageItem({ id: "local-user", text: "hello" }), + localUserMessage("local-user", "hello"), hookItem("hook-1"), { id: "assistant", kind: "message", role: "assistant", text: "working", messageKind: "assistantResponse", messageState: "completed" }, ]; @@ -128,7 +117,7 @@ describe("optimistic turn start helpers", () => { }); it("removes optimistic user and pending hook items after start failure", () => { - const items: MessageStreamItem[] = [localUserMessageItem({ id: "local-user", text: "hello" }), hookItem("hook-1"), hookItem("keep")]; + const items: MessageStreamItem[] = [localUserMessage("local-user", "hello"), hookItem("hook-1"), hookItem("keep")]; expect( cleanupFailedTurnStart({ @@ -141,6 +130,10 @@ describe("optimistic turn start helpers", () => { }); }); +function localUserMessage(id: string, text: string): MessageStreamItem { + return localUserMessageItemFromInput({ id, text, codexInput: [{ type: "text", text }] }); +} + function hookItem(id: string): MessageStreamItem { return { id, diff --git a/tests/features/chat/conversation/turns/slash-command-execution.test.ts b/tests/features/chat/conversation/turns/slash-command-execution.test.ts index 4479318a..5a978226 100644 --- a/tests/features/chat/conversation/turns/slash-command-execution.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-execution.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; -import { slashCommandHelpLines, slashCommandHelpSections } from "../../../../../src/features/chat/application/composer/slash-commands"; +import { slashCommandHelpSections } from "../../../../../src/features/chat/application/composer/slash-commands"; import type { Thread } from "../../../../../src/domain/threads/model"; import { executeSlashCommand, @@ -472,9 +472,7 @@ describe("slash commands", () => { }); it("documents archive", () => { - expect(slashCommandHelpLines().find((line) => line.startsWith("/archive"))).toBe( - "/archive - Archive the selected Codex thread.", - ); + expect(slashCommandHelpValue("/archive ")).toBe("Archive the selected Codex thread."); }); it("shows slash command help as a structured system result", async () => { @@ -574,40 +572,28 @@ describe("slash commands", () => { }); it("documents that /plan can take a message", () => { - expect(slashCommandHelpLines().find((line) => line.startsWith("/plan"))).toBe( - "/plan [message] - Toggle Plan mode, optionally with a message.", - ); + expect(slashCommandHelpValue("/plan [message]")).toBe("Toggle Plan mode, optionally with a message."); }); it("documents auto-review", () => { - expect(slashCommandHelpLines().find((line) => line.startsWith("/auto-review"))).toBe("/auto-review - Toggle approval auto-review."); + expect(slashCommandHelpValue("/auto-review")).toBe("Toggle approval auto-review."); }); it("documents rollback", () => { - expect(slashCommandHelpLines().find((line) => line.startsWith("/rollback"))).toBe( - "/rollback - Roll back the latest turn and restore its prompt.", - ); + expect(slashCommandHelpValue("/rollback")).toBe("Roll back the latest turn and restore its prompt."); }); it("documents rename", () => { - expect(slashCommandHelpLines().find((line) => line.startsWith("/rename"))).toBe( - "/rename - Rename the selected Codex thread.", - ); + expect(slashCommandHelpValue("/rename ")).toBe("Rename the selected Codex thread."); }); it("documents refer history size", () => { - expect(slashCommandHelpLines().find((line) => line.startsWith("/refer"))).toBe( - "/refer - Send a message with recent turns from another non-archived thread.", - ); + expect(slashCommandHelpValue("/refer ")).toBe("Send a message with recent turns from another non-archived thread."); }); it("documents status and doctor as separate commands", () => { - expect(slashCommandHelpLines().find((line) => line.startsWith("/status"))).toBe( - "/status - Show current thread, context, and usage limits.", - ); - expect(slashCommandHelpLines().find((line) => line.startsWith("/doctor"))).toBe( - "/doctor - Show Codex CLI and Codex App Server diagnostics.", - ); + expect(slashCommandHelpValue("/status")).toBe("Show current thread, context, and usage limits."); + expect(slashCommandHelpValue("/doctor")).toBe("Show Codex CLI and Codex App Server diagnostics."); }); it("rejects unsupported reasoning effort with usage", async () => { @@ -648,6 +634,35 @@ describe("slash commands", () => { expect(ctx.addSystemMessage).toHaveBeenCalledWith("Reasoning effort reset to default for subsequent turns."); }); + it("routes runtime reset aliases through reset commands", async () => { + const ctx = context(); + + for (const alias of ["reset", "clear", "off"]) { + await executeSlashCommand("model", alias, ctx); + await executeSlashCommand("reasoning", alias, ctx); + } + + expect(ctx.resetModelToConfig).toHaveBeenCalledTimes(3); + expect(ctx.resetReasoningEffortToConfig).toHaveBeenCalledTimes(3); + expect(ctx.requestModel).not.toHaveBeenCalled(); + expect(ctx.requestReasoningEffort).not.toHaveBeenCalled(); + }); + + it("shows model and reasoning status for empty runtime commands", async () => { + const ctx = context({ + modelStatusLines: () => ["model: gpt-5.5"], + effortStatusLines: () => ["effort: high"], + }); + + await executeSlashCommand("model", "", ctx); + await executeSlashCommand("reasoning", "", ctx); + + expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Model settings", [{ auditFacts: [{ key: "model", value: "gpt-5.5" }] }]); + expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Reasoning effort", [{ auditFacts: [{ key: "effort", value: "high" }] }]); + expect(ctx.requestModel).not.toHaveBeenCalled(); + expect(ctx.requestReasoningEffort).not.toHaveBeenCalled(); + }); + it("preserves supported reasoning effort casing", async () => { const ctx = context({ supportedReasoningEfforts: () => ["CaseSensitive"], @@ -687,6 +702,12 @@ describe("slash commands", () => { }); it("documents MCP status", () => { - expect(slashCommandHelpLines().find((line) => line.startsWith("/mcp"))).toBe("/mcp - Show MCP servers reported by Codex App Server."); + expect(slashCommandHelpValue("/mcp")).toBe("Show MCP servers reported by Codex App Server."); }); }); + +function slashCommandHelpValue(key: string): string | undefined { + return slashCommandHelpSections() + .flatMap((section) => section.rows) + .find((row) => row.key === key)?.value; +} diff --git a/tests/features/chat/diagnostics.test.ts b/tests/features/chat/diagnostics.test.ts index 1af05047..864ffed1 100644 --- a/tests/features/chat/diagnostics.test.ts +++ b/tests/features/chat/diagnostics.test.ts @@ -7,7 +7,7 @@ import { upsertMcpServerDiagnostic, upsertMcpServerStatusDiagnostics, } from "../../../src/domain/server/diagnostics"; -import { connectionDiagnosticSections, hasDiagnosticIssue } from "../../../src/features/chat/application/connection/diagnostics-display"; +import { connectionDiagnosticSections } from "../../../src/features/chat/application/connection/diagnostics-display"; describe("connection diagnostics", () => { it("formats base rows, capability probes, and MCP issues for /doctor", () => { @@ -59,34 +59,6 @@ describe("connection diagnostics", () => { ); }); - it("detects diagnostic issues without treating unknown probes as issues", () => { - expect(hasDiagnosticIssue(createServerDiagnostics())).toBe(false); - - const failed = createServerDiagnostics(); - failed.probes["model/list"] = diagnosticProbeError("model/list", new Error("network down"), 1); - expect(hasDiagnosticIssue(failed)).toBe(true); - }); - - it("detects MCP diagnostic issues", () => { - let authIssue = upsertMcpServerDiagnostic(createServerDiagnostics(), { - name: "docs", - startupStatus: "ready", - authStatus: "notLoggedIn", - toolCount: 0, - message: null, - }); - expect(hasDiagnosticIssue(authIssue)).toBe(true); - - authIssue = upsertMcpServerDiagnostic(authIssue, { - name: "github", - startupStatus: "failed", - authStatus: null, - toolCount: null, - message: "missing token", - }); - expect(hasDiagnosticIssue(authIssue)).toBe(true); - }); - it("maps app-server MCP status snapshots into diagnostics", () => { const diagnostics = upsertMcpServerDiagnostic(createServerDiagnostics(), { name: "github", diff --git a/tests/features/chat/message-stream/model.test.ts b/tests/features/chat/message-stream/model.test.ts index 7d4de96f..343753dc 100644 --- a/tests/features/chat/message-stream/model.test.ts +++ b/tests/features/chat/message-stream/model.test.ts @@ -3,46 +3,24 @@ import { describe, expect, it } from "vitest"; import { collabAgentStateExecutionState } from "../../../../src/features/chat/domain/message-stream/agent-state"; import { activeAgentRunSummary } from "../../../../src/features/chat/presentation/message-stream/agent-summary"; import { messageStreamLayoutBlocks } from "../../../../src/features/chat/presentation/message-stream/layout"; -import { - appendAssistantDelta, - appendItemOutput, - appendItemText, - appendPlanDelta, - appendToolOutput, - upsertMessageStreamItemById, -} from "../../../../src/features/chat/domain/message-stream/updates"; -import { - taskProgressMessageStreamItem, - taskProgressExecutionState, -} from "../../../../src/features/chat/domain/message-stream/factories/task-progress"; +import { upsertMessageStreamItemById } from "../../../../src/features/chat/domain/message-stream/updates"; +import { taskProgressMessageStreamItem } from "../../../../src/features/chat/domain/message-stream/factories/task-progress"; import { normalizeProposedPlanMarkdown } from "../../../../src/features/chat/domain/message-stream/format/proposed-plan"; import { pathRelativeToRoot } from "../../../../src/features/chat/domain/message-stream/format/path-labels"; import { permissionRows } from "../../../../src/features/chat/domain/message-stream/format/permission-rows"; import { - autoReviewExecutionState, createAutoReviewResultItem, createReviewResultItem, } from "../../../../src/features/chat/app-server/mappers/message-stream/review-result-items"; -import { - commandExecutionState, - dynamicToolCallExecutionState, - mcpToolCallExecutionState, - patchApplyExecutionState, -} from "../../../../src/features/chat/app-server/mappers/message-stream/turn-items"; import { messageStreamItemFromTurnItem, messageStreamItemsFromTurns, } from "../../../../src/features/chat/app-server/mappers/message-stream/turn-items"; -import { referencedThreadPrompt } from "../../../../src/domain/threads/reference"; +import { referencedThreadPromptBundle } from "../../../../src/domain/threads/reference"; import type { MessageStreamItem } from "../../../../src/features/chat/domain/message-stream/items"; import type { Thread } from "../../../../src/domain/threads/model"; import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn"; -function expectPresent(value: T | null | undefined): T { - if (value === null || value === undefined) throw new Error("Expected value to be present"); - return value; -} - function commandItem(id: string, text: string, turnId: string): MessageStreamItem { return { id, @@ -140,7 +118,7 @@ describe("turn item conversion preserves app-server semantics", () => { }); it("hides persisted /refer context in displayed user messages", () => { - const text = referencedThreadPrompt( + const { prompt: text } = referencedThreadPromptBundle( { id: "thread-reference", name: "参照元", preview: "", archived: false, createdAt: 1, updatedAt: 1 } satisfies Thread, [ { userText: "元の依頼", assistantText: "元の回答" }, @@ -234,38 +212,6 @@ describe("turn item conversion preserves app-server semantics", () => { expect(normalizeProposedPlanMarkdown("\n## Summary\n- Ship it\n")).toBe("## Summary\n- Ship it"); }); - it("streams assistant deltas as markdown", () => { - const items = appendAssistantDelta([], "a1", "t1", "**Hello**"); - const updated = appendAssistantDelta(items, "a1", "t1", "\n\n- world"); - expect(updated).toMatchObject([ - { - id: "a1", - kind: "message", - role: "assistant", - text: "**Hello**\n\n- world", - copyText: "**Hello**\n\n- world", - messageKind: "assistantResponse", - messageState: "streaming", - }, - ]); - }); - - it("streams plan deltas as plain assistant text until completion", () => { - const items = appendPlanDelta([], "p1", "t1", "\n# Plan"); - const updated = appendPlanDelta(items, "p1", "t1", "\n"); - expect(updated).toMatchObject([ - { - id: "p1", - kind: "message", - role: "assistant", - text: "# Plan", - copyText: "# Plan", - messageKind: "proposedPlan", - messageState: "streaming", - }, - ]); - }); - it("formats structured plan progress as task progress", () => { expect( taskProgressMessageStreamItem("t1", "Working plan", [ @@ -924,56 +870,6 @@ describe("permission detail rows", () => { }); }); -describe("streaming updates target item identity without mutating history", () => { - it("upserts assistant deltas by item id, not by last position", () => { - const items: MessageStreamItem[] = [ - { - id: "a1", - sourceItemId: "a1", - kind: "message", - role: "assistant", - text: "hello", - messageKind: "assistantResponse", - messageState: "completed", - }, - { id: "tool1", sourceItemId: "tool1", kind: "tool", role: "tool", text: "tool" }, - ]; - - const updated = appendAssistantDelta(items, "a1", "t1", " world"); - expect(expectPresent(updated[0])).toMatchObject({ text: "hello world" }); - expect(expectPresent(updated[0])).toMatchObject({ copyText: "hello world" }); - expect(updated).toHaveLength(2); - expect(expectPresent(items[0])).toMatchObject({ text: "hello" }); - expect(updated).not.toBe(items); - }); - - it("appends tool text and output without mutating existing stream items", () => { - const tool: MessageStreamItem = { id: "tool1", sourceItemId: "tool1", kind: "tool", role: "tool", text: "plan: " }; - const command: MessageStreamItem = { - id: "cmd1", - sourceItemId: "cmd1", - kind: "command", - role: "tool", - commandAction: "command", - commandTarget: { kind: "command", commandLine: "Command running" }, - command: "npm test", - cwd: "/vault", - status: "running", - output: "one", - }; - - const withText = appendItemText([tool], "tool1", "t1", "plan", "two"); - const withOutput = appendItemOutput([command], "cmd1", "t1", "two", "command", "Command running"); - const withToolOutput = appendToolOutput([tool], "tool1", "t1", "progress", "mcp progress"); - - expect(withText[0]).toMatchObject({ text: "plan: two" }); - expect(withOutput[0]).toMatchObject({ output: "onetwo" }); - expect(withToolOutput[0]).toMatchObject({ text: "plan: ", output: "progress" }); - expect(tool.text).toBe("plan: "); - expect(command).toMatchObject({ output: "one" }); - }); -}); - describe("display block grouping keeps work logs subordinate to conversation messages", () => { it("groups completed turn activities before the final assistant message", () => { const items: MessageStreamItem[] = [ @@ -1512,50 +1408,47 @@ describe("workspace path summaries stay readable without hiding audit paths", () describe("execution state uses typed status adapters before rendered text", () => { it("detects failed command state", () => { - expect(commandExecutionState("failed", 1)).toBe("failed"); + expect(commandExecutionItem({ status: "failed", exitCode: 1 })).toMatchObject({ executionState: "failed" }); }); it("does not infer command failure from the command text", () => { - expect(commandExecutionState("completed", 0)).toBe("completed"); + expect(commandExecutionItem({ command: "echo failed", status: "completed", exitCode: 0 })).toMatchObject({ + executionState: "completed", + }); }); it("uses typed command status before command text", () => { - expect(commandExecutionState("inProgress")).toBe("running"); + expect(commandExecutionItem({ command: "echo completed", status: "inProgress" })).toMatchObject({ executionState: "running" }); }); it("keeps command exit code precedence as a Panel display rule", () => { - expect(commandExecutionState("completed", 1)).toBe("failed"); - expect(commandExecutionState("unknown", 0)).toBe("completed"); + expect(commandExecutionItem({ status: "completed", exitCode: 1 })).toMatchObject({ executionState: "failed" }); + expect(commandExecutionItem({ status: "unknown", exitCode: 0 })).toMatchObject({ executionState: "completed" }); }); it("maps app-server status strings into Panel execution states", () => { - expect(patchApplyExecutionState("declined")).toBe("failed"); - expect(mcpToolCallExecutionState("completed")).toBe("completed"); - expect(taskProgressExecutionState("pending")).toBe("running"); - expect(autoReviewExecutionState("approved")).toBe("completed"); - expect(autoReviewExecutionState("timedOut")).toBe("failed"); + expect(fileChangeStreamItem({ status: "declined" })).toMatchObject({ executionState: "failed" }); + expect(mcpToolCallItem({ status: "completed" })).toMatchObject({ executionState: "completed" }); + expect( + taskProgressMessageStreamItem("turn", "Planning", [ + { step: "Read context", status: "pending" }, + { step: "Patch code", status: "completed" }, + ]), + ).toMatchObject({ executionState: "running" }); + expect(autoReviewItem("approved")).toMatchObject({ executionState: "completed" }); + expect(autoReviewItem("timedOut")).toMatchObject({ executionState: "failed" }); expect(collabAgentStateExecutionState("inProgress")).toBe("running"); expect(collabAgentStateExecutionState("failed")).toBe("failed"); }); it("uses dynamic tool success as a display fallback", () => { - expect(dynamicToolCallExecutionState("unknown", true)).toBe("completed"); - expect(dynamicToolCallExecutionState("completed", false)).toBe("failed"); + expect(dynamicToolCallItem({ status: "unknown", success: true })).toMatchObject({ executionState: "completed" }); + expect(dynamicToolCallItem({ status: "completed", success: false })).toMatchObject({ executionState: "failed" }); }); it("does not infer unknown status strings with broad matching", () => { - expect(patchApplyExecutionState("done_with_errors")).toBeNull(); - const item: MessageStreamItem = { - id: "c1", - kind: "command", - role: "tool", - commandAction: "command", - commandTarget: { kind: "command", commandLine: "Command" }, - command: "npm test", - cwd: "/vault", - status: "done_with_errors", - }; - expect(item.executionState ?? null).toBeNull(); + expect(fileChangeStreamItem({ status: "done_with_errors" })).toMatchObject({ executionState: null }); + expect(commandExecutionItem({ status: "done_with_errors", exitCode: null })).toMatchObject({ executionState: null }); }); it("does not overwrite streamed output with an empty completed item", () => { @@ -1587,3 +1480,92 @@ describe("execution state uses typed status adapters before rendered text", () = expect(upsertMessageStreamItemById([streamed], completed)[0]).toMatchObject({ output: "partial output", status: "completed" }); }); }); + +type CommandExecutionOverrides = Omit>, "status"> & { status?: string }; +type FileChangeOverrides = Omit>, "status"> & { status?: string }; +type McpToolCallOverrides = Omit>, "status"> & { status?: string }; +type DynamicToolCallOverrides = Omit>, "status"> & { status?: string }; + +function commandExecutionItem(overrides: CommandExecutionOverrides = {}): MessageStreamItem | null { + return messageStreamItemFromTurnItem( + { + type: "commandExecution", + id: "command-1", + command: "npm test", + cwd: "/vault", + processId: null, + source: "agent", + status: "completed", + commandActions: [{ type: "unknown", command: "npm test" }], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + ...overrides, + } as Extract, + "turn", + ); +} + +function fileChangeStreamItem(overrides: FileChangeOverrides = {}): MessageStreamItem | null { + return messageStreamItemFromTurnItem( + { + type: "fileChange", + id: "patch-1", + status: "completed", + changes: [{ path: "src/main.ts", kind: { type: "update", move_path: null }, diff: "@@\n-old\n+new" }], + ...overrides, + } as Extract, + "turn", + ); +} + +function mcpToolCallItem(overrides: McpToolCallOverrides = {}): MessageStreamItem | null { + return messageStreamItemFromTurnItem( + { + type: "mcpToolCall", + id: "mcp-1", + server: "github", + tool: "pull_request_read", + status: "completed", + arguments: { id: 123 }, + pluginId: null, + result: null, + error: null, + durationMs: null, + ...overrides, + } as Extract, + "turn", + ); +} + +function dynamicToolCallItem(overrides: DynamicToolCallOverrides = {}): MessageStreamItem | null { + return messageStreamItemFromTurnItem( + { + type: "dynamicToolCall", + id: "dynamic-1", + namespace: "web", + tool: "open", + arguments: { url: "https://example.com" }, + status: "completed", + contentItems: null, + success: true, + durationMs: null, + ...overrides, + } as Extract, + "turn", + ); +} + +function autoReviewItem(status: string): MessageStreamItem { + return createAutoReviewResultItem({ + threadId: "thread", + turnId: "turn", + startedAtMs: 1, + completedAtMs: 2, + reviewId: `review-${status}`, + targetItemId: "target-1", + decisionSource: "agent", + review: { status, riskLevel: "low", userAuthorization: "medium", rationale: "Allowed by policy." }, + action: { type: "applyPatch", cwd: "/vault", files: ["/vault/src/main.ts"] }, + }); +} diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index 42cc15f3..4267f91b 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; -import { TFile } from "obsidian"; +import { MarkdownRenderer, TFile } from "obsidian"; import { h } from "preact"; import { @@ -14,15 +14,11 @@ import { createChatStateStore, type ChatStateStore } from "../../../../../src/fe import { MessageStreamPresenter } from "../../../../../src/features/chat/panel/surface/message-stream-presenter"; import { type ChatMessageStreamSurfaceContext, - messageStreamContextFromState, - messageStreamStateProjection, + messageStreamSurfaceProjectionFromState, } from "../../../../../src/features/chat/panel/surface/message-stream-projection"; import { MessageStreamScrollBridge } from "../../../../../src/features/chat/panel/surface/message-stream-scroll"; import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport"; -import { - bindRenderedWikiLinks, - type RenderedMarkdownLinkContext, -} from "../../../../../src/features/chat/ui/message-stream/markdown-renderer"; +import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer"; import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-events"; import type { MessageStreamScrollIntent } from "../../../../../src/features/chat/ui/message-stream/virtualizer"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root"; @@ -58,13 +54,21 @@ describe("MessageStreamPresenter scroll pinning", () => { activePermissionProfile: null, }); - const projection = messageStreamStateProjection(store.getState(), "/vault"); + const projection = messageStreamSurfaceProjectionFromState( + store.getState(), + messageStreamSurfaceContext({ + vaultPath: "/vault", + dispatch: (action) => { + store.dispatch(action); + }, + }), + ); - expect(projection.activeThreadId).toBe("thread-1"); - expect(projection.workspaceRoot).toBe("/repo"); - expect(projection.items).toEqual([]); - expect(projection.disclosures.textDetails.size).toBe(0); - expect(projection.forkActionsItemId).toBeNull(); + expect(projection.context.activeThreadId).toBe("thread-1"); + expect(projection.context.workspaceRoot).toBe("/repo"); + expect(projection.blocks).toEqual([{ kind: "empty", key: "empty" }]); + expect(projection.context.disclosures.textDetails.size).toBe(0); + expect(projection.context.forkActionsItemId).toBeNull(); }); it("wires message stream disclosure actions through the surface context", () => { @@ -76,18 +80,17 @@ describe("MessageStreamPresenter scroll pinning", () => { }, }); - const context = messageStreamContextFromState(store.getState(), surfaceContext); + const context = messageStreamSurfaceProjectionFromState(store.getState(), surfaceContext).context; if (!context.onDisclosureToggle) throw new Error("Expected message stream disclosure action"); context.onDisclosureToggle("textDetails", "message:details", true); expect(store.getState().ui.disclosures.textDetails.has("message:details")).toBe(true); }); - it("normalizes rendered internal links that point at absolute vault paths", () => { + it("normalizes rendered internal links that point at absolute vault paths", async () => { const openLinkText = vi.fn(); const context = markdownLinkContext(openLinkText, "/Users/showhey/Vault", ["docs/Guide.md"]); - const parent = document.createElement("div"); - const link = parent.createEl("a", { + const { link, cleanup } = await renderedInternalLink(context, { cls: "internal-link", text: "Guide.md", attr: { @@ -96,17 +99,16 @@ describe("MessageStreamPresenter scroll pinning", () => { }, }); - bindRenderedWikiLinks(parent, "Inbox.md", context); link.click(); expect(openLinkText).toHaveBeenCalledWith("docs/Guide.md", "Inbox.md", false); + cleanup(); }); - it("normalizes rendered internal links for missing files inside the vault", () => { + it("normalizes rendered internal links for missing files inside the vault", async () => { const openLinkText = vi.fn(); const context = markdownLinkContext(openLinkText, "/Users/showhey/Vault"); - const parent = document.createElement("div"); - const link = parent.createEl("a", { + const { link, cleanup } = await renderedInternalLink(context, { cls: "internal-link", text: "Missing.md", attr: { @@ -115,17 +117,16 @@ describe("MessageStreamPresenter scroll pinning", () => { }, }); - bindRenderedWikiLinks(parent, "Inbox.md", context); link.click(); expect(openLinkText).toHaveBeenCalledWith("docs/Missing.md", "Inbox.md", false); + cleanup(); }); - it("keeps rendered internal links unchanged when they are not vault file paths", () => { + it("keeps rendered internal links unchanged when they are not vault file paths", async () => { const openLinkText = vi.fn(); const context = markdownLinkContext(openLinkText); - const parent = document.createElement("div"); - const link = parent.createEl("a", { + const { link, cleanup } = await renderedInternalLink(context, { cls: "internal-link", text: "Project", attr: { @@ -134,17 +135,16 @@ describe("MessageStreamPresenter scroll pinning", () => { }, }); - bindRenderedWikiLinks(parent, "Inbox.md", context); link.click(); expect(openLinkText).toHaveBeenCalledWith("Project", "Inbox.md", false); + cleanup(); }); - it("does not open rendered internal links for absolute paths outside the vault", () => { + it("does not open rendered internal links for absolute paths outside the vault", async () => { const openLinkText = vi.fn(); const context = markdownLinkContext(openLinkText, "/Users/showhey/Vault"); - const parent = document.createElement("div"); - const link = parent.createEl("a", { + const { link, cleanup } = await renderedInternalLink(context, { cls: "internal-link", text: "README.md", attr: { @@ -153,18 +153,17 @@ describe("MessageStreamPresenter scroll pinning", () => { }, }); - bindRenderedWikiLinks(parent, "Inbox.md", context); link.click(); expect(openLinkText).not.toHaveBeenCalled(); expect(notices).toEqual(["Cannot open files outside the vault."]); + cleanup(); }); - it("does not open rendered internal links for vault config paths", () => { + it("does not open rendered internal links for vault config paths", async () => { const openLinkText = vi.fn(); const context = markdownLinkContext(openLinkText, "/Users/showhey/Vault"); - const parent = document.createElement("div"); - const link = parent.createEl("a", { + const { link, cleanup } = await renderedInternalLink(context, { cls: "internal-link", text: "main.js", attr: { @@ -173,11 +172,11 @@ describe("MessageStreamPresenter scroll pinning", () => { }, }); - bindRenderedWikiLinks(parent, "Inbox.md", context); link.click(); expect(openLinkText).not.toHaveBeenCalled(); expect(notices).toEqual(["Cannot open files outside the vault."]); + cleanup(); }); it("pins to the scroll container bottom without aligning the last message element", async () => { @@ -609,12 +608,12 @@ function messageStreamSurfaceContext(options: { }; } -function markdownLinkContext(openLinkText = vi.fn(), vaultPath = "/vault", vaultFiles: string[] = []): RenderedMarkdownLinkContext { +function markdownLinkContext(openLinkText = vi.fn(), vaultPath = "/vault", vaultFiles: string[] = []) { const files = new Map(vaultFiles.map((path) => [path, tFile(path)])); return { app: { workspace: { - getActiveFile: vi.fn(() => null), + getActiveFile: vi.fn(() => tFile("Inbox.md")), openLinkText, }, vault: { @@ -626,6 +625,37 @@ function markdownLinkContext(openLinkText = vi.fn(), vaultPath = "/vault", vault }; } +async function renderedInternalLink( + context: ReturnType, + linkOptions: Parameters[1], +): Promise<{ link: HTMLAnchorElement; cleanup: () => void }> { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const renderMarkdown = vi.spyOn(MarkdownRenderer, "render").mockImplementationOnce((_app, _text, staging) => { + staging.createEl("a", linkOptions); + return Promise.resolve(); + }); + const markdownRenderer = new MarkdownMessageRenderer({ + app: context.app, + owner: {} as never, + vaultPath: context.vaultPath, + }); + + markdownRenderer.renderMarkdown(parent, "[[Link]]"); + await Promise.resolve(); + await Promise.resolve(); + renderMarkdown.mockRestore(); + + const link = parent.querySelector("a.internal-link"); + if (!link) throw new Error("Expected rendered internal link"); + return { + link, + cleanup: () => { + parent.remove(); + }, + }; +} + interface TestMessageStreamPresenter { presenter: MessageStreamPresenter; scrollBridge: MessageStreamScrollBridge; diff --git a/tests/features/chat/panel/surface/projection.test.ts b/tests/features/chat/panel/surface/projection.test.ts index fee6da7e..a58c13ec 100644 --- a/tests/features/chat/panel/surface/projection.test.ts +++ b/tests/features/chat/panel/surface/projection.test.ts @@ -1,4 +1,7 @@ +// @vitest-environment jsdom + import { describe, expect, it } from "vitest"; +import { h, type ComponentChild } from "preact"; import { createServerDiagnostics } from "../../../../../src/domain/server/diagnostics"; import { @@ -7,22 +10,23 @@ import { type RuntimeConfigSnapshot, } from "../../../../../src/app-server/protocol/runtime-config"; import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; -import { - chatPanelComposerProjection, - composerMetaViewModel, - composerPlaceholder, -} from "../../../../../src/features/chat/panel/surface/composer-projection"; +import { chatPanelComposerProjection } from "../../../../../src/features/chat/panel/surface/composer-projection"; import { effortStatusLines, modelStatusLines, statusSummaryLines } from "../../../../../src/features/chat/presentation/runtime/status"; -import { runtimeComposerChoices } from "../../../../../src/features/chat/panel/surface/composer-projection"; import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot"; -import { chatPanelToolbarProjection, toolbarStateProjection } from "../../../../../src/features/chat/panel/surface/toolbar-projection"; +import { ChatPanelToolbar } from "../../../../../src/features/chat/panel/surface/toolbar-projection"; import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import type { ModelMetadata } from "../../../../../src/domain/catalog/metadata"; import type { Thread } from "../../../../../src/domain/threads/model"; -import { chatPanelGoalProjection, chatPanelGoalViewModel } from "../../../../../src/features/chat/panel/surface/goal-projection"; +import { ChatPanelGoal } from "../../../../../src/features/chat/panel/surface/goal-projection"; import type { ChatPanelComposerSurface, ChatPanelGoalSurface } from "../../../../../src/features/chat/panel/surface/model"; import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; import { setChatStateMessageStreamItems } from "../../support/message-stream"; +import { ChatPanelShellStateContext, createChatPanelShellState } from "../../../../../src/features/chat/panel/shell-state"; +import type { ToolbarActions } from "../../../../../src/features/chat/ui/toolbar"; +import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root"; +import { installObsidianDomShims } from "../../../../support/dom"; + +installObsidianDomShims(); describe("chat panel surface projections", () => { it("builds toolbar rows from immutable chat state snapshots", () => { @@ -36,33 +40,21 @@ describe("chat panel surface projections", () => { state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" }); state.connection.serverDiagnostics = createServerDiagnostics(); - const input = { + const parent = renderWithShellState( state, - snapshot: runtimeSnapshotFixture(state), - connected: true, - nowMs: 0, - turnBusy: true, - vaultPath: "/vault", - configuredCommand: "codex", - archiveExportEnabled: true, - }; - const model = chatPanelToolbarProjection(input); + h(ChatPanelToolbar, { surface: toolbarSurfaceFixture({ archiveExportEnabled: true }), actions: toolbarActionsFixture() }), + ); - expect(model.openPanel).toBe("history"); - expect(model.newChatDisabled).toBe(true); - expect(model.threads).toMatchObject([ - { threadId: "thread-1", title: "Active", selected: true, disabled: false, rename: { draft: "Active" } }, - { threadId: "thread-2", title: "Other", selected: false, disabled: true, archiveConfirm: { active: true } }, - ]); - expect(toolbarStateProjection(input)).toMatchObject({ - newChatDisabled: true, - historyOpen: true, - openPanel: "history", - threads: [ - { threadId: "thread-1", selected: true, disabled: false }, - { threadId: "thread-2", selected: false, disabled: true }, - ], - }); + expect(parent.querySelector(".codex-panel__toolbar-panel--history")).not.toBeNull(); + expect(parent.querySelector(".codex-panel__new-chat")?.disabled).toBe(true); + expect(parent.querySelector(".codex-panel__thread-row--selected .codex-panel__thread-rename-input")?.value).toBe( + "Active", + ); + expect(parent.querySelector(".codex-panel__thread-row--archive-confirming .codex-panel__toolbar-panel-label")?.textContent).toBe( + "Other", + ); + expect(parent.querySelectorAll(".codex-panel__thread")[1]?.getAttribute("aria-disabled")).toBe("true"); + unmountUiRoot(parent); }); it("builds composer meta from context and runtime state", () => { @@ -81,7 +73,7 @@ describe("chat panel surface projections", () => { modelContextWindow: 100, }; - expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toEqual({ + expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({ fatal: null, context: { cells: [ @@ -97,7 +89,6 @@ describe("chat panel surface projections", () => { effort: "high", planActive: true, autoReviewActive: true, - fastActive: true, }); }); @@ -117,7 +108,7 @@ describe("chat panel surface projections", () => { ]); state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5" }); - expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toMatchObject({ + expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({ fatal: null, context: { cells: [ @@ -143,7 +134,7 @@ describe("chat panel surface projections", () => { modelContextWindow: 100, }; - expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toMatchObject({ + expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({ context: { cells: [ { text: "⣀", placeholder: true }, @@ -161,7 +152,7 @@ describe("chat panel surface projections", () => { const state = createChatState(); state.connection.phase = { kind: "failed", message: "Connection failed." }; - expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toEqual({ + expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({ fatal: "Codex app-server disconnected", context: { cells: [ @@ -177,7 +168,6 @@ describe("chat panel surface projections", () => { effort: null, planActive: false, autoReviewActive: false, - fastActive: false, }); }); @@ -226,25 +216,31 @@ describe("chat panel surface projections", () => { const selectedModels: string[] = []; const selectedEfforts: string[] = []; - const choices = runtimeComposerChoices({ + const choices = chatPanelComposerProjection( + composerSurfaceFixture({ + runtime: { + requestModel: async (model) => { + selectedModels.push(model); + }, + requestReasoningEffort: async (effort) => { + selectedEfforts.push(effort); + }, + }, + }), state, - snapshot: runtimeSnapshotFixture(state), - requestModel: (model) => { - selectedModels.push(model); - }, - requestReasoningEffort: (effort) => { - selectedEfforts.push(effort); - }, - }); + ).meta; - expect(choices.modelChoices).toMatchObject([ + const modelChoices = choices.modelChoices ?? []; + const effortChoices = choices.effortChoices ?? []; + + expect(modelChoices).toMatchObject([ { label: "gpt-5-mini", selected: false }, { label: "gpt-5.5", selected: true }, ]); - expect(choices.effortChoices).toMatchObject([{ label: "high", selected: true }]); + expect(effortChoices).toMatchObject([{ label: "high", selected: true }]); - choices.modelChoices[0]?.onClick(); - choices.effortChoices[0]?.onClick(); + modelChoices[0]?.onClick(); + effortChoices[0]?.onClick(); expect(selectedModels).toEqual(["gpt-5-mini"]); expect(selectedEfforts).toEqual(["high"]); }); @@ -276,17 +272,23 @@ describe("chat panel surface projections", () => { }, } satisfies ChatPanelGoalSurface; - const props = chatPanelGoalViewModel(surface, state); + const parent = renderWithShellState(state, h(ChatPanelGoal, { surface })); state.activeThread.id = "thread-current"; - props.actions.onPause(); - props.actions.onResume(); - props.actions.onClear(); + clickLabeledButton(parent, "Pause goal"); + clickLabeledButton(parent, "Clear goal"); + + state.activeThread.goal = { ...goalFixture("thread-rendered"), status: "paused" }; + const resumeParent = renderWithShellState(state, h(ChatPanelGoal, { surface })); + state.activeThread.id = "thread-current"; + clickLabeledButton(resumeParent, "Resume goal"); expect(statuses).toEqual([ ["thread-rendered", "paused"], ["thread-rendered", "active"], ]); expect(clears).toEqual(["thread-rendered"]); + unmountUiRoot(parent); + unmountUiRoot(resumeParent); }); it("builds composer meta from one captured chat state", () => { @@ -308,8 +310,12 @@ describe("chat panel surface projections", () => { }); it("derives composer placeholders", () => { - expect(composerPlaceholder("Active")).toBe("Ask Codex to work on “Active”..."); - expect(composerPlaceholder(null)).toBe("Ask Codex to work on this task..."); + const activeState = createChatState(); + activeState.activeThread.id = "thread-1"; + activeState.threadList.listedThreads = [threadFixture("thread-1", "Active")]; + + expect(chatPanelComposerProjection(composerSurfaceFixture(), activeState).placeholder).toBe("Ask Codex to work on “Active”..."); + expect(chatPanelComposerProjection(composerSurfaceFixture(), createChatState()).placeholder).toBe("Ask Codex to work on this task..."); }); it("uses restored thread names in the composer projection", () => { @@ -335,15 +341,79 @@ describe("chat panel surface projections", () => { state.ui.goalEditor = { kind: "editing", threadId: "thread-1", objectiveDraft: "Draft goal", tokenBudgetDraft: 1234 }; state.ui.disclosures.goalObjectiveExpanded = new Set(["thread-1"]); - expect(chatPanelGoalProjection(state)).toEqual({ - goal: state.activeThread.goal, - goalThreadId: "thread-1", - editor: { editing: true, objectiveDraft: "Draft goal", tokenBudgetDraft: 1234 }, - display: { objectiveExpanded: true }, - }); + const parent = renderWithShellState(state, h(ChatPanelGoal, { surface: goalSurfaceFixture() })); + + expect(parent.querySelector(".codex-panel__goal-objective-input")?.value).toBe("Draft goal"); + unmountUiRoot(parent); }); }); +function renderWithShellState(state: ChatState, node: ComponentChild): HTMLElement { + const parent = document.createElement("div"); + renderUiRoot(parent, h(ChatPanelShellStateContext.Provider, { value: createChatPanelShellState(state) }, node)); + return parent; +} + +function clickLabeledButton(parent: HTMLElement, label: string): void { + const button = Array.from(parent.querySelectorAll("button")).find((item) => item.getAttribute("aria-label") === label); + if (!button) throw new Error(`Expected button: ${label}`); + button.click(); +} + +function toolbarSurfaceFixture(overrides: { archiveExportEnabled?: boolean } = {}) { + return { + state: { + connected: () => true, + nowMs: () => 0, + }, + settings: { + vaultPath: () => "/vault", + configuredCommand: () => "codex", + archiveExportEnabled: () => overrides.archiveExportEnabled ?? false, + }, + }; +} + +function toolbarActionsFixture(): ToolbarActions { + return { + startNewThread: () => undefined, + toggleChatActions: () => undefined, + compactConversation: () => undefined, + setGoal: () => undefined, + toggleHistory: () => undefined, + toggleStatusPanel: () => undefined, + connect: () => undefined, + refreshStatus: () => undefined, + resumeThread: () => undefined, + startArchiveThread: () => undefined, + archiveThread: () => undefined, + startRenameThread: () => undefined, + updateRenameDraft: () => undefined, + saveRenameThread: () => undefined, + cancelRenameThread: () => undefined, + autoNameThread: () => undefined, + }; +} + +function goalSurfaceFixture(): ChatPanelGoalSurface { + return { + settings: { + sendShortcut: () => "enter", + }, + actions: { + goal: { + saveObjective: async () => undefined, + setStatus: async () => undefined, + clear: async () => undefined, + startEditing: () => undefined, + updateObjectiveDraft: () => undefined, + setObjectiveExpanded: () => undefined, + closeEditor: () => undefined, + }, + }, + }; +} + function composerSurfaceFixture(overrides: Partial = {}): ChatPanelComposerSurface { return { thread: { diff --git a/tests/features/chat/protocol/inbound/routing.test.ts b/tests/features/chat/protocol/inbound/routing.test.ts index d710d802..17244982 100644 --- a/tests/features/chat/protocol/inbound/routing.test.ts +++ b/tests/features/chat/protocol/inbound/routing.test.ts @@ -1,18 +1,12 @@ import { describe, expect, it } from "vitest"; -import { - isMessageInActiveScope, - messageThreadId, - messageTurnId, - routeServerNotification, - routeServerRequest, -} from "../../../../../src/features/chat/app-server/inbound/routing"; +import { routeServerNotification, routeServerRequest } from "../../../../../src/features/chat/app-server/inbound/routing"; import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages"; const activeScope = { activeThreadId: "thread-active", activeTurnId: "turn-active" }; describe("chat inbound routing", () => { - it("extracts thread and turn ids from app-server messages", () => { + it("routes turn-scoped app-server messages for the active scope", () => { const notification = { method: "turn/completed", params: { @@ -30,27 +24,19 @@ describe("chat inbound routing", () => { }, } satisfies Extract; - expect(messageThreadId(notification)).toBe("thread-active"); - expect(messageTurnId(notification)).toBe("turn-active"); - expect(isMessageInActiveScope(notification, activeScope)).toBe(true); + expect(routeServerNotification(notification, activeScope).kind).toBe("turnLifecycle"); + expect(routeServerNotification(notification, { activeThreadId: "thread-other", activeTurnId: "turn-active" }).kind).toBe("inactive"); + expect(routeServerNotification(notification, { activeThreadId: "thread-active", activeTurnId: "turn-other" }).kind).toBe("inactive"); }); - it("extracts the thread id from thread started notifications", () => { + it("routes thread-started notifications for the active thread", () => { const notification = { method: "thread/started", params: { thread: threadSnapshot("thread-active") }, } satisfies Extract; - expect(messageThreadId(notification)).toBe("thread-active"); - expect(messageTurnId(notification)).toBeNull(); - expect(isMessageInActiveScope(notification, activeScope)).toBe(true); - }); - - it("extracts thread and turn ids from approval and user input requests", () => { - expect(messageThreadId(commandApprovalRequest())).toBe("thread-active"); - expect(messageTurnId(commandApprovalRequest())).toBe("turn-active"); - expect(messageThreadId(userInputRequest())).toBe("thread-other"); - expect(messageTurnId(userInputRequest())).toBe("turn-active"); + expect(routeServerNotification(notification, activeScope).kind).toBe("threadLifecycle"); + expect(routeServerNotification(notification, { activeThreadId: "thread-other", activeTurnId: "turn-active" }).kind).toBe("inactive"); }); it("classifies every active-thread server request method and extracts its scope", () => { @@ -64,8 +50,6 @@ describe("chat inbound routing", () => { ] as const; for (const { request, kind } of requests) { - expect(messageThreadId(request)).toBe("thread-active"); - expect(messageTurnId(request)).toBe("turn-active"); expect(routeServerRequest(request, activeScope).kind).toBe(kind); expect( routeServerRequest({ ...request, params: { ...request.params, turnId: "turn-other" } } as ServerRequest, activeScope).kind, @@ -97,8 +81,6 @@ describe("chat inbound routing", () => { const idleActiveThreadScope = { activeThreadId: "thread-active", activeTurnId: null }; const request = userInputRequest({ threadId: "thread-active" }); - expect(messageThreadId(request)).toBe("thread-active"); - expect(messageTurnId(request)).toBe("turn-active"); expect(routeServerRequest(request, idleActiveThreadScope).kind).toBe("inactive"); }); @@ -142,8 +124,6 @@ describe("chat inbound routing", () => { it("keeps unscoped unsupported requests out of active-turn routing", () => { for (const request of unscopedUnsupportedRequests()) { - expect(messageThreadId(request)).toBeNull(); - expect(messageTurnId(request)).toBeNull(); expect(routeServerRequest(request, activeScope).kind).toBe("unsupported"); } }); @@ -155,8 +135,6 @@ describe("chat inbound routing", () => { params: { threadId: "thread-active", turnId: "turn-active" }, } as unknown as ServerRequest; - expect(messageThreadId(request)).toBe("thread-active"); - expect(messageTurnId(request)).toBe("turn-active"); expect(routeServerRequest(request, activeScope).kind).toBe("unknown"); expect( routeServerRequest( @@ -198,8 +176,6 @@ describe("chat inbound routing", () => { params: { threadId: "thread-active", turnId: "turn-active" }, } as unknown as ServerNotification; - expect(messageThreadId(notification)).toBe("thread-active"); - expect(messageTurnId(notification)).toBe("turn-active"); expect(routeServerNotification(notification, activeScope).kind).toBe("unhandled"); expect( routeServerNotification( @@ -240,7 +216,6 @@ describe("chat inbound routing", () => { }); it("scopes MCP startup status notifications when app-server provides a thread id", () => { - expect(messageThreadId(mcpStartupStatusNotificationForThread("thread-active"))).toBe("thread-active"); expect(routeServerNotification(mcpStartupStatusNotificationForThread("thread-active"), activeScope).kind).toBe("diagnosticStatus"); expect(routeServerNotification(mcpStartupStatusNotificationForThread("thread-other"), activeScope).kind).toBe("inactive"); }); diff --git a/tests/features/chat/state/message-stream-selectors.test.ts b/tests/features/chat/state/message-stream-selectors.test.ts index ee6dd34e..f36e9898 100644 --- a/tests/features/chat/state/message-stream-selectors.test.ts +++ b/tests/features/chat/state/message-stream-selectors.test.ts @@ -5,7 +5,6 @@ import { initialChatMessageStreamState, messageStreamRollbackCandidate, messageStreamTurnsAfterTurnId, - messageStreamTurnIds, messageStreamWithActiveTurnItems, } from "../../../../src/features/chat/application/state/message-stream"; @@ -13,7 +12,6 @@ describe("message stream selectors", () => { it("counts turns after a turn id from message stream state", () => { const state = initialChatMessageStreamState(items()); - expect(messageStreamTurnIds(state)).toEqual(["turn-1", "turn-2", "turn-3"]); expect(messageStreamTurnsAfterTurnId(state, "turn-1")).toBe(2); expect(messageStreamTurnsAfterTurnId(state, "turn-2")).toBe(1); expect(messageStreamTurnsAfterTurnId(state, "turn-3")).toBe(0); @@ -23,7 +21,6 @@ describe("message stream selectors", () => { it("includes the active segment when counting turns", () => { const state = messageStreamWithActiveTurnItems(initialChatMessageStreamState(items()), "turn-3", items()); - expect(messageStreamTurnIds(state)).toEqual(["turn-1", "turn-2", "turn-3"]); expect(messageStreamTurnsAfterTurnId(state, "turn-2")).toBe(1); }); diff --git a/tests/features/chat/threads/resume.test.ts b/tests/features/chat/threads/resume.test.ts index b251b9ca..3a6728bf 100644 --- a/tests/features/chat/threads/resume.test.ts +++ b/tests/features/chat/threads/resume.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { ThreadActivationSnapshot } from "../../../../src/app-server/services/thread-activation"; -import { resumedThreadAction } from "../../../../src/features/chat/application/threads/resume"; +import type { ThreadActivationResponse, ThreadActivationSnapshot } from "../../../../src/app-server/services/thread-activation"; +import { + resumedThreadActionFromActiveRuntime, + resumedThreadActionFromAppServerResponse, +} from "../../../../src/features/chat/application/threads/resume"; import type { Thread } from "../../../../src/domain/threads/model"; describe("chat thread resume helpers", () => { @@ -10,8 +13,17 @@ describe("chat thread resume helpers", () => { const resumed = threadFixture("thread", "Resumed"); const loading = { id: "loading", kind: "system" as const, role: "system" as const, text: "Loading thread..." }; - const action = resumedThreadAction({ - response: responseFixture(resumed), + const action = resumedThreadActionFromActiveRuntime({ + thread: resumed, + cwd: "/vault", + runtime: { + activeModel: "gpt-5.5", + activeReasoningEffort: "high", + activeServiceTier: "fast", + activeApprovalPolicy: "on-request", + activeApprovalsReviewer: "user", + activePermissionProfile: null, + }, listedThreads: [existing], items: [loading], }); @@ -34,8 +46,8 @@ describe("chat thread resume helpers", () => { it("can build thread start actions without mutating the thread list", () => { const resumed = threadFixture("thread", "Started"); - const action = resumedThreadAction({ - response: responseFixture(resumed), + const action = resumedThreadActionFromAppServerResponse({ + response: responseRecordFixture(resumed), preserveRequestedRuntimeSettings: true, }); @@ -61,6 +73,34 @@ function responseFixture(thread: Thread): ThreadActivationSnapshot { }; } +function responseRecordFixture(thread: Thread): ThreadActivationResponse { + return { + ...responseFixture(thread), + thread: { + id: thread.id, + sessionId: thread.id, + forkedFromId: null, + parentThreadId: null, + preview: thread.preview, + ephemeral: false, + modelProvider: "openai", + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + status: { type: "idle" }, + path: null, + cwd: "/vault", + cliVersion: "codex-cli 0.0.0", + source: "unknown", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: thread.name, + turns: [], + }, + }; +} + function threadFixture(id: string, name: string): Thread { return { id, diff --git a/tests/features/chat/threads/thread-management-actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts index 307d684b..ed388866 100644 --- a/tests/features/chat/threads/thread-management-actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -6,11 +6,7 @@ import type { ArchiveExportAdapter } from "../../../../src/features/thread-opera import { createChatState } from "../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import { - archiveThread, - compactThread, - forkThreadFromTurn, - renameThread, - rollbackThread as rollbackThreadAction, + createThreadManagementActions, type ThreadManagementActions, type ThreadManagementActionsHost, } from "../../../../src/features/chat/application/threads/thread-management-actions"; @@ -410,14 +406,39 @@ function clientMock() { } function threadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions { - return { - compactThread: (threadId) => compactThread(host, threadId), - archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown), - forkThread: (threadId) => forkThreadFromTurn(host, threadId, null, false), - forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource), - renameThread: (threadId, name) => renameThread(host, threadId, name), - rollbackThread: (threadId) => rollbackThreadAction(host, threadId), - }; + return createThreadManagementActions({ + obsidian: { archiveAdapter: host.archiveAdapter }, + settingsRef: { + get settings() { + return host.settings(); + }, + get vaultPath() { + return host.vaultPath; + }, + }, + workspace: { openThreadInNewView: host.openThreadInNewView }, + threadSurfaces: { + notifyThreadArchived: host.notifyThreadArchived, + notifyThreadRenamed: host.notifyThreadRenamed, + refreshSharedThreadListFromOpenSurface: host.refreshSharedThreadListFromOpenSurface, + }, + stateStore: host.stateStore, + client: { + currentClient: host.currentClient, + ensureConnected: host.ensureConnected, + }, + status: { + set: host.setStatus, + addSystemMessage: host.addSystemMessage, + }, + notify: { showNotice: host.showNotice }, + thread: { + selectThread: host.openThreadInCurrentPanel, + refreshThreads: host.refreshThreads, + notifyIdentityChanged: host.notifyActiveThreadIdentityChanged, + }, + composer: { setText: host.setComposerText }, + }); } function hostMock({ diff --git a/tests/features/chat/threads/title-display.test.ts b/tests/features/chat/threads/title-display.test.ts deleted file mode 100644 index 128af908..00000000 --- a/tests/features/chat/threads/title-display.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { Thread } from "../../../../src/domain/threads/model"; -import { codexPanelDisplayTitle } from "../../../../src/features/chat/host/session"; - -describe("chat thread title display", () => { - it("formats Codex panel display titles from the active thread", () => { - expect(codexPanelDisplayTitle(null, [])).toBe("Codex"); - expect(codexPanelDisplayTitle("thread-named", [thread({ id: "thread-named", name: "作業メモ" })])).toBe("Codex: 作業メモ"); - expect(codexPanelDisplayTitle("thread-preview", [thread({ id: "thread-preview", preview: "初回依頼" })])).toBe("Codex: 初回依頼"); - expect(codexPanelDisplayTitle("019e061e-0000-7000-8000-000000000001", [])).toBe("Codex: 019e061e"); - }); -}); - -function thread(overrides: Partial = {}): Thread { - return { - id: "thread-1", - preview: "", - createdAt: 1, - updatedAt: 1, - name: null, - archived: false, - ...overrides, - }; -} diff --git a/tests/features/chat/ui/message-stream/viewport.test.tsx b/tests/features/chat/ui/message-stream/viewport.test.tsx index 5bdfc2b1..2237724a 100644 --- a/tests/features/chat/ui/message-stream/viewport.test.tsx +++ b/tests/features/chat/ui/message-stream/viewport.test.tsx @@ -1,46 +1,95 @@ // @vitest-environment jsdom import { describe, expect, it } from "vitest"; +import { h } from "preact"; +import { act } from "preact/test-utils"; -import { messageStreamVirtualItems } from "../../../../../src/features/chat/ui/message-stream/viewport"; +import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport"; import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context"; +import { renderUiRoot } from "../../../../../src/shared/ui/ui-root"; +import { unmountUiRootInAct } from "./test-helpers"; +import type { MessageStreamScrollIntent } from "../../../../../src/features/chat/ui/message-stream/virtualizer"; -describe("message stream virtual item fallback", () => { +describe("message stream viewport fallback rendering", () => { it("starts fallback rendering near the current top scroll offset", () => { - const items = messageStreamVirtualItems([], messageBlocks(40), 0); + const parent = renderViewport(messageBlocks(40), 0); - expect(items[0]).toMatchObject({ index: 0, key: "block-0", start: 0 }); + expect(blockHosts(parent)[0]).toMatchObject({ index: "0", key: "block-0", transform: "translateY(0px)" }); + unmountUiRootInAct(parent); }); it("centers fallback rendering around the estimated scroll offset", () => { - const items = messageStreamVirtualItems([], messageBlocks(80), 96 * 40); + const parent = renderViewport(messageBlocks(80), 96 * 40); - expect(items[0]).toMatchObject({ index: 24, key: "block-24", start: 96 * 24 }); - expect(items).toHaveLength(32); + expect(blockHosts(parent)[0]).toMatchObject({ index: "24", key: "block-24", transform: "translateY(2304px)" }); + expect(blockHosts(parent)).toHaveLength(32); + unmountUiRootInAct(parent); }); it("clamps negative fallback scroll offsets to the first block", () => { - const items = messageStreamVirtualItems([], messageBlocks(40), -96); + const parent = renderViewport(messageBlocks(40), -96); - expect(items[0]).toMatchObject({ index: 0, key: "block-0", start: 0 }); - expect(items).toHaveLength(32); + expect(blockHosts(parent)[0]).toMatchObject({ index: "0", key: "block-0", transform: "translateY(0px)" }); + expect(blockHosts(parent)).toHaveLength(32); + unmountUiRootInAct(parent); }); it("clamps fallback rendering to the last full window near the end", () => { - const items = messageStreamVirtualItems([], messageBlocks(80), 96 * 10_000); + const parent = renderViewport(messageBlocks(80), 96 * 10_000); + const hosts = blockHosts(parent); - expect(items[0]).toMatchObject({ index: 48, key: "block-48", start: 96 * 48 }); - expect(items.at(-1)).toMatchObject({ index: 79, key: "block-79", start: 96 * 79 }); - expect(items).toHaveLength(32); - }); - - it("uses virtualizer items when TanStack has produced a range", () => { - const virtualItems = [{ index: 12, key: "virtual", start: 1234 }]; - - expect(messageStreamVirtualItems(virtualItems, messageBlocks(80), 0)).toBe(virtualItems); + expect(hosts[0]).toMatchObject({ index: "48", key: "block-48", transform: "translateY(4608px)" }); + expect(hosts.at(-1)).toMatchObject({ index: "79", key: "block-79", transform: "translateY(7584px)" }); + expect(hosts).toHaveLength(32); + unmountUiRootInAct(parent); }); }); +function renderViewport(blocks: MessageStreamBlock[], scrollTop: number): HTMLElement { + const parent = document.createElement("div"); + document.body.appendChild(parent); + renderUiRootInAct( + parent, + h(MessageStreamViewport, { + state: { + blocks, + consumeScrollIntent: consumePreserveScrollIntent, + }, + }), + ); + const viewport = parent.querySelector(".codex-panel__messages"); + if (!viewport) throw new Error("Expected message stream viewport"); + Object.defineProperty(viewport, "scrollTop", { value: scrollTop, configurable: true }); + renderUiRootInAct( + parent, + h(MessageStreamViewport, { + state: { + blocks, + consumeScrollIntent: consumePreserveScrollIntent, + }, + }), + ); + return parent; +} + +function renderUiRootInAct(parent: HTMLElement, node: Parameters[1]): void { + void act(() => { + renderUiRoot(parent, node); + }); +} + +function consumePreserveScrollIntent(): MessageStreamScrollIntent { + return "preserve"; +} + +function blockHosts(parent: HTMLElement): { index: string | null; key: string | null; transform: string }[] { + return Array.from(parent.querySelectorAll(".codex-panel__message-block")).map((element) => ({ + index: element.getAttribute("data-index"), + key: element.getAttribute("data-codex-panel-block-key"), + transform: element.style.transform, + })); +} + function messageBlocks(count: number): MessageStreamBlock[] { return Array.from({ length: count }, (_, index) => ({ key: `block-${String(index)}`, node: null })); } diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 14a00d7b..108b32f7 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS } from "../../../src/settings/model"; import type { CodexChatHost } from "../../../src/features/chat/application/ports/chat-host"; import { createServerDiagnostics } from "../../../src/domain/server/diagnostics"; +import type { Thread } from "../../../src/domain/threads/model"; import { emptyRuntimeConfigSnapshot } from "../../../src/app-server/protocol/runtime-config"; import type { ThreadRecord } from "../../../src/app-server/protocol/thread"; import type { ServerNotification } from "../../../src/app-server/connection/rpc-messages"; @@ -344,6 +345,21 @@ describe("CodexChatView connection lifecycle", () => { expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20); }); + it("formats the panel title from listed thread metadata", async () => { + const view = await chatView(); + + await view.setState({ threadId: "thread-named" }, {} as never); + view.applyThreadListSnapshot([panelThread({ id: "thread-named", name: "作業メモ" })]); + expect(view.getDisplayText()).toBe("Codex: 作業メモ"); + + view.applyThreadListSnapshot([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]); + expect(view.getDisplayText()).toBe("Codex: 初回依頼"); + + await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never); + view.applyThreadListSnapshot([]); + expect(view.getDisplayText()).toBe("Codex: 019e061e"); + }); + it("hydrates a restored thread when workspace state arrives after open", async () => { vi.useFakeTimers(); const client = connectedClient(); @@ -1075,6 +1091,18 @@ function threadFixture(threadId: string): ThreadRecord { }; } +function panelThread(overrides: Partial = {}): Thread { + return { + id: "thread-1", + preview: "", + createdAt: 1, + updatedAt: 1, + name: null, + archived: false, + ...overrides, + }; +} + function turnWithUserMessage(text: string) { return { id: "turn-1", diff --git a/tests/features/chat/view-lifecycle.test.ts b/tests/features/chat/view-lifecycle.test.ts index 1bd3b08a..075b6950 100644 --- a/tests/features/chat/view-lifecycle.test.ts +++ b/tests/features/chat/view-lifecycle.test.ts @@ -5,10 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ChatConnectionWorkTracker, ChatResumeWorkTracker, - transitionChatConnectionLifecycle, - transitionChatResumeLifecycle, transitionRestoredThreadLifecycle, - type ActiveChatConnection, } from "../../../src/features/chat/application/lifecycle"; import { createChatViewDeferredTasks } from "../../../src/features/chat/host/lifecycle"; @@ -66,22 +63,18 @@ describe("chat view lifecycle transitions", () => { it("keeps stale connection completions from clearing the active connection", () => { const firstPromise = Promise.resolve(); const secondPromise = Promise.resolve(); - const first: ActiveChatConnection = { kind: "connecting", promise: firstPromise }; - const second: ActiveChatConnection = { kind: "connecting", promise: secondPromise }; - const state = transitionChatConnectionLifecycle({ kind: "idle" }, { type: "started", connection: second }); + const tracker = new ChatConnectionWorkTracker(); + const first = tracker.begin(); + first.promise = firstPromise; + const second = tracker.begin(); + second.promise = secondPromise; - expect(transitionChatConnectionLifecycle(state, { type: "finished", connection: first, promise: firstPromise })).toBe(state); - expect(transitionChatConnectionLifecycle(state, { type: "finished", connection: second, promise: firstPromise })).toBe(state); - expect(transitionChatConnectionLifecycle(state, { type: "finished", connection: second, promise: secondPromise })).toEqual({ - kind: "idle", - }); - }); - - it("invalidates resume work explicitly", () => { - const resume = { kind: "resuming" as const, threadId: "thread" }; - - expect(transitionChatResumeLifecycle({ kind: "idle" }, { type: "started", resume })).toBe(resume); - expect(transitionChatResumeLifecycle(resume, { type: "invalidated" })).toEqual({ kind: "idle" }); + tracker.finish(first, firstPromise); + expect(tracker.active()).toBe(second); + tracker.finish(second, firstPromise); + expect(tracker.active()).toBe(second); + tracker.finish(second, secondPromise); + expect(tracker.active()).toBeNull(); }); it("clears restored-thread loading only for the active loading promise", () => { diff --git a/tests/features/chat/view-snapshot.test.ts b/tests/features/chat/view-snapshot.test.ts index 6da4d400..e9d653c4 100644 --- a/tests/features/chat/view-snapshot.test.ts +++ b/tests/features/chat/view-snapshot.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { latestProposedPlanItem, openPanelTurnLifecycle, parseRestoredThreadState } from "../../../src/features/chat/panel/snapshot"; +import { openPanelTurnLifecycle, parseRestoredThreadState } from "../../../src/features/chat/panel/snapshot"; describe("chat view snapshots", () => { it("projects open panel turn lifecycle without exposing full chat state", () => { @@ -11,16 +11,6 @@ describe("chat view snapshots", () => { expect(openPanelTurnLifecycle({ kind: "running", turnId: "turn" })).toEqual({ kind: "running", turnId: "turn" }); }); - it("finds the latest proposed plan item", () => { - expect( - latestProposedPlanItem([ - { id: "first", kind: "message", messageKind: "proposedPlan", role: "assistant", text: "plan", messageState: "completed" }, - { id: "user", kind: "message", messageKind: "user", role: "user", text: "ok" }, - { id: "latest", kind: "message", messageKind: "proposedPlan", role: "assistant", text: "plan", messageState: "streaming" }, - ])?.id, - ).toBe("latest"); - }); - it("parses restored thread view state defensively", () => { expect(parseRestoredThreadState({ threadId: "thread", threadTitle: "Title" })).toEqual({ threadId: "thread", diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 3f86e276..6fd00372 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -9,18 +9,12 @@ import { transitionSelectionRewriteState, type SelectionRewriteState, } from "../../../src/features/selection-rewrite/model"; -import { - parseSelectionRewriteOutput, - selectionRewriteOutputFromText, - selectionRewriteOutputParseResultFromText, -} from "../../../src/features/selection-rewrite/output"; -import { isSelectionRewriteActionKey, SelectionRewritePopover } from "../../../src/features/selection-rewrite/popover"; +import { selectionRewriteOutputParseResultFromText } from "../../../src/features/selection-rewrite/output"; +import { SelectionRewritePopover } from "../../../src/features/selection-rewrite/popover"; import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt"; import * as selectionRewriteRunner from "../../../src/features/selection-rewrite/runner"; import { runSelectionRewrite, - selectionRewriteRuntimeOverride, - validatedSelectionRewriteRuntimeOverride, type SelectionRewriteClient, type SelectionRewriteClientFactory, } from "../../../src/features/selection-rewrite/runner"; @@ -33,7 +27,6 @@ import type { RequestId, ServerNotification } from "../../../src/app-server/conn import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn"; import type { ModelMetadata, ReasoningEffort } from "../../../src/domain/catalog/metadata"; import type { ServerInitialization } from "../../../src/domain/server/initialization"; -import type { ComposerSendKeyEvent } from "../../../src/shared/ui/keyboard"; import { deferred } from "../../support/async"; import { installObsidianDomShims } from "../../support/dom"; @@ -58,17 +51,13 @@ afterEach(() => { describe("selection selection rewrite output", () => { it("parses valid selection rewrite JSON", () => { - expect(parseSelectionRewriteOutput('{"replacementText":"rewritten"}')).toEqual({ replacementText: "rewritten" }); + expect(selectionRewriteOutputParseResultFromText('{"replacementText":"rewritten"}').output).toEqual({ replacementText: "rewritten" }); }); it("rejects invalid selection rewrite JSON", () => { - expect(parseSelectionRewriteOutput("replacementText: rewritten")).toBeNull(); - expect(parseSelectionRewriteOutput('{"replacementText":42}')).toBeNull(); - expect(parseSelectionRewriteOutput('{"text":"rewritten"}')).toBeNull(); - }); - - it("parses selection rewrite output text", () => { - expect(selectionRewriteOutputFromText('{"replacementText":"final"}')).toEqual({ replacementText: "final" }); + expect(selectionRewriteOutputParseResultFromText("replacementText: rewritten").output).toBeNull(); + expect(selectionRewriteOutputParseResultFromText('{"replacementText":42}').output).toBeNull(); + expect(selectionRewriteOutputParseResultFromText('{"text":"rewritten"}').output).toBeNull(); }); it("keeps raw model text when selection rewrite output parsing fails", () => { @@ -225,6 +214,26 @@ describe("selection rewrite runner lifecycle", () => { await expect(rewriting).resolves.toEqual({ replacementText: "final" }); expect(previews).toEqual(['{"replacementText":"stream', '{"replacementText":"streamed"}']); }); + + it("passes validated runtime overrides to the structured rewrite turn", async () => { + const { clientFactory, client } = fakeSelectionRewriteClientFactory((fake) => { + fake.modelList = { data: [modelMetadata("gpt-5.4-mini", ["low", "medium", "high"]) as never], nextCursor: null }; + fake.startStructuredTurnImpl = async () => { + fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"replacementText":"final"}'))); + fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" }))); + return { turn: turn([], { id: "turn", status: "inProgress" }) }; + }; + }); + + await expect( + runSelectionRewrite({ + ...runOptions(clientFactory), + runtimeSettings: { rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" }, + }), + ).resolves.toEqual({ replacementText: "final" }); + + expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini" }); + }); }); describe("selection rewrite popover", () => { @@ -532,46 +541,6 @@ describe("selection rewrite popover", () => { }); }); -describe("selection rewrite runtime overrides", () => { - it("uses explicit selection rewrite runtime overrides", () => { - expect(selectionRewriteRuntimeOverride({ rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" })).toEqual({ - model: "gpt-5.4-mini", - effort: "minimal", - }); - }); - - it("omits selection rewrite runtime overrides that are set to Codex default", () => { - expect(selectionRewriteRuntimeOverride({ rewriteSelectionModel: null, rewriteSelectionEffort: null })).toEqual({}); - }); - - it("omits an explicit selection rewrite effort when the selected model does not support it", () => { - expect( - validatedSelectionRewriteRuntimeOverride({ rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" }, [ - modelMetadata("gpt-5.4-mini", ["low", "medium", "high", "xhigh"]), - ]), - ).toEqual({ model: "gpt-5.4-mini" }); - }); -}); - -describe("selection rewrite keys", () => { - const baseEvent: ComposerSendKeyEvent = { - key: "Enter", - shiftKey: false, - metaKey: false, - ctrlKey: false, - altKey: false, - isComposing: false, - }; - - it("treats plain Enter and Cmd/Ctrl+Enter as preview action keys", () => { - expect(isSelectionRewriteActionKey(baseEvent)).toBe(true); - expect(isSelectionRewriteActionKey({ ...baseEvent, metaKey: true })).toBe(true); - expect(isSelectionRewriteActionKey({ ...baseEvent, ctrlKey: true })).toBe(true); - expect(isSelectionRewriteActionKey({ ...baseEvent, shiftKey: true })).toBe(false); - expect(isSelectionRewriteActionKey({ ...baseEvent, isComposing: true })).toBe(false); - }); -}); - function rewriteState(overrides: Partial = {}): SelectionRewriteState { return { filePath: "Note.md", @@ -696,6 +665,8 @@ function fakeSelectionRewriteClientFactory(configure?: (client: FakeSelectionRew class FakeSelectionRewriteClient implements SelectionRewriteClient { disconnected = false; + modelList: ModelListResponse = { data: [], nextCursor: null }; + startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null; startStructuredTurnImpl: (() => Promise) | null = null; readonly structuredTurnStarted: Promise; private resolveStructuredTurnStarted!: () => void; @@ -715,7 +686,7 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient { } async listModels(): Promise { - return { data: [], nextCursor: null }; + return this.modelList; } rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void { @@ -726,7 +697,8 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient { return threadStartResponse("thread"); } - async startStructuredTurn(_options: AppServerStartStructuredTurnOptions): Promise { + async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise { + this.startStructuredTurnOptions = options; this.resolveStructuredTurnStarted(); return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) }; } diff --git a/tests/features/thread-operations/archive-markdown.test.ts b/tests/features/thread-operations/archive-markdown.test.ts index 362f62e6..9372c5f1 100644 --- a/tests/features/thread-operations/archive-markdown.test.ts +++ b/tests/features/thread-operations/archive-markdown.test.ts @@ -2,18 +2,16 @@ import { describe, expect, it } from "vitest"; import { exportArchivedThreadMarkdown, - markdownFromThread, - normalizeExportedMarkdownLinks, - normalizedArchiveTags, type ArchiveExportAdapter, + type ArchiveExportSettings, } from "../../../src/features/thread-operations/archive-markdown"; import type { Thread } from "../../../src/domain/threads/model"; -import { referencedThreadPrompt } from "../../../src/domain/threads/reference"; +import { referencedThreadPromptBundle } from "../../../src/domain/threads/reference"; import type { ThreadTranscriptEntry } from "../../../src/domain/threads/transcript"; describe("thread archive export", () => { - it("writes frontmatter and readable user/codex turns with turn timestamps", () => { - const output = markdownFromThread( + it("writes frontmatter and readable user/codex turns with turn timestamps", async () => { + const output = await exportedMarkdown( thread({ id: "thread-12345678", name: "Exported thread", @@ -56,8 +54,8 @@ describe("thread archive export", () => { expect(output).not.toContain("npm test"); }); - it("falls back when turn timestamps are missing and uses start time for incomplete agent output", () => { - const output = markdownFromThread( + it("falls back when turn timestamps are missing and uses start time for incomplete agent output", async () => { + const output = await exportedMarkdown( thread({ transcriptEntries: [ transcriptEntry("user", "古い依頼", null), @@ -75,7 +73,7 @@ describe("thread archive export", () => { expect(output).toContain("## Codex - 2026-05-18 10:01\n\n途中の回答"); }); - it("exports only the thread history remaining after rollback", () => { + it("exports only the thread history remaining after rollback", async () => { const rolledBackUserText = "rollbackされた依頼"; const rolledBackAssistantText = "rollbackされた回答"; const remainingEntries = [ @@ -86,7 +84,7 @@ describe("thread archive export", () => { transcriptEntry("user", rolledBackUserText, timestamp(2026, 5, 18, 10, 0)), transcriptEntry("assistant", rolledBackAssistantText, timestamp(2026, 5, 18, 10, 3)), ]; - const output = markdownFromThread( + const output = await exportedMarkdown( thread({ transcriptEntries: remainingEntries, }), @@ -100,14 +98,14 @@ describe("thread archive export", () => { expect(output).not.toContain(rolledBackAssistantText); }); - it("hides embedded /refer context and keeps a compact reference line", () => { - const prompt = referencedThreadPrompt( + it("hides embedded /refer context and keeps a compact reference line", async () => { + const { prompt } = referencedThreadPromptBundle( thread({ id: "thread-ref", name: "参照元" }), [{ userText: "元の依頼", assistantText: "回答" }], "続きです", ); - const output = markdownFromThread(thread({ transcriptEntries: [transcriptEntry("user", prompt, 1)] }), new Date(2026, 4, 18)); + const output = await exportedMarkdown(thread({ transcriptEntries: [transcriptEntry("user", prompt, 1)] }), new Date(2026, 4, 18)); expect(output).toContain("続きです"); expect(output).toContain("> Referenced: 参照元 (1/20 turns, thread-ref)"); @@ -115,47 +113,55 @@ describe("thread archive export", () => { expect(output).not.toContain("元の依頼"); }); - it("writes optional frontmatter tags from fixed comma-separated settings", () => { - const output = markdownFromThread(thread({ name: "Tagged thread" }), new Date(2026, 4, 18), { + it("writes optional frontmatter tags from fixed comma-separated settings", async () => { + const output = await exportedMarkdown(thread({ name: "Tagged thread" }), new Date(2026, 4, 18), { archiveExportTags: '#codex, "archive", codex, {{title}}', }); expect(output).toContain('tags: ["codex", "archive", "{{title}}"]'); }); - it("omits frontmatter tags when archive tags are empty", () => { - const output = markdownFromThread(thread(), new Date(2026, 4, 18), { archiveExportTags: " , # , " }); + it("omits frontmatter tags when archive tags are empty", async () => { + const output = await exportedMarkdown(thread(), new Date(2026, 4, 18), { archiveExportTags: " , # , " }); expect(output).not.toContain("tags:"); }); - it("normalizes archive tags without sorting or changing unmatched quotes", () => { - expect(normalizedArchiveTags(` "codex" , 'archive', #note/tag, codex, "unfinished `)).toEqual([ - "codex", - "archive", - "note/tag", - '"unfinished', - ]); + it("normalizes archive tags without sorting or changing unmatched quotes", async () => { + const output = await exportedMarkdown(thread(), new Date(2026, 4, 18), { + archiveExportTags: ` "codex" , 'archive', #note/tag, codex, "unfinished `, + }); + + expect(output).toContain('tags: ["codex", "archive", "note/tag", "\\"unfinished"]'); }); - it("normalizes exported markdown links for vault and external absolute paths", () => { - const output = normalizeExportedMarkdownLinks( - [ - "[Vault note]()", - "[Vault note with parens]()", - "[External file](/Users/showhey/Repos/project/README.md)", - "[Relative](topics/Other.md)", - "[Website](https://example.com/docs)", - "![Image](/Users/showhey/Repos/project/image.png)", - "`[Code link](/Users/showhey/Repos/project/README.md)`", - "```", - "[Code block link](/Users/showhey/Repos/project/README.md)", - "```", - ].join("\n"), - "/Users/showhey/Vault", + it("normalizes exported markdown links for vault and external absolute paths", async () => { + const output = await exportedMarkdown( + thread({ + transcriptEntries: [ + transcriptEntry( + "assistant", + [ + "[Vault note]()", + "[Vault note with parens]()", + "[External file](/Users/showhey/Repos/project/README.md)", + "[Relative](topics/Other.md)", + "[Website](https://example.com/docs)", + "![Image](/Users/showhey/Repos/project/image.png)", + "`[Code link](/Users/showhey/Repos/project/README.md)`", + "```", + "[Code block link](/Users/showhey/Repos/project/README.md)", + "```", + ].join("\n"), + 1, + ), + ], + }), + new Date(2026, 4, 18), + { vaultPath: "/Users/showhey/Vault" }, ); - expect(output).toBe( + expect(output).toContain( [ "[Vault note]()", "[Vault note with parens]()", @@ -171,8 +177,8 @@ describe("thread archive export", () => { ); }); - it("normalizes exported thread markdown links when vault path is provided", () => { - const output = markdownFromThread( + it("normalizes exported thread markdown links when vault path is provided", async () => { + const output = await exportedMarkdown( thread({ transcriptEntries: [ transcriptEntry( @@ -226,6 +232,27 @@ describe("thread archive export", () => { }); }); +async function exportedMarkdown( + source: Thread & { transcriptEntries: ThreadTranscriptEntry[] }, + now: Date, + settings: Partial = {}, +): Promise { + const adapter = new MemoryAdapter(); + const result = await exportArchivedThreadMarkdown( + source, + { + archiveExportFolderTemplate: "Exports", + archiveExportFilenameTemplate: "{{title}}", + ...settings, + }, + adapter, + now, + ); + const markdown = adapter.files.get(result.path); + if (markdown === undefined) throw new Error(`Expected exported markdown at ${result.path}`); + return markdown; +} + class MemoryAdapter implements ArchiveExportAdapter { readonly files = new Map(); readonly folders = new Set(); diff --git a/tests/features/thread-operations/title.test.ts b/tests/features/thread-operations/title.test.ts index ff310d81..6bacd93f 100644 --- a/tests/features/thread-operations/title.test.ts +++ b/tests/features/thread-operations/title.test.ts @@ -9,19 +9,14 @@ import type { } from "../../../src/app-server/connection/client"; import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn"; import type { RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages"; -import type { ModelMetadata } from "../../../src/domain/catalog/metadata"; import type { ServerInitialization } from "../../../src/domain/server/initialization"; import { generateThreadTitleWithCodex, - threadTitleFromGenerationTurn, - threadTitleRuntimeOverride, - validatedThreadTitleRuntimeOverride, type ThreadTitleClient, type ThreadTitleClientFactory, } from "../../../src/features/thread-operations/title-generation"; import { findThreadTitleContext, - normalizeGeneratedThreadTitle, threadTitleContextFromConversationSummary, threadTitleFromGeneratedText, threadTitlePrompt, @@ -81,26 +76,33 @@ describe("thread title", () => { ]); }); - it("parses structured title responses", () => { - expect( - threadTitleFromGenerationTurn( - turn([ - { - type: "agentMessage", - id: "a1", - text: '```json\n{"title":"Codex Panelの自動命名"}\n```', - phase: "final_answer", - memoryCitation: null, - }, - ]), - ), - ).toBe("Codex Panelの自動命名"); + it("parses structured title responses", async () => { + const { clientFactory } = fakeThreadTitleClientFactory((fake) => { + fake.startStructuredTurnImpl = async () => ({ + turn: turn( + [ + { + type: "agentMessage", + id: "a1", + text: '```json\n{"title":"Codex Panelの自動命名"}\n```', + phase: "final_answer", + memoryCitation: null, + }, + ], + { status: "completed" }, + ), + }); + }); + + await expect(generateThreadTitleWithCodex("/bin/codex", "/vault", titleContext(), runtimeSettings(), clientFactory)).resolves.toBe( + "Codex Panelの自動命名", + ); }); it("normalizes generated titles", () => { - expect(normalizeGeneratedThreadTitle(' ## "Codex Panelの自動命名"\n')).toBe("Codex Panelの自動命名"); - expect(normalizeGeneratedThreadTitle("")).toBeNull(); - expect(normalizeGeneratedThreadTitle("x".repeat(80))).toHaveLength(40); + expect(threadTitleFromGeneratedText(' ## "Codex Panelの自動命名"\n')).toBe("Codex Panelの自動命名"); + expect(threadTitleFromGeneratedText("")).toBeNull(); + expect(threadTitleFromGeneratedText("x".repeat(80))).toHaveLength(40); }); it("parses generated title text", () => { @@ -122,31 +124,67 @@ describe("thread title", () => { expect(prompt).not.toContain("English words"); }); - it("uses explicit title runtime overrides", () => { - expect(threadTitleRuntimeOverride({ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" })).toEqual({ + it("uses explicit title runtime overrides", async () => { + const { clientFactory, client } = fakeThreadTitleClientFactory((fake) => { + fake.startStructuredTurnImpl = async () => ({ turn: turn([], { status: "completed" }) }); + }); + + await generateThreadTitleWithCodex( + "/bin/codex", + "/vault", + titleContext(), + { threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" }, + clientFactory, + ); + + expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini", effort: "minimal", }); }); - it("omits title runtime overrides that are set to Codex default", () => { - expect(threadTitleRuntimeOverride({ threadNamingModel: null, threadNamingEffort: null })).toEqual({}); + it("omits title runtime overrides that are set to Codex default", async () => { + const { clientFactory, client } = fakeThreadTitleClientFactory((fake) => { + fake.startStructuredTurnImpl = async () => ({ turn: turn([], { status: "completed" }) }); + }); + + await generateThreadTitleWithCodex("/bin/codex", "/vault", titleContext(), runtimeSettings(), clientFactory); + + expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({}); }); - it("omits an explicit title effort when the selected model does not support it", () => { - expect( - validatedThreadTitleRuntimeOverride({ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" }, [ - model("gpt-5.4-mini", ["low", "medium", "high", "xhigh"]), - ]), - ).toEqual({ model: "gpt-5.4-mini" }); + it("omits an explicit title effort when the selected model does not support it", async () => { + const { clientFactory, client } = fakeThreadTitleClientFactory((fake) => { + fake.modelList = [appServerModel("gpt-5.4-mini", ["low", "medium", "high", "xhigh"])]; + fake.startStructuredTurnImpl = async () => ({ turn: turn([], { status: "completed" }) }); + }); + + await generateThreadTitleWithCodex( + "/bin/codex", + "/vault", + titleContext(), + { threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" }, + clientFactory, + ); + + expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini" }); }); - it("keeps an explicit title effort when the selected model supports it", () => { - expect( - validatedThreadTitleRuntimeOverride({ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "low" }, [ - model("gpt-5.4-mini", ["low", "medium", "high", "xhigh"]), - ]), - ).toEqual({ model: "gpt-5.4-mini", effort: "low" }); + it("keeps an explicit title effort when the selected model supports it", async () => { + const { clientFactory, client } = fakeThreadTitleClientFactory((fake) => { + fake.modelList = [appServerModel("gpt-5.4-mini", ["low", "medium", "high", "xhigh"])]; + fake.startStructuredTurnImpl = async () => ({ turn: turn([], { status: "completed" }) }); + }); + + await generateThreadTitleWithCodex( + "/bin/codex", + "/vault", + titleContext(), + { threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "low" }, + clientFactory, + ); + + expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini", effort: "low" }); }); it("keeps pre-acknowledgement completion when title notifications arrive before turn/start resolves", async () => { @@ -195,6 +233,8 @@ function fakeThreadTitleClientFactory(configure?: (client: FakeThreadTitleClient class FakeThreadTitleClient implements ThreadTitleClient { startStructuredTurnImpl: (() => Promise) | null = null; + startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null; + modelList: ModelListResponse["data"] = []; constructor(private readonly handlers: AppServerClientHandlers) {} @@ -207,7 +247,7 @@ class FakeThreadTitleClient implements ThreadTitleClient { } async listModels(): Promise { - return { data: [], nextCursor: null }; + return { data: this.modelList, nextCursor: null }; } rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void { @@ -218,7 +258,8 @@ class FakeThreadTitleClient implements ThreadTitleClient { return threadStartResponse("thread"); } - async startStructuredTurn(_options: AppServerStartStructuredTurnOptions): Promise { + async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise { + this.startStructuredTurnOptions = options; return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) }; } @@ -301,16 +342,24 @@ function turnCompletedNotification(threadId: string, completedTurn: Turn): Serve }; } -function model(name: string, efforts: string[]): ModelMetadata { +function appServerModel(name: string, efforts: string[]): ModelListResponse["data"][number] { return { id: name, model: name, + upgrade: null, + upgradeInfo: null, + availabilityNux: null, displayName: name, description: "", hidden: false, - supportedReasoningEfforts: efforts, - defaultReasoningEffort: efforts[0] ?? "low", + supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ + reasoningEffort: reasoningEffort as never, + label: reasoningEffort, + description: "", + })), + defaultReasoningEffort: (efforts[0] ?? "low") as never, inputModalities: ["text"], + supportsPersonality: false, additionalSpeedTiers: [], serviceTiers: [], defaultServiceTier: null, diff --git a/tests/features/thread-picker/modal.test.ts b/tests/features/thread-picker/modal.test.ts index 3b073e12..f8a72365 100644 --- a/tests/features/thread-picker/modal.test.ts +++ b/tests/features/thread-picker/modal.test.ts @@ -1,52 +1,125 @@ // @vitest-environment jsdom import { describe, expect, it } from "vitest"; +import { SuggestModal } from "obsidian"; import type { Thread } from "../../../src/domain/threads/model"; -import { threadOpenModeFromEvent, threadPickerSuggestions } from "../../../src/features/thread-picker/modal"; +import { openThreadPicker, type ThreadPickerHost } from "../../../src/features/thread-picker/modal"; describe("threadPickerSuggestions", () => { - it("orders title and id prefix matches before looser matches", () => { - const suggestions = threadPickerSuggestions( - [ - thread({ id: "thread-alpha", name: "Older Alpha", updatedAt: 10 }), - thread({ id: "thread-beta", name: "Recent unrelated alpha mention", updatedAt: 30 }), - thread({ id: "alpha-thread", name: "Newest unrelated", updatedAt: 40 }), - ], - "alpha", - ); + it("orders title and id prefix matches before looser matches", async () => { + const modal = await openedThreadPicker([ + thread({ id: "thread-alpha", name: "Older Alpha", updatedAt: 10 }), + thread({ id: "thread-beta", name: "Recent unrelated alpha mention", updatedAt: 30 }), + thread({ id: "alpha-thread", name: "Newest unrelated", updatedAt: 40 }), + ]); + const suggestions = modal.getSuggestions("alpha"); expect(suggestions.map((item) => item.thread.id)).toEqual(["alpha-thread", "thread-beta", "thread-alpha"]); }); - it("uses updated time for empty queries", () => { - const suggestions = threadPickerSuggestions([thread({ id: "older", updatedAt: 10 }), thread({ id: "newer", updatedAt: 20 })], ""); + it("uses updated time for empty queries", async () => { + const modal = await openedThreadPicker([thread({ id: "older", updatedAt: 10 }), thread({ id: "newer", updatedAt: 20 })]); + const suggestions = modal.getSuggestions(""); expect(suggestions.map((item) => item.thread.id)).toEqual(["newer", "older"]); }); - it("returns every matching thread", () => { - const suggestions = threadPickerSuggestions( + it("returns every matching thread", async () => { + const modal = await openedThreadPicker( Array.from({ length: 25 }, (_, index) => thread({ id: `thread-${String(index + 1)}`, name: "Match" })), - "match", ); + const suggestions = modal.getSuggestions("match"); expect(suggestions).toHaveLength(25); }); }); describe("threadOpenModeFromEvent", () => { - it("uses the current panel for plain Enter or mouse selection", () => { - expect(threadOpenModeFromEvent(new KeyboardEvent("keydown", { key: "Enter" }))).toBe("current"); - expect(threadOpenModeFromEvent(new MouseEvent("click"))).toBe("current"); + it("uses the current panel for plain Enter or mouse selection", async () => { + const host = threadPickerHost([thread({ id: "thread" })]); + const modal = await openedThreadPicker(host); + + modal.onChooseSuggestion(firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter" })); + modal.onChooseSuggestion(firstSuggestion(modal), new MouseEvent("click")); + + expect(host.openedCurrent).toEqual(["thread", "thread"]); + expect(host.openedAvailable).toEqual([]); }); - it("uses an available panel for Cmd/Ctrl+Enter", () => { - expect(threadOpenModeFromEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true }))).toBe("available"); - expect(threadOpenModeFromEvent(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }))).toBe("available"); + it("uses an available panel for Cmd/Ctrl+Enter", async () => { + const host = threadPickerHost([thread({ id: "thread" })]); + const modal = await openedThreadPicker(host); + + modal.onChooseSuggestion(firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter", metaKey: true })); + modal.onChooseSuggestion(firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true })); + + expect(host.openedCurrent).toEqual([]); + expect(host.openedAvailable).toEqual(["thread", "thread"]); }); }); +interface ThreadSuggestion { + thread: Thread; + title: string; +} + +interface CapturedThreadPickerModal { + getSuggestions(query: string): ThreadSuggestion[]; + onChooseSuggestion(item: ThreadSuggestion, evt: MouseEvent | KeyboardEvent): void; +} + +async function openedThreadPicker(input: readonly Thread[] | TestThreadPickerHost): Promise { + const captured: CapturedThreadPickerModal[] = []; + const originalOpen = Object.getOwnPropertyDescriptor(SuggestModal.prototype, "open"); + SuggestModal.prototype.open = function captureOpen(this: SuggestModal): void { + captured.push(this as CapturedThreadPickerModal); + }; + try { + await openThreadPicker(isThreadPickerHost(input) ? input : threadPickerHost(input)); + } finally { + if (originalOpen) Object.defineProperty(SuggestModal.prototype, "open", originalOpen); + } + const modal = captured[0]; + if (!modal) throw new Error("Expected thread picker modal to open"); + return modal; +} + +function firstSuggestion(modal: CapturedThreadPickerModal): ThreadSuggestion { + const suggestion = modal.getSuggestions("")[0]; + if (!suggestion) throw new Error("Expected thread picker suggestion"); + return suggestion; +} + +function isThreadPickerHost(input: readonly Thread[] | TestThreadPickerHost): input is TestThreadPickerHost { + return "cachedThreadList" in input; +} + +interface TestThreadPickerHost extends ThreadPickerHost { + openedCurrent: string[]; + openedAvailable: string[]; +} + +function threadPickerHost(threads: readonly Thread[]): TestThreadPickerHost { + const openedCurrent: string[] = []; + const openedAvailable: string[] = []; + return { + app: {} as never, + settings: { codexPath: "codex" } as never, + vaultPath: "/vault", + openedCurrent, + openedAvailable, + cachedThreadList: () => threads, + refreshThreadList: async () => threads, + openThreadInCurrentView: async (threadId) => { + openedCurrent.push(threadId); + }, + openThreadInAvailableView: async (threadId) => { + openedAvailable.push(threadId); + }, + }; +} + function thread(options: Partial & { id: string }): Thread { return { id: options.id, diff --git a/tests/features/threads-view/renderer.test.ts b/tests/features/threads-view/renderer.test.ts index a4df2971..eb52fe49 100644 --- a/tests/features/threads-view/renderer.test.ts +++ b/tests/features/threads-view/renderer.test.ts @@ -3,12 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderThreadsView } from "../../../src/features/threads-view/renderer"; -import { - liveStateForSnapshots, - selectedStateForSnapshots, - threadRows, - type ThreadsRowModel, -} from "../../../src/features/threads-view/state"; +import { threadRows, type ThreadsRowModel } from "../../../src/features/threads-view/state"; import type { Thread } from "../../../src/domain/threads/model"; import type { OpenCodexPanelSnapshot } from "../../../src/workspace/open-panel-snapshot"; import { changeInputValue, installObsidianDomShims } from "../../support/dom"; @@ -89,31 +84,48 @@ function threadsViewActions() { describe("threads view renderer decisions", () => { it("prioritizes open panel live state per thread", () => { expect( - liveStateForSnapshots([ - openPanelSnapshot({ viewId: "open", threadId: "thread" }), - openPanelSnapshot({ viewId: "running", threadId: "thread", turnLifecycle: { kind: "running", turnId: "turn" } }), - openPanelSnapshot({ viewId: "approval", threadId: "thread", pendingApprovals: 1 }), - openPanelSnapshot({ viewId: "input", threadId: "thread", pendingUserInputs: 1 }), - ]), + threadRows( + [threadFixture({ id: "thread" })], + [ + openPanelSnapshot({ viewId: "open", threadId: "thread" }), + openPanelSnapshot({ viewId: "running", threadId: "thread", turnLifecycle: { kind: "running", turnId: "turn" } }), + openPanelSnapshot({ viewId: "approval", threadId: "thread", pendingApprovals: 1 }), + openPanelSnapshot({ viewId: "input", threadId: "thread", pendingUserInputs: 1 }), + ], + new Map(), + )[0]?.live, ).toMatchObject({ status: "needs-input", label: "Needs input", viewId: "input", openPanels: 4 }); - expect(liveStateForSnapshots([openPanelSnapshot({ viewId: "draft", threadId: "thread", hasComposerDraft: true })])).toMatchObject({ + expect( + threadRows( + [threadFixture({ id: "thread" })], + [openPanelSnapshot({ viewId: "draft", threadId: "thread", hasComposerDraft: true })], + new Map(), + )[0]?.live, + ).toMatchObject({ status: "draft", label: "Draft", }); - expect(liveStateForSnapshots([openPanelSnapshot({ viewId: "offline", threadId: "thread", connected: false })])).toMatchObject({ + expect( + threadRows( + [threadFixture({ id: "thread" })], + [openPanelSnapshot({ viewId: "offline", threadId: "thread", connected: false })], + new Map(), + )[0]?.live, + ).toMatchObject({ status: "offline", label: "Offline", }); expect( - liveStateForSnapshots([openPanelSnapshot({ viewId: "none", threadId: null, turnLifecycle: { kind: "running", turnId: "turn" } })]), + threadRows( + [threadFixture({ id: "thread" })], + [openPanelSnapshot({ viewId: "none", threadId: null, turnLifecycle: { kind: "running", turnId: "turn" } })], + new Map(), + )[0]?.live, ).toBeNull(); }); it("marks a row selected when one open panel for the thread was last focused", () => { - expect(selectedStateForSnapshots([openPanelSnapshot({ threadId: null, lastFocused: true })])).toBe(false); - expect(selectedStateForSnapshots([openPanelSnapshot({ threadId: "thread", lastFocused: true })])).toBe(true); - const rows = threadRows( [ threadFixture({ id: "closed", preview: "Closed thread" }), diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index 85b67449..bfa1838e 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -7,8 +7,7 @@ import { } from "../../src/app-server/protocol/runtime-config"; import type { ModelMetadata } from "../../src/domain/catalog/metadata"; import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../src/features/chat/application/runtime/messages"; -import { compactModelLabel, compactReasoningEffortLabel } from "../../src/features/chat/presentation/runtime/messages"; -import { parseModelOverride, parseReasoningEffortOverride } from "../../src/features/chat/application/conversation/slash-command-execution"; +import { compactReasoningEffortLabel } from "../../src/features/chat/presentation/runtime/messages"; import { autoReviewActive, currentApprovalsReviewer, @@ -24,31 +23,19 @@ import type { RuntimeSnapshot } from "../../src/features/chat/application/runtim import { resetRuntimeSettingToConfig, setPendingRuntimeSetting } from "../../src/features/chat/domain/runtime/pending-settings"; import { pendingRuntimeSettingsPatch, - requestedTurnCollaborationModeSettings, serviceTierRequestForThreadStart, } from "../../src/features/chat/application/runtime/thread-settings-update"; -import { - contextSummary, - fastModeLabel, - runtimeConfigSections, - rateLimitSummary, - serviceTierLabel, -} from "../../src/features/chat/presentation/runtime/status"; +import { contextSummary, runtimeConfigSections, rateLimitSummary } from "../../src/features/chat/presentation/runtime/status"; + +function runtimeRows(snapshot: RuntimeSnapshot): Record { + return Object.fromEntries( + runtimeConfigSections(snapshot, "/vault") + .find((section) => section.title === "Runtime") + ?.rows.map((row) => [row.key, row.value]) ?? [], + ); +} describe("runtime settings", () => { - it("parses model overrides", () => { - expect(parseModelOverride("gpt-5.5")).toBe("gpt-5.5"); - expect(parseModelOverride(" default ")).toBeNull(); - expect(parseModelOverride("")).toBeUndefined(); - }); - - it("parses reasoning effort overrides", () => { - expect(parseReasoningEffortOverride("high")).toBe("high"); - expect(parseReasoningEffortOverride("default")).toBeNull(); - expect(parseReasoningEffortOverride("extreme")).toBe("extreme"); - expect(parseReasoningEffortOverride("CaseSensitive")).toBe("CaseSensitive"); - }); - it("formats runtime override messages", () => { expect(modelOverrideMessage("gpt-5.5")).toBe("Model set to gpt-5.5 for subsequent turns."); expect(modelOverrideMessage(null)).toBe("Model reset to default for subsequent turns."); @@ -57,9 +44,6 @@ describe("runtime settings", () => { }); it("formats compact runtime labels", () => { - expect(compactModelLabel("gpt-5.5")).toBe("5.5"); - expect(compactModelLabel("custom-model")).toBe("custom-model"); - expect(compactModelLabel(null)).toBe("default"); expect(compactReasoningEffortLabel("minimal")).toBe("min"); expect(compactReasoningEffortLabel("high")).toBe("high"); expect(compactReasoningEffortLabel(null)).toBe("default"); @@ -73,12 +57,6 @@ describe("runtime settings", () => { expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-5.5"); expect(currentReasoningEffort(snapshot, snapshotConfig(snapshot))).toBe("high"); - expect(requestedTurnCollaborationModeSettings(snapshot, snapshotConfig(snapshot))).toMatchObject({ - collaborationMode: { - mode: "default", - settings: { model: "gpt-5.5", reasoningEffort: "high" }, - }, - }); expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ update: { model: null, effort: null }, collaborationModeWarning: null, @@ -142,16 +120,18 @@ describe("runtime settings", () => { requestedReasoningEffort: setPendingRuntimeSetting("high"), }); - expect(requestedTurnCollaborationModeSettings(snapshot, snapshotConfig(snapshot))).toEqual({ - collaborationMode: { - mode: "plan", - settings: { - model: "gpt-5.5", - reasoningEffort: "high", - developerInstructions: null, + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { + collaborationMode: { + mode: "plan", + settings: { + model: "gpt-5.5", + reasoningEffort: "high", + developerInstructions: null, + }, }, }, - warning: null, + collaborationModeWarning: null, }); }); @@ -168,17 +148,6 @@ describe("runtime settings", () => { model_reasoning_effort: "high", }); - expect(requestedTurnCollaborationModeSettings(snapshot, explicitConfig)).toEqual({ - collaborationMode: { - mode: "plan", - settings: { - model: "explicit-model", - reasoningEffort: "high", - developerInstructions: null, - }, - }, - warning: null, - }); expect(pendingRuntimeSettingsPatch(snapshot, explicitConfig)).toMatchObject({ update: { collaborationMode: { @@ -194,6 +163,42 @@ describe("runtime settings", () => { }); }); + it("keeps collaboration mode settings separate from reviewer and direct runtime overrides", () => { + const reviewerSnapshot = runtimeSnapshot({ + selectedCollaborationMode: "plan", + requestedApprovalsReviewer: setPendingRuntimeSetting("auto_review"), + }); + const activeRuntimeSnapshot = runtimeSnapshot({ + selectedCollaborationMode: "plan", + activeModel: "gpt-5-active", + activeServiceTier: "fast", + runtimeConfig: runtimeConfigFixture({}), + }); + + expect(pendingRuntimeSettingsPatch(reviewerSnapshot, snapshotConfig(reviewerSnapshot))).toMatchObject({ + update: { + approvalsReviewer: "auto_review", + collaborationMode: { + mode: "plan", + settings: { model: "gpt-5.5", reasoningEffort: "high" }, + }, + }, + }); + expect( + pendingRuntimeSettingsPatch(reviewerSnapshot, snapshotConfig(reviewerSnapshot)).update.collaborationMode?.settings, + ).not.toHaveProperty("approvalsReviewer"); + expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot))).toMatchObject({ + update: { + collaborationMode: { + mode: "plan", + settings: { model: "gpt-5-active" }, + }, + }, + }); + expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot)).update).not.toHaveProperty("model"); + expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot)).update).not.toHaveProperty("effort"); + }); + it("resolves approval reviewer from requested, active, then effective config", () => { const requested = runtimeSnapshot({ requestedApprovalsReviewer: setPendingRuntimeSetting("user"), @@ -275,8 +280,8 @@ describe("runtime settings", () => { expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-profile"); expect(currentReasoningEffort(snapshot, snapshotConfig(snapshot))).toBe("high"); expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(serviceTierLabel(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("on"); + expect(runtimeRows(snapshot)["effective service tier"]).toBe("fast"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("on"); expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("fast"); }); @@ -287,7 +292,7 @@ describe("runtime settings", () => { }); expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("flex"); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("off"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("off"); }); it("treats the catalog Fast service tier id as fast mode while preserving the id", () => { @@ -302,9 +307,9 @@ describe("runtime settings", () => { }); expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("priority"); - expect(serviceTierLabel(snapshot, snapshotConfig(snapshot))).toBe("priority"); + expect(runtimeRows(snapshot)["effective service tier"]).toBe("priority"); expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("on"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("on"); expect(fastRuntimeServiceTierRequestValue(snapshot, snapshotConfig(snapshot))).toBe("priority"); }); @@ -319,9 +324,9 @@ describe("runtime settings", () => { }); expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("default"); - expect(serviceTierLabel(snapshot, snapshotConfig(snapshot))).toBe("default"); + expect(runtimeRows(snapshot)["effective service tier"]).toBe("default"); expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("off"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("off"); }); it("uses requested service tier above active and configured service tiers", () => { @@ -332,7 +337,7 @@ describe("runtime settings", () => { }); expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("off"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("off"); }); it("resolves requested approval reviewer without adding it to turn runtime settings", () => { @@ -340,7 +345,9 @@ describe("runtime settings", () => { expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); expect(currentApprovalsReviewer(snapshot, snapshotConfig(snapshot))).toBe("auto_review"); - expect(requestedTurnCollaborationModeSettings(snapshot, snapshotConfig(snapshot))).not.toHaveProperty("approvalsReviewer"); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { approvalsReviewer: "auto_review" }, + }); }); it("treats active thread runtime as display state without persisting it into turn overrides", () => { @@ -352,14 +359,8 @@ describe("runtime settings", () => { expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-5-active"); expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(requestedTurnCollaborationModeSettings(snapshot, snapshotConfig(snapshot))).toMatchObject({ - collaborationMode: { - mode: "default", - settings: { model: "gpt-5-active" }, - }, - }); - expect(requestedTurnCollaborationModeSettings(snapshot, snapshotConfig(snapshot))).not.toHaveProperty("model"); - expect(requestedTurnCollaborationModeSettings(snapshot, snapshotConfig(snapshot))).not.toHaveProperty("effort"); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot)).update).not.toHaveProperty("model"); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot)).update).not.toHaveProperty("effort"); }); it("separates effective runtime, config defaults, and pending changes in status details", () => { @@ -562,8 +563,8 @@ describe("runtime settings", () => { it("summarizes service tier and context meter state from one runtime snapshot", () => { const snapshot = runtimeSnapshot({ requestedServiceTier: setPendingRuntimeSetting("fast"), activeThreadId: "thread" }); - expect(serviceTierLabel(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("on"); + expect(runtimeRows(snapshot)["effective service tier"]).toBe("fast"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("on"); expect(contextSummary(snapshot)).toMatchObject({ label: "Context 0%", title: "Context: 0 / 100,000 (0%). No turns in this thread yet.", @@ -603,8 +604,8 @@ describe("runtime settings", () => { requestedServiceTier: setPendingRuntimeSetting("off"), }); - expect(serviceTierLabel(snapshot, snapshotConfig(snapshot))).toBe("(Codex default)"); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("off"); + expect(runtimeRows(snapshot)["effective service tier"]).toBe("(Codex default)"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("off"); expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBeNull(); }); @@ -614,8 +615,8 @@ describe("runtime settings", () => { requestedServiceTier: resetRuntimeSettingToConfig(), }); - expect(serviceTierLabel(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("on"); + expect(runtimeRows(snapshot)["effective service tier"]).toBe("fast"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("on"); expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBeNull(); }); @@ -628,7 +629,7 @@ describe("runtime settings", () => { availableModels: [model], }); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("on"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("on"); expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("priority"); }); @@ -641,8 +642,8 @@ describe("runtime settings", () => { it("passes through configured non-fast service tier ids", () => { const snapshot = runtimeSnapshot({ runtimeConfig: runtimeConfigFixture({ service_tier: "flex" }) }); - expect(serviceTierLabel(snapshot, snapshotConfig(snapshot))).toBe("flex"); - expect(fastModeLabel(snapshot, snapshotConfig(snapshot))).toBe("off"); + expect(runtimeRows(snapshot)["effective service tier"]).toBe("flex"); + expect(runtimeRows(snapshot)["fast mode"]).toBe("off"); expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("flex"); }); diff --git a/tests/runtime/service-tier-state.test.ts b/tests/runtime/service-tier-state.test.ts index 95e3d26f..fc49b36a 100644 --- a/tests/runtime/service-tier-state.test.ts +++ b/tests/runtime/service-tier-state.test.ts @@ -1,23 +1,68 @@ import { describe, expect, it } from "vitest"; -import { isFastServiceTier } from "../../src/features/chat/domain/runtime/effective"; +import { emptyRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../src/domain/runtime/config"; +import type { ModelMetadata } from "../../src/domain/catalog/metadata"; +import { fastModeActive } from "../../src/features/chat/domain/runtime/effective"; +import type { RuntimeSnapshot } from "../../src/features/chat/domain/runtime/snapshot"; describe("service tier runtime state", () => { it("recognizes Codex fast tier aliases without rejecting other tier ids", () => { - expect(isFastServiceTier("fast")).toBe(true); - expect(isFastServiceTier("priority")).toBe(true); - expect(isFastServiceTier("catalog-fast", [{ id: "catalog-fast", name: "Fast" }])).toBe(true); - expect(isFastServiceTier("priority", [{ id: "priority", name: "Priority" }])).toBe(false); - expect(isFastServiceTier("flex", [{ id: "flex", name: "Flex" }])).toBe(false); + expect(fastMode("fast")).toBe(true); + expect(fastMode("priority")).toBe(true); + expect(fastMode("catalog-fast", [{ id: "catalog-fast", name: "Fast" }])).toBe(true); + expect(fastMode("priority", [{ id: "priority", name: "Priority" }])).toBe(false); + expect(fastMode("flex", [{ id: "flex", name: "Flex" }])).toBe(false); }); it("locks the Codex 0.134.0 Fast catalog semantics observed from app-server", () => { // Codex app-server 0.134.0 reports Fast as serviceTiers[{ id: "priority", name: "Fast" }]. const codex01340FastTier = { id: "priority", name: "Fast" }; - expect(isFastServiceTier("priority", [codex01340FastTier])).toBe(true); + expect(fastMode("priority", [codex01340FastTier])).toBe(true); // Clearing Fast with serviceTier: null is reported back by 0.134.0 as "default", not null. - expect(isFastServiceTier("default", [codex01340FastTier])).toBe(false); + expect(fastMode("default", [codex01340FastTier])).toBe(false); }); }); + +function fastMode(serviceTier: string | null, serviceTiers: ModelMetadata["serviceTiers"] = []): boolean { + const config: RuntimeConfigSnapshot = { ...emptyRuntimeConfigSnapshot(), model: "gpt-5.5" }; + return fastModeActive( + { + runtimeConfig: config, + activeThreadId: "thread", + activeModel: "gpt-5.5", + activeReasoningEffort: null, + activeCollaborationMode: "default", + activeServiceTier: serviceTier, + activeApprovalPolicy: null, + activeApprovalsReviewer: null, + activePermissionProfile: null, + requestedModel: { kind: "unchanged" }, + requestedReasoningEffort: { kind: "unchanged" }, + requestedApprovalsReviewer: { kind: "unchanged" }, + selectedCollaborationMode: "default", + requestedServiceTier: { kind: "unchanged" }, + tokenUsage: null, + rateLimit: null, + hasThreadTurns: false, + availableModels: [ + { + id: "gpt-5.5", + model: "gpt-5.5", + displayName: "GPT-5.5", + description: "", + hidden: false, + isDefault: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: [], + additionalSpeedTiers: [], + serviceTiers, + defaultServiceTier: null, + }, + ], + } satisfies RuntimeSnapshot, + config, + ); +}