From c37832d0a35aa85e9e8944d8e5c9be736201cb99 Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 17 Jul 2026 07:39:16 +0900 Subject: [PATCH] refactor(test): align tests with owning modules --- .../protocol/runtime-config.test.ts | 76 ++ tests/domain/runtime/config.test.ts | 33 + .../server/diagnostics.test.ts} | 8 +- tests/domain/vault/paths.test.ts | 9 + .../app-server/mappers/thread-stream.test.ts | 616 --------- .../application/state/root-reducer.test.ts | 328 ----- .../application/state/thread-stream.test.ts | 166 ++- .../chat/application/state/ui-state.test.ts | 122 ++ .../turns/plan-implementation.test.ts | 7 + .../chat/domain/runtime/labels.test.ts | 21 + .../chat/domain/runtime/resolution.test.ts | 420 ++++++ .../chat/domain/runtime/state.test.ts | 95 ++ tests/features/chat/domain/runtime/support.ts | 150 +++ .../runtime/thread-settings-patch.test.ts | 367 ++++++ .../semantics/active-turn.test.ts | 95 ++ .../chat/domain/thread-stream/updates.test.ts | 37 + .../chat/presentation/runtime/status.test.ts | 163 +++ .../presentation/thread-stream/layout.test.ts | 466 +++++++ tests/runtime/runtime-settings.test.ts | 1149 ----------------- tests/runtime/service-tier-state.test.ts | 70 - .../obsidian/vault-write-destination.test.ts} | 6 +- 21 files changed, 2186 insertions(+), 2218 deletions(-) create mode 100644 tests/app-server/protocol/runtime-config.test.ts create mode 100644 tests/domain/runtime/config.test.ts rename tests/{app-server/app-server-diagnostics.test.ts => domain/server/diagnostics.test.ts} (93%) create mode 100644 tests/domain/vault/paths.test.ts create mode 100644 tests/features/chat/application/state/ui-state.test.ts create mode 100644 tests/features/chat/domain/runtime/labels.test.ts create mode 100644 tests/features/chat/domain/runtime/resolution.test.ts create mode 100644 tests/features/chat/domain/runtime/state.test.ts create mode 100644 tests/features/chat/domain/runtime/support.ts create mode 100644 tests/features/chat/domain/runtime/thread-settings-patch.test.ts create mode 100644 tests/features/chat/domain/thread-stream/semantics/active-turn.test.ts create mode 100644 tests/features/chat/domain/thread-stream/updates.test.ts create mode 100644 tests/features/chat/presentation/runtime/status.test.ts create mode 100644 tests/features/chat/presentation/thread-stream/layout.test.ts delete mode 100644 tests/runtime/runtime-settings.test.ts delete mode 100644 tests/runtime/service-tier-state.test.ts rename tests/{features/threads/obsidian/archive-export-destination.test.ts => shared/obsidian/vault-write-destination.test.ts} (84%) diff --git a/tests/app-server/protocol/runtime-config.test.ts b/tests/app-server/protocol/runtime-config.test.ts new file mode 100644 index 00000000..4d07fb87 --- /dev/null +++ b/tests/app-server/protocol/runtime-config.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { type ConfigReadResult, runtimeConfigSnapshotFromAppServerConfig } from "../../../src/app-server/protocol/runtime-config"; + +describe("runtime config protocol mapping", () => { + it("keeps startup permission defaults in the runtime config snapshot", () => { + expect( + runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + approvals_reviewer: "auto_review", + }), + ).toMatchObject({ + approvalsReviewer: "auto_review", + startupPermissions: { + activePermissionProfile: { id: ":workspace", extends: null }, + approvalPolicy: "on-request", + sandboxPolicy: null, + }, + }); + }); + + it("keeps default permissions separate from legacy sandbox fields", () => { + expect( + runtimeConfigFixture({ + default_permissions: "DevProfile", + approval_policy: "on-request", + sandbox_mode: "workspace-write", + sandbox_workspace_write: { + writable_roots: ["/vault"], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + }), + ).toMatchObject({ + startupPermissions: { + activePermissionProfile: { id: "DevProfile", extends: null }, + approvalPolicy: "on-request", + sandboxPolicy: null, + }, + }); + }); + + it("uses legacy sandbox config when default permissions are not reported", () => { + expect( + runtimeConfigFixture({ + approval_policy: "on-request", + sandbox_mode: "workspace-write", + sandbox_workspace_write: { + writable_roots: ["/vault"], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + }), + ).toMatchObject({ + startupPermissions: { + activePermissionProfile: null, + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: false, + }, + }, + }); + }); +}); + +function runtimeConfigFixture(config: Record) { + return runtimeConfigSnapshotFromAppServerConfig({ + config: config as ConfigReadResult["config"], + origins: {}, + layers: null, + }); +} diff --git a/tests/domain/runtime/config.test.ts b/tests/domain/runtime/config.test.ts new file mode 100644 index 00000000..3f1a2340 --- /dev/null +++ b/tests/domain/runtime/config.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { emptyRuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../../src/domain/runtime/config"; + +describe("runtime config", () => { + it("clones nested permission policy state", () => { + const config = { + ...emptyRuntimeConfigSnapshot(), + startupPermissions: { + activePermissionProfile: null, + sandboxPolicy: null, + approvalPolicy: { + granular: { + sandbox_approval: true, + rules: false, + skill_approval: true, + request_permissions: false, + mcp_elicitations: true, + }, + }, + }, + }; + + const cloned = runtimeConfigOrDefault(config); + + expect(cloned).toEqual(config); + expect(cloned.startupPermissions).not.toBe(config.startupPermissions); + expect(cloned.startupPermissions.approvalPolicy).not.toBe(config.startupPermissions.approvalPolicy); + if (!cloned.startupPermissions.approvalPolicy || typeof cloned.startupPermissions.approvalPolicy === "string") { + throw new Error("Expected granular approval policy"); + } + expect(cloned.startupPermissions.approvalPolicy.granular).not.toBe(config.startupPermissions.approvalPolicy.granular); + }); +}); diff --git a/tests/app-server/app-server-diagnostics.test.ts b/tests/domain/server/diagnostics.test.ts similarity index 93% rename from tests/app-server/app-server-diagnostics.test.ts rename to tests/domain/server/diagnostics.test.ts index 7a2b1c0a..a7a4546d 100644 --- a/tests/app-server/app-server-diagnostics.test.ts +++ b/tests/domain/server/diagnostics.test.ts @@ -8,11 +8,11 @@ import { serverIdentity, serverPlatform, upsertMcpServerDiagnostic, -} from "../../src/domain/server/diagnostics"; -import type { ServerInitialization } from "../../src/domain/server/initialization"; -import { mcpServerStatusSummariesFromStatuses } from "../../src/domain/server/mcp-status"; +} from "../../../src/domain/server/diagnostics"; +import type { ServerInitialization } from "../../../src/domain/server/initialization"; +import { mcpServerStatusSummariesFromStatuses } from "../../../src/domain/server/mcp-status"; -describe("app-server diagnostics", () => { +describe("server diagnostics", () => { it("formats initialize metadata", () => { const response = { userAgent: "codex-cli/0.128.0", diff --git a/tests/domain/vault/paths.test.ts b/tests/domain/vault/paths.test.ts new file mode 100644 index 00000000..c6774a20 --- /dev/null +++ b/tests/domain/vault/paths.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { pathRelativeToRoot } from "../../../src/domain/vault/paths"; + +describe("pathRelativeToRoot", () => { + it("keeps Windows sibling paths outside the workspace root", () => { + expect(pathRelativeToRoot("C:\\Vault\\project\\src\\main.ts", "C:\\Vault\\project")).toBe("src/main.ts"); + expect(pathRelativeToRoot("C:\\Vault\\project-other\\src\\main.ts", "C:\\Vault\\project")).toBe("C:/Vault/project-other/src/main.ts"); + }); +}); diff --git a/tests/features/chat/app-server/mappers/thread-stream.test.ts b/tests/features/chat/app-server/mappers/thread-stream.test.ts index 0261715b..c9cb1a33 100644 --- a/tests/features/chat/app-server/mappers/thread-stream.test.ts +++ b/tests/features/chat/app-server/mappers/thread-stream.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protocol/turn"; import type { Thread } from "../../../../../src/domain/threads/model"; import { referencedThreadPromptBundle } from "../../../../../src/domain/threads/reference"; -import { pathRelativeToRoot } from "../../../../../src/domain/vault/paths"; import { collabAgentStateExecutionState } from "../../../../../src/features/chat/app-server/mappers/thread-stream/execution-state"; import { hookRunThreadStreamItem } from "../../../../../src/features/chat/app-server/mappers/thread-stream/hook-run-items"; import { autoReviewPermissionRows } from "../../../../../src/features/chat/app-server/mappers/thread-stream/permission-rows"; @@ -16,53 +15,6 @@ import { threadStreamItemsFromTurns, } from "../../../../../src/features/chat/app-server/mappers/thread-stream/turn-items"; import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; -import { activeTurnLiveItems } from "../../../../../src/features/chat/domain/thread-stream/semantics/active-turn"; -import { upsertThreadStreamItemById } from "../../../../../src/features/chat/domain/thread-stream/updates"; -import { threadStreamLayoutBlocks } from "../../../../../src/features/chat/presentation/thread-stream/layout"; - -function commandItem(id: string, text: string, turnId: string): ThreadStreamItem { - return { - id, - kind: "command", - role: "tool", - turnId, - commandAction: "command", - commandTarget: { kind: "command", commandLine: text }, - command: text, - cwd: "/vault", - status: "completed", - executionState: "completed", - }; -} - -function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): ThreadStreamItem { - return { - id, - kind: "fileChange", - role: "tool", - turnId, - status: "completed", - executionState: "completed", - changes: [{ kind: "update", path, diff: "" }], - }; -} - -function autoReviewResultItem(id: string, turnId: string, text = "Auto-review approved: npm test"): ThreadStreamItem { - return { - id, - kind: "reviewResult", - role: "tool", - text, - turnId, - provenance: { source: "appServer", channel: "notification", event: "autoReview", sourceItemId: id }, - executionState: "completed", - }; -} - -function activeAgentSummary(items: readonly ThreadStreamItem[], activeTurnId: string | null) { - if (!activeTurnId) return null; - return activeTurnLiveItems({ items }, activeTurnId).find((item) => item.kind === "agentSummary")?.summary ?? null; -} describe("turn item conversion preserves app-server semantics", () => { it("sorts app-server turns oldest first before converting messages", () => { @@ -837,545 +789,6 @@ describe("auto-review permission detail rows", () => { }); }); -describe("display block grouping keeps thread stream details subordinate to conversation messages", () => { - it("groups completed turn activities before the final assistant message", () => { - const items: ThreadStreamItem[] = [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" }, - commandItem("c1", "npm test", "t1"), - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, null); - expect(blocks.map((block) => block.type)).toEqual(["item", "activityGroup", "item"]); - expect(blocks[1]).toMatchObject({ summary: "Work details" }); - }); - - it("groups completed hook and review logs before the final assistant message", () => { - const items: ThreadStreamItem[] = [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - { - id: "hook-1", - kind: "hook", - role: "tool", - text: "userPromptSubmit: Saving jj baseline", - toolName: "hook", - turnId: "t1", - status: "completed", - }, - commandItem("c1", "npm test", "t1"), - { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1", status: "completed" }, - autoReviewResultItem("review-1", "t1"), - autoReviewResultItem("review-2", "t1"), - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, null); - - expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "turn-t1-activity", "a1"]); - expect(blocks[1]).toMatchObject({ - summary: "Work details", - items: [ - { item: { id: "hook-1" } }, - { item: { id: "c1" } }, - { item: { id: "r1" } }, - { item: { id: "review-1" } }, - { item: { id: "review-2" } }, - ], - }); - expect(blocks[2]).toMatchObject({ - item: { id: "a1" }, - annotations: { autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"] }, - }); - }); - - it("hides empty completed reasoning items", () => { - const items: ThreadStreamItem[] = [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - { id: "r1", kind: "reasoning", role: "tool", text: "", turnId: "t1", status: "completed", executionState: "completed" }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, null); - expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "a1"]); - }); - - it("collapses earlier assistant responses with their turn details", () => { - const items: ThreadStreamItem[] = [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "I'll inspect it.", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - commandItem("c1", "rg issue", "t1"), - { - id: "a2", - kind: "dialogue", - role: "assistant", - text: "I found the cause.", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - fileChangeItem("f1", "t1"), - { - id: "a3", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, null); - expect(blocks.map((block) => block.type)).toEqual(["item", "activityGroup", "item"]); - expect(blocks[1]).toMatchObject({ - summary: "Work details", - items: [{ item: { id: "a1" } }, { item: { id: "c1" } }, { item: { id: "a2" } }, { item: { id: "f1" } }], - }); - expect(blocks[2]).toMatchObject({ item: { id: "a3" } }); - }); - - it("keeps completed turn work details attached to the final response even when system messages are interleaved", () => { - const items: ThreadStreamItem[] = [ - { id: "s1", kind: "system", role: "system", text: "debug before hook" }, - commandItem("c1", "npm test", "t1"), - { id: "s2", kind: "system", role: "system", text: "debug after hook" }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, null); - - expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["s1", "s2", "turn-t1-activity", "a1"]); - }); - - it("adds steering markers to completed turn work details without hiding the steering message", () => { - const items: ThreadStreamItem[] = [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - commandItem("c1", "rg issue", "t1"), - { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "also check tests", turnId: "t1", clientId: "local-steer-1" }, - commandItem("c2", "npm test", "t1"), - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, null); - - expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "u2", "turn-t1-activity", "a1"]); - expect(blocks[2]).toMatchObject({ - summary: "Work details", - items: [ - { type: "item", item: { id: "c1" } }, - { - type: "steering", - id: "steer-activity-u2", - label: "steering", - text: "also check tests", - sourceItemId: "u2", - }, - { type: "item", item: { id: "c2" } }, - ], - }); - }); - - it("keeps multiple steering markers in completed turn work details", () => { - const items: ThreadStreamItem[] = [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "also check tests", turnId: "t1" }, - { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "and update docs", turnId: "t1" }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, null); - const activityGroup = blocks.find((block) => block.type === "activityGroup"); - - expect(activityGroup).toMatchObject({ - summary: "Work details", - items: [ - { type: "steering", id: "steer-activity-u2" }, - { type: "steering", id: "steer-activity-u3" }, - ], - }); - }); - - it("keeps active turn activities expanded", () => { - const items: ThreadStreamItem[] = [ - { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - expect(threadStreamLayoutBlocks(items, "t1").map((block) => block.type)).toEqual(["item", "item"]); - }); - - it("keeps active task progress chronological in the display model", () => { - const items: ThreadStreamItem[] = [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - { - id: "plan-progress-t1", - kind: "taskProgress", - role: "tool", - text: "[>] Patch UI", - turnId: "t1", - explanation: null, - steps: [{ step: "Patch UI", status: "inProgress" }], - status: "inProgress", - }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "working", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, "t1"); - - expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "plan-progress-t1", "a1"]); - }); - - it("groups task progress and agent activity inside completed turn work details", () => { - const items: ThreadStreamItem[] = [ - { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, - { - id: "plan-progress-t1", - kind: "taskProgress", - role: "tool", - text: "[>] Patch UI", - turnId: "t1", - explanation: null, - steps: [{ step: "Patch UI", status: "inProgress" }], - status: "inProgress", - }, - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Spawn agent", - turnId: "t1", - tool: "spawnAgent", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: ["child"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [{ threadId: "child", status: "completed", executionState: "completed", message: null }], - }, - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const blocks = threadStreamLayoutBlocks(items, null); - expect(blocks[1]).toMatchObject({ - summary: "Work details", - items: [{ item: { id: "plan-progress-t1" } }, { item: { id: "agent-1" } }], - }); - }); - - it("summarizes active subagent states while a turn is running", () => { - const items: ThreadStreamItem[] = [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "t1", - tool: "wait", - status: "running", - senderThreadId: "parent", - receiverThreadIds: ["done", "running", "failed"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [ - { threadId: "done", status: "completed", executionState: "completed", message: null }, - { threadId: "running", status: "running", executionState: "running", message: null }, - { threadId: "failed", status: "errored", executionState: "failed", message: null }, - ], - }, - ]; - - expect(activeAgentSummary(items, "t1")).toEqual({ - running: 1, - completed: 1, - failed: 1, - agents: [{ threadId: "running", status: "running", messagePreview: null }], - additionalAgents: 0, - }); - expect(activeAgentSummary(items, null)).toBeNull(); - }); - - it("summarizes active subagent previews and fallback receiver states", () => { - const items: ThreadStreamItem[] = [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Spawn agent", - turnId: "t1", - tool: "spawnAgent", - status: "inProgress", - senderThreadId: "parent", - receiverThreadIds: ["fallback-child"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [], - }, - { - id: "agent-2", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "t1", - tool: "wait", - status: "running", - senderThreadId: "parent", - receiverThreadIds: ["a", "b", "c", "d", "e"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [ - { threadId: "a", status: "running", executionState: "running", message: "\n Inspecting renderer tests \nmore details" }, - { threadId: "b", status: "failed", executionState: "failed", message: "Could not reproduce" }, - { threadId: "c", status: "running", executionState: "running", message: null }, - { threadId: "d", status: "running", executionState: "running", message: "Reviewing details" }, - { threadId: "e", status: "running", executionState: "running", message: "Checking scroll behavior" }, - ], - }, - ]; - - expect(activeAgentSummary(items, "t1")).toMatchObject({ - running: 5, - completed: 0, - failed: 1, - agents: [ - { threadId: "a", status: "running", messagePreview: "Inspecting renderer tests" }, - { threadId: "c", status: "running", messagePreview: null }, - { threadId: "d", status: "running", messagePreview: "Reviewing details" }, - { threadId: "e", status: "running", messagePreview: "Checking scroll behavior" }, - { threadId: "fallback-child", status: "inProgress", messagePreview: null }, - ], - additionalAgents: 0, - }); - }); - - it("omits active subagent summaries once every subagent is complete", () => { - const items: ThreadStreamItem[] = [ - { - id: "agent-1", - kind: "agent", - role: "tool", - text: "Wait for agent", - turnId: "t1", - tool: "wait", - status: "completed", - senderThreadId: "parent", - receiverThreadIds: ["done"], - prompt: null, - model: null, - reasoningEffort: null, - agents: [{ threadId: "done", status: "completed", executionState: "completed", message: null }], - }, - ]; - - expect(activeAgentSummary(items, "t1")).toBeNull(); - }); - - it("adds edited files to the final assistant message", () => { - const items: ThreadStreamItem[] = [ - fileChangeItem("f1", "t1", "src/main.ts"), - fileChangeItem("f2", "t1", "styles.css"), - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "intermediate", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - { - id: "a2", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const assistantBlock = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); - expect(assistantBlock).toMatchObject({ annotations: { editedFiles: ["src/main.ts", "styles.css"] } }); - }); - - it("adds turn diff metadata to the final assistant message only when aggregated diff exists", () => { - const items: ThreadStreamItem[] = [ - fileChangeItem("f1", "t1", "src/main.ts"), - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const withoutDiff = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); - expect(withoutDiff).toMatchObject({ annotations: { editedFiles: ["src/main.ts"] } }); - expect(withoutDiff?.type === "item" ? withoutDiff.annotations : null).not.toHaveProperty("turnDiff"); - - const withDiff = threadStreamLayoutBlocks(items, null, null, new Map([["t1", "@@\n-old\n+new"]])).find( - (block) => block.type === "item" && block.item.role === "assistant", - ); - expect(withDiff).toMatchObject({ annotations: { editedFiles: ["src/main.ts"], turnDiff: { diff: "@@\n-old\n+new" } } }); - }); - - it("adds auto-review summaries to the final assistant message", () => { - const items: ThreadStreamItem[] = [ - autoReviewResultItem("review-1", "t1"), - autoReviewResultItem("review-2", "t1"), - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const assistantBlock = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); - expect(assistantBlock).toMatchObject({ - annotations: { autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"] }, - }); - }); - - it("does not add edited file or auto-review summaries to active turn messages", () => { - const items: ThreadStreamItem[] = [ - fileChangeItem("f1", "t1", "src/main.ts"), - autoReviewResultItem("review-1", "t1"), - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "still working", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const assistantBlock = threadStreamLayoutBlocks(items, "t1").find((block) => block.type === "item" && block.item.id === "a1"); - expect(assistantBlock?.type === "item" ? assistantBlock.annotations : null).toBeUndefined(); - }); -}); - -describe("workspace path summaries stay readable without hiding audit paths", () => { - it("shows edited files relative to the workspace root", () => { - const items: ThreadStreamItem[] = [ - fileChangeItem("f1", "t1", "/vault/project/src/main.ts"), - fileChangeItem("f2", "t1", "/vault/project/styles.css"), - fileChangeItem("f3", "t1", "/tmp/outside.txt"), - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "done", - turnId: "t1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]; - - const assistantBlock = threadStreamLayoutBlocks(items, null, "/vault/project").find( - (block) => block.type === "item" && block.item.role === "assistant", - ); - expect(assistantBlock).toMatchObject({ annotations: { editedFiles: ["/tmp/outside.txt", "src/main.ts", "styles.css"] } }); - }); - - it("keeps Windows sibling paths outside the workspace root", () => { - expect(pathRelativeToRoot("C:\\Vault\\project\\src\\main.ts", "C:\\Vault\\project")).toBe("src/main.ts"); - expect(pathRelativeToRoot("C:\\Vault\\project-other\\src\\main.ts", "C:\\Vault\\project")).toBe("C:/Vault/project-other/src/main.ts"); - }); -}); - describe("execution state uses typed status adapters before rendered text", () => { it("detects failed command state", () => { expect(commandExecutionItem({ status: "failed", exitCode: 1 })).toMatchObject({ executionState: "failed" }); @@ -1422,35 +835,6 @@ describe("execution state uses typed status adapters before rendered text", () = expect(fileChangeStreamItem({ status: "done_with_errors" })).toMatchObject({ executionState: null }); expect(commandExecutionItem({ status: "done_with_errors", exitCode: null })).toMatchObject({ executionState: null }); }); - - it("does not overwrite streamed output with an empty completed item", () => { - const streamed: ThreadStreamItem = { - id: "c1", - sourceItemId: "c1", - kind: "command", - role: "tool", - commandAction: "command", - commandTarget: { kind: "command", commandLine: "Command running" }, - command: "npm test", - cwd: "/vault", - status: "running", - output: "partial output", - }; - const completed: ThreadStreamItem = { - id: "c1", - sourceItemId: "c1", - kind: "command", - role: "tool", - commandAction: "command", - commandTarget: { kind: "command", commandLine: "npm test" }, - command: "npm test", - cwd: "/vault", - status: "completed", - output: "", - }; - - expect(upsertThreadStreamItemById([streamed], completed)[0]).toMatchObject({ output: "partial output", status: "completed" }); - }); }); type CommandExecutionOverrides = Omit>, "status"> & { status?: string }; diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index cbe65de0..4a3f74fd 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -405,29 +405,6 @@ describe("chatReducer", () => { 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 = { @@ -468,144 +445,6 @@ describe("chatReducer", () => { 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, { @@ -729,141 +568,6 @@ describe("chatReducer", () => { 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", @@ -886,38 +590,6 @@ describe("chatReducer", () => { 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", diff --git a/tests/features/chat/application/state/thread-stream.test.ts b/tests/features/chat/application/state/thread-stream.test.ts index 37880f53..bde2481f 100644 --- a/tests/features/chat/application/state/thread-stream.test.ts +++ b/tests/features/chat/application/state/thread-stream.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { initialChatThreadStreamState, reduceThreadStreamSlice, + threadStreamItems, threadStreamRollbackCandidate, threadStreamStartActiveSegment, threadStreamTurnsAfterTurnId, @@ -9,30 +10,122 @@ import { } from "../../../../../src/features/chat/application/state/thread-stream"; import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; -describe("thread stream selectors", () => { - it("appends deltas to an existing active-segment item", () => { - const initial = threadStreamStartActiveSegment(initialChatThreadStreamState(), "turn-1", [ - { - id: "assistant-1", - sourceItemId: "source-1", - kind: "dialogue", - dialogueKind: "assistantResponse", - dialogueState: "streaming", - role: "assistant", - text: "hello", - turnId: "turn-1", - }, - ]); - const next = reduceThreadStreamSlice(initial, { - type: "thread-stream/assistant-delta-appended", - itemId: "source-1", - turnId: "turn-1", - delta: " world", +describe("thread stream state", () => { + it("updates turn diffs and deduplicates reported logs", () => { + let state = reduceThreadStreamSlice(initialChatThreadStreamState(), { + type: "thread-stream/turn-diff-updated", + turnId: "turn", + diff: "@@", }); + const log = { id: "log", kind: "system", role: "system", text: "warning" } satisfies ThreadStreamItem; + state = reduceThreadStreamSlice(state, { type: "thread-stream/deduped-log-added", text: "warning", item: log }); + state = reduceThreadStreamSlice(state, { type: "thread-stream/deduped-log-added", text: "warning", item: log }); - expect(next.activeSegment?.items[0]).toMatchObject({ text: "hello world" }); + expect(state.turnDiffs).toEqual(new Map([["turn", "@@"]])); + expect(threadStreamItems(state)).toEqual([log]); }); + it("appends assistant deltas after stable history", () => { + const history = dialogueItem("history"); + const running = threadStreamStartActiveSegment(initialChatThreadStreamState([history]), "turn", []); + const next = reduceThreadStreamSlice(running, { + type: "thread-stream/assistant-delta-appended", + itemId: "assistant", + turnId: "turn", + delta: "hello", + }); + + expect(threadStreamItems(next)).toEqual([history, expect.objectContaining({ id: "assistant", text: "hello", turnId: "turn" })]); + }); + + it("updates repeated output by source item id without exposing the private index", () => { + let state = threadStreamStartActiveSegment(initialChatThreadStreamState(), "turn", []); + state = reduceThreadStreamSlice(state, { + type: "thread-stream/item-output-appended", + itemId: "cmd", + turnId: "turn", + delta: "one", + kind: "command", + fallbackText: "Command running", + }); + state = reduceThreadStreamSlice(state, { + type: "thread-stream/item-output-appended", + itemId: "cmd", + turnId: "turn", + delta: "two", + kind: "command", + fallbackText: "Command running", + }); + + expect(threadStreamItems(state)).toEqual([expect.objectContaining({ id: "cmd", output: "onetwo" })]); + }); + + it("ignores deltas from a different active turn", () => { + let state = threadStreamStartActiveSegment(initialChatThreadStreamState(), "turn-active", []); + state = reduceThreadStreamSlice(state, { + type: "thread-stream/assistant-delta-appended", + itemId: "assistant", + turnId: "turn-active", + delta: "active", + }); + const next = reduceThreadStreamSlice(state, { + type: "thread-stream/assistant-delta-appended", + itemId: "stale", + turnId: "turn-stale", + delta: "stale", + }); + + expect(threadStreamItems(next)).toEqual([expect.objectContaining({ id: "assistant", text: "active" })]); + }); + + it("keeps optimistic items when the active turn is acknowledged by a delta", () => { + const optimistic = threadStreamStartActiveSegment(initialChatThreadStreamState(), null, [dialogueItem("local-user")]); + const next = reduceThreadStreamSlice(optimistic, { + type: "thread-stream/assistant-delta-appended", + itemId: "assistant", + turnId: "turn", + delta: "ack", + }); + + expect(threadStreamItems(next)).toEqual([ + expect.objectContaining({ id: "local-user" }), + expect.objectContaining({ id: "assistant", text: "ack", turnId: "turn" }), + ]); + }); + + it("appends text only when an existing source item has the same kind", () => { + let state = threadStreamStartActiveSegment(initialChatThreadStreamState(), "turn", []); + state = reduceThreadStreamSlice(state, { + type: "thread-stream/item-text-appended", + itemId: "shared-source", + turnId: "turn", + label: "Tool", + delta: "tool text", + kind: "tool", + }); + const mismatched = reduceThreadStreamSlice(state, { + type: "thread-stream/item-text-appended", + itemId: "shared-source", + turnId: "turn", + label: "Reasoning", + delta: "reasoning text", + kind: "reasoning", + }); + const matching = reduceThreadStreamSlice(state, { + type: "thread-stream/item-text-appended", + itemId: "shared-source", + turnId: "turn", + label: "Tool", + delta: " more", + kind: "tool", + }); + + expect(threadStreamItems(mismatched)).toEqual([expect.objectContaining({ kind: "tool", text: "Tool: tool text" })]); + expect(threadStreamItems(matching)).toEqual([expect.objectContaining({ kind: "tool", text: "Tool: tool text more" })]); + }); +}); + +describe("thread stream selectors", () => { it("counts turns after a turn id from thread stream state", () => { const state = initialChatThreadStreamState(items()); @@ -72,7 +165,7 @@ describe("thread stream selectors", () => { expect(threadStreamRollbackCandidate(state)).toEqual({ turnId: "turn-1", itemId: "u1", text: "initial" }); }); - it("restores the raw user dialogue text instead of rendered display text", () => { + it("restores raw user dialogue text instead of rendered display text", () => { const state = initialChatThreadStreamState([ { id: "u1", @@ -94,27 +187,13 @@ describe("thread stream selectors", () => { it("returns null when rollback has no user dialogue candidate", () => { expect(threadStreamRollbackCandidate(initialChatThreadStreamState([]))).toBeNull(); - expect( - threadStreamRollbackCandidate(initialChatThreadStreamState([{ id: "system", kind: "system", role: "system", text: "Idle" }])), - ).toBeNull(); - expect( - threadStreamRollbackCandidate( - initialChatThreadStreamState([ - { - id: "a1", - kind: "dialogue", - role: "assistant", - text: "answer", - turnId: "turn-1", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, - ]), - ), - ).toBeNull(); }); }); +function dialogueItem(id: string): ThreadStreamItem { + return { id, kind: "dialogue", role: "assistant", text: id, dialogueKind: "assistantResponse", dialogueState: "completed" }; +} + function items(): ThreadStreamItem[] { return [ { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "first", turnId: "turn-1" }, @@ -138,14 +217,5 @@ function items(): ThreadStreamItem[] { dialogueState: "completed", }, { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "third", turnId: "turn-3" }, - { - id: "a3", - kind: "dialogue", - role: "assistant", - text: "third answer", - turnId: "turn-3", - dialogueKind: "assistantResponse", - dialogueState: "completed", - }, ]; } diff --git a/tests/features/chat/application/state/ui-state.test.ts b/tests/features/chat/application/state/ui-state.test.ts new file mode 100644 index 00000000..888c5820 --- /dev/null +++ b/tests/features/chat/application/state/ui-state.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; +import { + initialUiState, + maybeClearGoalObjectiveExpansion, + reduceUiSlice, +} from "../../../../../src/features/chat/application/state/ui-state"; + +describe("chat UI state", () => { + it("keeps toolbar panels mutually exclusive", () => { + let state = reduceUiSlice(initialUiState(), { type: "ui/panel-set", panel: "history" }); + state = reduceUiSlice(state, { type: "ui/panel-set", panel: "chat-actions" }); + state = reduceUiSlice(state, { type: "ui/panel-set", panel: "status-panel" }); + + expect(state.toolbarPanel).toBe("status-panel"); + }); + + it("updates disclosures, the action menu, and goal editor through typed actions", () => { + let state = reduceUiSlice(initialUiState(), { + type: "ui/disclosure-set", + bucket: "approvalDetails", + id: "1:details", + open: true, + }); + state = reduceUiSlice(state, { type: "ui/thread-stream-fork-menu-set", itemId: "message-1" }); + state = reduceUiSlice(state, { + type: "ui/goal-editor-started", + threadId: "thread", + objective: "old", + tokenBudget: 10, + }); + state = reduceUiSlice(state, { type: "ui/goal-editor-draft-updated", objective: "new" }); + + expect(state.disclosures.approvalDetails.has("1:details")).toBe(true); + expect(state.threadStreamActionMenu.forkMenuItemId).toBe("message-1"); + expect(state.goalEditor).toEqual({ + kind: "editing", + threadId: "thread", + objectiveDraft: "new", + tokenBudgetDraft: 10, + }); + }); + + it("scopes rename generation callbacks to the active thread and generation", () => { + let state = reduceUiSlice(initialUiState(), { + type: "ui/rename-started", + threadId: "thread", + draft: "Original", + }); + state = reduceUiSlice(state, { + type: "ui/rename-generation-started", + threadId: "thread", + generationToken: 1, + }); + state = reduceUiSlice(state, { + type: "ui/rename-generation-succeeded", + threadId: "other-thread", + generationToken: 1, + draft: "Wrong thread", + }); + state = reduceUiSlice(state, { + type: "ui/rename-generation-succeeded", + threadId: "thread", + generationToken: 2, + draft: "Wrong generation", + }); + state = reduceUiSlice(state, { + type: "ui/rename-draft-updated", + threadId: "thread", + draft: "Manual draft", + }); + state = reduceUiSlice(state, { + type: "ui/rename-generation-succeeded", + threadId: "thread", + generationToken: 1, + draft: "Late title", + }); + state = reduceUiSlice(state, { + type: "ui/rename-generation-finished", + threadId: "thread", + generationToken: 1, + }); + + expect(state.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Manual draft" }); + }); + + it("clears goal expansion only when the displayed goal identity changes", () => { + const current = goal(); + const expanded = reduceUiSlice(initialUiState(), { + type: "ui/disclosure-set", + bucket: "goalObjectiveExpanded", + id: "thread", + open: true, + }); + + const usageOnly = maybeClearGoalObjectiveExpansion(expanded, current, { + ...current, + tokensUsed: 10, + timeUsedSeconds: 30, + }); + const changed = maybeClearGoalObjectiveExpansion(usageOnly, current, { + ...current, + objective: "Changed", + }); + + expect(usageOnly.disclosures.goalObjectiveExpanded.has("thread")).toBe(true); + expect(changed.disclosures.goalObjectiveExpanded.size).toBe(0); + }); +}); + +function goal(): ThreadGoal { + return { + threadId: "thread", + objective: "Finish", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1, + updatedAt: 1, + }; +} diff --git a/tests/features/chat/application/turns/plan-implementation.test.ts b/tests/features/chat/application/turns/plan-implementation.test.ts index 212df46c..e53a086b 100644 --- a/tests/features/chat/application/turns/plan-implementation.test.ts +++ b/tests/features/chat/application/turns/plan-implementation.test.ts @@ -81,6 +81,13 @@ describe("implementPlan", () => { stateStore.dispatch({ type: "composer/draft-set", draft: "edit first" }); expect(implementPlanTargetFromState(stateStore.getState())).toEqual({ itemId: latest.id }); + + stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" }); + expect(implementPlanTargetFromState(stateStore.getState())).toBeNull(); + + stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" }); + stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" }); + expect(implementPlanTargetFromState(stateStore.getState())).toBeNull(); }); it("ignores streaming proposed plans until they are implementable turn outcomes", () => { diff --git a/tests/features/chat/domain/runtime/labels.test.ts b/tests/features/chat/domain/runtime/labels.test.ts new file mode 100644 index 00000000..76bb415f --- /dev/null +++ b/tests/features/chat/domain/runtime/labels.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + compactReasoningEffortLabel, + modelOverrideMessage, + reasoningEffortOverrideMessage, +} from "../../../../../src/features/chat/domain/runtime/labels"; + +describe("runtime labels", () => { + it("formats runtime override messages", () => { + expect(modelOverrideMessage("gpt-5.5")).toBe("Model set to gpt-5.5 for subsequent turns."); + expect(modelOverrideMessage(null)).toBe("Model reset to default for subsequent turns."); + expect(reasoningEffortOverrideMessage("low")).toBe("Reasoning effort set to low for subsequent turns."); + expect(reasoningEffortOverrideMessage(null)).toBe("Reasoning effort reset to default for subsequent turns."); + }); + + it("formats compact runtime labels", () => { + expect(compactReasoningEffortLabel("minimal")).toBe("min"); + expect(compactReasoningEffortLabel("high")).toBe("high"); + expect(compactReasoningEffortLabel(null)).toBe("default"); + }); +}); diff --git a/tests/features/chat/domain/runtime/resolution.test.ts b/tests/features/chat/domain/runtime/resolution.test.ts new file mode 100644 index 00000000..b63bc9c1 --- /dev/null +++ b/tests/features/chat/domain/runtime/resolution.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, it } from "vitest"; +import { runtimeConfigOrDefault } from "../../../../../src/domain/runtime/config"; +import { + resetRuntimeIntentToConfig, + setCollaborationModeIntent, + setRuntimeIntentValue, +} from "../../../../../src/features/chat/domain/runtime/intent"; +import { resolveRuntimeControls } from "../../../../../src/features/chat/domain/runtime/resolution"; +import { serviceTierRequestForThreadStart } from "../../../../../src/features/chat/domain/runtime/thread-settings-patch"; +import { + autoReviewActive, + configLayer, + currentModel, + currentReasoningEffort, + currentServiceTier, + fastModeActive, + fastRuntimeServiceTierRequestValue, + modelFixture, + modelPendingIntentCases, + runtimeConfigFixture, + runtimeLayerCase, + runtimeSnapshot, + snapshotConfig, + supportedReasoningEfforts, +} from "./support"; + +describe("runtime control resolution", () => { + it("falls back to startup permissions until active thread permissions are reported", () => { + const configured = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + }), + }); + expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({ + permissionProfile: { effective: ":workspace", source: "config" }, + sandboxPolicy: { effective: null, source: "none" }, + approvalPolicy: { effective: "on-request", source: "config" }, + }); + + const activeUnreported = runtimeSnapshot({ + activeThreadId: "thread", + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + }), + }); + expect(resolveRuntimeControls(activeUnreported, snapshotConfig(activeUnreported))).toMatchObject({ + permissionProfile: { configured: ":workspace", active: null, effective: ":workspace", source: "config" }, + sandboxPolicy: { configured: null, active: null, effective: null, source: "none" }, + approvalPolicy: { configured: "on-request", active: null, effective: "on-request", source: "config" }, + }); + + const activeReported = runtimeSnapshot({ + activeThreadId: "thread", + active: { + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly", networkAccess: false }, + activePermissionProfile: { id: ":read-only", extends: null }, + approvalsReviewer: "user", + }, + pending: { approvalsReviewer: setRuntimeIntentValue("auto_review") }, + }); + expect(resolveRuntimeControls(activeReported, snapshotConfig(activeReported))).toMatchObject({ + approvalsReviewer: { effective: "auto_review", source: "pending" }, + permissionProfile: { effective: ":read-only", source: "active-thread" }, + sandboxPolicy: { effective: { type: "readOnly", networkAccess: false }, source: "active-thread" }, + approvalPolicy: { effective: "never", source: "active-thread" }, + }); + + const approvalOnlyReported = runtimeSnapshot({ + activeThreadId: "thread", + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + }), + active: { + approvalPolicyKnown: true, + approvalPolicy: "never", + }, + }); + expect(resolveRuntimeControls(approvalOnlyReported, snapshotConfig(approvalOnlyReported))).toMatchObject({ + permissionProfile: { effective: ":workspace", source: "config" }, + sandboxPolicy: { effective: null, source: "none" }, + approvalPolicy: { effective: "never", source: "active-thread" }, + }); + }); + + it("resolves auto-review mode from requested, active, then effective config", () => { + const requested = runtimeSnapshot({ + pending: { approvalsReviewer: setRuntimeIntentValue("user") }, + active: { approvalsReviewer: "auto_review" }, + runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "guardian_subagent" }), + }); + const active = runtimeSnapshot({ + active: { approvalsReviewer: "user" }, + runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "guardian_subagent" }), + }); + const configured = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "guardian_subagent" }), + }); + + expect(autoReviewActive(requested, snapshotConfig(requested))).toBe(false); + expect(autoReviewActive(active, snapshotConfig(active))).toBe(false); + expect(autoReviewActive(configured, snapshotConfig(configured))).toBe(true); + }); + + it("uses the active reviewer before configured reviewer", () => { + const snapshot = runtimeSnapshot({ + active: { approvalsReviewer: "user" }, + runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "auto_review" }), + }); + + expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(false); + }); + + it("treats guardian subagent reviewer as active auto-review", () => { + const snapshot = runtimeSnapshot({ + active: { approvalsReviewer: "guardian_subagent" }, + }); + + expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); + }); + + it("uses requested reviewer above active and configured reviewers", () => { + const snapshot = runtimeSnapshot({ + pending: { approvalsReviewer: setRuntimeIntentValue("user") }, + active: { approvalsReviewer: "user" }, + runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "auto_review" }), + }); + + expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(false); + }); + + it("uses effective approval reviewer values and reports selected profile metadata", () => { + const runtimeConfig = runtimeConfigFixture({ approvals_reviewer: "auto_review" }, [ + configLayer({}, null), + configLayer({ approvals_reviewer: "auto_review" }, "auto"), + ]); + const snapshot = runtimeSnapshot({ + runtimeConfig, + }); + + expect(runtimeConfigOrDefault(runtimeConfig).profile).toBe("auto"); + expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); + }); + + it("uses effective model, effort, and fast mode config values", () => { + const snapshot = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture( + { + model: "gpt-profile", + model_reasoning_effort: "high", + service_tier: "fast", + }, + [ + configLayer({}, null), + configLayer({ model: "gpt-profile", model_reasoning_effort: "high", service_tier: "fast" }, "fast-profile"), + ], + ), + }); + + expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-profile"); + expect(currentReasoningEffort(snapshot, snapshotConfig(snapshot))).toBe("high"); + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); + expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("fast"); + }); + + it("uses active service tier before configured service tier", () => { + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + active: { serviceTier: "flex" }, + runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), + }); + const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("flex"); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); + expect(resolution.serviceTier).toMatchObject({ effective: "flex", source: "active-thread" }); + expect(resolution.fastMode).toMatchObject({ active: false, source: "active-thread", effectiveServiceTier: "flex" }); + }); + + it("treats the catalog Fast service tier id as fast mode while preserving the id", () => { + const model = { + ...modelFixture("gpt-5.5"), + serviceTiers: [{ id: "priority", name: "Fast" }], + }; + // app-server may advertise Fast with an id such as "priority"; + // last verified against codex app-server 0.142.0. + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + active: { model: "gpt-5.5", serviceTier: "priority" }, + runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }), + availableModels: [model], + }); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("priority"); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); + expect(fastRuntimeServiceTierRequestValue(snapshot, snapshotConfig(snapshot))).toBe("priority"); + }); + + it("treats the app-server reported default tier after clearing Fast as fast mode off", () => { + const model = { + ...modelFixture("gpt-5.5"), + serviceTiers: [{ id: "priority", name: "Fast" }], + }; + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + active: { model: "gpt-5.5", serviceTier: "default" }, + runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }), + availableModels: [model], + }); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("default"); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); + }); + + it("uses requested Fast mode above active and configured service tiers", () => { + const snapshot = runtimeSnapshot({ + active: { serviceTier: "flex" }, + pending: { fastMode: setRuntimeIntentValue("disabled") }, + runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), + }); + const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); + expect(resolution.serviceTier).toMatchObject({ effective: null, source: "pending" }); + expect(resolution.fastMode).toMatchObject({ active: false, source: "pending", effectiveServiceTier: null }); + }); + + it("keeps a cleared active thread service tier above configured Fast mode", () => { + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + active: { serviceTier: null }, + runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), + }); + const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); + expect(resolution.serviceTier).toMatchObject({ effective: null, source: "active-thread" }); + }); + + it("resolves all runtime controls through pending, active, and config layers", () => { + const configured = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ + model: "gpt-config", + model_reasoning_effort: "medium", + default_permissions: ":workspace", + approval_policy: "on-request", + sandbox_mode: "workspace-write", + sandbox_workspace_write: { + writable_roots: ["/vault"], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + approvals_reviewer: "auto_review", + service_tier: "fast", + }), + }); + const active = runtimeSnapshot({ + ...configured, + activeThreadId: "thread", + active: { + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + model: "gpt-active", + reasoningEffort: "high", + activePermissionProfile: { id: ":read-only", extends: null }, + sandboxPolicy: { type: "readOnly", networkAccess: false }, + approvalPolicy: "never", + approvalsReviewer: "user", + serviceTier: "flex", + }, + }); + const pending = runtimeSnapshot({ + ...active, + pending: { + ...active.pending, + model: setRuntimeIntentValue("gpt-pending"), + reasoningEffort: setRuntimeIntentValue("low"), + permissionProfile: setRuntimeIntentValue(":workspace"), + approvalPolicy: setRuntimeIntentValue("on-request"), + approvalsReviewer: setRuntimeIntentValue("guardian_subagent"), + fastMode: setRuntimeIntentValue("enabled"), + }, + }); + + expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({ + model: { effective: "gpt-config", source: "config" }, + reasoningEffort: { effective: "medium", source: "config" }, + permissionProfile: { effective: ":workspace", source: "config" }, + sandboxPolicy: { effective: null, source: "none" }, + approvalPolicy: { effective: "on-request", source: "config" }, + approvalsReviewer: { effective: "auto_review", source: "config" }, + serviceTier: { effective: "fast", source: "config" }, + }); + expect(resolveRuntimeControls(active, snapshotConfig(active))).toMatchObject({ + model: { effective: "gpt-active", source: "active-thread" }, + reasoningEffort: { effective: "high", source: "active-thread" }, + permissionProfile: { effective: ":read-only", source: "active-thread" }, + sandboxPolicy: { effective: { type: "readOnly", networkAccess: false }, source: "active-thread" }, + approvalPolicy: { effective: "never", source: "active-thread" }, + approvalsReviewer: { effective: "user", source: "active-thread" }, + serviceTier: { effective: "flex", source: "active-thread" }, + }); + expect(resolveRuntimeControls(pending, snapshotConfig(pending))).toMatchObject({ + model: { confirmed: "gpt-active", confirmedSource: "active-thread", effective: "gpt-pending", source: "pending" }, + reasoningEffort: { confirmed: "high", confirmedSource: "active-thread", effective: "low", source: "pending" }, + permissionProfile: { confirmed: ":read-only", confirmedSource: "active-thread", effective: ":workspace", source: "pending" }, + sandboxPolicy: { + confirmed: { type: "readOnly", networkAccess: false }, + confirmedSource: "active-thread", + effective: null, + source: "pending", + }, + approvalPolicy: { confirmed: "never", confirmedSource: "active-thread", effective: "on-request", source: "pending" }, + approvalsReviewer: { confirmed: "user", confirmedSource: "active-thread", effective: "guardian_subagent", source: "pending" }, + serviceTier: { confirmed: "flex", confirmedSource: "active-thread", effective: "fast", source: "pending" }, + fastMode: { + active: true, + confirmedActive: false, + source: "pending", + confirmedSource: "active-thread", + serviceTierRequestValue: "fast", + }, + }); + }); + + it("model-checks runtime value precedence for configured, active, and pending model layers", () => { + for (const configured of [null, "gpt-config"] as const) { + for (const active of [null, "gpt-active"] as const) { + for (const pending of modelPendingIntentCases()) { + const snapshot = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture(configured ? { model: configured } : {}), + active: { model: active }, + pending: { model: pending.intent }, + }); + const model = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).model; + const expectedConfirmed = active ?? configured; + const expectedConfirmedSource = active ? "active-thread" : configured ? "config" : "none"; + + expect(model, runtimeLayerCase(configured, active, pending.name)).toMatchObject({ + configured, + active, + pending: pending.intent, + confirmed: expectedConfirmed, + confirmedSource: expectedConfirmedSource, + effective: pending.name === "set" ? "gpt-pending" : pending.name === "resetToConfig" ? configured : expectedConfirmed, + source: pending.name === "set" ? "pending" : pending.name === "resetToConfig" ? "config" : expectedConfirmedSource, + }); + } + } + } + }); + + it("reports collaboration mode dirtiness and missing model blockers from the resolved runtime", () => { + const blocked = runtimeSnapshot({ + pending: { collaborationMode: setCollaborationModeIntent("plan") }, + runtimeConfig: runtimeConfigFixture({}), + }); + const ready = runtimeSnapshot({ + pending: { + collaborationMode: setCollaborationModeIntent("plan"), + model: setRuntimeIntentValue("gpt-5.5"), + }, + }); + + expect(resolveRuntimeControls(blocked, snapshotConfig(blocked)).collaborationMode).toMatchObject({ + pending: setCollaborationModeIntent("plan"), + confirmed: "default", + effective: "plan", + dirty: true, + blockedReason: "missing-model", + }); + expect(resolveRuntimeControls(ready, snapshotConfig(ready)).collaborationMode).toMatchObject({ + pending: setCollaborationModeIntent("plan"), + confirmed: "default", + effective: "plan", + dirty: true, + blockedReason: null, + }); + }); + + it("uses the explicit config when finding supported reasoning efforts", () => { + const snapshot = runtimeSnapshot({ + pending: { model: resetRuntimeIntentToConfig() }, + runtimeConfig: runtimeConfigFixture({ model: "snapshot-model" }), + availableModels: [ + { ...modelFixture("snapshot-model"), supportedReasoningEfforts: ["low"] }, + { ...modelFixture("explicit-model"), supportedReasoningEfforts: ["high"] }, + ], + }); + const explicitConfig = runtimeConfigFixture({ model: "explicit-model" }); + + expect(supportedReasoningEfforts(snapshot, explicitConfig)).toEqual(["high"]); + }); + + it.each([ + { name: "catalog Fast tier", serviceTier: "catalog-fast", serviceTiers: [{ id: "catalog-fast", name: "Fast" }], expected: true }, + { name: "catalog Priority tier", serviceTier: "priority", serviceTiers: [{ id: "priority", name: "Priority" }], expected: false }, + { name: "catalog Flex tier", serviceTier: "flex", serviceTiers: [{ id: "flex", name: "Flex" }], expected: false }, + ])("classifies received $name from catalog metadata", ({ serviceTier, serviceTiers, expected }) => { + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + active: { model: "gpt-5.5", serviceTier }, + runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }), + availableModels: [{ ...modelFixture("gpt-5.5"), serviceTiers }], + }); + + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(expected); + }); +}); diff --git a/tests/features/chat/domain/runtime/state.test.ts b/tests/features/chat/domain/runtime/state.test.ts new file mode 100644 index 00000000..7db1205e --- /dev/null +++ b/tests/features/chat/domain/runtime/state.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + clearRequestedApprovalsReviewerRuntimeState, + clearRequestedFastModeRuntimeState, + commitAppliedRuntimeSettingsPatchState, + initialChatRuntimeState, + requestApprovalsReviewerRuntimeState, + requestFastModeRuntimeState, + requestModelRuntimeState, + requestReasoningEffortRuntimeState, + resetModelToConfigRuntimeState, + resetReasoningEffortToConfigRuntimeState, +} from "../../../../../src/features/chat/domain/runtime/state"; + +describe("chat runtime state", () => { + it("commits applied settings and clears only their pending intents", () => { + let state = initialChatRuntimeState(); + state = requestModelRuntimeState(state, "gpt-5.1"); + state = requestReasoningEffortRuntimeState(state, "high"); + state = requestFastModeRuntimeState(state, "enabled"); + state = requestApprovalsReviewerRuntimeState(state, "auto_review"); + + const next = commitAppliedRuntimeSettingsPatchState(state, { + 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.active).toMatchObject({ + model: "gpt-5.1", + reasoningEffort: "high", + serviceTier: "fast", + serviceTierKnown: true, + approvalsReviewer: "auto_review", + collaborationMode: "plan", + }); + expect(next.pending).toMatchObject({ + model: { kind: "unchanged" }, + reasoningEffort: { kind: "unchanged" }, + fastMode: { kind: "unchanged" }, + approvalsReviewer: { kind: "unchanged" }, + collaborationMode: { kind: "unchanged" }, + }); + }); + + it("preserves unknown service tier state when committing unrelated settings", () => { + const state = requestModelRuntimeState(initialChatRuntimeState(), "gpt-5.1"); + const next = commitAppliedRuntimeSettingsPatchState(state, { model: "gpt-5.1" }); + + expect(next.active.model).toBe("gpt-5.1"); + expect(next.active.serviceTier).toBeNull(); + expect(next.active.serviceTierKnown).toBe(false); + }); + + it("keeps requested policy toggles pending until settings commit", () => { + const initial = initialChatRuntimeState(); + const active = { + ...initial, + active: { ...initial.active, serviceTier: "flex" as const, approvalsReviewer: "user" as const }, + }; + const requested = requestApprovalsReviewerRuntimeState(requestFastModeRuntimeState(active, "enabled"), "auto_review"); + + expect(requested.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); + expect(requested.active.serviceTier).toBe("flex"); + expect(requested.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); + expect(requested.active.approvalsReviewer).toBe("user"); + }); + + it("keeps reset and cleared request semantics explicit", () => { + let state = requestModelRuntimeState(initialChatRuntimeState(), "gpt-5.1"); + state = requestReasoningEffortRuntimeState(state, "high"); + state = requestFastModeRuntimeState(state, "enabled"); + state = requestApprovalsReviewerRuntimeState(state, "auto_review"); + state = resetModelToConfigRuntimeState(state); + state = resetReasoningEffortToConfigRuntimeState(state); + state = clearRequestedFastModeRuntimeState(state); + state = clearRequestedApprovalsReviewerRuntimeState(state); + + expect(state.pending).toMatchObject({ + model: { kind: "resetToConfig" }, + reasoningEffort: { kind: "resetToConfig" }, + fastMode: { kind: "unchanged" }, + approvalsReviewer: { kind: "unchanged" }, + }); + }); +}); diff --git a/tests/features/chat/domain/runtime/support.ts b/tests/features/chat/domain/runtime/support.ts new file mode 100644 index 00000000..8665f1e6 --- /dev/null +++ b/tests/features/chat/domain/runtime/support.ts @@ -0,0 +1,150 @@ +import { type ConfigReadResult, runtimeConfigSnapshotFromAppServerConfig } from "../../../../../src/app-server/protocol/runtime-config"; +import type { ModelMetadata } from "../../../../../src/domain/catalog/metadata"; +import { type RuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../../../../src/domain/runtime/config"; +import { + resetRuntimeIntentToConfig, + setRuntimeIntentValue, + unchangedCollaborationModeIntent, + unchangedRuntimeIntent, +} from "../../../../../src/features/chat/domain/runtime/intent"; +import { resolveRuntimeControls } from "../../../../../src/features/chat/domain/runtime/resolution"; +import type { RuntimeSnapshot } from "../../../../../src/features/chat/domain/runtime/snapshot"; + +interface RuntimeSnapshotPatch extends Partial> { + active?: Partial; + pending?: Partial; +} + +export function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot { + const { active, pending, ...snapshotOverrides } = overrides; + const snapshot: RuntimeSnapshot = { + runtimeConfig: runtimeConfigFixture({ + model: "gpt-5.5", + model_reasoning_effort: "high", + service_tier: "flex", + model_context_window: 100_000, + }), + activeThreadId: null, + active: { + approvalPolicyKnown: false, + sandboxPolicyKnown: false, + permissionProfileKnown: false, + serviceTierKnown: false, + model: null, + reasoningEffort: null, + collaborationMode: null, + serviceTier: null, + approvalsReviewer: null, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + }, + pending: { + model: { kind: "unchanged" }, + reasoningEffort: { kind: "unchanged" }, + permissionProfile: { kind: "unchanged" }, + approvalPolicy: { kind: "unchanged" }, + approvalsReviewer: { kind: "unchanged" }, + collaborationMode: unchangedCollaborationModeIntent(), + fastMode: { kind: "unchanged" }, + }, + tokenUsage: null, + rateLimit: null, + hasThreadTurns: false, + availableModels: [], + }; + return { + ...snapshot, + ...snapshotOverrides, + active: { + ...snapshot.active, + ...(active && "serviceTier" in active ? { serviceTierKnown: true } : {}), + ...active, + }, + pending: { + ...snapshot.pending, + ...pending, + }, + }; +} + +export function snapshotConfig(snapshot: RuntimeSnapshot): RuntimeConfigSnapshot { + return runtimeConfigOrDefault(snapshot.runtimeConfig); +} + +function runtimeControls(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot = snapshotConfig(snapshot)) { + return resolveRuntimeControls(snapshot, config); +} + +export function currentModel(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): string | null { + return runtimeControls(snapshot, config).model.effective; +} + +export function currentReasoningEffort(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): string | null { + return runtimeControls(snapshot, config).reasoningEffort.effective; +} + +export function currentServiceTier(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): string | null { + return runtimeControls(snapshot, config).serviceTier.effective; +} + +export function autoReviewActive(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): boolean { + return runtimeControls(snapshot, config).autoReview.active; +} + +export function fastModeActive(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): boolean { + return runtimeControls(snapshot, config).fastMode.active; +} + +export function fastRuntimeServiceTierRequestValue(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): string { + return runtimeControls(snapshot, config).fastMode.serviceTierRequestValue; +} + +export function supportedReasoningEfforts(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): readonly string[] { + return runtimeControls(snapshot, config).supportedReasoningEfforts; +} + +export function modelPendingIntentCases() { + return [ + { name: "unchanged", intent: unchangedRuntimeIntent() }, + { name: "set", intent: setRuntimeIntentValue("gpt-pending") }, + { name: "resetToConfig", intent: resetRuntimeIntentToConfig() }, + ] as const; +} + +export function runtimeLayerCase(configured: string | null, active: string | null, pending: string): string { + return `configured=${configured ?? "none"} active=${active ?? "none"} pending=${pending}`; +} + +export function runtimeConfigFixture(config: Record, layers: ConfigReadResult["layers"] = null): RuntimeConfigSnapshot { + return runtimeConfigSnapshotFromAppServerConfig({ + config: config as ConfigReadResult["config"], + origins: {}, + layers, + }); +} + +export function configLayer(config: Record, profile: string | null): NonNullable[number] { + return { + name: { type: "user", file: "/home/me/.codex/config.toml", profile }, + version: "1", + config: config as NonNullable[number]["config"], + disabledReason: null, + }; +} + +export function modelFixture(model: string): ModelMetadata { + return { + id: model, + model, + displayName: model, + description: "", + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: "medium", + inputModalities: [], + serviceTiers: [], + defaultServiceTier: null, + isDefault: false, + }; +} diff --git a/tests/features/chat/domain/runtime/thread-settings-patch.test.ts b/tests/features/chat/domain/runtime/thread-settings-patch.test.ts new file mode 100644 index 00000000..05dcc72a --- /dev/null +++ b/tests/features/chat/domain/runtime/thread-settings-patch.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from "vitest"; +import { + resetRuntimeIntentToConfig, + setCollaborationModeIntent, + setRuntimeIntentValue, +} from "../../../../../src/features/chat/domain/runtime/intent"; +import { resolveRuntimeControls } from "../../../../../src/features/chat/domain/runtime/resolution"; +import { + pendingRuntimeSettingsPatch, + permissionProfileRequestForThreadStart, + serviceTierRequestForThreadStart, +} from "../../../../../src/features/chat/domain/runtime/thread-settings-patch"; +import { + autoReviewActive, + currentModel, + currentReasoningEffort, + currentServiceTier, + fastModeActive, + modelFixture, + runtimeConfigFixture, + runtimeSnapshot, + snapshotConfig, +} from "./support"; + +describe("runtime thread settings patch", () => { + it("keeps permission display scope separate from pending permission source", () => { + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + active: { + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: "on-request", + sandboxPolicy: { type: "readOnly", networkAccess: false }, + activePermissionProfile: null, + }, + pending: { + approvalPolicy: setRuntimeIntentValue("never"), + permissionProfile: setRuntimeIntentValue(":workspace"), + }, + }); + + expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot))).toMatchObject({ + permissionProfile: { effective: ":workspace", source: "pending" }, + sandboxPolicy: { effective: null, source: "pending" }, + approvalPolicy: { effective: "never", source: "pending" }, + }); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { + approvalPolicy: "never", + permissions: ":workspace", + }, + }); + }); + + it("resolves permission profile reset intent back to legacy sandbox config", () => { + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + runtimeConfig: runtimeConfigFixture({ + sandbox_mode: "workspace-write", + sandbox_workspace_write: { + writable_roots: ["/vault"], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + }), + active: { + sandboxPolicyKnown: true, + permissionProfileKnown: true, + activePermissionProfile: { id: ":workspace", extends: null }, + sandboxPolicy: null, + }, + pending: { permissionProfile: resetRuntimeIntentToConfig() }, + }); + + expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot))).toMatchObject({ + permissionProfile: { effective: null, source: "config" }, + sandboxPolicy: { + effective: { + type: "workspaceWrite", + writableRoots: ["/vault"], + networkAccess: false, + }, + source: "config", + }, + }); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { permissions: null }, + }); + }); + + it("selects config permission profiles for empty-panel thread starts", () => { + const configured = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + }), + }); + + expect(permissionProfileRequestForThreadStart(configured, snapshotConfig(configured))).toBe(":workspace"); + + const pending = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + }), + pending: { permissionProfile: setRuntimeIntentValue(":read-only") }, + }); + + expect(permissionProfileRequestForThreadStart(pending, snapshotConfig(pending))).toBe(":read-only"); + + const legacySandbox = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ + sandbox_mode: "workspace-write", + }), + }); + + expect(permissionProfileRequestForThreadStart(legacySandbox, snapshotConfig(legacySandbox))).toBeUndefined(); + }); + + it("keeps runtime defaults, resets, and collaboration mode semantics distinct", () => { + const snapshot = runtimeSnapshot({ + pending: { + model: resetRuntimeIntentToConfig(), + reasoningEffort: resetRuntimeIntentToConfig(), + }, + }); + + expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-5.5"); + expect(currentReasoningEffort(snapshot, snapshotConfig(snapshot))).toBe("high"); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { model: null, effort: null }, + collaborationModeWarning: null, + }); + }); + + it("projects explicit runtime intents into current values and settings payload values", () => { + const snapshot = runtimeSnapshot({ + pending: { + model: setRuntimeIntentValue("gpt-5.4"), + reasoningEffort: setRuntimeIntentValue("low"), + }, + }); + const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); + + expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-5.4"); + expect(currentReasoningEffort(snapshot, snapshotConfig(snapshot))).toBe("low"); + expect(resolution.model).toMatchObject({ effective: "gpt-5.4", source: "pending" }); + expect(resolution.reasoningEffort).toMatchObject({ effective: "low", source: "pending" }); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { model: "gpt-5.4", effort: "low" }, + collaborationModeWarning: null, + }); + }); + + it("treats unreported thread collaboration mode as default without losing the unknown state", () => { + const snapshot = runtimeSnapshot({ + active: { collaborationMode: null }, + }); + + expect(snapshot.active.collaborationMode).toBeNull(); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toEqual({ + update: {}, + collaborationModeWarning: null, + }); + + expect(snapshot.pending.collaborationMode).toEqual({ kind: "unchanged" }); + }); + + it("keeps model reset tied to config when active thread model differs", () => { + const snapshot = runtimeSnapshot({ + active: { model: "gpt-5-active" }, + pending: { model: resetRuntimeIntentToConfig() }, + runtimeConfig: runtimeConfigFixture({ + model_reasoning_effort: "high", + service_tier: "flex", + model_context_window: 100_000, + }), + }); + + expect(currentModel(snapshot, snapshotConfig(snapshot))).toBeNull(); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { model: null }, + collaborationModeWarning: null, + }); + }); + + it("builds the Plan collaboration mode payload from selected runtime settings", () => { + const snapshot = runtimeSnapshot({ + pending: { + collaborationMode: setCollaborationModeIntent("plan"), + model: setRuntimeIntentValue("gpt-5.5"), + reasoningEffort: setRuntimeIntentValue("high"), + }, + }); + + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { + collaborationMode: { + mode: "plan", + settings: { + model: "gpt-5.5", + reasoningEffort: "high", + developerInstructions: null, + }, + }, + }, + collaborationModeWarning: null, + }); + }); + + it("uses the explicit config for collaboration mode thread settings", () => { + const snapshot = runtimeSnapshot({ + pending: { collaborationMode: setCollaborationModeIntent("plan") }, + runtimeConfig: runtimeConfigFixture({ + model: "snapshot-model", + model_reasoning_effort: "low", + }), + }); + const explicitConfig = runtimeConfigFixture({ + model: "explicit-model", + model_reasoning_effort: "high", + }); + + expect(pendingRuntimeSettingsPatch(snapshot, explicitConfig)).toMatchObject({ + update: { + collaborationMode: { + mode: "plan", + settings: { + model: "explicit-model", + reasoningEffort: "high", + developerInstructions: null, + }, + }, + }, + collaborationModeWarning: null, + }); + }); + + it("keeps collaboration mode settings separate from reviewer and direct runtime intents", () => { + const reviewerSnapshot = runtimeSnapshot({ + pending: { + collaborationMode: setCollaborationModeIntent("plan"), + approvalsReviewer: setRuntimeIntentValue("auto_review"), + }, + }); + const activeRuntimeSnapshot = runtimeSnapshot({ + pending: { collaborationMode: setCollaborationModeIntent("plan") }, + active: { model: "gpt-5-active", serviceTier: "fast" }, + runtimeConfig: runtimeConfigFixture({}), + }); + + expect(pendingRuntimeSettingsPatch(reviewerSnapshot, snapshotConfig(reviewerSnapshot))).toMatchObject({ + update: { + approvalsReviewer: "auto_review", + collaborationMode: { + mode: "plan", + settings: { model: "gpt-5.5", reasoningEffort: "high" }, + }, + }, + }); + expect( + pendingRuntimeSettingsPatch(reviewerSnapshot, snapshotConfig(reviewerSnapshot)).update.collaborationMode?.settings, + ).not.toHaveProperty("approvalsReviewer"); + expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot))).toMatchObject({ + update: { + collaborationMode: { + mode: "plan", + settings: { model: "gpt-5-active" }, + }, + }, + }); + expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot)).update).not.toHaveProperty("model"); + expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot)).update).not.toHaveProperty("effort"); + }); + + it("resolves requested approval reviewer without adding it to turn runtime settings", () => { + const snapshot = runtimeSnapshot({ pending: { approvalsReviewer: setRuntimeIntentValue("auto_review") } }); + const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); + + expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); + expect(resolution.autoReview).toMatchObject({ active: true, confirmedActive: false, source: "pending", confirmedSource: "none" }); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ + update: { approvalsReviewer: "auto_review" }, + }); + }); + + it("treats active thread runtime as display state without persisting it into runtime intent patches", () => { + const snapshot = runtimeSnapshot({ + activeThreadId: "thread", + active: { model: "gpt-5-active", serviceTier: "fast" }, + runtimeConfig: runtimeConfigFixture({}), + }); + + expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-5-active"); + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot)).update).not.toHaveProperty("model"); + expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot)).update).not.toHaveProperty("effort"); + }); + + it("serializes disabled Fast mode as a null service tier request", () => { + const snapshot = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), + pending: { fastMode: setRuntimeIntentValue("disabled") }, + }); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); + expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBeNull(); + }); + + it("serializes service tier reset for thread start as the configured tier instead of null", () => { + const snapshot = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), + pending: { fastMode: resetRuntimeIntentToConfig() }, + }); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); + expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("fast"); + }); + + it("omits service tier reset for thread start when config has no service tier", () => { + const snapshot = runtimeSnapshot({ + runtimeConfig: runtimeConfigFixture({}), + pending: { fastMode: resetRuntimeIntentToConfig() }, + }); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); + expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBeUndefined(); + }); + + it("serializes requested fast mode using the catalog Fast service tier id", () => { + const model = { + ...modelFixture("gpt-5.5"), + serviceTiers: [{ id: "priority", name: "Fast" }], + }; + const snapshot = runtimeSnapshot({ + pending: { fastMode: setRuntimeIntentValue("enabled") }, + runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }), + availableModels: [model], + }); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); + expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).fastMode).toMatchObject({ + active: true, + effectiveServiceTier: "fast", + serviceTierRequestValue: "priority", + }); + expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("priority"); + }); + + it("omits service tier when neither config nor pending intent selects one", () => { + const snapshot = runtimeSnapshot({ runtimeConfig: runtimeConfigFixture({}) }); + + expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBeUndefined(); + }); + + it("passes through configured non-fast service tier ids", () => { + const snapshot = runtimeSnapshot({ runtimeConfig: runtimeConfigFixture({ service_tier: "flex" }) }); + + expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("flex"); + expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); + expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("flex"); + }); +}); diff --git a/tests/features/chat/domain/thread-stream/semantics/active-turn.test.ts b/tests/features/chat/domain/thread-stream/semantics/active-turn.test.ts new file mode 100644 index 00000000..b3a00cd9 --- /dev/null +++ b/tests/features/chat/domain/thread-stream/semantics/active-turn.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { ThreadStreamItem } from "../../../../../../src/features/chat/domain/thread-stream/items"; +import { activeTurnLiveItems } from "../../../../../../src/features/chat/domain/thread-stream/semantics/active-turn"; + +describe("active turn semantics", () => { + it("summarizes active subagent states while a turn is running", () => { + expect( + activeAgentSummary([ + agentItem({ + tool: "wait", + receiverThreadIds: ["done", "running", "failed"], + agents: [ + { threadId: "done", status: "completed", executionState: "completed", message: null }, + { threadId: "running", status: "running", executionState: "running", message: null }, + { threadId: "failed", status: "errored", executionState: "failed", message: null }, + ], + }), + ]), + ).toEqual({ + running: 1, + completed: 1, + failed: 1, + agents: [{ threadId: "running", status: "running", messagePreview: null }], + additionalAgents: 0, + }); + }); + + it("summarizes active subagent previews and fallback receiver states", () => { + expect( + activeAgentSummary([ + agentItem({ tool: "spawnAgent", status: "inProgress", receiverThreadIds: ["fallback-child"], agents: [] }), + agentItem({ + id: "agent-2", + tool: "wait", + receiverThreadIds: ["a", "b", "c", "d", "e"], + agents: [ + { threadId: "a", status: "running", executionState: "running", message: "\n Inspecting renderer tests \nmore details" }, + { threadId: "b", status: "failed", executionState: "failed", message: "Could not reproduce" }, + { threadId: "c", status: "running", executionState: "running", message: null }, + { threadId: "d", status: "running", executionState: "running", message: "Reviewing details" }, + { threadId: "e", status: "running", executionState: "running", message: "Checking scroll behavior" }, + ], + }), + ]), + ).toMatchObject({ + running: 5, + completed: 0, + failed: 1, + agents: [ + { threadId: "a", status: "running", messagePreview: "Inspecting renderer tests" }, + { threadId: "c", status: "running", messagePreview: null }, + { threadId: "d", status: "running", messagePreview: "Reviewing details" }, + { threadId: "e", status: "running", messagePreview: "Checking scroll behavior" }, + { threadId: "fallback-child", status: "inProgress", messagePreview: null }, + ], + additionalAgents: 0, + }); + }); + + it("omits active subagent summaries once every subagent is complete", () => { + expect( + activeAgentSummary([ + agentItem({ + tool: "wait", + status: "completed", + receiverThreadIds: ["done"], + agents: [{ threadId: "done", status: "completed", executionState: "completed", message: null }], + }), + ]), + ).toBeNull(); + }); +}); + +function activeAgentSummary(items: readonly ThreadStreamItem[]) { + return activeTurnLiveItems({ items }, "t1").find((item) => item.kind === "agentSummary")?.summary ?? null; +} + +function agentItem(overrides: Partial>): Extract { + return { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "t1", + tool: "wait", + status: "running", + senderThreadId: "parent", + receiverThreadIds: [], + prompt: null, + model: null, + reasoningEffort: null, + agents: [], + ...overrides, + }; +} diff --git a/tests/features/chat/domain/thread-stream/updates.test.ts b/tests/features/chat/domain/thread-stream/updates.test.ts new file mode 100644 index 00000000..9b9ec3cb --- /dev/null +++ b/tests/features/chat/domain/thread-stream/updates.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; +import { upsertThreadStreamItemById } from "../../../../../src/features/chat/domain/thread-stream/updates"; + +describe("thread stream item updates", () => { + it("does not overwrite streamed output with an empty completed item", () => { + const streamed: ThreadStreamItem = { + id: "c1", + sourceItemId: "c1", + kind: "command", + role: "tool", + commandAction: "command", + commandTarget: { kind: "command", commandLine: "Command running" }, + command: "npm test", + cwd: "/vault", + status: "running", + output: "partial output", + }; + const completed: ThreadStreamItem = { + id: "c1", + sourceItemId: "c1", + kind: "command", + role: "tool", + commandAction: "command", + commandTarget: { kind: "command", commandLine: "npm test" }, + command: "npm test", + cwd: "/vault", + status: "completed", + output: "", + }; + + expect(upsertThreadStreamItemById([streamed], completed)[0]).toMatchObject({ + output: "partial output", + status: "completed", + }); + }); +}); diff --git a/tests/features/chat/presentation/runtime/status.test.ts b/tests/features/chat/presentation/runtime/status.test.ts new file mode 100644 index 00000000..88606c12 --- /dev/null +++ b/tests/features/chat/presentation/runtime/status.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { contextSummary, rateLimitSummary, statusDetails } from "../../../../../src/features/chat/presentation/runtime/status"; +import { runtimeSnapshot } from "../../domain/runtime/support"; + +describe("runtime status presentation", () => { + it("summarizes context meter state from one runtime snapshot", () => { + const snapshot = runtimeSnapshot({ activeThreadId: "thread" }); + expect(contextSummary(snapshot)).toMatchObject({ + label: "Context 0%", + title: "Context: 0 / 100,000 (0%). No turns in this thread yet.", + percent: 0, + level: "ok", + }); + expect( + contextSummary( + runtimeSnapshot({ + activeThreadId: "thread", + tokenUsage: { + last: { inputTokens: 1000, cachedInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 50, totalTokens: 1250 }, + total: { inputTokens: 2000, cachedInputTokens: 0, outputTokens: 500, reasoningOutputTokens: 100, totalTokens: 2600 }, + modelContextWindow: 100_000, + }, + }), + ), + ).toMatchObject({ + title: "Context: 1,000 / 100,000 (1%). Latest usage: 1,000 input, 200 output, 50 reasoning. Total: 2,600 tokens.", + }); + expect( + contextSummary( + runtimeSnapshot({ + activeThreadId: "thread", + hasThreadTurns: true, + }), + ), + ).toMatchObject({ + label: "Context unknown", + percent: null, + }); + }); + + it("summarizes Codex usage limits independently from context usage", () => { + expect( + rateLimitSummary( + runtimeSnapshot({ + rateLimit: { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent: 72.4, windowDurationMins: 300, resetsAt: 1_800_000_000 }, + secondary: null, + individualLimit: null, + rateLimitReachedType: null, + }, + }), + 1_799_991_600_000, + ), + ).toMatchObject({ + rows: [{ label: "5h", value: "72%", resetLabel: "reset in 2h 20m", percent: 72, meterDivisions: 5 }], + level: "warn", + }); + + expect( + rateLimitSummary( + runtimeSnapshot({ + rateLimit: { + limitId: "codex", + limitName: null, + primary: { usedPercent: 95, windowDurationMins: null, resetsAt: null }, + secondary: null, + individualLimit: null, + rateLimitReachedType: "rate_limit_reached", + }, + }), + 0, + ), + ).toMatchObject({ + rows: [{ percent: 95, resetLabel: null }], + level: "danger", + }); + + expect( + rateLimitSummary( + runtimeSnapshot({ + rateLimit: { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent: 15, windowDurationMins: 300, resetsAt: null }, + secondary: { usedPercent: 38, windowDurationMins: 10_080, resetsAt: null }, + individualLimit: null, + rateLimitReachedType: null, + }, + }), + 0, + ), + ).toMatchObject({ + rows: [ + { label: "5h", value: "15%", meterDivisions: 5 }, + { label: "1w", value: "38%", meterDivisions: 7 }, + ], + level: "ok", + }); + + expect( + rateLimitSummary( + runtimeSnapshot({ + rateLimit: { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 1_800_000_000 }, + secondary: null, + individualLimit: null, + rateLimitReachedType: null, + }, + }), + 1_800_000_001_000, + ), + ).toMatchObject({ + rows: [{ resetLabel: "reset due" }], + }); + + expect( + rateLimitSummary( + runtimeSnapshot({ + rateLimit: { + limitId: "codex", + limitName: "Codex", + primary: null, + secondary: null, + individualLimit: { limit: "$100", used: "$72", remainingPercent: 28, resetsAt: 1_800_000_000 }, + rateLimitReachedType: null, + }, + }), + 1_799_991_600_000, + ), + ).toMatchObject({ + rows: [{ label: "monthly", value: "$72 / $100", resetLabel: "reset in 2h 20m", percent: 72, meterDivisions: null }], + level: "warn", + }); + }); + + it("formats runtime status details as flat rows", () => { + expect( + statusDetails({ + activeThreadId: "thread", + snapshot: runtimeSnapshot({ + activeThreadId: "thread", + rateLimit: { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent: 15, windowDurationMins: 300, resetsAt: null }, + secondary: { usedPercent: 38, windowDurationMins: 10_080, resetsAt: null }, + individualLimit: null, + rateLimitReachedType: null, + }, + }), + nowMs: 0, + }), + ).toEqual([ + { label: "Thread", value: "thread" }, + { label: "Context", value: "0 / 100,000 (0%). No turns in this thread yet." }, + { label: "Usage Limits", value: "5h 15%, 1w 38%" }, + ]); + }); +}); diff --git a/tests/features/chat/presentation/thread-stream/layout.test.ts b/tests/features/chat/presentation/thread-stream/layout.test.ts new file mode 100644 index 00000000..23ca48c0 --- /dev/null +++ b/tests/features/chat/presentation/thread-stream/layout.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, it } from "vitest"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; +import { threadStreamLayoutBlocks } from "../../../../../src/features/chat/presentation/thread-stream/layout"; + +function commandItem(id: string, text: string, turnId: string): ThreadStreamItem { + return { + id, + kind: "command", + role: "tool", + turnId, + commandAction: "command", + commandTarget: { kind: "command", commandLine: text }, + command: text, + cwd: "/vault", + status: "completed", + executionState: "completed", + }; +} + +function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): ThreadStreamItem { + return { + id, + kind: "fileChange", + role: "tool", + turnId, + status: "completed", + executionState: "completed", + changes: [{ kind: "update", path, diff: "" }], + }; +} + +function autoReviewResultItem(id: string, turnId: string, text = "Auto-review approved: npm test"): ThreadStreamItem { + return { + id, + kind: "reviewResult", + role: "tool", + text, + turnId, + provenance: { source: "appServer", channel: "notification", event: "autoReview", sourceItemId: id }, + executionState: "completed", + }; +} + +describe("display block grouping keeps thread stream details subordinate to conversation messages", () => { + it("groups completed turn activities before the final assistant message", () => { + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" }, + commandItem("c1", "npm test", "t1"), + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, null); + expect(blocks.map((block) => block.type)).toEqual(["item", "activityGroup", "item"]); + expect(blocks[1]).toMatchObject({ summary: "Work details" }); + }); + + it("groups completed hook and review logs before the final assistant message", () => { + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + { + id: "hook-1", + kind: "hook", + role: "tool", + text: "userPromptSubmit: Saving jj baseline", + toolName: "hook", + turnId: "t1", + status: "completed", + }, + commandItem("c1", "npm test", "t1"), + { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1", status: "completed" }, + autoReviewResultItem("review-1", "t1"), + autoReviewResultItem("review-2", "t1"), + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, null); + + expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "turn-t1-activity", "a1"]); + expect(blocks[1]).toMatchObject({ + summary: "Work details", + items: [ + { item: { id: "hook-1" } }, + { item: { id: "c1" } }, + { item: { id: "r1" } }, + { item: { id: "review-1" } }, + { item: { id: "review-2" } }, + ], + }); + expect(blocks[2]).toMatchObject({ + item: { id: "a1" }, + annotations: { autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"] }, + }); + }); + + it("hides empty completed reasoning items", () => { + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + { id: "r1", kind: "reasoning", role: "tool", text: "", turnId: "t1", status: "completed", executionState: "completed" }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, null); + expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "a1"]); + }); + + it("collapses earlier assistant responses with their turn details", () => { + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "I'll inspect it.", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + commandItem("c1", "rg issue", "t1"), + { + id: "a2", + kind: "dialogue", + role: "assistant", + text: "I found the cause.", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + fileChangeItem("f1", "t1"), + { + id: "a3", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, null); + expect(blocks.map((block) => block.type)).toEqual(["item", "activityGroup", "item"]); + expect(blocks[1]).toMatchObject({ + summary: "Work details", + items: [{ item: { id: "a1" } }, { item: { id: "c1" } }, { item: { id: "a2" } }, { item: { id: "f1" } }], + }); + expect(blocks[2]).toMatchObject({ item: { id: "a3" } }); + }); + + it("keeps completed turn work details attached to the final response even when system messages are interleaved", () => { + const items: ThreadStreamItem[] = [ + { id: "s1", kind: "system", role: "system", text: "debug before hook" }, + commandItem("c1", "npm test", "t1"), + { id: "s2", kind: "system", role: "system", text: "debug after hook" }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, null); + + expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["s1", "s2", "turn-t1-activity", "a1"]); + }); + + it("adds steering markers to completed turn work details without hiding the steering message", () => { + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + commandItem("c1", "rg issue", "t1"), + { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "also check tests", turnId: "t1", clientId: "local-steer-1" }, + commandItem("c2", "npm test", "t1"), + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, null); + + expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "u2", "turn-t1-activity", "a1"]); + expect(blocks[2]).toMatchObject({ + summary: "Work details", + items: [ + { type: "item", item: { id: "c1" } }, + { + type: "steering", + id: "steer-activity-u2", + label: "steering", + text: "also check tests", + sourceItemId: "u2", + }, + { type: "item", item: { id: "c2" } }, + ], + }); + }); + + it("keeps multiple steering markers in completed turn work details", () => { + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + { id: "u2", kind: "dialogue", dialogueKind: "user", role: "user", text: "also check tests", turnId: "t1" }, + { id: "u3", kind: "dialogue", dialogueKind: "user", role: "user", text: "and update docs", turnId: "t1" }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, null); + const activityGroup = blocks.find((block) => block.type === "activityGroup"); + + expect(activityGroup).toMatchObject({ + summary: "Work details", + items: [ + { type: "steering", id: "steer-activity-u2" }, + { type: "steering", id: "steer-activity-u3" }, + ], + }); + }); + + it("keeps active turn activities expanded", () => { + const items: ThreadStreamItem[] = [ + { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + expect(threadStreamLayoutBlocks(items, "t1").map((block) => block.type)).toEqual(["item", "item"]); + }); + + it("keeps active task progress chronological in the display model", () => { + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + { + id: "plan-progress-t1", + kind: "taskProgress", + role: "tool", + text: "[>] Patch UI", + turnId: "t1", + explanation: null, + steps: [{ step: "Patch UI", status: "inProgress" }], + status: "inProgress", + }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "working", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, "t1"); + + expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "plan-progress-t1", "a1"]); + }); + + it("groups task progress and agent activity inside completed turn work details", () => { + const items: ThreadStreamItem[] = [ + { id: "u1", kind: "dialogue", dialogueKind: "user", role: "user", text: "do it", turnId: "t1" }, + { + id: "plan-progress-t1", + kind: "taskProgress", + role: "tool", + text: "[>] Patch UI", + turnId: "t1", + explanation: null, + steps: [{ step: "Patch UI", status: "inProgress" }], + status: "inProgress", + }, + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Spawn agent", + turnId: "t1", + tool: "spawnAgent", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId: "child", status: "completed", executionState: "completed", message: null }], + }, + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const blocks = threadStreamLayoutBlocks(items, null); + expect(blocks[1]).toMatchObject({ + summary: "Work details", + items: [{ item: { id: "plan-progress-t1" } }, { item: { id: "agent-1" } }], + }); + }); + + it("adds edited files to the final assistant message", () => { + const items: ThreadStreamItem[] = [ + fileChangeItem("f1", "t1", "src/main.ts"), + fileChangeItem("f2", "t1", "styles.css"), + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "intermediate", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + { + id: "a2", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const assistantBlock = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); + expect(assistantBlock).toMatchObject({ annotations: { editedFiles: ["src/main.ts", "styles.css"] } }); + }); + + it("adds turn diff metadata to the final assistant message only when aggregated diff exists", () => { + const items: ThreadStreamItem[] = [ + fileChangeItem("f1", "t1", "src/main.ts"), + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const withoutDiff = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); + expect(withoutDiff).toMatchObject({ annotations: { editedFiles: ["src/main.ts"] } }); + expect(withoutDiff?.type === "item" ? withoutDiff.annotations : null).not.toHaveProperty("turnDiff"); + + const withDiff = threadStreamLayoutBlocks(items, null, null, new Map([["t1", "@@\n-old\n+new"]])).find( + (block) => block.type === "item" && block.item.role === "assistant", + ); + expect(withDiff).toMatchObject({ annotations: { editedFiles: ["src/main.ts"], turnDiff: { diff: "@@\n-old\n+new" } } }); + }); + + it("adds auto-review summaries to the final assistant message", () => { + const items: ThreadStreamItem[] = [ + autoReviewResultItem("review-1", "t1"), + autoReviewResultItem("review-2", "t1"), + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const assistantBlock = threadStreamLayoutBlocks(items, null).find((block) => block.type === "item" && block.item.role === "assistant"); + expect(assistantBlock).toMatchObject({ + annotations: { autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"] }, + }); + }); + + it("does not add edited file or auto-review summaries to active turn messages", () => { + const items: ThreadStreamItem[] = [ + fileChangeItem("f1", "t1", "src/main.ts"), + autoReviewResultItem("review-1", "t1"), + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "still working", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const assistantBlock = threadStreamLayoutBlocks(items, "t1").find((block) => block.type === "item" && block.item.id === "a1"); + expect(assistantBlock?.type === "item" ? assistantBlock.annotations : null).toBeUndefined(); + }); +}); + +describe("workspace-relative presentation paths", () => { + it("shows edited files relative to the workspace root", () => { + const items: ThreadStreamItem[] = [ + fileChangeItem("f1", "t1", "/vault/project/src/main.ts"), + fileChangeItem("f2", "t1", "/vault/project/styles.css"), + fileChangeItem("f3", "t1", "/tmp/outside.txt"), + { + id: "a1", + kind: "dialogue", + role: "assistant", + text: "done", + turnId: "t1", + dialogueKind: "assistantResponse", + dialogueState: "completed", + }, + ]; + + const assistantBlock = threadStreamLayoutBlocks(items, null, "/vault/project").find( + (block) => block.type === "item" && block.item.role === "assistant", + ); + expect(assistantBlock).toMatchObject({ annotations: { editedFiles: ["/tmp/outside.txt", "src/main.ts", "styles.css"] } }); + }); +}); diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts deleted file mode 100644 index 2cc55ad0..00000000 --- a/tests/runtime/runtime-settings.test.ts +++ /dev/null @@ -1,1149 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { type ConfigReadResult, runtimeConfigSnapshotFromAppServerConfig } from "../../src/app-server/protocol/runtime-config"; -import type { ModelMetadata } from "../../src/domain/catalog/metadata"; -import { type RuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../src/domain/runtime/config"; -import { - resetRuntimeIntentToConfig, - setCollaborationModeIntent, - setRuntimeIntentValue, - unchangedCollaborationModeIntent, - unchangedRuntimeIntent, -} from "../../src/features/chat/domain/runtime/intent"; -import { - compactReasoningEffortLabel, - modelOverrideMessage, - reasoningEffortOverrideMessage, -} from "../../src/features/chat/domain/runtime/labels"; -import { resolveRuntimeControls } from "../../src/features/chat/domain/runtime/resolution"; -import type { RuntimeSnapshot } from "../../src/features/chat/domain/runtime/snapshot"; -import { - pendingRuntimeSettingsPatch, - permissionProfileRequestForThreadStart, - serviceTierRequestForThreadStart, -} from "../../src/features/chat/domain/runtime/thread-settings-patch"; -import { contextSummary, rateLimitSummary, statusDetails } from "../../src/features/chat/presentation/runtime/status"; - -describe("runtime settings", () => { - it("formats runtime override messages", () => { - expect(modelOverrideMessage("gpt-5.5")).toBe("Model set to gpt-5.5 for subsequent turns."); - expect(modelOverrideMessage(null)).toBe("Model reset to default for subsequent turns."); - expect(reasoningEffortOverrideMessage("low")).toBe("Reasoning effort set to low for subsequent turns."); - expect(reasoningEffortOverrideMessage(null)).toBe("Reasoning effort reset to default for subsequent turns."); - }); - - it("formats compact runtime labels", () => { - expect(compactReasoningEffortLabel("minimal")).toBe("min"); - expect(compactReasoningEffortLabel("high")).toBe("high"); - expect(compactReasoningEffortLabel(null)).toBe("default"); - }); - - it("keeps startup permission defaults in the runtime config snapshot", () => { - expect( - runtimeConfigFixture({ - default_permissions: ":workspace", - approval_policy: "on-request", - approvals_reviewer: "auto_review", - }), - ).toMatchObject({ - approvalsReviewer: "auto_review", - startupPermissions: { - activePermissionProfile: { id: ":workspace", extends: null }, - approvalPolicy: "on-request", - sandboxPolicy: null, - }, - }); - }); - - it("deep-clones granular startup approval policy in runtime config snapshots", () => { - const config = runtimeConfigFixture({ - approval_policy: { - granular: { - sandbox_approval: true, - rules: false, - skill_approval: true, - request_permissions: false, - mcp_elicitations: true, - }, - }, - }); - - const cloned = runtimeConfigOrDefault(config); - const originalPolicy = config.startupPermissions.approvalPolicy; - const clonedPolicy = cloned.startupPermissions.approvalPolicy; - - expect(clonedPolicy).toEqual(originalPolicy); - if (!originalPolicy || typeof originalPolicy === "string" || !clonedPolicy || typeof clonedPolicy === "string") { - throw new Error("expected granular approval policy"); - } - expect(clonedPolicy).not.toBe(originalPolicy); - expect(clonedPolicy.granular).not.toBe(originalPolicy.granular); - }); - - it("keeps default permissions separate from legacy sandbox fields", () => { - expect( - runtimeConfigFixture({ - default_permissions: "DevProfile", - approval_policy: "on-request", - sandbox_mode: "workspace-write", - sandbox_workspace_write: { - writable_roots: ["/vault"], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - }), - ).toMatchObject({ - startupPermissions: { - activePermissionProfile: { id: "DevProfile", extends: null }, - approvalPolicy: "on-request", - sandboxPolicy: null, - }, - }); - }); - - it("uses legacy sandbox config when default permissions are not reported", () => { - expect( - runtimeConfigFixture({ - approval_policy: "on-request", - sandbox_mode: "workspace-write", - sandbox_workspace_write: { - writable_roots: ["/vault"], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - }), - ).toMatchObject({ - startupPermissions: { - activePermissionProfile: null, - approvalPolicy: "on-request", - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: ["/vault"], - networkAccess: false, - }, - }, - }); - }); - - it("falls back to startup permissions until active thread permissions are reported", () => { - const configured = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({ - default_permissions: ":workspace", - approval_policy: "on-request", - }), - }); - expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({ - permissionProfile: { effective: ":workspace", source: "config" }, - sandboxPolicy: { effective: null, source: "none" }, - approvalPolicy: { effective: "on-request", source: "config" }, - }); - - const activeUnreported = runtimeSnapshot({ - activeThreadId: "thread", - runtimeConfig: runtimeConfigFixture({ - default_permissions: ":workspace", - approval_policy: "on-request", - }), - }); - expect(resolveRuntimeControls(activeUnreported, snapshotConfig(activeUnreported))).toMatchObject({ - permissionProfile: { configured: ":workspace", active: null, effective: ":workspace", source: "config" }, - sandboxPolicy: { configured: null, active: null, effective: null, source: "none" }, - approvalPolicy: { configured: "on-request", active: null, effective: "on-request", source: "config" }, - }); - - const activeReported = runtimeSnapshot({ - activeThreadId: "thread", - active: { - approvalPolicyKnown: true, - sandboxPolicyKnown: true, - permissionProfileKnown: true, - approvalPolicy: "never", - sandboxPolicy: { type: "readOnly", networkAccess: false }, - activePermissionProfile: { id: ":read-only", extends: null }, - approvalsReviewer: "user", - }, - pending: { approvalsReviewer: setRuntimeIntentValue("auto_review") }, - }); - expect(resolveRuntimeControls(activeReported, snapshotConfig(activeReported))).toMatchObject({ - approvalsReviewer: { effective: "auto_review", source: "pending" }, - permissionProfile: { effective: ":read-only", source: "active-thread" }, - sandboxPolicy: { effective: { type: "readOnly", networkAccess: false }, source: "active-thread" }, - approvalPolicy: { effective: "never", source: "active-thread" }, - }); - - const approvalOnlyReported = runtimeSnapshot({ - activeThreadId: "thread", - runtimeConfig: runtimeConfigFixture({ - default_permissions: ":workspace", - approval_policy: "on-request", - }), - active: { - approvalPolicyKnown: true, - approvalPolicy: "never", - }, - }); - expect(resolveRuntimeControls(approvalOnlyReported, snapshotConfig(approvalOnlyReported))).toMatchObject({ - permissionProfile: { effective: ":workspace", source: "config" }, - sandboxPolicy: { effective: null, source: "none" }, - approvalPolicy: { effective: "never", source: "active-thread" }, - }); - }); - - it("keeps permission display scope separate from pending permission source", () => { - const snapshot = runtimeSnapshot({ - activeThreadId: "thread", - active: { - approvalPolicyKnown: true, - sandboxPolicyKnown: true, - permissionProfileKnown: true, - approvalPolicy: "on-request", - sandboxPolicy: { type: "readOnly", networkAccess: false }, - activePermissionProfile: null, - }, - pending: { - approvalPolicy: setRuntimeIntentValue("never"), - permissionProfile: setRuntimeIntentValue(":workspace"), - }, - }); - - expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot))).toMatchObject({ - permissionProfile: { effective: ":workspace", source: "pending" }, - sandboxPolicy: { effective: null, source: "pending" }, - approvalPolicy: { effective: "never", source: "pending" }, - }); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ - update: { - approvalPolicy: "never", - permissions: ":workspace", - }, - }); - }); - - it("resolves permission profile reset intent back to legacy sandbox config", () => { - const snapshot = runtimeSnapshot({ - activeThreadId: "thread", - runtimeConfig: runtimeConfigFixture({ - sandbox_mode: "workspace-write", - sandbox_workspace_write: { - writable_roots: ["/vault"], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - }), - active: { - sandboxPolicyKnown: true, - permissionProfileKnown: true, - activePermissionProfile: { id: ":workspace", extends: null }, - sandboxPolicy: null, - }, - pending: { permissionProfile: resetRuntimeIntentToConfig() }, - }); - - expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot))).toMatchObject({ - permissionProfile: { effective: null, source: "config" }, - sandboxPolicy: { - effective: { - type: "workspaceWrite", - writableRoots: ["/vault"], - networkAccess: false, - }, - source: "config", - }, - }); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ - update: { permissions: null }, - }); - }); - - it("selects config permission profiles for empty-panel thread starts", () => { - const configured = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({ - default_permissions: ":workspace", - }), - }); - - expect(permissionProfileRequestForThreadStart(configured, snapshotConfig(configured))).toBe(":workspace"); - - const pending = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({ - default_permissions: ":workspace", - }), - pending: { permissionProfile: setRuntimeIntentValue(":read-only") }, - }); - - expect(permissionProfileRequestForThreadStart(pending, snapshotConfig(pending))).toBe(":read-only"); - - const legacySandbox = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({ - sandbox_mode: "workspace-write", - }), - }); - - expect(permissionProfileRequestForThreadStart(legacySandbox, snapshotConfig(legacySandbox))).toBeUndefined(); - }); - - it("keeps runtime defaults, resets, and collaboration mode semantics distinct", () => { - const snapshot = runtimeSnapshot({ - pending: { - model: resetRuntimeIntentToConfig(), - reasoningEffort: resetRuntimeIntentToConfig(), - }, - }); - - expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-5.5"); - expect(currentReasoningEffort(snapshot, snapshotConfig(snapshot))).toBe("high"); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ - update: { model: null, effort: null }, - collaborationModeWarning: null, - }); - }); - - it("projects explicit runtime intents into current values and settings payload values", () => { - const snapshot = runtimeSnapshot({ - pending: { - model: setRuntimeIntentValue("gpt-5.4"), - reasoningEffort: setRuntimeIntentValue("low"), - }, - }); - const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); - - expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-5.4"); - expect(currentReasoningEffort(snapshot, snapshotConfig(snapshot))).toBe("low"); - expect(resolution.model).toMatchObject({ effective: "gpt-5.4", source: "pending" }); - expect(resolution.reasoningEffort).toMatchObject({ effective: "low", source: "pending" }); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ - update: { model: "gpt-5.4", effort: "low" }, - collaborationModeWarning: null, - }); - }); - - it("treats unreported thread collaboration mode as default without losing the unknown state", () => { - const snapshot = runtimeSnapshot({ - active: { collaborationMode: null }, - }); - - expect(snapshot.active.collaborationMode).toBeNull(); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toEqual({ - update: {}, - collaborationModeWarning: null, - }); - - expect(snapshot.pending.collaborationMode).toEqual({ kind: "unchanged" }); - }); - - it("keeps model reset tied to config when active thread model differs", () => { - const snapshot = runtimeSnapshot({ - active: { model: "gpt-5-active" }, - pending: { model: resetRuntimeIntentToConfig() }, - runtimeConfig: runtimeConfigFixture({ - model_reasoning_effort: "high", - service_tier: "flex", - model_context_window: 100_000, - }), - }); - - expect(currentModel(snapshot, snapshotConfig(snapshot))).toBeNull(); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ - update: { model: null }, - collaborationModeWarning: null, - }); - }); - - it("builds the Plan collaboration mode payload from selected runtime settings", () => { - const snapshot = runtimeSnapshot({ - pending: { - collaborationMode: setCollaborationModeIntent("plan"), - model: setRuntimeIntentValue("gpt-5.5"), - reasoningEffort: setRuntimeIntentValue("high"), - }, - }); - - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ - update: { - collaborationMode: { - mode: "plan", - settings: { - model: "gpt-5.5", - reasoningEffort: "high", - developerInstructions: null, - }, - }, - }, - collaborationModeWarning: null, - }); - }); - - it("uses the explicit config for collaboration mode thread settings", () => { - const snapshot = runtimeSnapshot({ - pending: { collaborationMode: setCollaborationModeIntent("plan") }, - runtimeConfig: runtimeConfigFixture({ - model: "snapshot-model", - model_reasoning_effort: "low", - }), - }); - const explicitConfig = runtimeConfigFixture({ - model: "explicit-model", - model_reasoning_effort: "high", - }); - - expect(pendingRuntimeSettingsPatch(snapshot, explicitConfig)).toMatchObject({ - update: { - collaborationMode: { - mode: "plan", - settings: { - model: "explicit-model", - reasoningEffort: "high", - developerInstructions: null, - }, - }, - }, - collaborationModeWarning: null, - }); - }); - - it("keeps collaboration mode settings separate from reviewer and direct runtime intents", () => { - const reviewerSnapshot = runtimeSnapshot({ - pending: { - collaborationMode: setCollaborationModeIntent("plan"), - approvalsReviewer: setRuntimeIntentValue("auto_review"), - }, - }); - const activeRuntimeSnapshot = runtimeSnapshot({ - pending: { collaborationMode: setCollaborationModeIntent("plan") }, - active: { model: "gpt-5-active", serviceTier: "fast" }, - runtimeConfig: runtimeConfigFixture({}), - }); - - expect(pendingRuntimeSettingsPatch(reviewerSnapshot, snapshotConfig(reviewerSnapshot))).toMatchObject({ - update: { - approvalsReviewer: "auto_review", - collaborationMode: { - mode: "plan", - settings: { model: "gpt-5.5", reasoningEffort: "high" }, - }, - }, - }); - expect( - pendingRuntimeSettingsPatch(reviewerSnapshot, snapshotConfig(reviewerSnapshot)).update.collaborationMode?.settings, - ).not.toHaveProperty("approvalsReviewer"); - expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot))).toMatchObject({ - update: { - collaborationMode: { - mode: "plan", - settings: { model: "gpt-5-active" }, - }, - }, - }); - expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot)).update).not.toHaveProperty("model"); - expect(pendingRuntimeSettingsPatch(activeRuntimeSnapshot, snapshotConfig(activeRuntimeSnapshot)).update).not.toHaveProperty("effort"); - }); - - it("resolves auto-review mode from requested, active, then effective config", () => { - const requested = runtimeSnapshot({ - pending: { approvalsReviewer: setRuntimeIntentValue("user") }, - active: { approvalsReviewer: "auto_review" }, - runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "guardian_subagent" }), - }); - const active = runtimeSnapshot({ - active: { approvalsReviewer: "user" }, - runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "guardian_subagent" }), - }); - const configured = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "guardian_subagent" }), - }); - - expect(autoReviewActive(requested, snapshotConfig(requested))).toBe(false); - expect(autoReviewActive(active, snapshotConfig(active))).toBe(false); - expect(autoReviewActive(configured, snapshotConfig(configured))).toBe(true); - }); - - it("uses the active reviewer before configured reviewer", () => { - const snapshot = runtimeSnapshot({ - active: { approvalsReviewer: "user" }, - runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "auto_review" }), - }); - - expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(false); - }); - - it("treats guardian subagent reviewer as active auto-review", () => { - const snapshot = runtimeSnapshot({ - active: { approvalsReviewer: "guardian_subagent" }, - }); - - expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); - }); - - it("uses requested reviewer above active and configured reviewers", () => { - const snapshot = runtimeSnapshot({ - pending: { approvalsReviewer: setRuntimeIntentValue("user") }, - active: { approvalsReviewer: "user" }, - runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "auto_review" }), - }); - - expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(false); - }); - - it("uses effective approval reviewer values and reports selected profile metadata", () => { - const runtimeConfig = runtimeConfigFixture({ approvals_reviewer: "auto_review" }, [ - configLayer({}, null), - configLayer({ approvals_reviewer: "auto_review" }, "auto"), - ]); - const snapshot = runtimeSnapshot({ - runtimeConfig, - }); - - expect(runtimeConfigOrDefault(runtimeConfig).profile).toBe("auto"); - expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); - }); - - it("uses effective model, effort, and fast mode config values", () => { - const snapshot = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture( - { - model: "gpt-profile", - model_reasoning_effort: "high", - service_tier: "fast", - }, - [ - configLayer({}, null), - configLayer({ model: "gpt-profile", model_reasoning_effort: "high", service_tier: "fast" }, "fast-profile"), - ], - ), - }); - - expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-profile"); - expect(currentReasoningEffort(snapshot, snapshotConfig(snapshot))).toBe("high"); - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); - expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("fast"); - }); - - it("uses active service tier before configured service tier", () => { - const snapshot = runtimeSnapshot({ - activeThreadId: "thread", - active: { serviceTier: "flex" }, - runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), - }); - const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("flex"); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); - expect(resolution.serviceTier).toMatchObject({ effective: "flex", source: "active-thread" }); - expect(resolution.fastMode).toMatchObject({ active: false, source: "active-thread", effectiveServiceTier: "flex" }); - }); - - it("treats the catalog Fast service tier id as fast mode while preserving the id", () => { - const model = { - ...modelFixture("gpt-5.5"), - serviceTiers: [{ id: "priority", name: "Fast" }], - }; - // app-server may advertise Fast with an id such as "priority"; - // last verified against codex app-server 0.142.0. - const snapshot = runtimeSnapshot({ - activeThreadId: "thread", - active: { model: "gpt-5.5", serviceTier: "priority" }, - runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }), - availableModels: [model], - }); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("priority"); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); - expect(fastRuntimeServiceTierRequestValue(snapshot, snapshotConfig(snapshot))).toBe("priority"); - }); - - it("treats the app-server reported default tier after clearing Fast as fast mode off", () => { - const model = { - ...modelFixture("gpt-5.5"), - serviceTiers: [{ id: "priority", name: "Fast" }], - }; - const snapshot = runtimeSnapshot({ - activeThreadId: "thread", - active: { model: "gpt-5.5", serviceTier: "default" }, - runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }), - availableModels: [model], - }); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("default"); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); - }); - - it("uses requested Fast mode above active and configured service tiers", () => { - const snapshot = runtimeSnapshot({ - active: { serviceTier: "flex" }, - pending: { fastMode: setRuntimeIntentValue("disabled") }, - runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), - }); - const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); - expect(resolution.serviceTier).toMatchObject({ effective: null, source: "pending" }); - expect(resolution.fastMode).toMatchObject({ active: false, source: "pending", effectiveServiceTier: null }); - }); - - it("keeps a cleared active thread service tier above configured Fast mode", () => { - const snapshot = runtimeSnapshot({ - activeThreadId: "thread", - active: { serviceTier: null }, - runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), - }); - const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); - expect(resolution.serviceTier).toMatchObject({ effective: null, source: "active-thread" }); - }); - - it("resolves all runtime controls through pending, active, and config layers", () => { - const configured = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({ - model: "gpt-config", - model_reasoning_effort: "medium", - default_permissions: ":workspace", - approval_policy: "on-request", - sandbox_mode: "workspace-write", - sandbox_workspace_write: { - writable_roots: ["/vault"], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - approvals_reviewer: "auto_review", - service_tier: "fast", - }), - }); - const active = runtimeSnapshot({ - ...configured, - activeThreadId: "thread", - active: { - approvalPolicyKnown: true, - sandboxPolicyKnown: true, - permissionProfileKnown: true, - model: "gpt-active", - reasoningEffort: "high", - activePermissionProfile: { id: ":read-only", extends: null }, - sandboxPolicy: { type: "readOnly", networkAccess: false }, - approvalPolicy: "never", - approvalsReviewer: "user", - serviceTier: "flex", - }, - }); - const pending = runtimeSnapshot({ - ...active, - pending: { - ...active.pending, - model: setRuntimeIntentValue("gpt-pending"), - reasoningEffort: setRuntimeIntentValue("low"), - permissionProfile: setRuntimeIntentValue(":workspace"), - approvalPolicy: setRuntimeIntentValue("on-request"), - approvalsReviewer: setRuntimeIntentValue("guardian_subagent"), - fastMode: setRuntimeIntentValue("enabled"), - }, - }); - - expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({ - model: { effective: "gpt-config", source: "config" }, - reasoningEffort: { effective: "medium", source: "config" }, - permissionProfile: { effective: ":workspace", source: "config" }, - sandboxPolicy: { effective: null, source: "none" }, - approvalPolicy: { effective: "on-request", source: "config" }, - approvalsReviewer: { effective: "auto_review", source: "config" }, - serviceTier: { effective: "fast", source: "config" }, - }); - expect(resolveRuntimeControls(active, snapshotConfig(active))).toMatchObject({ - model: { effective: "gpt-active", source: "active-thread" }, - reasoningEffort: { effective: "high", source: "active-thread" }, - permissionProfile: { effective: ":read-only", source: "active-thread" }, - sandboxPolicy: { effective: { type: "readOnly", networkAccess: false }, source: "active-thread" }, - approvalPolicy: { effective: "never", source: "active-thread" }, - approvalsReviewer: { effective: "user", source: "active-thread" }, - serviceTier: { effective: "flex", source: "active-thread" }, - }); - expect(resolveRuntimeControls(pending, snapshotConfig(pending))).toMatchObject({ - model: { confirmed: "gpt-active", confirmedSource: "active-thread", effective: "gpt-pending", source: "pending" }, - reasoningEffort: { confirmed: "high", confirmedSource: "active-thread", effective: "low", source: "pending" }, - permissionProfile: { confirmed: ":read-only", confirmedSource: "active-thread", effective: ":workspace", source: "pending" }, - sandboxPolicy: { - confirmed: { type: "readOnly", networkAccess: false }, - confirmedSource: "active-thread", - effective: null, - source: "pending", - }, - approvalPolicy: { confirmed: "never", confirmedSource: "active-thread", effective: "on-request", source: "pending" }, - approvalsReviewer: { confirmed: "user", confirmedSource: "active-thread", effective: "guardian_subagent", source: "pending" }, - serviceTier: { confirmed: "flex", confirmedSource: "active-thread", effective: "fast", source: "pending" }, - fastMode: { - active: true, - confirmedActive: false, - source: "pending", - confirmedSource: "active-thread", - serviceTierRequestValue: "fast", - }, - }); - }); - - it("model-checks runtime value precedence for configured, active, and pending model layers", () => { - for (const configured of [null, "gpt-config"] as const) { - for (const active of [null, "gpt-active"] as const) { - for (const pending of modelPendingIntentCases()) { - const snapshot = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture(configured ? { model: configured } : {}), - active: { model: active }, - pending: { model: pending.intent }, - }); - const model = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).model; - const expectedConfirmed = active ?? configured; - const expectedConfirmedSource = active ? "active-thread" : configured ? "config" : "none"; - - expect(model, runtimeLayerCase(configured, active, pending.name)).toMatchObject({ - configured, - active, - pending: pending.intent, - confirmed: expectedConfirmed, - confirmedSource: expectedConfirmedSource, - effective: pending.name === "set" ? "gpt-pending" : pending.name === "resetToConfig" ? configured : expectedConfirmed, - source: pending.name === "set" ? "pending" : pending.name === "resetToConfig" ? "config" : expectedConfirmedSource, - }); - } - } - } - }); - - it("reports collaboration mode dirtiness and missing model blockers from the resolved runtime", () => { - const blocked = runtimeSnapshot({ - pending: { collaborationMode: setCollaborationModeIntent("plan") }, - runtimeConfig: runtimeConfigFixture({}), - }); - const ready = runtimeSnapshot({ - pending: { - collaborationMode: setCollaborationModeIntent("plan"), - model: setRuntimeIntentValue("gpt-5.5"), - }, - }); - - expect(resolveRuntimeControls(blocked, snapshotConfig(blocked)).collaborationMode).toMatchObject({ - pending: setCollaborationModeIntent("plan"), - confirmed: "default", - effective: "plan", - dirty: true, - blockedReason: "missing-model", - }); - expect(resolveRuntimeControls(ready, snapshotConfig(ready)).collaborationMode).toMatchObject({ - pending: setCollaborationModeIntent("plan"), - confirmed: "default", - effective: "plan", - dirty: true, - blockedReason: null, - }); - }); - - it("resolves requested approval reviewer without adding it to turn runtime settings", () => { - const snapshot = runtimeSnapshot({ pending: { approvalsReviewer: setRuntimeIntentValue("auto_review") } }); - const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); - - expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); - expect(resolution.autoReview).toMatchObject({ active: true, confirmedActive: false, source: "pending", confirmedSource: "none" }); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ - update: { approvalsReviewer: "auto_review" }, - }); - }); - - it("treats active thread runtime as display state without persisting it into runtime intent patches", () => { - const snapshot = runtimeSnapshot({ - activeThreadId: "thread", - active: { model: "gpt-5-active", serviceTier: "fast" }, - runtimeConfig: runtimeConfigFixture({}), - }); - - expect(currentModel(snapshot, snapshotConfig(snapshot))).toBe("gpt-5-active"); - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot)).update).not.toHaveProperty("model"); - expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot)).update).not.toHaveProperty("effort"); - }); - - it("uses the explicit config when finding supported reasoning efforts", () => { - const snapshot = runtimeSnapshot({ - pending: { model: resetRuntimeIntentToConfig() }, - runtimeConfig: runtimeConfigFixture({ model: "snapshot-model" }), - availableModels: [ - { ...modelFixture("snapshot-model"), supportedReasoningEfforts: ["low"] }, - { ...modelFixture("explicit-model"), supportedReasoningEfforts: ["high"] }, - ], - }); - const explicitConfig = runtimeConfigFixture({ model: "explicit-model" }); - - expect(supportedReasoningEfforts(snapshot, explicitConfig)).toEqual(["high"]); - }); - - it("summarizes service tier and context meter state from one runtime snapshot", () => { - const snapshot = runtimeSnapshot({ pending: { fastMode: setRuntimeIntentValue("enabled") }, activeThreadId: "thread" }); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); - expect(contextSummary(snapshot)).toMatchObject({ - label: "Context 0%", - title: "Context: 0 / 100,000 (0%). No turns in this thread yet.", - percent: 0, - level: "ok", - }); - expect( - contextSummary( - runtimeSnapshot({ - activeThreadId: "thread", - tokenUsage: { - last: { inputTokens: 1000, cachedInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 50, totalTokens: 1250 }, - total: { inputTokens: 2000, cachedInputTokens: 0, outputTokens: 500, reasoningOutputTokens: 100, totalTokens: 2600 }, - modelContextWindow: 100_000, - }, - }), - ), - ).toMatchObject({ - title: "Context: 1,000 / 100,000 (1%). Latest usage: 1,000 input, 200 output, 50 reasoning. Total: 2,600 tokens.", - }); - expect( - contextSummary( - runtimeSnapshot({ - activeThreadId: "thread", - hasThreadTurns: true, - }), - ), - ).toMatchObject({ - label: "Context unknown", - percent: null, - }); - }); - - it("serializes disabled Fast mode as a null service tier request", () => { - const snapshot = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), - pending: { fastMode: setRuntimeIntentValue("disabled") }, - }); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); - expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBeNull(); - }); - - it("serializes service tier reset for thread start as the configured tier instead of null", () => { - const snapshot = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({ service_tier: "fast" }), - pending: { fastMode: resetRuntimeIntentToConfig() }, - }); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); - expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("fast"); - }); - - it("omits service tier reset for thread start when config has no service tier", () => { - const snapshot = runtimeSnapshot({ - runtimeConfig: runtimeConfigFixture({}), - pending: { fastMode: resetRuntimeIntentToConfig() }, - }); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBeNull(); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); - expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBeUndefined(); - }); - - it("serializes requested fast mode using the catalog Fast service tier id", () => { - const model = { - ...modelFixture("gpt-5.5"), - serviceTiers: [{ id: "priority", name: "Fast" }], - }; - const snapshot = runtimeSnapshot({ - pending: { fastMode: setRuntimeIntentValue("enabled") }, - runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }), - availableModels: [model], - }); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("fast"); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(true); - expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).fastMode).toMatchObject({ - active: true, - effectiveServiceTier: "fast", - serviceTierRequestValue: "priority", - }); - expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("priority"); - }); - - it("omits service tier when neither config nor pending intent selects one", () => { - const snapshot = runtimeSnapshot({ runtimeConfig: runtimeConfigFixture({}) }); - - expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBeUndefined(); - }); - - it("passes through configured non-fast service tier ids", () => { - const snapshot = runtimeSnapshot({ runtimeConfig: runtimeConfigFixture({ service_tier: "flex" }) }); - - expect(currentServiceTier(snapshot, snapshotConfig(snapshot))).toBe("flex"); - expect(fastModeActive(snapshot, snapshotConfig(snapshot))).toBe(false); - expect(serviceTierRequestForThreadStart(snapshot, snapshotConfig(snapshot))).toBe("flex"); - }); - - it("summarizes Codex usage limits independently from context usage", () => { - expect( - rateLimitSummary( - runtimeSnapshot({ - rateLimit: { - limitId: "codex", - limitName: "Codex", - primary: { usedPercent: 72.4, windowDurationMins: 300, resetsAt: 1_800_000_000 }, - secondary: null, - individualLimit: null, - rateLimitReachedType: null, - }, - }), - 1_799_991_600_000, - ), - ).toMatchObject({ - rows: [{ label: "5h", value: "72%", resetLabel: "reset in 2h 20m", percent: 72, meterDivisions: 5 }], - level: "warn", - }); - - expect( - rateLimitSummary( - runtimeSnapshot({ - rateLimit: { - limitId: "codex", - limitName: null, - primary: { usedPercent: 95, windowDurationMins: null, resetsAt: null }, - secondary: null, - individualLimit: null, - rateLimitReachedType: "rate_limit_reached", - }, - }), - 0, - ), - ).toMatchObject({ - rows: [{ percent: 95, resetLabel: null }], - level: "danger", - }); - - expect( - rateLimitSummary( - runtimeSnapshot({ - rateLimit: { - limitId: "codex", - limitName: "Codex", - primary: { usedPercent: 15, windowDurationMins: 300, resetsAt: null }, - secondary: { usedPercent: 38, windowDurationMins: 10_080, resetsAt: null }, - individualLimit: null, - rateLimitReachedType: null, - }, - }), - 0, - ), - ).toMatchObject({ - rows: [ - { label: "5h", value: "15%", meterDivisions: 5 }, - { label: "1w", value: "38%", meterDivisions: 7 }, - ], - level: "ok", - }); - - expect( - rateLimitSummary( - runtimeSnapshot({ - rateLimit: { - limitId: "codex", - limitName: "Codex", - primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 1_800_000_000 }, - secondary: null, - individualLimit: null, - rateLimitReachedType: null, - }, - }), - 1_800_000_001_000, - ), - ).toMatchObject({ - rows: [{ resetLabel: "reset due" }], - }); - - expect( - rateLimitSummary( - runtimeSnapshot({ - rateLimit: { - limitId: "codex", - limitName: "Codex", - primary: null, - secondary: null, - individualLimit: { limit: "$100", used: "$72", remainingPercent: 28, resetsAt: 1_800_000_000 }, - rateLimitReachedType: null, - }, - }), - 1_799_991_600_000, - ), - ).toMatchObject({ - rows: [{ label: "monthly", value: "$72 / $100", resetLabel: "reset in 2h 20m", percent: 72, meterDivisions: null }], - level: "warn", - }); - }); - - it("formats runtime status details as flat rows", () => { - expect( - statusDetails({ - activeThreadId: "thread", - snapshot: runtimeSnapshot({ - activeThreadId: "thread", - rateLimit: { - limitId: "codex", - limitName: "Codex", - primary: { usedPercent: 15, windowDurationMins: 300, resetsAt: null }, - secondary: { usedPercent: 38, windowDurationMins: 10_080, resetsAt: null }, - individualLimit: null, - rateLimitReachedType: null, - }, - }), - nowMs: 0, - }), - ).toEqual([ - { label: "Thread", value: "thread" }, - { label: "Context", value: "0 / 100,000 (0%). No turns in this thread yet." }, - { label: "Usage Limits", value: "5h 15%, 1w 38%" }, - ]); - }); -}); - -interface RuntimeSnapshotPatch extends Partial> { - active?: Partial; - pending?: Partial; -} - -function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot { - const { active, pending, ...snapshotOverrides } = overrides; - const snapshot: RuntimeSnapshot = { - runtimeConfig: runtimeConfigFixture({ - model: "gpt-5.5", - model_reasoning_effort: "high", - service_tier: "flex", - model_context_window: 100_000, - }), - activeThreadId: null, - active: { - approvalPolicyKnown: false, - sandboxPolicyKnown: false, - permissionProfileKnown: false, - serviceTierKnown: false, - model: null, - reasoningEffort: null, - collaborationMode: null, - serviceTier: null, - approvalsReviewer: null, - approvalPolicy: null, - sandboxPolicy: null, - activePermissionProfile: null, - }, - pending: { - model: { kind: "unchanged" }, - reasoningEffort: { kind: "unchanged" }, - permissionProfile: { kind: "unchanged" }, - approvalPolicy: { kind: "unchanged" }, - approvalsReviewer: { kind: "unchanged" }, - collaborationMode: unchangedCollaborationModeIntent(), - fastMode: { kind: "unchanged" }, - }, - tokenUsage: null, - rateLimit: null, - hasThreadTurns: false, - availableModels: [], - }; - return { - ...snapshot, - ...snapshotOverrides, - active: { - ...snapshot.active, - ...(active && "serviceTier" in active ? { serviceTierKnown: true } : {}), - ...active, - }, - pending: { - ...snapshot.pending, - ...pending, - }, - }; -} - -function snapshotConfig(snapshot: RuntimeSnapshot): RuntimeConfigSnapshot { - return runtimeConfigOrDefault(snapshot.runtimeConfig); -} - -function runtimeControls(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot = snapshotConfig(snapshot)) { - return resolveRuntimeControls(snapshot, config); -} - -function currentModel(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): string | null { - return runtimeControls(snapshot, config).model.effective; -} - -function currentReasoningEffort(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): string | null { - return runtimeControls(snapshot, config).reasoningEffort.effective; -} - -function currentServiceTier(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): string | null { - return runtimeControls(snapshot, config).serviceTier.effective; -} - -function autoReviewActive(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): boolean { - return runtimeControls(snapshot, config).autoReview.active; -} - -function fastModeActive(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): boolean { - return runtimeControls(snapshot, config).fastMode.active; -} - -function fastRuntimeServiceTierRequestValue(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): string { - return runtimeControls(snapshot, config).fastMode.serviceTierRequestValue; -} - -function supportedReasoningEfforts(snapshot: RuntimeSnapshot, config?: RuntimeConfigSnapshot): readonly string[] { - return runtimeControls(snapshot, config).supportedReasoningEfforts; -} - -function modelPendingIntentCases() { - return [ - { name: "unchanged", intent: unchangedRuntimeIntent() }, - { name: "set", intent: setRuntimeIntentValue("gpt-pending") }, - { name: "resetToConfig", intent: resetRuntimeIntentToConfig() }, - ] as const; -} - -function runtimeLayerCase(configured: string | null, active: string | null, pending: string): string { - return `configured=${configured ?? "none"} active=${active ?? "none"} pending=${pending}`; -} - -function runtimeConfigFixture(config: Record, layers: ConfigReadResult["layers"] = null): RuntimeConfigSnapshot { - return runtimeConfigSnapshotFromAppServerConfig({ - config: config as ConfigReadResult["config"], - origins: {}, - layers, - }); -} - -function configLayer(config: Record, profile: string | null): NonNullable[number] { - return { - name: { type: "user", file: "/home/me/.codex/config.toml", profile }, - version: "1", - config: config as NonNullable[number]["config"], - disabledReason: null, - }; -} - -function modelFixture(model: string): ModelMetadata { - return { - id: model, - model, - displayName: model, - description: "", - hidden: false, - supportedReasoningEfforts: [], - defaultReasoningEffort: "medium", - inputModalities: [], - serviceTiers: [], - defaultServiceTier: null, - isDefault: false, - }; -} diff --git a/tests/runtime/service-tier-state.test.ts b/tests/runtime/service-tier-state.test.ts deleted file mode 100644 index eea4821a..00000000 --- a/tests/runtime/service-tier-state.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ModelMetadata } from "../../src/domain/catalog/metadata"; -import { emptyRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../src/domain/runtime/config"; -import { unchangedCollaborationModeIntent } from "../../src/features/chat/domain/runtime/intent"; -import { resolveRuntimeControls } from "../../src/features/chat/domain/runtime/resolution"; -import type { RuntimeSnapshot } from "../../src/features/chat/domain/runtime/snapshot"; - -describe("service tier runtime state", () => { - it.each([ - { name: "built-in fast", serviceTier: "fast", serviceTiers: [], expected: true }, - { name: "built-in priority", serviceTier: "priority", serviceTiers: [], expected: true }, - { name: "catalog Fast tier", serviceTier: "catalog-fast", serviceTiers: [{ id: "catalog-fast", name: "Fast" }], expected: true }, - { name: "catalog Priority tier", serviceTier: "priority", serviceTiers: [{ id: "priority", name: "Priority" }], expected: false }, - { name: "catalog Flex tier", serviceTier: "flex", serviceTiers: [{ id: "flex", name: "Flex" }], expected: false }, - ])("recognizes $name without rejecting other tier ids", ({ serviceTier, serviceTiers, expected }) => { - expect(fastMode(serviceTier, serviceTiers)).toBe(expected); - }); -}); - -function fastMode(serviceTier: string | null, serviceTiers: ModelMetadata["serviceTiers"] = []): boolean { - const config: RuntimeConfigSnapshot = { ...emptyRuntimeConfigSnapshot(), model: "gpt-5.5" }; - return resolveRuntimeControls( - { - runtimeConfig: config, - activeThreadId: "thread", - active: { - approvalPolicyKnown: false, - sandboxPolicyKnown: false, - permissionProfileKnown: false, - serviceTierKnown: true, - model: "gpt-5.5", - reasoningEffort: null, - collaborationMode: "default", - serviceTier, - approvalsReviewer: null, - approvalPolicy: null, - sandboxPolicy: null, - activePermissionProfile: null, - }, - pending: { - model: { kind: "unchanged" }, - reasoningEffort: { kind: "unchanged" }, - permissionProfile: { kind: "unchanged" }, - approvalPolicy: { kind: "unchanged" }, - approvalsReviewer: { kind: "unchanged" }, - collaborationMode: unchangedCollaborationModeIntent(), - fastMode: { kind: "unchanged" }, - }, - tokenUsage: null, - rateLimit: null, - hasThreadTurns: false, - availableModels: [ - { - id: "gpt-5.5", - model: "gpt-5.5", - displayName: "GPT-5.5", - description: "", - hidden: false, - isDefault: true, - supportedReasoningEfforts: [], - defaultReasoningEffort: null, - inputModalities: [], - serviceTiers, - defaultServiceTier: null, - }, - ], - } satisfies RuntimeSnapshot, - config, - ).fastMode.active; -} diff --git a/tests/features/threads/obsidian/archive-export-destination.test.ts b/tests/shared/obsidian/vault-write-destination.test.ts similarity index 84% rename from tests/features/threads/obsidian/archive-export-destination.test.ts rename to tests/shared/obsidian/vault-write-destination.test.ts index 6e85ce66..ac920bbc 100644 --- a/tests/features/threads/obsidian/archive-export-destination.test.ts +++ b/tests/shared/obsidian/vault-write-destination.test.ts @@ -1,12 +1,12 @@ import type { Vault } from "obsidian"; import { describe, expect, it, vi } from "vitest"; -import { createObsidianArchiveExportDestination } from "../../../../src/features/threads/obsidian/archive-export-destination.obsidian"; +import { createObsidianVaultMarkdownDestination } from "../../../src/shared/obsidian/vault-write-destination.obsidian"; -describe("createObsidianArchiveExportDestination", () => { +describe("createObsidianVaultMarkdownDestination", () => { it("uses normalized Vault paths for existence checks, folder creation, and file writes", async () => { const vault = vaultMock(new Set(["Codex Archives/Café"])); - const destination = createObsidianArchiveExportDestination(vault); + const destination = createObsidianVaultMarkdownDestination(vault); await expect(destination.exists("//Codex\u00a0Archives//Cafe\u0301//")).resolves.toBe(true); await destination.createFolder("//Codex\u00a0Archives//New//");