refactor: remove test-only source exports

This commit is contained in:
murashit 2026-07-20 18:09:42 +09:00
parent 8d952fcfbf
commit b00f6292c4
45 changed files with 410 additions and 293 deletions

View file

@ -11,7 +11,7 @@ export interface ConnectionManagerHandlers {
onExit: () => void;
}
export type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
type ConnectionLifecycleState =
| { kind: "idle"; generation: number }

View file

@ -24,10 +24,7 @@ export interface AppServerTransportHandlers {
onError: (error: Error) => void;
}
export function createAppServerSpawnSpec(
codexPath: string,
options: { platform?: NodeJS.Platform; comSpec?: string } = {},
): AppServerSpawnSpec {
function createAppServerSpawnSpec(codexPath: string, options: { platform?: NodeJS.Platform; comSpec?: string } = {}): AppServerSpawnSpec {
const platform = options.platform ?? process.platform;
if (platform !== "win32" || !isWindowsCommandScript(codexPath)) {
return { command: codexPath, args: ["app-server"], killProcessTreeOnStop: false };

View file

@ -26,8 +26,8 @@ export interface AppServerTurnInput {
additionalContext?: Record<string, AppServerAdditionalContextEntry>;
}
export const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
export const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
export function toAppServerUserInput(input: readonly CodexInputItem[], manifest: TurnContextManifest | null = null): AppServerUserInput[] {
const userInput = input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
@ -37,13 +37,6 @@ export function toAppServerUserInput(input: readonly CodexInputItem[], manifest:
return userInput;
}
export function additionalContextFromCodexInput(
input: readonly CodexInputItem[],
submissionId = "submission",
): Record<string, AppServerAdditionalContextEntry> | undefined {
return appServerTurnInputFromCodexInput(input, submissionId).additionalContext;
}
export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput {
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
const manifest: TurnContextManifestEntry[] = [];

View file

@ -38,7 +38,7 @@ export interface EphemeralStructuredTurnClient {
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
export type EphemeralStructuredTurnClientFactory = (
type EphemeralStructuredTurnClientFactory = (
codexPath: string,
cwd: string,
handlers: AppServerClientHandlers,

View file

@ -79,7 +79,7 @@ export function boundedTurnContextManifest(
return { ...base, ...(included.length > 0 ? { fileReferences: included } : {}) };
}
export function turnContextManifestFromText(text: string): TurnContextManifest | null {
function turnContextManifestFromText(text: string): TurnContextManifest | null {
const trimmed = text.trim();
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null;
if (utf8ByteLength(trimmed) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;

View file

@ -19,7 +19,7 @@ export interface RuntimeConfigSnapshot {
readonly autoCompactTokenLimit: number | null;
}
export function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
return {
profile: null,
model: null,

View file

@ -125,12 +125,6 @@ export function upsertMcpServerDiagnostic(diagnostics: Diagnostics, server: McpS
};
}
export function upsertMcpServerStatusDiagnostics(diagnostics: Diagnostics, servers: readonly McpServerStatusSummary[]): Diagnostics {
let next = diagnostics;
for (const server of servers) next = upsertMcpServerDiagnostic(next, mcpServerDiagnosticFromStatus(server));
return next;
}
export function replaceMcpServerStatusDiagnostics(diagnostics: Diagnostics, servers: readonly McpServerStatusSummary[]): Diagnostics {
return {
...diagnostics,

View file

@ -4,7 +4,7 @@ export type ThreadRenameLifecycleState =
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number };
export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
export type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
export type ThreadRenameLifecycleEvent =
| { type: "started"; draft: string }

View file

@ -34,10 +34,6 @@ export interface ComposerContextReferenceProvider {
dispose(): void;
}
export function emptyComposerContextReferences(): ComposerContextReferences {
return { activeNote: null, selection: null, activeNoteSnapshots: [], selectionSnapshots: [] };
}
export function formatComposerContextRange(range: ComposerContextRange): string {
return `${formatComposerContextPosition(range.from)}-${formatComposerContextPosition(range.to)}`;
}

View file

@ -1,4 +1,4 @@
import { type ActivePanelOperation, activePanelOperationDecisionForFacts } from "../panel-operation-policy";
import type { ActivePanelOperation } from "../panel-operation-policy";
type SlashCommandArgsKind =
| "none"
@ -217,15 +217,6 @@ export function slashCommandRequiresConnection(command: SlashCommandName): boole
return !CONNECTION_INDEPENDENT_SLASH_COMMANDS.has(command);
}
export function slashCommandAvailableInSideChat(command: SlashCommandName): boolean {
const operation = activePanelOperationForSlashCommandSuggestion(command);
return (
!operation ||
activePanelOperationDecisionForFacts({ phase: "active", lifetime: "ephemeral", provenance: "interactive" }, operation).kind ===
"allowed"
);
}
/** Maps a parsed command to the active-panel operation it performs, if any. */
export function activePanelOperationForSlashCommand(command: SlashCommandName, args: string): ActivePanelOperation | null {
switch (command) {

View file

@ -7,16 +7,16 @@ const STATUS_CONNECTION_STARTING = "Starting Codex app-server...";
const STATUS_CONNECTED = "Connected.";
const STATUS_CONNECTION_FAILED = "Connection failed.";
export interface ChatConnectionAdapter {
interface ChatConnectionAdapter {
connect(): Promise<ServerInitialization>;
isConnected(): boolean;
}
export interface ChatConnectionMetadataActions {
interface ChatConnectionMetadataActions {
refreshAppServerMetadata: () => Promise<unknown>;
}
export interface ChatConnectionDiagnosticsActions {
interface ChatConnectionDiagnosticsActions {
refreshServerDiagnostics: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
}

View file

@ -24,7 +24,7 @@ export type ActivePanelOperationDecision =
| { readonly kind: "blocked"; readonly message: string }
| { readonly kind: "resume-required" };
export type ActivePanelThreadFacts =
type ActivePanelThreadFacts =
| { readonly phase: "empty" }
| { readonly phase: "awaiting-resume"; readonly provenance: "interactive" | "subagent" | null }
| {
@ -39,7 +39,7 @@ export function activePanelOperationDecision(state: ChatState, operation: Active
return activePanelOperationDecisionForFacts(activePanelThreadFacts(state), operation);
}
export function activePanelOperationDecisionForFacts(
function activePanelOperationDecisionForFacts(
facts: ActivePanelThreadFacts,
operation: ActivePanelOperation,
): ActivePanelOperationDecision {

View file

@ -24,7 +24,7 @@ interface PlanImplementationState {
threadStream: Pick<ChatThreadStreamState, "stableItems" | "activeSegment">;
}
export function implementPlanTargetFromState(state: ChatState): PlanImplementationTarget | null {
function implementPlanTargetFromState(state: ChatState): PlanImplementationTarget | null {
return implementPlanTarget({
activeThread: activeThreadState(state),
modeAllowed: activePanelOperationDecision(state, "implement-plan").kind === "allowed",

View file

@ -54,14 +54,14 @@ export interface ChatPanelConnectionBundle {
refreshSharedThreads: () => Promise<void>;
}
export interface DeferredDiagnosticsRefreshHost {
interface DeferredDiagnosticsRefreshHost {
scheduleDiagnostics(callback: () => void): void;
isConnected(): boolean;
refreshServerDiagnostics(options: { appServerMetadataSnapshot: true }): Promise<void>;
addSystemMessage(text: string): void;
}
export function scheduleDeferredDiagnosticsRefresh(host: DeferredDiagnosticsRefreshHost): void {
function scheduleDeferredDiagnosticsRefresh(host: DeferredDiagnosticsRefreshHost): void {
host.scheduleDiagnostics(() => {
if (!host.isConnected()) return;
void host.refreshServerDiagnostics({ appServerMetadataSnapshot: true }).catch((error: unknown) => {
@ -70,14 +70,14 @@ export function scheduleDeferredDiagnosticsRefresh(host: DeferredDiagnosticsRefr
});
}
export interface ServerRequestResponderRegistry {
interface ServerRequestResponderRegistry {
remember(requestId: RespondRequestId, responder: AppServerServerRequestResponder): void;
respond(requestId: RespondRequestId, result: unknown): boolean;
reject(requestId: RejectRequestId, code: number, message: string): boolean;
clear(): void;
}
export function createServerRequestResponderRegistry(): ServerRequestResponderRegistry {
function createServerRequestResponderRegistry(): ServerRequestResponderRegistry {
const responders = new Map<RespondRequestId, AppServerServerRequestResponder>();
const take = (requestId: RespondRequestId): AppServerServerRequestResponder | null => {
const responder = responders.get(requestId) ?? null;

View file

@ -26,7 +26,7 @@ export function configuredDailyNoteReferences(app: App, sourcePath: string): rea
}
}
export function dailyNoteReferencesFromSettings(
function dailyNoteReferencesFromSettings(
app: App,
sourcePath: string,
settings: IPeriodicNoteSettings,

View file

@ -72,7 +72,7 @@ export interface ChatComposerControllerOptions {
onAttachmentError?: (message: string) => void;
}
export interface ChatComposerRenderActions {
interface ChatComposerRenderActions {
submit: () => void;
}

View file

@ -15,7 +15,7 @@ import {
syncComposerHeight,
} from "./composer.dom";
export interface ComposerSuggestion {
interface ComposerSuggestion {
display: string;
detail: string;
replacement: string;

View file

@ -18,7 +18,7 @@ type SettingsDynamicSectionLifecycleState =
| { kind: "loaded"; status: string }
| { kind: "failed"; status: string };
export interface SettingsDynamicSectionsSnapshot {
interface SettingsDynamicSectionsSnapshot {
archivedThreads: readonly Thread[];
archivedThreadsLifecycle: SettingsDynamicSectionLifecycleState;
archivedThreadsLoaded: boolean;

View file

@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppServerClient } from "../../src/app-server/connection/client";
import {
type AppServerClientFactory,
ConnectionManager,
type ConnectionManagerHandlers,
StaleConnectionError,
@ -186,6 +185,8 @@ function silentConnectionHandlers(): ConnectionManagerHandlers {
};
}
type AppServerClientFactory = NonNullable<ConstructorParameters<typeof ConnectionManager>[3]>;
function testClientFactory(options: {
requestTimeoutMs?: number;
onTransport: (transport: SilentTransport) => void;

View file

@ -4,11 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../src/app-server/connection/client";
import type { ClientRequestParams } from "../../src/app-server/connection/rpc-messages";
import type { TurnItem, TurnRecord } from "../../src/app-server/protocol/turn";
import {
type EphemeralStructuredTurnClient,
type EphemeralStructuredTurnClientFactory,
runEphemeralStructuredTurn,
} from "../../src/app-server/services/ephemeral-structured-turn";
import { type EphemeralStructuredTurnClient, runEphemeralStructuredTurn } from "../../src/app-server/services/ephemeral-structured-turn";
import type { AppServerStartEphemeralThreadOptions } from "../../src/app-server/services/threads";
import type { AppServerStartStructuredTurnOptions } from "../../src/app-server/services/turns";
import type { InitializeResponse } from "../../src/generated/app-server/InitializeResponse";
@ -302,6 +298,8 @@ function runOptions(): Parameters<typeof runEphemeralStructuredTurn>[0] {
};
}
type EphemeralStructuredTurnClientFactory = NonNullable<Parameters<typeof runEphemeralStructuredTurn>[1]>["clientFactory"];
function fakeStructuredTurnClientFactory(configure?: (client: FakeStructuredTurnClient) => void): {
clientFactory: EphemeralStructuredTurnClientFactory;
client: { current: FakeStructuredTurnClient | null };

View file

@ -1,15 +1,22 @@
import { describe, expect, it } from "vitest";
import {
ADDITIONAL_CONTEXT_MAX_PARTS,
ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES,
additionalContextFromCodexInput,
appServerTurnInputFromCodexInput,
toAppServerUserInput,
} from "../../src/app-server/protocol/request-input";
import { appServerTurnInputFromCodexInput, toAppServerUserInput } from "../../src/app-server/protocol/request-input";
import { utf8ByteLength } from "../../src/domain/chat/context-budget";
import { turnContextManifestFromText } from "../../src/domain/chat/context-manifest";
import { type TurnContextManifest, userMessageContextProjection } from "../../src/domain/chat/context-manifest";
import { type CodexInput, codexTextInputWithAttachments, codexTextInputWithReferences } from "../../src/domain/chat/input";
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
const PANEL_SUBMISSION_ID = "local-user-1-seed-1-1";
function turnContextManifestFromText(text: string, submissionId: string): TurnContextManifest | null {
return userMessageContextProjection(
[
{ type: "text", text: "visible request" },
{ type: "text", text: text.startsWith("\n") ? text : `\n${text}` },
],
submissionId,
).manifest;
}
describe("app-server request input", () => {
it("builds text input with file references and skills", () => {
expect(
@ -61,7 +68,7 @@ describe("app-server request input", () => {
);
expect(toAppServerUserInput(input)).toEqual([{ type: "text", text: "Use [[Note]]", text_elements: [] }]);
expect(additionalContextFromCodexInput(input, "local-user")).toEqual({
expect(appServerTurnInputFromCodexInput(input, "local-user").additionalContext).toEqual({
"codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": {
kind: "untrusted",
value: "Codex Panel context part 1/1.\nSource: codex_panel_obsidian_context\n\n- [[Note]] -> Note.md",
@ -82,7 +89,7 @@ describe("app-server request input", () => {
expect(prepared.input).toHaveLength(2);
expect(prepared.input).not.toContainEqual(expect.objectContaining({ type: "mention" }));
const descriptor = prepared.input.at(-1);
const manifest = descriptor?.type === "text" ? turnContextManifestFromText(descriptor.text) : null;
const manifest = descriptor?.type === "text" ? turnContextManifestFromText(descriptor.text, "local-user-1-seed-1-1") : null;
expect(manifest).toEqual({
version: 2,
submissionId: "local-user-1-seed-1-1",
@ -115,7 +122,7 @@ describe("app-server request input", () => {
const descriptor = prepared.input.at(-1);
const descriptorText = descriptor?.type === "text" ? descriptor.text : "";
const manifest = turnContextManifestFromText(descriptorText);
const manifest = turnContextManifestFromText(descriptorText, "local-user-1-seed-1-1");
expect(utf8ByteLength(descriptorText)).toBeLessThanOrEqual(2_801);
expect(manifest?.contexts).toEqual([expect.objectContaining({ kind: "web" })]);
expect(manifest?.fileReferences?.length).toBeGreaterThan(0);
@ -135,7 +142,7 @@ describe("app-server request input", () => {
attachment: { kind: "web" },
},
],
"local-user-1",
PANEL_SUBMISSION_ID,
);
const entries = Object.entries(prepared.additionalContext ?? {});
@ -143,11 +150,10 @@ describe("app-server request input", () => {
expect(entries.map(([key]) => key)).toEqual([...entries.map(([key]) => key)].sort());
expect(entries.every(([, entry]) => utf8ByteLength(entry.value) < 4_000)).toBe(true);
expect(entries.every(([, entry]) => entry.value.includes("Codex Panel context part"))).toBe(true);
expect(ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES).toBeLessThan(4_000);
const manifestInput = prepared.input.at(-1);
expect(manifestInput?.type).toBe("text");
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text) : null;
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text, PANEL_SUBMISSION_ID) : null;
expect(manifest?.contexts).toEqual([
expect.objectContaining({
kind: "web",
@ -180,13 +186,13 @@ describe("app-server request input", () => {
attachment: { kind: "obsidian", inlineExcerpts: 1 },
},
],
"local-user",
PANEL_SUBMISSION_ID,
);
const firstPart = Object.values(prepared.additionalContext ?? {})[0];
expect(firstPart?.value).toContain(reference);
const manifestInput = prepared.input.at(-1);
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text) : null;
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text, PANEL_SUBMISSION_ID) : null;
expect(manifest?.contexts).toEqual([expect.objectContaining({ kind: "obsidian", inlineExcerpts: 1, truncated: true })]);
});
@ -195,8 +201,8 @@ describe("app-server request input", () => {
{ type: "text", text: "read it" },
{ type: "additionalContext", key: "same", kind: "untrusted", value: "same" },
];
const first = Object.keys(additionalContextFromCodexInput(input, "first") ?? {});
const second = Object.keys(additionalContextFromCodexInput(input, "second") ?? {});
const first = Object.keys(appServerTurnInputFromCodexInput(input, "first").additionalContext ?? {});
const second = Object.keys(appServerTurnInputFromCodexInput(input, "second").additionalContext ?? {});
expect(first).not.toEqual(second);
});
@ -207,14 +213,14 @@ describe("app-server request input", () => {
{ type: "additionalContext", key: "web", kind: "untrusted", value: "w".repeat(30_000), attachment: { kind: "web" } },
{ type: "additionalContext", key: "selection", kind: "untrusted", value: "selected text" },
],
"local-user",
PANEL_SUBMISSION_ID,
);
const entries = Object.entries(prepared.additionalContext ?? {});
expect(entries).toHaveLength(ADDITIONAL_CONTEXT_MAX_PARTS);
expect(entries.some(([key, entry]) => key.includes(".selection.") && entry.value.includes("selected text"))).toBe(true);
const manifestInput = prepared.input.at(-1);
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text) : null;
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text, PANEL_SUBMISSION_ID) : null;
expect(manifest?.contexts.map((context) => context.parts)).toEqual([7]);
});

View file

@ -1,12 +1,8 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type AppServerTransportHandlers,
createAppServerSpawnSpec,
StdioAppServerTransport,
} from "../../src/app-server/connection/transport";
import { type AppServerTransportHandlers, StdioAppServerTransport } from "../../src/app-server/connection/transport";
const spawnMock = vi.hoisted(() => vi.fn());
@ -19,42 +15,16 @@ interface TestableTransport {
flushStderr(): void;
}
describe("createAppServerSpawnSpec", () => {
it("starts regular commands directly", () => {
expect(createAppServerSpawnSpec("codex", { platform: "darwin" })).toEqual({
command: "codex",
args: ["app-server"],
killProcessTreeOnStop: false,
});
});
it("starts Windows executables directly", () => {
const codexExe = String.raw`C:\Program Files\Codex\codex.exe`;
expect(createAppServerSpawnSpec(codexExe, { platform: "win32" })).toEqual({
command: codexExe,
args: ["app-server"],
killProcessTreeOnStop: false,
});
});
it("quotes Windows command shim paths that contain spaces", () => {
const codexBat = String.raw`C:\Program Files\nodejs\codex.bat`;
expect(createAppServerSpawnSpec(codexBat, { platform: "win32", comSpec: " " })).toEqual({
command: "cmd.exe",
args: ["/d", "/s", "/c", `""${codexBat}" app-server"`],
killProcessTreeOnStop: true,
windowsVerbatimArguments: true,
});
});
});
describe("StdioAppServerTransport", () => {
beforeEach(() => {
spawnMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("starts once, forwards stdout lines, and reports its running state", () => {
const child = fakeChildProcess();
spawnMock.mockReturnValue(child);
@ -73,6 +43,38 @@ describe("StdioAppServerTransport", () => {
expect(() => instance.start()).toThrow("Codex app-server is already running.");
});
it("starts Windows executables directly", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const child = fakeChildProcess();
const codexExe = String.raw`C:\Program Files\Codex\codex.exe`;
spawnMock.mockReturnValue(child);
transportInstance(codexExe).instance.start();
expect(spawnMock).toHaveBeenCalledWith(codexExe, ["app-server"], {
cwd: "/vault",
stdio: ["pipe", "pipe", "pipe"],
windowsVerbatimArguments: undefined,
});
});
it("quotes Windows command shim paths that contain spaces", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.stubEnv("ComSpec", " ");
vi.stubEnv("COMSPEC", " ");
const child = fakeChildProcess();
const codexBat = String.raw`C:\Program Files\nodejs\codex.bat`;
spawnMock.mockReturnValue(child);
transportInstance(codexBat).instance.start();
expect(spawnMock).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", `""${codexBat}" app-server"`], {
cwd: "/vault",
stdio: ["pipe", "pipe", "pipe"],
windowsVerbatimArguments: true,
});
});
it("serializes outbound messages and reports asynchronous write failures", () => {
const child = fakeChildProcess();
spawnMock.mockReturnValue(child);
@ -195,7 +197,7 @@ describe("StdioAppServerTransport", () => {
});
});
function transportInstance() {
function transportInstance(codexPath = "codex") {
const handlers: AppServerTransportHandlers = {
onLine: vi.fn(),
onLog: vi.fn(),
@ -204,7 +206,7 @@ function transportInstance() {
};
return {
handlers,
instance: new StdioAppServerTransport("codex", "/vault", handlers),
instance: new StdioAppServerTransport(codexPath, "/vault", handlers),
};
}

View file

@ -1,14 +1,19 @@
import { describe, expect, it } from "vitest";
import {
type TurnContextManifest,
turnContextManifestFromText,
turnContextManifestText,
userMessageContextProjection,
} from "../../../src/domain/chat/context-manifest";
import { type TurnContextManifest, turnContextManifestText, userMessageContextProjection } from "../../../src/domain/chat/context-manifest";
const SUBMISSION_ID = "local-user-1-seed-1-1";
function turnContextManifestFromText(text: string): TurnContextManifest | null {
return userMessageContextProjection(
[
{ type: "text", text: "visible request" },
{ type: "text", text: text.startsWith("\n") ? text : `\n${text}` },
],
SUBMISSION_ID,
).manifest;
}
function webEntry(): Record<string, unknown> {
return {
kind: "web",

View file

@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { emptyRuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../../src/domain/runtime/config";
import { runtimeConfigOrDefault } from "../../../src/domain/runtime/config";
import { runtimeConfigFixture } from "../../support/runtime-config";
describe("runtime config", () => {
it("clones nested permission policy state", () => {
const config = {
...emptyRuntimeConfigSnapshot(),
...runtimeConfigFixture(),
startupPermissions: {
activePermissionProfile: null,
sandboxPolicy: null,

View file

@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
initialThreadRenameLifecycleState,
type ThreadRenameGeneratingState,
type ThreadRenameLifecycleState,
transitionThreadRenameLifecycleState,
} from "../../../src/domain/threads/rename-lifecycle";
@ -41,6 +40,8 @@ describe("thread rename lifecycle", () => {
});
});
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
function generatingRenameState(draft: string, generationToken: number): ThreadRenameGeneratingState {
const editing = expectRenameState(transitionThreadRenameLifecycleState(initialThreadRenameLifecycleState(), { type: "started", draft }));
const generating = transitionThreadRenameLifecycleState(editing, { type: "generation-started", generationToken });

View file

@ -4,7 +4,7 @@ import type { AppServerClient, ClientResponseByMethod } from "../../../../src/ap
import * as shortLivedClient from "../../../../src/app-server/connection/short-lived-client";
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
import { turnContextManifestFromText } from "../../../../src/domain/chat/context-manifest";
import { userMessageContextProjection } from "../../../../src/domain/chat/context-manifest";
import type { CodexInput } from "../../../../src/domain/chat/input";
import { createServerDiagnostics, diagnosticProbeOk } from "../../../../src/domain/server/diagnostics";
import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../../../../src/features/chat/app-server/session-gateway";
@ -126,21 +126,31 @@ describe("chat app-server transports", () => {
await transport.startTurn({
threadId: "thread",
input: prepared.input,
clientUserMessageId: "local-user",
clientUserMessageId: "local-user-1-seed-1-1",
});
const [, params] = request.mock.calls[0] ?? [];
expect(params).toMatchObject({
threadId: "thread",
cwd: "/vault",
clientUserMessageId: "local-user",
clientUserMessageId: "local-user-1-seed-1-1",
});
expect(params?.input[0]).toEqual({ type: "text", text, text_elements: [] });
expect(params?.input).toHaveLength(2);
const descriptor = params?.input.at(-1);
expect(descriptor?.type === "text" ? turnContextManifestFromText(descriptor.text) : null).toEqual({
expect(
descriptor?.type === "text"
? userMessageContextProjection(
[
{ type: "text", text },
{ type: "text", text: descriptor.text },
],
"local-user-1-seed-1-1",
).manifest
: null,
).toEqual({
version: 2,
submissionId: "local-user",
submissionId: "local-user-1-seed-1-1",
contexts: [],
fileReferences: [
{ name: "Alpha", path: "notes/Alpha.md" },
@ -148,7 +158,7 @@ describe("chat app-server transports", () => {
],
});
expect(params?.additionalContext).toEqual({
"codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": {
"codex_panel.local-user-1-seed-1-1.00.codex_panel_obsidian_context.part_01_of_01": {
kind: "untrusted",
value:
"Codex Panel context part 1/1.\nSource: codex_panel_obsidian_context\n\nObsidian references for the current user input:\n- [[Alpha]] -> notes/Alpha.md\n- <active> -> notes/Alpha.md",

View file

@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import {
SLASH_COMMANDS,
type SlashCommandName,
slashCommandAvailableInSideChat,
slashCommandHelpSections,
slashCommandRequiresConnection,
} from "../../../../../src/features/chat/application/composer/slash-commands";
@ -28,16 +27,11 @@ describe("slash command catalog", () => {
expect(keys("Composition")).toEqual(["/refer <thread> <message>", "/web <url> [message]"]);
});
it("owns connection and side-chat availability metadata", () => {
it("owns connection availability metadata", () => {
expect(
SLASH_COMMANDS.filter((definition) => !slashCommandRequiresConnection(definition.command.slice(1) as SlashCommandName)).map(
(item) => item.command,
),
).toEqual(["/reconnect", "/compact"]);
expect(
SLASH_COMMANDS.filter((definition) => !slashCommandAvailableInSideChat(definition.command.slice(1) as SlashCommandName)).map(
(item) => item.command,
),
).toEqual(["/fork", "/btw", "/rollback", "/auto-review", "/fast", "/plan", "/goal"]);
});
});

View file

@ -1,8 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { ModelMetadata, ReasoningEffort, SkillMetadata } from "../../../../../src/domain/catalog/metadata";
import type { Thread } from "../../../../../src/domain/threads/model";
import { emptyComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references";
import { slashCommandAvailableInSideChat } from "../../../../../src/features/chat/application/composer/slash-commands";
import type { ComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references";
import {
activeComposerSuggestions,
applyComposerSuggestionInsertion,
@ -25,6 +24,10 @@ function wikiLinkSuggestions(query: string, notes: Parameters<typeof activeCompo
return activeComposerSuggestions(`[[${query}`, notes, []);
}
function emptyComposerContextReferences(): ComposerContextReferences {
return { activeNote: null, selection: null, activeNoteSnapshots: [], selectionSnapshots: [] };
}
function userInputWithWikiLinkReferencesAndSkills(
text: string,
resolveFileReference: WikiLinkFileReferenceResolver,
@ -286,16 +289,6 @@ describe("composer suggestions", () => {
expect(activeComposerSuggestions("/help", notes, [])).toEqual([]);
});
it("omits unavailable thread mutations from side-chat slash suggestions", () => {
const options = { slashCommandAvailable: slashCommandAvailableInSideChat };
expect(suggestionReplacements(activeComposerSuggestions("/f", notes, [], [], [], null, options))).not.toContain("/fork");
expect(suggestionReplacements(activeComposerSuggestions("/r", notes, [], [], [], null, options))).not.toContain("/rollback");
expect(suggestionReplacements(activeComposerSuggestions("/b", notes, [], [], [], null, options))).not.toContain("/btw");
expect(suggestionReplacements(activeComposerSuggestions("/c", notes, [], [], [], null, options))).toContain("/compact");
expect(suggestionReplacements(activeComposerSuggestions("/g", notes, [], [], [], null, options))).not.toContain("/goal");
});
it("omits slash suggestions from subagent threads", () => {
const options = { slashCommandAvailable: () => false };
expect(activeComposerSuggestions("/", notes, [], [], [], null, options)).toEqual([]);

View file

@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata";
import type { CodexInput } from "../../../../../src/domain/chat/input";
import { emptyComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references";
import type { ComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references";
import {
preparedUserInputWithWikiLinkReferencesSkillsAndContext,
type WikiLinkFileReferenceResolver,
@ -17,6 +17,10 @@ const obsidianContext = (...sections: string[]) => ({
const wikilinkContext = (...mappings: string[]) => obsidianContext(...mappings);
function emptyComposerContextReferences(): ComposerContextReferences {
return { activeNote: null, selection: null, activeNoteSnapshots: [], selectionSnapshots: [] };
}
function userInputWithWikiLinkReferencesAndSkills(
text: string,
resolveFileReference: WikiLinkFileReferenceResolver,

View file

@ -1,16 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
import {
type ChatConnectionActionsHost,
type ChatConnectionAdapter,
type ChatConnectionDiagnosticsActions,
type ChatConnectionMetadataActions,
createChatConnectionActions,
} from "../../../../../src/features/chat/application/connection/connection-actions";
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { deferred } from "../../../../support/async";
import { runtimeConfigFixture } from "../../../../support/runtime-config";
type ChatConnectionAdapter = ChatConnectionActionsHost["connection"];
type ChatConnectionMetadataActions = ChatConnectionActionsHost["metadata"];
type ChatConnectionDiagnosticsActions = ChatConnectionActionsHost["diagnostics"];
function createActionsHarness({ connected = false, canConnect = true } = {}) {
const stateStore = createChatStateStore(createChatState());
@ -183,7 +183,7 @@ describe("ChatConnectionActions", () => {
it("clears disconnected connection state on server exit while keeping last startup metadata", () => {
const { actions, host, stateStore } = createActionsHarness({ connected: true });
const initializeResponse = { codexHome: "/codex", platformFamily: "unix", platformOs: "macos", userAgent: "test" } as const;
const runtimeConfig = { ...emptyRuntimeConfigSnapshot(), model: "gpt-5.1" };
const runtimeConfig = { ...runtimeConfigFixture(), model: "gpt-5.1" };
stateStore.dispatch({ type: "connection/initialized", initializeResponse });
stateStore.dispatch({
type: "thread-list/applied",

View file

@ -1,7 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata";
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
import {
createServerDiagnostics,
type DiagnosticProbeResult,
@ -18,6 +17,7 @@ import { createServerMetadataActions } from "../../../../../src/features/chat/ap
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { toolInventoryDiagnosticSections } from "../../../../../src/features/chat/presentation/runtime/tool-inventory-diagnostic-sections";
import { deferred } from "../../../../support/async";
import { runtimeConfigFixture } from "../../../../support/runtime-config";
import { chatStateFixture, chatStateWith } from "../../support/state";
describe("server metadata actions", () => {
@ -408,7 +408,7 @@ function skillFixture(name: string): SkillMetadata {
function serverMetadataFixture(overrides: Partial<SharedServerMetadata> = {}): SharedServerMetadata {
return {
runtimeConfig: emptyRuntimeConfigSnapshot(),
runtimeConfig: runtimeConfigFixture(),
availableSkills: [],
availablePermissionProfiles: [],
rateLimit: null,

View file

@ -1,9 +1,15 @@
import { describe, expect, it } from "vitest";
import {
type ActivePanelOperation,
type ActivePanelThreadFacts,
activePanelOperationDecisionForFacts,
} from "../../../../src/features/chat/application/panel-operation-policy";
import { type ActivePanelOperation, activePanelOperationDecision } from "../../../../src/features/chat/application/panel-operation-policy";
import { type ChatState, createChatState } from "../../../../src/features/chat/application/state/root-reducer";
type ActivePanelThreadFacts =
| { readonly phase: "empty" }
| { readonly phase: "awaiting-resume"; readonly provenance: "interactive" | "subagent" | null }
| {
readonly phase: "active";
readonly lifetime: "persistent" | "ephemeral";
readonly provenance: "interactive" | "subagent" | null;
};
const operations: readonly ActivePanelOperation[] = [
"submit",
@ -39,13 +45,13 @@ describe("activePanelOperationDecisionForFacts", () => {
});
it("requires restoration before a persistent goal mutation", () => {
expect(activePanelOperationDecisionForFacts({ phase: "awaiting-resume", provenance: "interactive" }, "goal-mutation")).toEqual({
expect(activePanelOperationDecision(stateFromFacts({ phase: "awaiting-resume", provenance: "interactive" }), "goal-mutation")).toEqual({
kind: "resume-required",
});
});
it("keeps restored subagent goals read-only before loading", () => {
expect(activePanelOperationDecisionForFacts({ phase: "awaiting-resume", provenance: "subagent" }, "goal-mutation")).toEqual({
expect(activePanelOperationDecision(stateFromFacts({ phase: "awaiting-resume", provenance: "subagent" }), "goal-mutation")).toEqual({
kind: "blocked",
message: "Goals are read-only in agent threads.",
});
@ -69,6 +75,47 @@ function decisions(allowed: Partial<Record<ActivePanelOperation, "allowed">>): R
function expectDecisionKinds(facts: ActivePanelThreadFacts, expected: Record<ActivePanelOperation, "allowed" | "blocked">): void {
for (const operation of operations) {
expect(activePanelOperationDecisionForFacts(facts, operation).kind).toBe(expected[operation]);
expect(activePanelOperationDecision(stateFromFacts(facts), operation).kind).toBe(expected[operation]);
}
}
function stateFromFacts(facts: ActivePanelThreadFacts): ChatState {
const state = createChatState();
if (facts.phase === "empty") return state;
const provenance =
facts.provenance === null
? null
: facts.provenance === "interactive"
? ({ kind: "interactive" } as const)
: ({
kind: "subagent",
subagentKind: "other",
parentThreadId: null,
sessionId: null,
depth: null,
agentNickname: null,
agentRole: null,
} as const);
if (facts.phase === "awaiting-resume") {
return {
...state,
panelThread: { kind: "awaiting-resume", threadId: "thread", fallbackTitle: null, provenance },
};
}
return {
...state,
panelThread: {
kind: "active",
thread: {
id: "thread",
goal: null,
tokenUsage: null,
lifetime:
facts.lifetime === "persistent"
? { kind: "persistent" }
: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: null },
provenance,
},
},
};
}

View file

@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { ModelMetadata } from "../../../../../src/domain/catalog/metadata";
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
import {
type ChatRuntimeSettingsActions,
createChatRuntimeSettingsActions,
@ -12,6 +11,7 @@ import { activeThreadId, type ChatState } from "../../../../../src/features/chat
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { setCollaborationModeIntent, setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent";
import { createKeyedOperationQueue, type KeyedOperationQueue } from "../../../../../src/shared/runtime/keyed-operation-queue";
import { runtimeConfigFixture } from "../../../../support/runtime-config";
import { chatStateFixture, chatStateWith } from "../../support/state";
describe("createChatRuntimeSettingsActions", () => {
@ -234,7 +234,7 @@ describe("createChatRuntimeSettingsActions", () => {
it("keeps Fast disabled after clearing a thread tier when config defaults to Fast", async () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "thread" } });
state = chatStateWith(state, { connection: { runtimeConfig: { ...emptyRuntimeConfigSnapshot(), serviceTier: "fast" } } });
state = chatStateWith(state, { connection: { runtimeConfig: { ...runtimeConfigFixture(), serviceTier: "fast" } } });
state = chatStateWith(state, { runtime: { active: { serviceTier: "fast" } } });
const store = createChatStateStore(state);
const transport = settingsTransportFixture();
@ -318,7 +318,7 @@ describe("createChatRuntimeSettingsActions", () => {
state = chatStateWith(state, {
connection: {
runtimeConfig: {
...emptyRuntimeConfigSnapshot(),
...runtimeConfigFixture(),
model: "gpt-config",
reasoningEffort: "medium",
},

View file

@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
import { createServerDiagnostics, diagnosticProbeOk, diagnosticsWithProbe } from "../../../../../src/domain/server/diagnostics";
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
import type { Thread } from "../../../../../src/domain/threads/model";
@ -11,6 +10,7 @@ import { activeTurnId, chatTurnBusy, pendingTurnStart } from "../../../../../src
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
import { setCollaborationModeIntent, setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent";
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
import { runtimeConfigFixture } from "../../../../support/runtime-config";
import { chatStateFixture, chatStateWith } from "../../support/state";
import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream";
@ -227,7 +227,7 @@ describe("chatReducer", () => {
};
const state = chatStateWith(chatStateFixture(), {
connection: {
runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-old" },
runtimeConfig: { ...runtimeConfigFixture(), model: "gpt-old" },
initializeResponse: {
codexHome: "/old/codex-home",
platformFamily: "unix",
@ -264,7 +264,7 @@ describe("chatReducer", () => {
const serverDiagnostics = diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "1 model", 1));
let state = chatStateWith(chatStateFixture(), {
connection: {
runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-old" },
runtimeConfig: { ...runtimeConfigFixture(), model: "gpt-old" },
initializeResponse: {
codexHome: "/old/codex-home",
platformFamily: "unix",

View file

@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation";
import type { Thread } from "../../../../../src/domain/threads/model";
import type { EffectOutcome } from "../../../../../src/features/chat/application/effect-outcome";
@ -11,6 +10,7 @@ import { createThreadStartActions } from "../../../../../src/features/chat/appli
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
import { deferred } from "../../../../support/async";
import { runtimeConfigFixture } from "../../../../support/runtime-config";
import { chatStateFixture, chatStateWith } from "../../support/state";
describe("thread start actions", () => {
@ -161,7 +161,7 @@ describe("thread start actions", () => {
it("starts threads with service tier from explicit effective config", async () => {
let state = chatStateFixture();
state = chatStateWith(state, { connection: { runtimeConfig: { ...emptyRuntimeConfigSnapshot(), serviceTier: "flex" } } });
state = chatStateWith(state, { connection: { runtimeConfig: { ...runtimeConfigFixture(), serviceTier: "flex" } } });
const stateStore = createChatStateStore(state);
const startThread = vi
.fn()
@ -184,9 +184,9 @@ describe("thread start actions", () => {
state = chatStateWith(state, {
connection: {
runtimeConfig: {
...emptyRuntimeConfigSnapshot(),
...runtimeConfigFixture(),
startupPermissions: {
...emptyRuntimeConfigSnapshot().startupPermissions,
...runtimeConfigFixture().startupPermissions,
activePermissionProfile: { id: ":workspace", extends: null },
},
},

View file

@ -1,9 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { activePanelOperationDecision } from "../../../../../src/features/chat/application/panel-operation-policy";
import { activeThreadState, createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { type ChatStateStore, createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import {
implementPlan,
implementPlanTargetFromState,
implementPlanTarget,
type PlanImplementationHost,
} from "../../../../../src/features/chat/application/turns/plan-implementation";
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
@ -77,6 +78,16 @@ function createPlanImplementationHost() {
};
}
function implementPlanTargetFromState(state: ReturnType<ChatStateStore["getState"]>) {
return implementPlanTarget({
activeThread: activeThreadState(state),
modeAllowed: activePanelOperationDecision(state, "implement-plan").kind === "allowed",
turn: state.turn,
runtime: state.runtime,
threadStream: state.threadStream,
});
}
describe("implementPlan", () => {
it("finds the latest proposed plan only when the thread is idle and in plan mode", () => {
const stateStore = createChatStateStore(createChatState());

View file

@ -1,79 +1,156 @@
import { describe, expect, it, vi } from "vitest";
import type { ConnectionManagerHandlers } from "../../../../src/app-server/connection/connection-manager";
import type { ServerRequest } from "../../../../src/app-server/connection/rpc-messages";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { createConnectionBundle } from "../../../../src/features/chat/host/bundles/connection-bundle";
import { chatStateFixture } from "../support/state";
import {
createServerRequestResponderRegistry,
scheduleDeferredDiagnosticsRefresh,
} from "../../../../src/features/chat/host/bundles/connection-bundle";
type ConnectionBundleHost = Parameters<typeof createConnectionBundle>[0];
type ConnectionBundleInput = Parameters<typeof createConnectionBundle>[1];
describe("connection bundle server request responders", () => {
it("responds through the client that delivered the request", () => {
const currentClient = { respondToServerRequest: vi.fn() };
const originalResponder = { respond: vi.fn(), reject: vi.fn() };
const registry = createServerRequestResponderRegistry();
registry.remember(1, originalResponder);
describe("connection bundle", () => {
it("responds through the responder that delivered the request", async () => {
const fixture = connectionBundleFixture();
await fixture.bundle.connection.actions.ensureConnected();
const responder = { respond: vi.fn(), reject: vi.fn() };
registry.respond(1, { decision: "accept" });
fixture.connectionHandlers().onServerRequest(userInputRequest(1), responder);
fixture.bundle.inboundHandler.resolveUserInput(1, { note: "Continue" });
expect(originalResponder.respond).toHaveBeenCalledWith({ decision: "accept" });
expect(currentClient.respondToServerRequest).not.toHaveBeenCalled();
expect(responder.respond).toHaveBeenCalledWith({ answers: { note: { answers: ["Continue"] } } });
});
it("cannot reuse a responder after the connection generation is cleared", () => {
it("cannot reuse a responder after its connection scope is invalidated", async () => {
const fixture = connectionBundleFixture();
await fixture.bundle.connection.actions.ensureConnected();
const responder = { respond: vi.fn(), reject: vi.fn() };
const registry = createServerRequestResponderRegistry();
registry.remember(1, responder);
registry.clear();
expect(registry.respond(1, { decision: "accept" })).toBe(false);
expect(registry.reject(1, -32000, "cancelled")).toBe(false);
fixture.connectionHandlers().onServerRequest(userInputRequest(1), responder);
fixture.bundle.invalidateConnectionScope();
fixture.bundle.inboundHandler.resolveUserInput(1, { note: "Continue" });
expect(responder.respond).not.toHaveBeenCalled();
expect(responder.reject).not.toHaveBeenCalled();
});
});
describe("connection bundle deferred diagnostics", () => {
it("reports deferred diagnostics failures without returning a blocking promise", async () => {
const callbacks: Array<() => void> = [];
it("reports deferred diagnostics failures as system messages", async () => {
const error = new Error("diagnostics failed");
const refreshServerDiagnostics = vi.fn().mockRejectedValue(error);
const addSystemMessage = vi.fn();
scheduleDeferredDiagnosticsRefresh({
scheduleDiagnostics: (scheduled) => {
callbacks.push(scheduled);
},
isConnected: () => true,
refreshServerDiagnostics,
addSystemMessage,
const fixture = connectionBundleFixture({
readServerDiagnostics: vi.fn().mockRejectedValue(error),
});
await fixture.bundle.connection.actions.ensureConnected();
expect(callbacks).toHaveLength(1);
const scheduled = callbacks[0];
if (!scheduled) throw new Error("Expected deferred diagnostics callback.");
scheduled();
await Promise.resolve();
expect(refreshServerDiagnostics).toHaveBeenCalledWith({ appServerMetadataSnapshot: true });
expect(addSystemMessage).toHaveBeenCalledWith("diagnostics failed");
fixture.runScheduledDiagnostics();
await vi.waitFor(() => {
expect(fixture.addSystemMessage).toHaveBeenCalledWith("diagnostics failed");
});
});
it("does not refresh deferred diagnostics after disconnect", () => {
const callbacks: Array<() => void> = [];
const refreshServerDiagnostics = vi.fn().mockResolvedValue(undefined);
it("does not run deferred diagnostics after disconnect", async () => {
const readServerDiagnostics = vi.fn().mockResolvedValue(null);
const fixture = connectionBundleFixture({ readServerDiagnostics });
await fixture.bundle.connection.actions.ensureConnected();
fixture.setConnected(false);
scheduleDeferredDiagnosticsRefresh({
scheduleDiagnostics: (scheduled) => {
callbacks.push(scheduled);
},
isConnected: () => false,
refreshServerDiagnostics,
addSystemMessage: vi.fn(),
});
fixture.runScheduledDiagnostics();
const scheduled = callbacks[0];
if (!scheduled) throw new Error("Expected deferred diagnostics callback.");
scheduled();
expect(refreshServerDiagnostics).not.toHaveBeenCalled();
expect(readServerDiagnostics).not.toHaveBeenCalled();
});
});
function connectionBundleFixture(overrides: { readServerDiagnostics?: ReturnType<typeof vi.fn> } = {}) {
let connected = false;
let handlers: ConnectionManagerHandlers | null = null;
let scheduledDiagnostics: (() => void) | null = null;
const addSystemMessage = vi.fn();
const stateStore = createChatStateStore(
chatStateFixture({
activeThread: { id: "thread-active" },
turn: { lifecycle: { kind: "running", turnId: "turn-active" } },
}),
);
const host = {
environment: {
plugin: {
appServerQueries: {
appServerMetadataSnapshot: () => null,
refreshAppServerMetadata: vi.fn().mockResolvedValue(undefined),
refreshSkills: vi.fn().mockResolvedValue(undefined),
refreshRateLimits: vi.fn().mockResolvedValue(undefined),
},
threadCatalog: {
refreshActive: vi.fn().mockResolvedValue(undefined),
apply: vi.fn(),
},
settingsRef: {
settings: {
codexPath: () => "codex",
},
},
},
},
stateStore,
canConnect: () => true,
deferredTasks: {
scheduleDiagnostics: (callback: () => void) => {
scheduledDiagnostics = callback;
},
clearDiagnostics: vi.fn(),
},
invalidateThreadWork: vi.fn(),
refreshTabHeader: vi.fn(),
} as unknown as ConnectionBundleHost;
const input = {
connection: {
connect: async (nextHandlers: ConnectionManagerHandlers) => {
handlers = nextHandlers;
connected = true;
return { codexHome: "/codex", platformFamily: "unix", platformOs: "macos", userAgent: "test" };
},
isConnected: () => connected,
},
diagnosticsTransport: {
readServerDiagnostics: overrides.readServerDiagnostics ?? vi.fn().mockResolvedValue(null),
},
localItemIds: {
next: (prefix: string) => `${prefix}-1`,
},
status: {
set: vi.fn(),
addSystemMessage,
},
autoTitleCoordinator: {
maybeAutoTitleThread: vi.fn(),
resetThreadTurnPresence: vi.fn(),
},
} as unknown as ConnectionBundleInput;
return {
addSystemMessage,
bundle: createConnectionBundle(host, input),
connectionHandlers: () => {
if (!handlers) throw new Error("Expected connection handlers.");
return handlers;
},
runScheduledDiagnostics: () => {
if (!scheduledDiagnostics) throw new Error("Expected deferred diagnostics callback.");
scheduledDiagnostics();
},
setConnected: (value: boolean) => {
connected = value;
},
};
}
function userInputRequest(id: number): Extract<ServerRequest, { method: "item/tool/requestUserInput" }> {
return {
id,
method: "item/tool/requestUserInput",
params: {
threadId: "thread-active",
turnId: "turn-active",
itemId: "input-1",
questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }],
autoResolutionMs: null,
},
};
}

View file

@ -1,5 +1,5 @@
import { type App, type EventRef, TFile } from "obsidian";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const dailyNotesInterface = vi.hoisted(() => ({
appHasDailyNotesPluginLoaded: vi.fn<() => boolean>(),
@ -9,10 +9,7 @@ const dailyNotesInterface = vi.hoisted(() => ({
vi.mock("obsidian-daily-notes-interface", () => dailyNotesInterface);
import { VaultComposerContextReferenceProvider } from "../../../../src/features/chat/host/obsidian/vault-composer-context-reference-provider.obsidian";
import {
configuredDailyNoteReferences,
dailyNoteReferencesFromSettings,
} from "../../../../src/features/chat/host/obsidian/vault-daily-note-references.obsidian";
import { configuredDailyNoteReferences } from "../../../../src/features/chat/host/obsidian/vault-daily-note-references.obsidian";
import { VaultNoteCandidateProvider } from "../../../../src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian";
describe("VaultNoteCandidateProvider", () => {
@ -21,6 +18,10 @@ describe("VaultNoteCandidateProvider", () => {
dailyNotesInterface.getDailyNoteSettings.mockReset().mockReturnValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps optional daily-note integration failures out of composer suggestions", () => {
dailyNotesInterface.appHasDailyNotesPluginLoaded.mockImplementation(() => {
throw new Error("Daily Notes API unavailable");
@ -30,6 +31,14 @@ describe("VaultNoteCandidateProvider", () => {
});
it("builds relative references from the configured daily-note folder and format", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 6, 10, 12));
dailyNotesInterface.appHasDailyNotesPluginLoaded.mockReturnValue(true);
dailyNotesInterface.getDailyNoteSettings.mockReturnValue({
folder: "Journal",
format: "YYYY/MM/YYYY-MM-DD",
template: "",
});
const existingToday = tFile("Journal/2026/07/2026-07-10.md", "2026-07-10");
const fileToLinktext = vi.fn((file: TFile) => (file === existingToday ? "2026-07-10" : file.path));
const app = appFixture({
@ -37,14 +46,7 @@ describe("VaultNoteCandidateProvider", () => {
fileToLinktext,
});
expect(
dailyNoteReferencesFromSettings(
app,
"Projects/Codex.md",
{ folder: "Journal", format: "YYYY/MM/YYYY-MM-DD", template: "" },
new Date(2026, 6, 10, 12),
),
).toEqual([
expect(configuredDailyNoteReferences(app, "Projects/Codex.md")).toEqual([
{
keyword: "today",
display: "Today",

View file

@ -6,7 +6,6 @@ import { modelMetadataFromCatalogModels } from "../../../../src/app-server/proto
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { ObservedPaginatedResult, ObservedResult } from "../../../../src/app-server/query/observed-result";
import type { ModelMetadata } from "../../../../src/domain/catalog/metadata";
import { emptyRuntimeConfigSnapshot } from "../../../../src/domain/runtime/config";
import { createServerDiagnostics, diagnosticProbeOk } from "../../../../src/domain/server/diagnostics";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../../src/domain/server/metadata";
import type { Thread } from "../../../../src/domain/threads/model";
@ -18,6 +17,7 @@ import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/setti
import { createKeyedOperationQueue } from "../../../../src/shared/runtime/keyed-operation-queue";
import { notices } from "../../../mocks/obsidian";
import { installObsidianDomShims } from "../../../support/dom";
import { runtimeConfigFixture } from "../../../support/runtime-config";
import { chatPanelSettingsAccess } from "../support/settings";
export interface TestCodexChatHost extends CodexChatHost {
@ -501,7 +501,7 @@ export function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexCha
await client.request("account/rateLimits/read", undefined);
if (!connectionStillCurrent()) return null;
return {
runtimeConfig: emptyRuntimeConfigSnapshot(),
runtimeConfig: runtimeConfigFixture(),
availableSkills: skillsResponse.data.flatMap(
(entry: { skills: { name: string; description?: string; path?: string; enabled?: boolean }[] }) =>
entry.skills.map((skill) => ({

View file

@ -2,10 +2,10 @@
import { describe, expect, it, vi } from "vitest";
import type { ServerNotification } from "../../../../src/app-server/connection/rpc-messages";
import { emptyRuntimeConfigSnapshot } from "../../../../src/domain/runtime/config";
import { createServerDiagnostics } from "../../../../src/domain/server/diagnostics";
import type { ChatPanelSession } from "../../../../src/features/chat/host/session";
import { deferred, waitForAsyncWork } from "../../../support/async";
import { runtimeConfigFixture } from "../../../support/runtime-config";
import {
chatHost,
chatView,
@ -185,7 +185,7 @@ describe("CodexChatView workspace restoration", () => {
appServerMetadataSnapshot: vi.fn(
() =>
({
runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-cached" },
runtimeConfig: { ...runtimeConfigFixture(), model: "gpt-cached" },
availableSkills: [{ name: "writer", enabled: true }],
availablePermissionProfiles: [],
rateLimit: null,

View file

@ -14,7 +14,7 @@ import { createChatStateStore } from "../../../../src/features/chat/application/
import { threadStreamItems } from "../../../../src/features/chat/application/state/thread-stream";
import { pendingWebSubmissionItem } from "../../../../src/features/chat/application/turns/web-submission";
import type { ThreadStreamItem } from "../../../../src/features/chat/domain/thread-stream/items";
import { ChatComposerController, type ChatComposerRenderActions } from "../../../../src/features/chat/panel/composer-controller";
import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller";
import type { ChatPanelComposerModel } from "../../../../src/features/chat/panel/shell-selectors";
import { ComposerShell } from "../../../../src/features/chat/ui/composer";
import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-root.dom";
@ -25,6 +25,8 @@ import { chatStateFixture, chatStateWith } from "../support/state";
installObsidianDomShims();
type ChatComposerRenderActions = Parameters<ChatComposerController["renderState"]>[1];
const renderedComposerParents = new Set<HTMLElement>();
const composerControllerTestCleanups: (() => void)[] = [];
@ -303,7 +305,14 @@ describe("ChatComposerController", () => {
expect(controller.captureInputSnapshot().threadCommandTarget).toBeUndefined();
});
it("applies ephemeral-thread suggestion restrictions on input without waiting for keyup", () => {
it.each([
["/f", "/fork", false],
["/f", "/fast", false],
["/b", "/btw", false],
["/r", "/rollback", false],
["/g", "/goal", false],
["/c", "/compact", true],
])("applies ephemeral-thread suggestion policy for %s -> %s", (draft, suggestion, available) => {
const stateStore = createChatStateStore(
chatStateFixture({
activeThread: {
@ -315,12 +324,16 @@ describe("ChatComposerController", () => {
const { controller, parent } = composerControllerFixture({ stateStore });
renderComposerController(parent, controller, stateStore);
setTextAreaValue(composer(parent), "/f");
composer(parent).setSelectionRange(2, 2);
setTextAreaValue(composer(parent), draft);
composer(parent).setSelectionRange(draft.length, draft.length);
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).not.toContain("/fork");
expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).not.toContain("/fast");
expect(
stateStore
.getState()
.composer.suggestions.map((item) => item.replacement)
.includes(suggestion),
).toBe(available);
});
it("keeps a disconnected subagent composer read-only without slash suggestions", () => {

View file

@ -6,7 +6,6 @@ import {
diagnosticProbeOk,
diagnosticsWithProbe,
upsertMcpServerDiagnostic,
upsertMcpServerStatusDiagnostics,
} from "../../../../../src/domain/server/diagnostics";
import type { ToolInventorySnapshot } from "../../../../../src/domain/server/tool-inventory";
import { appServerDiagnosticSections } from "../../../../../src/features/chat/presentation/runtime/diagnostic-sections";
@ -64,36 +63,6 @@ describe("connection diagnostics", () => {
expect(rows.find((row) => row.label === "mcp github")).toBeUndefined();
});
it("maps app-server MCP status snapshots into diagnostics", () => {
const diagnostics = upsertMcpServerDiagnostic(createServerDiagnostics(), {
name: "github",
startupStatus: "starting",
authStatus: null,
toolCount: null,
message: "launching",
});
const next = upsertMcpServerStatusDiagnostics(diagnostics, [
{
name: "github",
authStatus: "oAuth",
toolCount: 2,
resourceCount: 0,
resourceTemplateCount: 0,
},
]);
expect(next.mcpServers).toEqual([
{
name: "github",
startupStatus: "starting",
authStatus: "oAuth",
toolCount: 2,
message: "launching",
},
]);
});
it("summarizes usable Codex capabilities and groups skills by provenance", () => {
const inventory: ToolInventorySnapshot = {
checkedAt: 1,

View file

@ -3,7 +3,7 @@
import { h } from "preact";
import { describe, expect, it, vi } from "vitest";
import type { ComposerMetaViewModel } from "../../../../src/features/chat/ui/composer";
import { type ComposerCallbacks, ComposerShell, type ComposerSuggestion } from "../../../../src/features/chat/ui/composer";
import { type ComposerCallbacks, ComposerShell } from "../../../../src/features/chat/ui/composer";
import { scrollComposerSuggestionIntoView, syncComposerHeight } from "../../../../src/features/chat/ui/composer.dom";
import { renderUiRoot } from "../../../../src/shared/dom/preact-root.dom";
import { waitForAsyncWork } from "../../../support/async";
@ -11,6 +11,8 @@ import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomSh
installObsidianDomShims();
type ComposerSuggestion = Parameters<ComposerCallbacks["onSuggestionInsert"]>[0];
function mountComposerShell(
parent: HTMLElement,
viewId: string,

View file

@ -6,7 +6,7 @@ import type { ObservedResult } from "../../src/app-server/query/observed-result"
import { StaleAppServerResourceContextError } from "../../src/app-server/query/resource-store";
import type { ModelMetadata } from "../../src/domain/catalog/metadata";
import type { Thread } from "../../src/domain/threads/model";
import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller";
import { SettingsDynamicSectionsController } from "../../src/settings/dynamic-sections-controller";
import { deferred } from "../support/async";
import {
appServerThread,
@ -21,6 +21,8 @@ import {
useShortLivedClients,
} from "./test-support";
type SettingsDynamicSectionsSnapshot = ReturnType<SettingsDynamicSectionsController["snapshot"]>;
const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({
withShortLivedAppServerClientMock: vi.fn(),
}));

View file

@ -0,0 +1,8 @@
import { type RuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../src/domain/runtime/config";
export function runtimeConfigFixture(overrides: Partial<RuntimeConfigSnapshot> = {}): RuntimeConfigSnapshot {
return {
...runtimeConfigOrDefault(null),
...overrides,
};
}