mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(test): align tests with owning modules
This commit is contained in:
parent
760bdf3016
commit
c37832d0a3
21 changed files with 2186 additions and 2218 deletions
76
tests/app-server/protocol/runtime-config.test.ts
Normal file
76
tests/app-server/protocol/runtime-config.test.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||
return runtimeConfigSnapshotFromAppServerConfig({
|
||||
config: config as ConfigReadResult["config"],
|
||||
origins: {},
|
||||
layers: null,
|
||||
});
|
||||
}
|
||||
33
tests/domain/runtime/config.test.ts
Normal file
33
tests/domain/runtime/config.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
9
tests/domain/vault/paths.test.ts
Normal file
9
tests/domain/vault/paths.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Partial<Extract<TurnItem, { type: "commandExecution" }>>, "status"> & { status?: string };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
122
tests/features/chat/application/state/ui-state.test.ts
Normal file
122
tests/features/chat/application/state/ui-state.test.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
21
tests/features/chat/domain/runtime/labels.test.ts
Normal file
21
tests/features/chat/domain/runtime/labels.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
420
tests/features/chat/domain/runtime/resolution.test.ts
Normal file
420
tests/features/chat/domain/runtime/resolution.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
95
tests/features/chat/domain/runtime/state.test.ts
Normal file
95
tests/features/chat/domain/runtime/state.test.ts
Normal file
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
150
tests/features/chat/domain/runtime/support.ts
Normal file
150
tests/features/chat/domain/runtime/support.ts
Normal file
|
|
@ -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<Omit<RuntimeSnapshot, "active" | "pending">> {
|
||||
active?: Partial<RuntimeSnapshot["active"]>;
|
||||
pending?: Partial<RuntimeSnapshot["pending"]>;
|
||||
}
|
||||
|
||||
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<string>() },
|
||||
{ name: "set", intent: setRuntimeIntentValue("gpt-pending") },
|
||||
{ name: "resetToConfig", intent: resetRuntimeIntentToConfig<string>() },
|
||||
] 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<string, unknown>, layers: ConfigReadResult["layers"] = null): RuntimeConfigSnapshot {
|
||||
return runtimeConfigSnapshotFromAppServerConfig({
|
||||
config: config as ConfigReadResult["config"],
|
||||
origins: {},
|
||||
layers,
|
||||
});
|
||||
}
|
||||
|
||||
export function configLayer(config: Record<string, unknown>, profile: string | null): NonNullable<ConfigReadResult["layers"]>[number] {
|
||||
return {
|
||||
name: { type: "user", file: "/home/me/.codex/config.toml", profile },
|
||||
version: "1",
|
||||
config: config as NonNullable<ConfigReadResult["layers"]>[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,
|
||||
};
|
||||
}
|
||||
367
tests/features/chat/domain/runtime/thread-settings-patch.test.ts
Normal file
367
tests/features/chat/domain/runtime/thread-settings-patch.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ThreadStreamItem, { kind: "agent" }>>): Extract<ThreadStreamItem, { kind: "agent" }> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
37
tests/features/chat/domain/thread-stream/updates.test.ts
Normal file
37
tests/features/chat/domain/thread-stream/updates.test.ts
Normal file
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
163
tests/features/chat/presentation/runtime/status.test.ts
Normal file
163
tests/features/chat/presentation/runtime/status.test.ts
Normal file
|
|
@ -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%" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
466
tests/features/chat/presentation/thread-stream/layout.test.ts
Normal file
466
tests/features/chat/presentation/thread-stream/layout.test.ts
Normal file
|
|
@ -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"] } });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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//");
|
||||
Loading…
Reference in a new issue