murashit_codex-panel/tests/features/chat/application/state/root-reducer.test.ts

999 lines
42 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
import type { Thread } from "../../../../../src/domain/threads/model";
import { activeThreadState, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { threadStreamItems } from "../../../../../src/features/chat/application/state/thread-stream";
import { activeTurnId, chatTurnBusy, pendingTurnStart } from "../../../../../src/features/chat/application/turns/turn-state";
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 { chatStateFixture, chatStateWith } from "../../support/state";
import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream";
describe("chatReducer", () => {
it.each([
"connection/scoped-cleared",
"connection/context-replaced",
] as const)("restores cancellable web drafts when %s clears their context", (type) => {
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
const state = chatReducer(chatReducer(chatStateFixture(), { type: "composer/draft-set", draft: "" }), {
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: " /web https://example.com summarize ",
phase: "cancellable",
},
} as never);
const cleared = chatReducer(state, { type });
expect(cleared.pendingSubmission).toBeNull();
expect(cleared.composer.draft).toBe(" /web https://example.com summarize ");
});
it("does not restore committed web drafts when their connection context is replaced", () => {
const pending = pendingWebSubmissionItem("local-web", "https://example.com", "summarize");
if (!pending) throw new Error("Expected pending web submission");
const state = chatReducer(chatStateFixture(), {
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: null,
originalDraft: "/web https://example.com summarize",
phase: "committed",
},
} as never);
const cleared = chatReducer(state, { type: "connection/context-replaced" });
expect(cleared.pendingSubmission).toBeNull();
expect(cleared.composer.draft).toBe("");
});
it("clears connection metadata only when the app-server context is replaced", () => {
const state = chatStateWith(chatStateFixture(), {
connection: {
runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-old" },
initializeResponse: {
codexHome: "/old/codex-home",
platformFamily: "unix",
platformOs: "macos",
userAgent: "codex-old",
},
},
});
const disconnected = chatReducer(state, { type: "connection/scoped-cleared" });
expect(disconnected.connection.runtimeConfig).toEqual(state.connection.runtimeConfig);
expect(disconnected.connection.initializeResponse).toEqual(state.connection.initializeResponse);
const replaced = chatReducer(state, { type: "connection/context-replaced" });
expect(replaced.connection.runtimeConfig).toBeNull();
expect(replaced.connection.initializeResponse).toBeNull();
});
it("applies restored thread identity as an atomic thread-scoped transition", () => {
const state = threadScopedResidue({ threadId: "old-thread", draft: "stale draft", itemId: "stale-item" });
const restored = chatReducer(state, {
type: "panel/restored-thread-applied",
threadId: "restored-thread",
fallbackTitle: "Restored title",
});
expect(restored.panelThread).toEqual({
kind: "awaiting-resume",
threadId: "restored-thread",
fallbackTitle: "Restored title",
});
expect(restored.connection.statusText).toBe("Thread ready to resume.");
expectThreadScopeReset(restored, { items: [] });
const disconnected = chatReducer(restored, { type: "connection/scoped-cleared" });
expect(disconnected.panelThread).toEqual(restored.panelThread);
});
it("keeps active-only metadata out of the awaiting-resume phase", () => {
let state = chatReducer(chatStateFixture(), {
type: "panel/restored-thread-applied",
threadId: "restored-thread",
fallbackTitle: "Restored title",
});
const awaitingResume = state.panelThread;
state = chatReducer(state, { type: "active-thread/cwd-set", cwd: "/stale" });
const usage = {
total: { totalTokens: 1, inputTokens: 1, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
last: { totalTokens: 1, inputTokens: 1, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
modelContextWindow: 100,
};
state = chatReducer(state, { type: "active-thread/token-usage-set", tokenUsage: usage });
state = chatReducer(state, { type: "active-thread/goal-set", goal: goal("restored-thread") });
state = chatReducer(state, { type: "turn/started", threadId: "other-thread", turnId: "stale-turn" });
expect(state.panelThread).toEqual(awaitingResume);
expect(state.turn.lifecycle).toEqual({ kind: "idle" });
});
it("clears active turn and thread-scoped state", () => {
let state = threadScopedResidue({ draft: "keep me", itemId: "m1" });
state = chatStateWith(state, {
runtime: {
active: {
model: "gpt-5.1",
reasoningEffort: "high",
serviceTier: "fast",
approvalsReviewer: "auto_review",
activePermissionProfile: { id: ":workspace", extends: null },
},
},
});
let pendingState = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.2" });
pendingState = chatReducer(pendingState, { type: "runtime/permission-profile-requested", permissionProfile: ":read-only" });
pendingState = chatReducer(pendingState, { type: "runtime/reasoning-effort-requested", effort: "medium" });
pendingState = chatReducer(pendingState, { type: "runtime/fast-mode-requested", fastMode: "disabled" });
pendingState = chatReducer(pendingState, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "user" });
const next = chatReducer(pendingState, { type: "active-thread/cleared" });
expect(next.panelThread).toEqual({ kind: "empty" });
expect(next.runtime.active.model).toBeNull();
expect(next.runtime.active.reasoningEffort).toBeNull();
expect(next.runtime.active.serviceTier).toBeNull();
expect(next.runtime.active.approvalsReviewer).toBeNull();
expect(next.runtime.active.activePermissionProfile).toBeNull();
expect(next.runtime.active.collaborationMode).toBeNull();
expect(next.runtime.pending.model).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.permissionProfile).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" });
expectThreadScopeReset(next, { items: [] });
});
it("resets thread-scoped state when resuming a thread", () => {
const state = threadScopedResidue({ threadId: "previous-thread", turnId: "previous-turn" });
const resumedItems = [dialogueItem("resumed-message")];
const next = chatReducer(state, {
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("resumed-thread"),
cwd: "/vault",
model: "gpt-5.1",
reasoningEffort: "high",
serviceTier: "fast",
approvalsReviewer: "user",
items: resumedItems,
});
expect(activeThreadState(next)).toMatchObject({ id: "resumed-thread", goal: null });
expect(next.runtime.active.collaborationMode).toBeNull();
expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" });
expectThreadScopeReset(next, { items: resumedItems });
});
it("preserves empty-panel runtime reservations when thread activation explicitly requests it", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.5" });
state = chatReducer(state, { type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
state = chatReducer(state, { type: "runtime/reasoning-effort-requested", effort: "high" });
state = chatReducer(state, { type: "runtime/fast-mode-requested", fastMode: "enabled" });
state = chatReducer(state, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
state = chatReducer(state, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" });
const next = chatReducer(state, {
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("started-thread"),
cwd: "/vault",
model: "gpt-5",
reasoningEffort: "medium",
serviceTier: "fast",
approvalsReviewer: "user",
preserveRequestedRuntimeSettings: true,
});
expect(activeThreadState(next)?.id).toBe("started-thread");
expect(next.runtime.active.model).toBe("gpt-5");
expect(next.runtime.active.reasoningEffort).toBe("medium");
expect(next.runtime.active.serviceTier).toBe("fast");
expect(next.runtime.active.approvalsReviewer).toBe("user");
expect(next.runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
expect(next.runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" });
expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" });
expect(next.runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" });
expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" });
expect(next.runtime.pending.collaborationMode).toEqual(setCollaborationModeIntent("plan"));
expect(next.runtime.active.collaborationMode).toBeNull();
});
it("keeps reset and set permission profile requests explicit", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
expect(state.runtime.pending.permissionProfile).toEqual(setRuntimeIntentValue(":workspace"));
state = chatReducer(state, { type: "runtime/permission-profile-reset-to-config" });
expect(state.runtime.pending.permissionProfile).toEqual({ kind: "resetToConfig" });
});
it("starts resumed threads with empty display state when no history items are supplied", () => {
let state = chatStateFixture();
state = withChatStateThreadStreamItems(state, [dialogueItem("previous-message")]);
const next = chatReducer(state, {
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("resumed-thread"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
expect(chatStateThreadStreamItems(next)).toEqual([]);
});
it("updates map-backed turn diffs and toolbar panel state", () => {
let state = chatStateFixture();
state = chatStateWith(state, { threadStream: { turnDiffs: new Map([["turn", "old"]]) } });
state = chatStateWith(state, { ui: { toolbarPanel: "status-panel" } });
const withDiff = chatReducer(state, { type: "thread-stream/turn-diff-updated", turnId: "turn", diff: "new" });
const withHistoryPanel = chatReducer(withDiff, { type: "ui/panel-set", panel: "history" });
expect(withDiff.threadStream.turnDiffs.get("turn")).toBe("new");
expect(withHistoryPanel.ui.toolbarPanel).toBe("history");
});
it("deduplicates reported logs", () => {
const state = chatStateFixture();
const item = { id: "log", kind: "system", role: "system", text: "once" } satisfies ThreadStreamItem;
const first = chatReducer(state, { type: "thread-stream/deduped-log-added", text: "once", item });
const second = chatReducer(first, { type: "thread-stream/deduped-log-added", text: "once", item });
expect(chatStateThreadStreamItems(first)).toEqual([item]);
expect(chatStateThreadStreamItems(second)).toEqual([item]);
});
it("keeps turn lifecycle fields synchronized through start and completion", () => {
const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: [] };
const optimisticItem = {
id: "local-user",
kind: "dialogue",
dialogueKind: "user",
role: "user",
text: "hello",
} satisfies ThreadStreamItem;
const acknowledgedItem = { ...optimisticItem, turnId: "turn" } satisfies ThreadStreamItem;
const optimistic = chatReducer(chatStateFixture(), {
type: "turn/optimistic-started",
item: optimisticItem,
pendingTurnStart: pending,
});
expect(chatTurnBusy(optimistic)).toBe(true);
expect(activeTurnId(optimistic)).toBeNull();
expect(pendingTurnStart(optimistic)).toEqual(pending);
const running = chatReducer(optimistic, {
type: "turn/start-acknowledged",
turnId: "turn",
items: [acknowledgedItem],
});
expect(chatTurnBusy(running)).toBe(true);
expect(activeTurnId(running)).toBe("turn");
expect(pendingTurnStart(running)).toBeNull();
const completed = chatReducer(running, {
type: "turn/completed",
turnId: "turn",
status: "completed",
items: [acknowledgedItem],
});
expect(chatTurnBusy(completed)).toBe(false);
expect(activeTurnId(completed)).toBeNull();
expect(pendingTurnStart(completed)).toBeNull();
});
it("appends streaming assistant deltas after stable history", () => {
let state = chatStateFixture();
state = withChatStateThreadStreamItems(state, [dialogueItem("history")]);
const running = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" });
const next = chatReducer(running, {
type: "thread-stream/assistant-delta-appended",
itemId: "assistant",
turnId: "turn",
delta: "hello",
});
expect(next.threadStream.activeSegment?.items).toEqual([expect.objectContaining({ id: "assistant", text: "hello", turnId: "turn" })]);
expect(threadStreamItems(next.threadStream)).toEqual([
dialogueItem("history"),
expect.objectContaining({ id: "assistant", text: "hello" }),
]);
});
it("updates repeated streaming output through the active source-item index", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" });
state = chatReducer(state, {
type: "thread-stream/item-output-appended",
itemId: "cmd",
turnId: "turn",
delta: "one",
kind: "command",
fallbackText: "Command running",
});
const next = chatReducer(state, {
type: "thread-stream/item-output-appended",
itemId: "cmd",
turnId: "turn",
delta: "two",
kind: "command",
fallbackText: "Command running",
});
expect(next.threadStream.activeSegment?.items).toHaveLength(1);
expect(next.threadStream.activeSegment?.indexBySourceItemId.get("cmd")).toBe(0);
expect(next.threadStream.activeSegment?.items[0]).toMatchObject({ id: "cmd", output: "onetwo" });
});
it("ignores streaming deltas for a different active turn", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn-active" });
state = chatReducer(state, {
type: "thread-stream/assistant-delta-appended",
itemId: "assistant",
turnId: "turn-active",
delta: "active",
});
const next = chatReducer(state, {
type: "thread-stream/assistant-delta-appended",
itemId: "stale",
turnId: "turn-stale",
delta: "stale",
});
expect(next).toBe(state);
expect(next.threadStream.activeSegment?.turnId).toBe("turn-active");
expect(next.threadStream.activeSegment?.items).toEqual([expect.objectContaining({ id: "assistant", text: "active" })]);
});
it("keeps optimistic active segment items when the first acknowledged delta arrives", () => {
let state = chatStateFixture();
state = chatReducer(state, {
type: "turn/optimistic-started",
pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] },
item: dialogueItem("local-user"),
});
const next = chatReducer(state, {
type: "thread-stream/assistant-delta-appended",
itemId: "assistant",
turnId: "turn",
delta: "ack",
});
expect(next.threadStream.activeSegment?.turnId).toBe("turn");
expect(next.threadStream.activeSegment?.items).toEqual([
expect.objectContaining({ id: "local-user" }),
expect.objectContaining({ id: "assistant", text: "ack", turnId: "turn" }),
]);
});
it("does not append text to an existing item with a different kind", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" });
state = chatReducer(state, {
type: "thread-stream/item-text-appended",
itemId: "shared-source",
turnId: "turn",
label: "Tool",
delta: "tool text",
kind: "tool",
});
const next = chatReducer(state, {
type: "thread-stream/item-text-appended",
itemId: "shared-source",
turnId: "turn",
label: "Reasoning",
delta: "reasoning text",
kind: "reasoning",
});
expect(next).toBe(state);
expect(next.threadStream.activeSegment?.items).toEqual([expect.objectContaining({ kind: "tool", text: "Tool: tool text" })]);
});
it("appends text to an existing item when the item kind matches", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "turn/started", threadId: "thread", turnId: "turn" });
state = chatReducer(state, {
type: "thread-stream/item-text-appended",
itemId: "reasoning",
turnId: "turn",
label: "Reasoning",
delta: "one",
kind: "reasoning",
});
const next = chatReducer(state, {
type: "thread-stream/item-text-appended",
itemId: "reasoning",
turnId: "turn",
label: "Reasoning",
delta: "two",
kind: "reasoning",
});
expect(next.threadStream.activeSegment?.items).toEqual([expect.objectContaining({ kind: "reasoning", text: "Reasoning: onetwo" })]);
});
it("clears running state when a turn start fails", () => {
let state = chatStateFixture();
state = chatStateWith(state, {
turn: { lifecycle: { kind: "starting", pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] } } },
});
const next = chatReducer(state, { type: "turn/start-failed", items: [] });
expect(chatTurnBusy(next)).toBe(false);
expect(activeTurnId(next)).toBeNull();
expect(pendingTurnStart(next)).toBeNull();
});
it("ignores stale turn start failures after the turn is already running", () => {
let state = chatStateFixture();
state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn" } } });
state = withChatStateThreadStreamItems(state, [dialogueItem("existing")]);
const next = chatReducer(state, { type: "turn/start-failed", items: [] });
expect(chatTurnBusy(next)).toBe(true);
expect(activeTurnId(next)).toBe("turn");
expect(chatStateThreadStreamItems(next)).toEqual([dialogueItem("existing")]);
});
it("clears turn-scoped requests when clearing the local turn scope", () => {
let state = chatStateFixture();
state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn" } } });
state = chatStateWith(state, { requests: { approvals: [approval(1)] } });
state = chatStateWith(state, { requests: { pendingUserInputs: [userInput(2)] } });
state = chatStateWith(state, { requests: { userInputDrafts: new Map([["2:note", "draft"]]) } });
state = withChatStateThreadStreamItems(state, [dialogueItem("kept")]);
state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } });
state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["kept:details"]) } } });
const next = chatReducer(state, { type: "turn/scoped-cleared" });
expect(chatTurnBusy(next)).toBe(false);
expect(activeTurnId(next)).toBeNull();
expect(next.requests.approvals).toEqual([]);
expect(next.requests.pendingUserInputs).toEqual([]);
expect(next.requests.userInputDrafts.size).toBe(0);
expect(chatStateThreadStreamItems(next)).toEqual([dialogueItem("kept")]);
expect([...next.ui.disclosures.approvalDetails]).toEqual([]);
expect([...next.ui.disclosures.textDetails]).toEqual(["kept:details"]);
});
it("resolves requests while optionally appending a result item", () => {
let state = chatStateFixture();
state = chatStateWith(state, { requests: { approvals: [approval(1)] } });
state = chatStateWith(state, { requests: { pendingUserInputs: [userInput(2)] } });
state = chatStateWith(state, {
requests: {
userInputDrafts: new Map([
["2:note", "draft"],
["2:note:other", "other draft"],
]),
},
});
state = withChatStateThreadStreamItems(state, [dialogueItem("existing")]);
state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } });
state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["existing:details"]) } } });
const withoutResult = chatReducer(state, { type: "request/resolved", requestId: 1 });
expect(withoutResult.requests.approvals).toEqual([]);
expect(withoutResult.requests.pendingUserInputs).toEqual([userInput(2)]);
expect(chatStateThreadStreamItems(withoutResult)).toEqual([dialogueItem("existing")]);
expect([...withoutResult.ui.disclosures.approvalDetails]).toEqual([]);
expect([...withoutResult.ui.disclosures.textDetails]).toEqual(["existing:details"]);
const resultItem = dialogueItem("result");
const withResult = chatReducer(withoutResult, { type: "request/resolved", requestId: 2, resultItem });
expect(withResult.requests.pendingUserInputs).toEqual([]);
expect(withResult.requests.userInputDrafts.size).toBe(0);
expect(chatStateThreadStreamItems(withResult)).toEqual([dialogueItem("existing"), resultItem]);
expect([...withResult.ui.disclosures.textDetails]).toEqual(["existing:details"]);
});
it("ignores stale request resolutions without appending result items", () => {
let state = chatStateFixture();
state = withChatStateThreadStreamItems(state, [dialogueItem("existing")]);
state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["existing:details"]) } } });
const next = chatReducer(state, { type: "request/resolved", requestId: 99, resultItem: dialogueItem("stale result") });
expect(chatStateThreadStreamItems(next)).toEqual([dialogueItem("existing")]);
expect([...next.ui.disclosures.textDetails]).toEqual(["existing:details"]);
});
it("ignores turn start acknowledgements after the turn has already gone idle", () => {
let state = chatStateFixture();
state = chatStateWith(state, { turn: { lifecycle: { kind: "idle" } } });
const next = chatReducer(state, {
type: "turn/start-acknowledged",
turnId: "completed-turn",
items: [{ id: "local-user", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello", turnId: "completed-turn" }],
});
expect(chatTurnBusy(next)).toBe(false);
expect(activeTurnId(next)).toBeNull();
});
it("ignores completed turns while a new turn is still starting", () => {
const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] };
let state = chatStateFixture();
state = chatStateWith(state, { turn: { lifecycle: { kind: "starting", pendingTurnStart: pending } } });
state = withChatStateThreadStreamItems(state, [
{ id: "local-user", kind: "dialogue", dialogueKind: "user", role: "user", text: "hello" },
]);
const next = chatReducer(state, {
type: "turn/completed",
turnId: "stale-turn",
status: "completed",
items: [],
});
expect(chatTurnBusy(next)).toBe(true);
expect(pendingTurnStart(next)).toEqual(pending);
expect(chatStateThreadStreamItems(next)).toEqual(chatStateThreadStreamItems(state));
});
it("keeps toolbar panels mutually exclusive", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "ui/panel-set", panel: "history" });
expect(state.ui.toolbarPanel).toBe("history");
state = chatReducer(state, { type: "ui/panel-set", panel: "chat-actions" });
expect(state.ui.toolbarPanel).toBe("chat-actions");
state = chatReducer(state, { type: "ui/panel-set", panel: "status-panel" });
expect(state.ui.toolbarPanel).toBe("status-panel");
});
it("updates typed disclosures, thread stream action menu, goal editor, and user input drafts through typed UI actions", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "ui/disclosure-set", bucket: "approvalDetails", id: "1:details", open: true });
expect(state.ui.disclosures.approvalDetails.has("1:details")).toBe(true);
state = chatReducer(state, { type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: "thread", open: true });
expect(state.ui.disclosures.goalObjectiveExpanded.has("thread")).toBe(true);
state = chatReducer(state, { type: "ui/thread-stream-fork-menu-set", itemId: "message-1" });
expect(state.ui.threadStreamActionMenu.forkMenuItemId).toBe("message-1");
state = chatReducer(state, { type: "ui/goal-editor-started", threadId: "thread", objective: "old", tokenBudget: 10 });
state = chatReducer(state, { type: "ui/goal-editor-draft-updated", objective: "new" });
expect(state.ui.goalEditor).toEqual({ kind: "editing", threadId: "thread", objectiveDraft: "new", tokenBudgetDraft: 10 });
state = chatReducer(state, { type: "request/user-input-draft-set", key: "1:note", value: "answer" });
expect(state.requests.userInputDrafts.get("1:note")).toBe("answer");
state = chatReducer(state, { type: "ui/disclosure-set", bucket: "approvalDetails", id: "1:details", open: false });
expect(state.ui.disclosures.approvalDetails.has("1:details")).toBe(false);
state = chatReducer(state, { type: "ui/goal-editor-closed" });
expect(state.ui.goalEditor.kind).toBe("closed");
});
it("keeps rename generation callbacks scoped to the active generation", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "ui/rename-started", threadId: "thread", draft: "Original" });
state = chatReducer(state, {
type: "ui/rename-generation-started",
threadId: "thread",
generationToken: 1,
});
const generatingState = state.ui.rename;
if (generatingState.kind !== "generating") throw new Error("Expected generating rename state.");
const staleSucceeded = chatReducer(state, {
type: "ui/rename-generation-succeeded",
threadId: "thread",
generationToken: 2,
draft: "Late title",
});
expect(staleSucceeded).toBe(state);
const manuallyEdited = chatReducer(state, { type: "ui/rename-draft-updated", threadId: "thread", draft: "Manual draft" });
const generatedAfterManualEdit = chatReducer(manuallyEdited, {
type: "ui/rename-generation-succeeded",
threadId: "thread",
generationToken: generatingState.generationToken,
draft: "Generated title",
});
expect(generatedAfterManualEdit).toBe(manuallyEdited);
const finished = chatReducer(manuallyEdited, {
type: "ui/rename-generation-finished",
threadId: "thread",
generationToken: generatingState.generationToken,
});
expect(finished.ui.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Manual draft" });
});
it("clears expanded goal objective state when the displayed goal identity changes", () => {
let state = chatStateWith(chatStateFixture(), { activeThread: { id: "thread" } });
state = chatReducer(state, { type: "active-thread/goal-set", goal: goal("thread") });
state = chatReducer(state, { type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: "thread", open: true });
const usageOnly = chatReducer(state, {
type: "active-thread/goal-set",
goal: { ...goal("thread"), tokensUsed: 10, timeUsedSeconds: 30 },
});
expect(usageOnly.ui.disclosures.goalObjectiveExpanded.has("thread")).toBe(true);
const objectiveChanged = chatReducer(usageOnly, {
type: "active-thread/goal-set",
goal: { ...goal("thread"), objective: "Changed" },
});
expect(objectiveChanged.ui.disclosures.goalObjectiveExpanded.size).toBe(0);
});
it("commits applied runtime settings and clears applied intents", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.1" });
state = chatReducer(state, { type: "runtime/reasoning-effort-requested", effort: "high" });
state = chatReducer(state, { type: "runtime/fast-mode-requested", fastMode: "enabled" });
state = chatReducer(state, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
const next = chatReducer(state, {
type: "runtime/pending-thread-settings-committed",
update: {
model: "gpt-5.1",
effort: "high",
serviceTier: "fast",
approvalsReviewer: "auto_review",
collaborationMode: { mode: "plan", settings: { model: "gpt-5.1", reasoningEffort: "high", developerInstructions: null } },
},
});
expect(next.runtime.active.model).toBe("gpt-5.1");
expect(next.runtime.pending.model).toEqual({ kind: "unchanged" });
expect(next.runtime.active.reasoningEffort).toBe("high");
expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "unchanged" });
expect(next.runtime.active.serviceTier).toBe("fast");
expect(next.runtime.active.serviceTierKnown).toBe(true);
expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" });
expect(next.runtime.active.approvalsReviewer).toBe("auto_review");
expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" });
expect(next.runtime.active.collaborationMode).toBe("plan");
});
it("does not mark service tier as known when committing unrelated runtime settings", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.1" });
const next = chatReducer(state, {
type: "runtime/pending-thread-settings-committed",
update: { model: "gpt-5.1" },
});
expect(next.runtime.active.model).toBe("gpt-5.1");
expect(next.runtime.active.serviceTier).toBeNull();
expect(next.runtime.active.serviceTierKnown).toBe(false);
});
it("preserves unknown service tier state when resuming from active runtime", () => {
const state = chatReducer(chatStateFixture(), {
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread"),
cwd: "/vault",
model: "gpt-5.1",
reasoningEffort: "high",
serviceTier: null,
serviceTierKnown: false,
approvalsReviewer: "user",
});
expect(state.runtime.active.serviceTier).toBeNull();
expect(state.runtime.active.serviceTierKnown).toBe(false);
});
it("keeps requested policy toggles pending until app-server settings commit", () => {
let state = chatStateFixture();
state = chatStateWith(state, { runtime: { active: { serviceTier: "flex" } } });
state = chatStateWith(state, { runtime: { active: { approvalsReviewer: "user" } } });
state = chatReducer(state, { type: "runtime/fast-mode-requested", fastMode: "enabled" });
state = chatReducer(state, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
expect(state.runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" });
expect(state.runtime.active.serviceTier).toBe("flex");
expect(state.runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" });
expect(state.runtime.active.approvalsReviewer).toBe("user");
});
it("keeps reset and unset runtime request semantics explicit", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.1" });
state = chatReducer(state, { type: "runtime/reasoning-effort-requested", effort: "high" });
state = chatReducer(state, { type: "runtime/fast-mode-requested", fastMode: "enabled" });
state = chatReducer(state, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
state = chatReducer(state, { type: "runtime/model-reset-to-config" });
state = chatReducer(state, { type: "runtime/reasoning-effort-reset-to-config" });
state = chatReducer(state, { type: "runtime/fast-mode-request-cleared" });
state = chatReducer(state, { type: "runtime/approvals-reviewer-request-cleared" });
expect(state.runtime.pending.model).toEqual({ kind: "resetToConfig" });
expect(state.runtime.pending.reasoningEffort).toEqual({ kind: "resetToConfig" });
expect(state.runtime.pending.fastMode).toEqual({ kind: "unchanged" });
expect(state.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" });
});
it("updates composer suggestions when insertion-only fields change", () => {
const initialSuggestion = {
display: "Alpha",
detail: "alpha.md",
replacement: "[[Alpha]]",
start: 0,
tabCursorOffset: -2,
} satisfies ChatState["composer"]["suggestions"][number];
const nextSuggestion = {
...initialSuggestion,
tabCursorOffset: 0,
suffixOnInsert: "]]",
} satisfies ChatState["composer"]["suggestions"][number];
let state = chatReducer(chatStateFixture(), { type: "composer/suggestions-set", suggestions: [initialSuggestion] });
state = chatReducer(state, { type: "composer/suggestions-set", suggestions: [nextSuggestion] });
expect(state.composer.suggestions).toEqual([nextSuggestion]);
});
it("stores updates through ChatStateStore without mutating the initial snapshot", () => {
let initial = chatStateFixture();
initial = withChatStateThreadStreamItems(initial, [dialogueItem("initial")]);
const store = createChatStateStore(initial);
store.dispatch({ type: "thread-stream/item-upserted", item: dialogueItem("next") });
expect(chatStateThreadStreamItems(initial)).toEqual([dialogueItem("initial")]);
expect(chatStateThreadStreamItems(store.getState())).toEqual([dialogueItem("initial"), dialogueItem("next")]);
});
it("keeps panel-local thread, request, and composer state isolated across stores", () => {
const panelA = createChatStateStore();
const panelB = createChatStateStore();
panelA.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread-a"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
panelA.dispatch({ type: "composer/draft-set", draft: "panel A draft" });
panelA.dispatch({ type: "request/user-input-queued", input: userInput(1) });
panelA.dispatch({ type: "request/user-input-draft-set", key: "1:note", value: "panel A answer" });
panelB.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread-b"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
panelB.dispatch({ type: "composer/draft-set", draft: "panel B draft" });
panelB.dispatch({ type: "request/user-input-queued", input: userInput(2) });
panelB.dispatch({ type: "request/user-input-draft-set", key: "2:note", value: "panel B answer" });
panelA.dispatch({ type: "request/resolved", requestId: 1 });
panelA.dispatch({ type: "active-thread/cleared" });
expect(panelA.getState()).toMatchObject({
panelThread: { kind: "empty" },
composer: { draft: "" },
requests: { pendingUserInputs: [] },
});
expect(panelA.getState().requests.userInputDrafts.size).toBe(0);
expect(panelB.getState()).toMatchObject({
panelThread: { kind: "active", thread: { id: "thread-b" } },
composer: { draft: "panel B draft" },
requests: { pendingUserInputs: [expect.objectContaining({ requestId: 2 })] },
});
expect(panelB.getState().requests.userInputDrafts.get("2:note")).toBe("panel B answer");
});
});
function dialogueItem(id: string): ThreadStreamItem {
return { id, kind: "dialogue", role: "assistant", text: id, dialogueKind: "assistantResponse", dialogueState: "completed" };
}
function threadScopedResidue(options: { threadId?: string; turnId?: string; draft?: string; itemId?: string } = {}): ChatState {
const threadId = options.threadId ?? "thread";
const turnId = options.turnId ?? "turn";
let state = chatStateFixture({
activeThread: { id: threadId, goal: goal(threadId) },
turn: { lifecycle: { kind: "running", turnId } },
runtime: {
active: { collaborationMode: "plan" },
pending: { collaborationMode: setCollaborationModeIntent("plan") },
},
threadStream: {
historyCursor: "cursor",
loadingHistory: true,
turnDiffs: new Map([[turnId, "@@"]]),
},
requests: {
approvals: [approval(1)],
pendingUserInputs: [userInput(2)],
userInputDrafts: new Map([["2:note", "draft"]]),
},
composer: {
draft: options.draft ?? "previous draft",
suggestSelected: 1,
suggestions: [suggestion("/plan")],
suggestionsDismissedSignature: "dismissed",
},
ui: {
disclosures: {
approvalDetails: new Set(["1:details"]),
textDetails: new Set(["previous:details"]),
},
threadStreamActionMenu: { forkMenuItemId: "previous" },
goalEditor: { kind: "editing", threadId, objectiveDraft: "draft", tokenBudgetDraft: null },
},
});
state = withChatStateThreadStreamItems(state, [dialogueItem(options.itemId ?? "previous-message")]);
return state;
}
function expectThreadScopeReset(state: ChatState, options: { items: readonly ThreadStreamItem[] }): void {
expect(activeTurnId(state)).toBeNull();
expect(chatStateThreadStreamItems(state)).toEqual(options.items);
expect(state.threadStream.turnDiffs.size).toBe(0);
expect(state.threadStream.historyCursor).toBeNull();
expect(state.threadStream.loadingHistory).toBe(false);
expect(state.requests.approvals).toEqual([]);
expect(state.requests.pendingUserInputs).toEqual([]);
expect(state.requests.userInputDrafts.size).toBe(0);
expect(state.composer.draft).toBe("");
expect(state.composer.suggestSelected).toBe(0);
expect(state.composer.suggestions).toEqual([]);
expect(state.composer.suggestionsDismissedSignature).toBeNull();
expect(uiDisclosureCount(state)).toBe(0);
expect(state.ui.threadStreamActionMenu.forkMenuItemId).toBeNull();
expect(state.ui.goalEditor.kind).toBe("closed");
}
function suggestion(display: string): ChatState["composer"]["suggestions"][number] {
return { display, detail: "Plan mode", replacement: display, start: 0, appendSpaceOnInsert: true };
}
function approval(requestId: number): ChatState["requests"]["approvals"][number] {
return {
requestId,
kind: "command",
turnId: "turn",
title: "Command approval",
summary: "Need access\npwd",
resultSummary: "Need access",
details: [
{ key: "reason", value: "Need access" },
{ key: "command", value: "pwd" },
{ key: "cwd", value: "/tmp" },
],
responses: { accept: {}, acceptSession: {}, decline: {}, cancel: {} },
actionOptions: null,
};
}
function userInput(requestId: number): ChatState["requests"]["pendingUserInputs"][number] {
return {
requestId,
params: {
threadId: "thread",
turnId: "turn",
itemId: "input",
questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }],
autoResolutionMs: null,
},
};
}
function goal(threadId: string): ThreadGoal {
return {
threadId,
objective: "Finish",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
};
}
function uiDisclosureCount(state: ChatState): number {
const disclosures = state.ui.disclosures;
return (
disclosures.details.size +
disclosures.activityGroups.size +
disclosures.textDetails.size +
disclosures.userDialogueExpanded.size +
disclosures.goalObjectiveExpanded.size +
disclosures.approvalDetails.size
);
}
function thread(id: string): Thread {
return {
id,
preview: "",
createdAt: 1,
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
};
}