mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Remove protocol thread cwd from panel state and operations. Require the immutable vault root wherever path presentation needs a workspace base.
476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
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";
|
|
|
|
const DEFAULT_WORKSPACE_ROOT = "/vault";
|
|
|
|
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, DEFAULT_WORKSPACE_ROOT);
|
|
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, DEFAULT_WORKSPACE_ROOT);
|
|
|
|
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, DEFAULT_WORKSPACE_ROOT);
|
|
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, DEFAULT_WORKSPACE_ROOT);
|
|
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, DEFAULT_WORKSPACE_ROOT);
|
|
|
|
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, DEFAULT_WORKSPACE_ROOT);
|
|
|
|
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, DEFAULT_WORKSPACE_ROOT);
|
|
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", DEFAULT_WORKSPACE_ROOT).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", DEFAULT_WORKSPACE_ROOT);
|
|
|
|
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, DEFAULT_WORKSPACE_ROOT);
|
|
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, DEFAULT_WORKSPACE_ROOT).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, DEFAULT_WORKSPACE_ROOT).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, DEFAULT_WORKSPACE_ROOT, 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, DEFAULT_WORKSPACE_ROOT).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", DEFAULT_WORKSPACE_ROOT).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"] } });
|
|
});
|
|
});
|