mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { createServerDiagnostics } from "../../../src/domain/server/diagnostics";
|
|
import {
|
|
runtimeConfigSnapshotFromAppServerConfig,
|
|
type ConfigReadResult,
|
|
type RuntimeConfigSnapshot,
|
|
} from "../../../src/app-server/protocol/runtime-config";
|
|
import { createChatState } from "../../../src/features/chat/state/reducer";
|
|
import { composerMetaViewModel, composerPlaceholder } from "../../../src/features/chat/panel/surface/composer";
|
|
import { effortStatusLines, modelStatusLines, statusSummaryLines } from "../../../src/features/chat/display/status/runtime";
|
|
import { runtimeComposerChoices } from "../../../src/features/chat/panel/surface/composer";
|
|
import { runtimeSnapshotForChatState } from "../../../src/features/chat/runtime/snapshot";
|
|
import { toolbarViewModel } from "../../../src/features/chat/panel/surface/toolbar";
|
|
import type { ChatState } from "../../../src/features/chat/state/reducer";
|
|
import type { ModelMetadata } from "../../../src/domain/catalog/metadata";
|
|
import type { Thread } from "../../../src/domain/threads/model";
|
|
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder } from "../../../src/features/chat/panel/surface/composer";
|
|
import { chatPanelGoalProps } from "../../../src/features/chat/panel/surface/goal";
|
|
import type { ChatPanelComposerPorts, ChatPanelGoalPorts } from "../../../src/features/chat/panel/surface/ports";
|
|
import type { ThreadGoal } from "../../../src/domain/threads/goal";
|
|
import { setChatStateDisplayItems } from "./support/message-stream";
|
|
|
|
describe("chat view model", () => {
|
|
it("builds toolbar rows from immutable chat state snapshots", () => {
|
|
const state = createChatState();
|
|
state.activeThread.id = "thread-1";
|
|
state.threadList.listedThreads = [threadFixture("thread-1", "Active"), threadFixture("thread-2", "Other")];
|
|
state.turn.lifecycle = { kind: "running", turnId: "turn" };
|
|
state.ui.toolbarPanel = "history";
|
|
state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
|
|
state.connection.serverDiagnostics = createServerDiagnostics();
|
|
|
|
const model = toolbarViewModel({
|
|
state,
|
|
snapshot: runtimeSnapshotFixture(state),
|
|
connected: true,
|
|
turnBusy: true,
|
|
vaultPath: "/vault",
|
|
configuredCommand: "codex",
|
|
archiveConfirmThreadId: "thread-2",
|
|
archiveExportEnabled: true,
|
|
renameRevision: 1,
|
|
renameState: (threadId, _renameRevision) => (threadId === "thread-1" ? { draft: "Active", generating: false } : null),
|
|
});
|
|
|
|
expect(model.openPanel).toBe("history");
|
|
expect(model.newChatDisabled).toBe(true);
|
|
expect(model.threads).toMatchObject([
|
|
{ threadId: "thread-1", title: "Active", selected: true, disabled: false, rename: { draft: "Active" } },
|
|
{ threadId: "thread-2", title: "Other", selected: false, disabled: true, archiveConfirm: { active: true } },
|
|
]);
|
|
});
|
|
|
|
it("builds composer meta from context and runtime state", () => {
|
|
const state = createChatState();
|
|
state.activeThread.id = "thread-1";
|
|
state.runtime.selectedCollaborationMode = "plan";
|
|
state.connection.runtimeConfig = runtimeConfigFixture({
|
|
model: "gpt-5.5",
|
|
model_reasoning_effort: "high",
|
|
approvals_reviewer: "auto_review",
|
|
service_tier: "fast",
|
|
});
|
|
state.activeThread.tokenUsage = {
|
|
last: { inputTokens: 42, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 44 },
|
|
total: { inputTokens: 40, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 42 },
|
|
modelContextWindow: 100,
|
|
};
|
|
|
|
expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toEqual({
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣿", placeholder: false },
|
|
{ text: "⣶", placeholder: false },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "42%",
|
|
},
|
|
statusSummary: "Context 42%, plan on, auto-review on, fast on, model gpt-5.5, reasoning effort high",
|
|
model: "gpt-5.5",
|
|
effort: "high",
|
|
planActive: true,
|
|
autoReviewActive: true,
|
|
fastActive: true,
|
|
});
|
|
});
|
|
|
|
it("uses a neutral composer context indicator when usage is unavailable", () => {
|
|
const state = createChatState();
|
|
state.activeThread.id = "thread-1";
|
|
setChatStateDisplayItems(state, [
|
|
{
|
|
id: "item",
|
|
turnId: "turn-1",
|
|
kind: "message",
|
|
messageKind: "assistantResponse",
|
|
messageState: "completed",
|
|
text: "Existing turn",
|
|
role: "assistant",
|
|
},
|
|
]);
|
|
state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5" });
|
|
|
|
expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toMatchObject({
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "--%",
|
|
},
|
|
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model gpt-5.5, reasoning effort default",
|
|
model: "gpt-5.5",
|
|
effort: null,
|
|
});
|
|
});
|
|
|
|
it("keeps zero percent composer context fixed-width and visible", () => {
|
|
const state = createChatState();
|
|
state.activeThread.id = "thread-1";
|
|
state.activeThread.tokenUsage = {
|
|
last: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 },
|
|
total: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 },
|
|
modelContextWindow: 100,
|
|
};
|
|
|
|
expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toMatchObject({
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: " 0%",
|
|
},
|
|
statusSummary: "Context 0%, plan off, auto-review off, fast off, model default, reasoning effort default",
|
|
});
|
|
});
|
|
|
|
it("replaces composer meta with fatal connection state", () => {
|
|
const state = createChatState();
|
|
state.connection.status = "Connection failed.";
|
|
|
|
expect(composerMetaViewModel(state, runtimeSnapshotFixture(state))).toEqual({
|
|
fatal: "Codex app-server disconnected",
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "--%",
|
|
},
|
|
statusSummary: "Codex app-server disconnected",
|
|
model: "",
|
|
effort: null,
|
|
planActive: false,
|
|
autoReviewActive: false,
|
|
fastActive: false,
|
|
});
|
|
});
|
|
|
|
it("builds slash-command status lines from chat state", () => {
|
|
const state = createChatState();
|
|
state.activeThread.id = "thread-1";
|
|
state.connection.runtimeConfig = runtimeConfigFixture({
|
|
model: "gpt-5.5",
|
|
model_provider: "openai",
|
|
model_reasoning_effort: "high",
|
|
service_tier: "fast",
|
|
});
|
|
state.connection.availableModels = [modelFixture("gpt-5.5")];
|
|
const snapshot = runtimeSnapshotFixture(state);
|
|
|
|
expect(statusSummaryLines({ activeThreadId: state.activeThread.id, snapshot, nowMs: 0 })[1]).toBe("Thread: thread-1");
|
|
expect(
|
|
modelStatusLines({
|
|
runtimeConfig: state.connection.runtimeConfig,
|
|
requestedModel: state.runtime.requestedModel,
|
|
snapshot,
|
|
collaborationModeLabel: "Default",
|
|
}),
|
|
).toContain("Model: gpt-5.5");
|
|
expect(
|
|
modelStatusLines({
|
|
runtimeConfig: state.connection.runtimeConfig,
|
|
requestedModel: state.runtime.requestedModel,
|
|
snapshot,
|
|
collaborationModeLabel: "Default",
|
|
}),
|
|
).toContain("Mode: Default");
|
|
expect(
|
|
effortStatusLines({
|
|
runtimeConfig: state.connection.runtimeConfig,
|
|
requestedReasoningEffort: state.runtime.requestedReasoningEffort,
|
|
snapshot,
|
|
}),
|
|
).toContain("Supported: high");
|
|
});
|
|
|
|
it("builds runtime composer choices from immutable chat state snapshots", () => {
|
|
const state = createChatState();
|
|
state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
|
|
state.connection.availableModels = [modelFixture("gpt-5.5"), modelFixture("gpt-5-mini")];
|
|
const selectedModels: string[] = [];
|
|
const selectedEfforts: string[] = [];
|
|
let resetEffortCount = 0;
|
|
|
|
const choices = runtimeComposerChoices({
|
|
state,
|
|
snapshot: runtimeSnapshotFixture(state),
|
|
requestModel: (model) => {
|
|
selectedModels.push(model);
|
|
},
|
|
requestReasoningEffort: (effort) => {
|
|
selectedEfforts.push(effort);
|
|
},
|
|
resetReasoningEffortToConfig: () => {
|
|
resetEffortCount += 1;
|
|
},
|
|
});
|
|
|
|
expect(choices.modelChoices).toMatchObject([
|
|
{ label: "gpt-5-mini", selected: false },
|
|
{ label: "gpt-5.5", selected: true },
|
|
]);
|
|
expect(choices.effortChoices).toMatchObject([
|
|
{ label: "Codex default", selected: false },
|
|
{ label: "high", selected: true },
|
|
]);
|
|
|
|
choices.modelChoices[0]?.onClick();
|
|
choices.effortChoices[0]?.onClick();
|
|
choices.effortChoices[1]?.onClick();
|
|
expect(selectedModels).toEqual(["gpt-5-mini"]);
|
|
expect(resetEffortCount).toBe(1);
|
|
expect(selectedEfforts).toEqual(["high"]);
|
|
});
|
|
|
|
it("routes goal status actions to the rendered goal thread", () => {
|
|
const state = createChatState();
|
|
state.activeThread.id = "thread-rendered";
|
|
state.activeThread.goal = goalFixture("thread-rendered");
|
|
const statuses: [string, string][] = [];
|
|
const clears: string[] = [];
|
|
const ports = {
|
|
settings: {
|
|
sendShortcut: () => "enter",
|
|
},
|
|
actions: {
|
|
goal: {
|
|
saveObjective: async () => undefined,
|
|
setStatus: async (threadId, status) => {
|
|
statuses.push([threadId, status]);
|
|
},
|
|
clear: async (threadId) => {
|
|
clears.push(threadId);
|
|
},
|
|
setEditingOpen: () => undefined,
|
|
},
|
|
},
|
|
} satisfies ChatPanelGoalPorts;
|
|
|
|
const props = chatPanelGoalProps(ports, state);
|
|
state.activeThread.id = "thread-current";
|
|
props.actions.onPause();
|
|
props.actions.onResume();
|
|
props.actions.onClear();
|
|
|
|
expect(statuses).toEqual([
|
|
["thread-rendered", "paused"],
|
|
["thread-rendered", "active"],
|
|
]);
|
|
expect(clears).toEqual(["thread-rendered"]);
|
|
});
|
|
|
|
it("builds composer meta from one captured chat state", () => {
|
|
const state = createChatState();
|
|
state.connection.runtimeConfig = runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
|
|
state.connection.availableModels = [modelFixture("gpt-5.5")];
|
|
|
|
const model = chatPanelComposerMetaViewModel(
|
|
{
|
|
thread: {
|
|
restoredPlaceholder: () => null,
|
|
},
|
|
runtime: {
|
|
requestModel: async () => undefined,
|
|
requestReasoningEffort: async () => undefined,
|
|
resetReasoningEffortToConfig: async () => undefined,
|
|
},
|
|
} satisfies ChatPanelComposerPorts,
|
|
state,
|
|
);
|
|
|
|
expect(model).toMatchObject({
|
|
model: "gpt-5.5",
|
|
effort: "high",
|
|
modelChoices: [{ label: "gpt-5.5", selected: true }],
|
|
effortChoices: [
|
|
{ label: "Codex default", selected: false },
|
|
{ label: "high", selected: true },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("derives composer placeholders", () => {
|
|
expect(composerPlaceholder("Active")).toBe("Ask Codex to work on “Active”...");
|
|
expect(composerPlaceholder(null)).toBe("Ask Codex to work on this task...");
|
|
});
|
|
|
|
it("uses restored thread names for composer region placeholders when the listed thread has no explicit name", () => {
|
|
const state = createChatState();
|
|
state.activeThread.id = "thread-1";
|
|
state.threadList.listedThreads = [threadFixture("thread-1", null)];
|
|
|
|
expect(
|
|
chatPanelComposerPlaceholder(
|
|
{
|
|
thread: {
|
|
restoredPlaceholder: () => ({ threadId: "thread-1", title: "Restored", explicitName: "Restored" }),
|
|
},
|
|
runtime: {
|
|
requestModel: async () => undefined,
|
|
requestReasoningEffort: async () => undefined,
|
|
resetReasoningEffortToConfig: async () => undefined,
|
|
},
|
|
} satisfies ChatPanelComposerPorts,
|
|
state,
|
|
),
|
|
).toBe("Ask Codex to work on “Restored”...");
|
|
});
|
|
});
|
|
|
|
function runtimeConfigFixture(config: Record<string, unknown>): RuntimeConfigSnapshot {
|
|
return runtimeConfigSnapshotFromAppServerConfig({
|
|
config: config as ConfigReadResult["config"],
|
|
origins: {},
|
|
layers: null,
|
|
});
|
|
}
|
|
|
|
function runtimeSnapshotFixture(state: ChatState) {
|
|
return runtimeSnapshotForChatState(state);
|
|
}
|
|
|
|
function threadFixture(id: string, name: string | null): Thread {
|
|
return {
|
|
id,
|
|
preview: "",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
name,
|
|
archived: false,
|
|
};
|
|
}
|
|
|
|
function modelFixture(model: string, fastTierId?: string): ModelMetadata {
|
|
return {
|
|
id: model,
|
|
model,
|
|
displayName: model,
|
|
description: "",
|
|
hidden: false,
|
|
supportedReasoningEfforts: ["high"],
|
|
defaultReasoningEffort: "high",
|
|
inputModalities: [],
|
|
additionalSpeedTiers: fastTierId ? ["fast"] : [],
|
|
serviceTiers: fastTierId ? [{ id: fastTierId, name: "Fast" }] : [],
|
|
defaultServiceTier: null,
|
|
isDefault: true,
|
|
};
|
|
}
|
|
|
|
function goalFixture(threadId: string): ThreadGoal {
|
|
return {
|
|
threadId,
|
|
objective: "Ship it",
|
|
status: "active",
|
|
tokenBudget: null,
|
|
tokensUsed: 0,
|
|
timeUsedSeconds: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|