mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor: remove test-only source exports
This commit is contained in:
parent
8d952fcfbf
commit
b00f6292c4
45 changed files with 410 additions and 293 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export interface EphemeralStructuredTurnClient {
|
|||
|
||||
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
|
||||
|
||||
export type EphemeralStructuredTurnClientFactory = (
|
||||
type EphemeralStructuredTurnClientFactory = (
|
||||
codexPath: string,
|
||||
cwd: string,
|
||||
handlers: AppServerClientHandlers,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export interface RuntimeConfigSnapshot {
|
|||
readonly autoCompactTokenLimit: number | null;
|
||||
}
|
||||
|
||||
export function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
|
||||
function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
|
||||
return {
|
||||
profile: null,
|
||||
model: null,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function configuredDailyNoteReferences(app: App, sourcePath: string): rea
|
|||
}
|
||||
}
|
||||
|
||||
export function dailyNoteReferencesFromSettings(
|
||||
function dailyNoteReferencesFromSettings(
|
||||
app: App,
|
||||
sourcePath: string,
|
||||
settings: IPeriodicNoteSettings,
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export interface ChatComposerControllerOptions {
|
|||
onAttachmentError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface ChatComposerRenderActions {
|
||||
interface ChatComposerRenderActions {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
syncComposerHeight,
|
||||
} from "./composer.dom";
|
||||
|
||||
export interface ComposerSuggestion {
|
||||
interface ComposerSuggestion {
|
||||
display: string;
|
||||
detail: string;
|
||||
replacement: string;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
|
|
|||
8
tests/support/runtime-config.ts
Normal file
8
tests/support/runtime-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue