mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Hide test-only helper exports
This commit is contained in:
parent
4108529a91
commit
89099deed6
72 changed files with 1173 additions and 1128 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
} {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
): {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(",")) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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: "この続きです",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
"",
|
||||
"`[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)",
|
||||
"",
|
||||
"`[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>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue