Hide test-only helper exports

This commit is contained in:
murashit 2026-06-15 05:57:32 +09:00
parent 4108529a91
commit 89099deed6
72 changed files with 1173 additions and 1128 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string>();
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<string>();
for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) {

View file

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

View file

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

View file

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

View file

@ -13,11 +13,11 @@ export interface RestoredThreadState {
export type ChatResumeLifecycleState = { kind: "idle" } | { kind: "resuming"; threadId: string };
export type ActiveChatResume = Extract<ChatResumeLifecycleState, { kind: "resuming" }>;
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;

View file

@ -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<RuntimeSettingsPatch["collaborationMode"]>;
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" };

View file

@ -98,7 +98,7 @@ export function messageStreamIsEmpty(state: Pick<ChatMessageStreamState, "stable
return state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0);
}
export function messageStreamTurnIds(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): string[] {
function messageStreamTurnIds(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): string[] {
return orderedTurnIds(messageStreamItems(state));
}

View file

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

View file

@ -131,7 +131,7 @@ export function createThreadManagementActions(context: ThreadManagementActionsCo
};
}
export async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
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<boolean> {
async function renameThread(host: RenameThreadHost, threadId: string, value: string): Promise<boolean> {
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<void> {
async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
if (chatTurnBusy(threadManagementState(host))) {
host.addSystemMessage(interruptBeforeRollbackMessage());
return;

View file

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

View file

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

View file

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

View file

@ -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<MessageStreamItemKind, "tool" | "hook" | "reasoning"> = "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,

View file

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

View file

@ -155,7 +155,7 @@ function createChatPanelSessionDeferredRef<T>(name: string): ChatPanelSessionDef
};
}
export interface ChatPanelWarmupHost {
interface ChatPanelWarmupHost {
deferredTasks: ChatViewDeferredTasks;
opened: () => boolean;
closing: () => boolean;
@ -163,7 +163,7 @@ export interface ChatPanelWarmupHost {
ensureConnected: () => Promise<void>;
}
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);

View file

@ -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<string, unknown>;

View file

@ -39,14 +39,14 @@ export interface ChatPanelComposerActions {
submit: () => void;
}
export interface RuntimeComposerChoicesInput {
interface RuntimeComposerChoicesInput {
state: Pick<ChatState, "connection">;
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<ChatPanelComposerMeta, "modelChoices" | "effortChoices"> {
@ -125,7 +125,7 @@ export function composerMetaViewModel(
};
}
export function runtimeComposerChoices(input: RuntimeComposerChoicesInput): {
function runtimeComposerChoices(input: RuntimeComposerChoicesInput): {
modelChoices: ChatPanelComposerRuntimeChoice[];
effortChoices: ChatPanelComposerRuntimeChoice[];
} {

View file

@ -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,
): {

View file

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

View file

@ -14,7 +14,7 @@ import { runtimeSnapshotForToolbarShellState } from "./runtime-snapshot";
type ToolbarState = Pick<ChatState, "connection" | "threadList" | "activeThread" | "ui">;
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<ToolbarViewModelInput, "state" | "turnBusy" | "archiveExportEnabled">,
): ToolbarStateProjection {
function toolbarStateProjection(input: Pick<ToolbarViewModelInput, "state" | "turnBusy" | "archiveExportEnabled">): ToolbarStateProjection {
const historyOpen = input.state.ui.toolbarPanel === "history";
const chatActionsOpen = input.state.ui.toolbarPanel === "chat-actions";
const statusPanelOpen = input.state.ui.toolbarPanel === "status-panel";

View file

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

View file

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

View file

@ -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<HTMLAnchorElement>("a.internal-link").forEach((link) => {
link.addClass("codex-panel__wikilink");
link.onclick = (event) => {

View file

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

View file

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

View file

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

View file

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

View file

@ -55,7 +55,7 @@ export async function exportArchivedThreadMarkdown(
return { path };
}
export function markdownFromThread(thread: ArchiveThreadInput, exportedAt = new Date(), settings?: Partial<ArchiveExportSettings>): string {
function markdownFromThread(thread: ArchiveThreadInput, exportedAt = new Date(), settings?: Partial<ArchiveExportSettings>): 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<string>();
const tags: string[] = [];
for (const rawTag of value.split(",")) {

View file

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

View file

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

View file

@ -39,7 +39,7 @@ export async function openThreadPicker(host: ThreadPickerHost): Promise<void> {
}
}
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";
}

View file

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

View file

@ -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<AppServerRpcError>);
});
});
it("resumes and forks threads without loading full turn history", async () => {

View file

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

View file

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

View file

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

View file

@ -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> = {}): Thread {
return {
@ -22,7 +18,7 @@ function thread(overrides: Partial<Thread> = {}): 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: "この続きです",

View file

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

View file

@ -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<T>(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<typeof activeComposerSuggestions>[1]) {
return activeComposerSuggestions(`[[${query}`, notes, []);
}
function thread(overrides: Partial<Thread> = {}): 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({

View file

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

View file

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

View file

@ -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 <thread> - Archive the selected Codex thread.",
);
expect(slashCommandHelpValue("/archive <thread>")).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 <thread> <name> - Rename the selected Codex thread.",
);
expect(slashCommandHelpValue("/rename <thread> <name>")).toBe("Rename the selected Codex thread.");
});
it("documents refer history size", () => {
expect(slashCommandHelpLines().find((line) => line.startsWith("/refer"))).toBe(
"/refer <thread> <message> - Send a message with recent turns from another non-archived thread.",
);
expect(slashCommandHelpValue("/refer <thread> <message>")).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;
}

View file

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

View file

@ -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<T>(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("<proposed_plan>\n## Summary\n- Ship it\n</proposed_plan>")).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", "<proposed_plan>\n# Plan");
const updated = appendPlanDelta(items, "p1", "t1", "\n</proposed_plan>");
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<Partial<Extract<TurnItem, { type: "commandExecution" }>>, "status"> & { status?: string };
type FileChangeOverrides = Omit<Partial<Extract<TurnItem, { type: "fileChange" }>>, "status"> & { status?: string };
type McpToolCallOverrides = Omit<Partial<Extract<TurnItem, { type: "mcpToolCall" }>>, "status"> & { status?: string };
type DynamicToolCallOverrides = Omit<Partial<Extract<TurnItem, { type: "dynamicToolCall" }>>, "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<TurnItem, { type: "commandExecution" }>,
"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<TurnItem, { type: "fileChange" }>,
"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<TurnItem, { type: "mcpToolCall" }>,
"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<TurnItem, { type: "dynamicToolCall" }>,
"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"] },
});
}

View file

@ -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<typeof markdownLinkContext>,
linkOptions: Parameters<HTMLElement["createEl"]>[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<HTMLAnchorElement>("a.internal-link");
if (!link) throw new Error("Expected rendered internal link");
return {
link,
cleanup: () => {
parent.remove();
},
};
}
interface TestMessageStreamPresenter {
presenter: MessageStreamPresenter;
scrollBridge: MessageStreamScrollBridge;

View file

@ -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<HTMLButtonElement>(".codex-panel__new-chat")?.disabled).toBe(true);
expect(parent.querySelector<HTMLInputElement>(".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<HTMLElement>(".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<HTMLTextAreaElement>(".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<HTMLButtonElement>("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> = {}): ChatPanelComposerSurface {
return {
thread: {

View file

@ -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<ServerNotification, { method: "turn/completed" }>;
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<ServerNotification, { method: "thread/started" }>;
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");
});

View file

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

View file

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

View file

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

View file

@ -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> = {}): Thread {
return {
id: "thread-1",
preview: "",
createdAt: 1,
updatedAt: 1,
name: null,
archived: false,
...overrides,
};
}

View file

@ -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<HTMLElement>(".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<typeof renderUiRoot>[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<HTMLElement>(".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 }));
}

View file

@ -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> = {}): Thread {
return {
id: "thread-1",
preview: "",
createdAt: 1,
updatedAt: 1,
name: null,
archived: false,
...overrides,
};
}
function turnWithUserMessage(text: string) {
return {
id: "turn-1",

View file

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

View file

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

View file

@ -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> = {}): 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<TurnStartResponse>) | null = null;
readonly structuredTurnStarted: Promise<void>;
private resolveStructuredTurnStarted!: () => void;
@ -715,7 +686,7 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient {
}
async listModels(): Promise<ModelListResponse> {
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<TurnStartResponse> {
async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
this.startStructuredTurnOptions = options;
this.resolveStructuredTurnStarted();
return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
}

View file

@ -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](</Users/showhey/Vault/topics/My Note.md>)",
"[Vault note with parens](</Users/showhey/Vault/topics/My (Note).md>)",
"[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](</Users/showhey/Vault/topics/My Note.md>)",
"[Vault note with parens](</Users/showhey/Vault/topics/My (Note).md>)",
"[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](<topics/My Note.md>)",
"[Vault note with parens](<topics/My (Note).md>)",
@ -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<ArchiveExportSettings> = {},
): Promise<string> {
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<string, string>();
readonly folders = new Set<string>();

View file

@ -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<TurnStartResponse>) | 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<ModelListResponse> {
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<TurnStartResponse> {
async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
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,

View file

@ -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<CapturedThreadPickerModal> {
const captured: CapturedThreadPickerModal[] = [];
const originalOpen = Object.getOwnPropertyDescriptor(SuggestModal.prototype, "open");
SuggestModal.prototype.open = function captureOpen(this: SuggestModal<ThreadSuggestion>): 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<Thread> & { id: string }): Thread {
return {
id: options.id,

View file

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

View file

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

View file

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