Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
3006 lines
88 KiB
TypeScript
3006 lines
88 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { normalizeAgentActivitySession } from "@tutti-os/agent-activity-core";
|
|
import type { WorkspaceAgentSessionDetailViewModel } from "../../workspaceAgentSessionDetailViewModel";
|
|
import {
|
|
projectAgentConversationVM,
|
|
reconcileProjectedAgentConversationVM
|
|
} from "./agentConversationProjection";
|
|
|
|
describe("projectAgentConversationVM", () => {
|
|
it("interleaves guidance with earlier and later assistant output in the same turn", () => {
|
|
const sourceItem = (
|
|
seq: number,
|
|
role: "user" | "assistant",
|
|
content: string
|
|
) => ({
|
|
id: seq,
|
|
workspaceId: "workspace-1",
|
|
agentSessionId: "session-1",
|
|
seq,
|
|
turnId: "turn-1",
|
|
eventId: `event-${seq}`,
|
|
actorType: role === "user" ? "user" : "agent",
|
|
actorId: role === "user" ? "user" : "session-1",
|
|
itemType: role === "user" ? "message.user" : "message.assistant",
|
|
role,
|
|
content,
|
|
occurredAtUnixMs: seq * 100
|
|
});
|
|
const initialUser = {
|
|
id: "user-1",
|
|
body: "Build the game",
|
|
turnId: "turn-1",
|
|
occurredAtUnixMs: 100,
|
|
sourceTimelineItems: [sourceItem(1, "user", "Build the game")]
|
|
};
|
|
const earlierAssistant = {
|
|
id: "assistant-1",
|
|
body: "I will inspect the workspace first.",
|
|
turnId: "turn-1",
|
|
occurredAtUnixMs: 200,
|
|
sourceTimelineItems: [
|
|
sourceItem(2, "assistant", "I will inspect the workspace first.")
|
|
]
|
|
};
|
|
const guidance = {
|
|
id: "user-2",
|
|
body: "Make it colorful",
|
|
turnId: "turn-1",
|
|
occurredAtUnixMs: 300,
|
|
sourceTimelineItems: [sourceItem(3, "user", "Make it colorful")]
|
|
};
|
|
const laterAssistant = {
|
|
id: "assistant-2",
|
|
body: "I will update the palette.",
|
|
turnId: "turn-1",
|
|
occurredAtUnixMs: 400,
|
|
sourceTimelineItems: [
|
|
sourceItem(4, "assistant", "I will update the palette.")
|
|
]
|
|
};
|
|
const agentItems = [
|
|
{ kind: "message" as const, message: earlierAssistant },
|
|
{ kind: "message" as const, message: laterAssistant }
|
|
];
|
|
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: initialUser,
|
|
userMessages: [initialUser, guidance],
|
|
agentMessages: [earlierAssistant, laterAssistant],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
rawAgentItems: agentItems,
|
|
agentItems
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
expect(
|
|
conversation.rows.flatMap((row) =>
|
|
row.kind === "message"
|
|
? row.messages.map((message) => message.body)
|
|
: []
|
|
)
|
|
).toEqual([
|
|
"Build the game",
|
|
"I will inspect the workspace first.",
|
|
"Make it colorful",
|
|
"I will update the palette."
|
|
]);
|
|
});
|
|
|
|
it("groups trailing tools while the session is still processing without appending summary rows", () => {
|
|
const detail = detailViewModel();
|
|
const conversation = projectAgentConversationVM(detail);
|
|
|
|
expect(conversation.rows.map((row) => row.kind)).toEqual([
|
|
"message",
|
|
"message",
|
|
"tool-group",
|
|
"processing"
|
|
]);
|
|
|
|
const toolRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "tool-group" }
|
|
> => row.kind === "tool-group"
|
|
);
|
|
expect(toolRows).toHaveLength(1);
|
|
expect(toolRows[0]?.grouped).toBe(true);
|
|
expect(toolRows[0]?.calls).toHaveLength(2);
|
|
expect(toolRows[0]?.entries.map((entry) => entry.kind)).toEqual([
|
|
"tool-call",
|
|
"thinking",
|
|
"tool-call"
|
|
]);
|
|
});
|
|
|
|
it("promotes a completed generated image beside its retained tool-call group", () => {
|
|
const imageCall = {
|
|
id: "call:image-1",
|
|
name: "Generate image",
|
|
toolName: "image_generation",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed" as const,
|
|
summary: "Generated image",
|
|
payload: {
|
|
input: {
|
|
prompt: "A friendly corgi"
|
|
},
|
|
output: {
|
|
savedPath: "/workspace/output/generated-corgi.png",
|
|
imageMimeType: "image/png"
|
|
}
|
|
}
|
|
};
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-image",
|
|
userMessage: { id: "user-image", body: "Generate a corgi" },
|
|
userMessages: [{ id: "user-image", body: "Generate a corgi" }],
|
|
agentMessages: [],
|
|
toolCalls: [imageCall],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-image",
|
|
toolCalls: [imageCall],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
expect(
|
|
conversation.rows.filter((row) => row.kind === "tool-group")
|
|
).toHaveLength(1);
|
|
expect(
|
|
conversation.rows.find((row) => row.kind === "generated-image")
|
|
).toMatchObject({
|
|
kind: "generated-image",
|
|
id: "generated-image:call:image-1",
|
|
turnId: "turn-image",
|
|
sourceCallId: "call:image-1",
|
|
uri: "/workspace/output/generated-corgi.png",
|
|
mimeType: "image/png",
|
|
prompt: "A friendly corgi"
|
|
});
|
|
});
|
|
|
|
it("does not promote an image before generation completes", () => {
|
|
const imageCall = {
|
|
id: "call:image-running",
|
|
name: "Generate image",
|
|
toolName: "image_generation",
|
|
callType: "tool",
|
|
status: "Running",
|
|
statusKind: "working" as const,
|
|
summary: "Generating image",
|
|
payload: {
|
|
input: {
|
|
prompt: "A friendly corgi"
|
|
},
|
|
output: {
|
|
savedPath: "/workspace/output/generated-corgi.png"
|
|
}
|
|
}
|
|
};
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-image-running",
|
|
userMessage: { id: "user-image", body: "Generate a corgi" },
|
|
userMessages: [{ id: "user-image", body: "Generate a corgi" }],
|
|
agentMessages: [],
|
|
toolCalls: [imageCall],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-image",
|
|
toolCalls: [imageCall],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
expect(
|
|
conversation.rows.some((row) => row.kind === "generated-image")
|
|
).toBe(false);
|
|
});
|
|
|
|
it("removes final Markdown that repeats a generated-image artifact", () => {
|
|
const savedPath = "/workspace/output/generated-corgi.png";
|
|
const imageCall = {
|
|
id: "call:image-deduplicated",
|
|
name: "Generate image",
|
|
toolName: "image_generation",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed" as const,
|
|
summary: "Generated image",
|
|
payload: {
|
|
input: {
|
|
prompt: "A friendly corgi"
|
|
},
|
|
output: {
|
|
savedPath,
|
|
content: [
|
|
{
|
|
type: "image",
|
|
uri: savedPath,
|
|
mimeType: "image/png"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
};
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-image-deduplicated",
|
|
userMessage: { id: "user-image", body: "Generate a corgi" },
|
|
userMessages: [{ id: "user-image", body: "Generate a corgi" }],
|
|
agentMessages: [
|
|
{
|
|
id: "assistant-image",
|
|
body: `Done.\n\n`
|
|
}
|
|
],
|
|
toolCalls: [imageCall],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-image",
|
|
toolCalls: [imageCall],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-image",
|
|
body: `Done.\n\n`
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
expect(
|
|
conversation.rows.filter((row) => row.kind === "generated-image")
|
|
).toHaveLength(1);
|
|
expect(
|
|
conversation.rows
|
|
.filter(
|
|
(row): row is Extract<typeof row, { kind: "message" }> =>
|
|
row.kind === "message" && row.speaker === "assistant"
|
|
)
|
|
.flatMap((row) => row.messages.map((message) => message.body))
|
|
).toEqual(["Done."]);
|
|
});
|
|
|
|
it("keeps assistant Markdown images that are not generated artifacts", () => {
|
|
const generatedPath = "/workspace/output/generated-corgi.png";
|
|
const otherPath = "/workspace/reference/reference-corgi.png";
|
|
const imageCall = {
|
|
id: "call:image-keep-other",
|
|
name: "Generate image",
|
|
toolName: "image_generation",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed" as const,
|
|
summary: "Generated image",
|
|
payload: {
|
|
output: {
|
|
savedPath: generatedPath,
|
|
content: [
|
|
{
|
|
type: "image",
|
|
uri: generatedPath,
|
|
mimeType: "image/png"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
};
|
|
const body = `\n\n`;
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-image-keep-other",
|
|
userMessage: { id: "user-image", body: "Generate a corgi" },
|
|
userMessages: [{ id: "user-image", body: "Generate a corgi" }],
|
|
agentMessages: [{ id: "assistant-image", body }],
|
|
toolCalls: [imageCall],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-image",
|
|
toolCalls: [imageCall],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-image", body }
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
expect(
|
|
conversation.rows
|
|
.filter(
|
|
(row): row is Extract<typeof row, { kind: "message" }> =>
|
|
row.kind === "message" && row.speaker === "assistant"
|
|
)
|
|
.flatMap((row) => row.messages.map((message) => message.body))
|
|
).toEqual([``]);
|
|
});
|
|
|
|
const tailChainConversation = (latestTail: {
|
|
status: string;
|
|
statusKind: "completed" | "working" | "waiting";
|
|
}) => {
|
|
const calls = [
|
|
{
|
|
id: "call:1",
|
|
name: "Read web page",
|
|
toolName: "web_fetch",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed" as const,
|
|
summary: "https://example.com/a",
|
|
payload: null
|
|
},
|
|
{
|
|
id: "call:2",
|
|
name: "Read web page",
|
|
toolName: "web_fetch",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed" as const,
|
|
summary: "https://example.com/b",
|
|
payload: null
|
|
},
|
|
{
|
|
id: "call:3",
|
|
name: "Read web page",
|
|
toolName: "web_fetch",
|
|
callType: "tool",
|
|
status: latestTail.status,
|
|
statusKind: latestTail.statusKind,
|
|
summary: "https://example.com/c",
|
|
payload: null
|
|
}
|
|
];
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
activeTurn: {
|
|
agentSessionId: "session-1",
|
|
outcome: null,
|
|
origin: "user_prompt",
|
|
phase: "running",
|
|
settledAtUnixMs: null,
|
|
startedAtUnixMs: 1,
|
|
turnId: "turn-2",
|
|
updatedAtUnixMs: 10
|
|
},
|
|
activeTurnId: "turn-2"
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Read pages" },
|
|
userMessages: [{ id: "user-1", body: "Read pages" }],
|
|
agentMessages: [],
|
|
toolCalls: calls,
|
|
toolCallCount: calls.length,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-1",
|
|
toolCalls: calls,
|
|
toolCallCount: calls.length,
|
|
hasFailedToolCall: false
|
|
}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
);
|
|
return conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "tool-group" }
|
|
> => row.kind === "tool-group"
|
|
);
|
|
};
|
|
|
|
it("keeps completed tail tools in one stable group", () => {
|
|
const toolRows = tailChainConversation({
|
|
status: "Completed",
|
|
statusKind: "completed"
|
|
});
|
|
|
|
expect(toolRows).toHaveLength(1);
|
|
expect(toolRows[0]?.calls.map((call) => call.id)).toEqual([
|
|
"call:1",
|
|
"call:2",
|
|
"call:3"
|
|
]);
|
|
expect(toolRows[0]?.grouped).toBe(true);
|
|
});
|
|
|
|
it("keeps the latest running tool in the same stable group", () => {
|
|
const toolRows = tailChainConversation({
|
|
status: "Running",
|
|
statusKind: "working"
|
|
});
|
|
|
|
expect(toolRows).toHaveLength(1);
|
|
expect(toolRows[0]?.calls.map((call) => call.id)).toEqual([
|
|
"call:1",
|
|
"call:2",
|
|
"call:3"
|
|
]);
|
|
expect(toolRows[0]?.calls.at(-1)?.statusKind).toBe("working");
|
|
expect(toolRows[0]?.grouped).toBe(true);
|
|
});
|
|
|
|
it("keeps Codex transport retry notices out of the working processing label", () => {
|
|
const base = detailViewModel();
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: base.turns.map((turn) => ({
|
|
...turn,
|
|
agentItems: [
|
|
...turn.agentItems,
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-retry-1",
|
|
body: "Codex connection interrupted. Reconnecting...",
|
|
systemNotice: {
|
|
noticeKind: "transport_retry",
|
|
severity: "warning",
|
|
title: "Codex connection interrupted. Reconnecting...",
|
|
detail: "Handled error during turn: Reconnecting... 1/5",
|
|
retryable: true
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}))
|
|
})
|
|
);
|
|
|
|
const processing = conversation.rows.find(
|
|
(row) => row.kind === "processing"
|
|
);
|
|
expect(processing?.kind).toBe("processing");
|
|
expect(processing).not.toHaveProperty("noticeKind");
|
|
});
|
|
|
|
it("merges only adjacent Codex transport retry notice rows", () => {
|
|
const retryNotice = (id: string, detail: string, sourceId: number) => ({
|
|
id,
|
|
body: "Codex connection interrupted. Reconnecting...",
|
|
occurredAtUnixMs: sourceId,
|
|
sourceTimelineItems: [
|
|
{
|
|
id: sourceId,
|
|
agentSessionId: "session-1",
|
|
eventId: `event-${sourceId}`,
|
|
actorType: "agent",
|
|
actorId: "codex",
|
|
itemType: "message",
|
|
role: "assistant",
|
|
payload: {
|
|
kind: "agent_system_notice",
|
|
noticeKind: "transport_retry"
|
|
}
|
|
}
|
|
],
|
|
systemNotice: {
|
|
noticeKind: "transport_retry",
|
|
severity: "warning",
|
|
title: "Codex connection interrupted. Reconnecting...",
|
|
detail,
|
|
retryable: true
|
|
}
|
|
});
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Ship it" },
|
|
userMessages: [{ id: "user-1", body: "Ship it" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: retryNotice(
|
|
"assistant-retry-1",
|
|
"Handled error during turn: Reconnecting... 1/5",
|
|
11
|
|
)
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: retryNotice(
|
|
"assistant-retry-2",
|
|
"Handled error during turn: Reconnecting... 2/5",
|
|
12
|
|
)
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-1", body: "Still working" }
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: retryNotice(
|
|
"assistant-retry-3",
|
|
"Handled error during turn: Reconnecting... 3/5",
|
|
13
|
|
)
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
|
|
expect(assistantRows).toHaveLength(3);
|
|
expect(assistantRows.map((row) => row.messages[0]?.id)).toEqual([
|
|
"assistant-retry-1",
|
|
"assistant-1",
|
|
"assistant-retry-3"
|
|
]);
|
|
expect(
|
|
assistantRows[0]?.messages[0]?.sourceTimelineItems?.map((item) => item.id)
|
|
).toEqual([11, 12]);
|
|
expect(assistantRows[0]?.messages[0]?.systemNotice?.detail).toBe(
|
|
"Handled error during turn: Reconnecting... 2/5"
|
|
);
|
|
expect(assistantRows[2]?.messages[0]?.sourceTimelineItems?.[0]?.id).toBe(
|
|
13
|
|
);
|
|
});
|
|
|
|
it("drops redundant Codex error warning notices when a visible error is present", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Ship it" },
|
|
userMessages: [{ id: "user-1", body: "Ship it" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-warning-1",
|
|
body: "Codex reported an error.",
|
|
systemNotice: {
|
|
noticeKind: "warning",
|
|
severity: "warning",
|
|
title: "Codex reported an error.",
|
|
detail: "stream disconnected",
|
|
retryable: false
|
|
}
|
|
}
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-visible-error-1",
|
|
body: "Codex request failed.",
|
|
visibleError: {
|
|
code: "request_failed",
|
|
phase: "turn",
|
|
provider: "codex",
|
|
detail: "stream disconnected",
|
|
retryable: false
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
expect(assistantRows).toHaveLength(1);
|
|
expect(assistantRows[0]?.messages[0]?.visibleError?.provider).toBe("codex");
|
|
expect(assistantRows[0]?.messages[0]?.systemNotice).toBeNull();
|
|
});
|
|
|
|
it("drops Codex skills context budget runtime warning notices", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Start" },
|
|
userMessages: [{ id: "user-1", body: "Start" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-warning-1",
|
|
body: "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter.",
|
|
systemNotice: {
|
|
noticeKind: "warning",
|
|
severity: "warning",
|
|
source: "runtime",
|
|
title:
|
|
"Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter.",
|
|
detail:
|
|
"Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter.",
|
|
retryable: null
|
|
}
|
|
}
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-1",
|
|
body: "I will inspect the logs."
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
expect(assistantRows).toHaveLength(1);
|
|
expect(assistantRows[0]?.messages[0]?.id).toBe("assistant-1");
|
|
expect(assistantRows[0]?.messages[0]?.systemNotice).toBeNull();
|
|
});
|
|
|
|
it("keeps processing when normalized rows cannot host canonical live timing", () => {
|
|
const baseDetail = detailViewModel();
|
|
const activeTurn = {
|
|
agentSessionId: "session-1",
|
|
turnId: "turn-1",
|
|
phase: "running" as const,
|
|
origin: "user_prompt" as const,
|
|
startedAtUnixMs: 1,
|
|
updatedAtUnixMs: 10
|
|
};
|
|
const runtimeWarning =
|
|
"Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter.";
|
|
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...baseDetail.session,
|
|
activeTurnId: "turn-1",
|
|
activeTurn,
|
|
latestTurn: activeTurn
|
|
},
|
|
sessionTurns: [activeTurn],
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: null,
|
|
userMessages: [],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-warning-1",
|
|
body: runtimeWarning,
|
|
systemNotice: {
|
|
noticeKind: "warning",
|
|
severity: "warning",
|
|
source: "runtime",
|
|
title: runtimeWarning,
|
|
detail: runtimeWarning,
|
|
retryable: null
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: true
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows).toEqual([
|
|
expect.objectContaining({
|
|
kind: "processing",
|
|
id: "processing:turn-1",
|
|
turnId: "turn-1"
|
|
})
|
|
]);
|
|
});
|
|
|
|
it("drops Codex model metadata fallback runtime warning notices", () => {
|
|
const metadataWarning =
|
|
"Model metadata for `minimax/minimax-m2.5` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.";
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "你好" },
|
|
userMessages: [{ id: "user-1", body: "你好" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-warning-1",
|
|
body: metadataWarning,
|
|
systemNotice: {
|
|
noticeKind: "warning",
|
|
severity: "warning",
|
|
source: "runtime",
|
|
title: metadataWarning,
|
|
detail: metadataWarning,
|
|
retryable: null
|
|
}
|
|
}
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-1",
|
|
body: "你好!有什么我可以帮你的吗?"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
expect(assistantRows).toHaveLength(1);
|
|
expect(assistantRows[0]?.messages[0]?.id).toBe("assistant-1");
|
|
expect(assistantRows[0]?.messages[0]?.systemNotice).toBeNull();
|
|
});
|
|
|
|
it("groups bridge thinking inside completed tool disclosures", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session
|
|
},
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.map((row) => row.kind)).toEqual([
|
|
"message",
|
|
"message",
|
|
"tool-group",
|
|
"turn-summary"
|
|
]);
|
|
|
|
const groupedRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "tool-group" }
|
|
> => row.kind === "tool-group" && row.grouped
|
|
);
|
|
|
|
expect(groupedRow?.entries.map((entry) => entry.kind)).toEqual([
|
|
"tool-call",
|
|
"thinking",
|
|
"tool-call"
|
|
]);
|
|
expect(groupedRow?.entries[1]?.kind).toBe("thinking");
|
|
expect(
|
|
groupedRow?.entries[1]?.kind === "thinking"
|
|
? groupedRow.entries[1].thinking.body
|
|
: null
|
|
).toBe("Need to inspect before editing.");
|
|
});
|
|
|
|
it("places each canonical file summary directly after its settled turn", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "First" },
|
|
userMessages: [{ id: "user-1", body: "First" }],
|
|
agentMessages: [{ id: "assistant-1", body: "Done first" }],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-1", body: "Done first" }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: "turn-2",
|
|
userMessage: { id: "user-2", body: "Second" },
|
|
userMessages: [{ id: "user-2", body: "Second" }],
|
|
agentMessages: [{ id: "assistant-2", body: "Done second" }],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-2", body: "Done second" }
|
|
}
|
|
]
|
|
}
|
|
],
|
|
sessionTurns: [
|
|
{
|
|
agentSessionId: "session-1",
|
|
turnId: "turn-1",
|
|
phase: "settled",
|
|
origin: "user_prompt",
|
|
outcome: "completed",
|
|
startedAtUnixMs: 1,
|
|
settledAtUnixMs: 10,
|
|
updatedAtUnixMs: 10,
|
|
fileChanges: {
|
|
files: [{ path: "/workspace/demo/first.ts", change: "added" }]
|
|
}
|
|
},
|
|
{
|
|
agentSessionId: "session-1",
|
|
turnId: "turn-2",
|
|
phase: "settled",
|
|
origin: "user_prompt",
|
|
outcome: "completed",
|
|
startedAtUnixMs: 11,
|
|
settledAtUnixMs: 20,
|
|
updatedAtUnixMs: 20,
|
|
fileChanges: {
|
|
files: [{ path: "/workspace/demo/second.ts", change: "deleted" }]
|
|
}
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.map((row) => `${row.turnId}:${row.kind}`)).toEqual(
|
|
[
|
|
"turn-1:message",
|
|
"turn-1:message",
|
|
"turn-1:turn-summary",
|
|
"turn-2:message",
|
|
"turn-2:message",
|
|
"turn-2:turn-summary"
|
|
]
|
|
);
|
|
});
|
|
|
|
it("uses the session cwd for turn summaries when no workspace root is selected", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
workspaceRoot: null,
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const summary = conversation.rows.find(
|
|
(row) => row.kind === "turn-summary"
|
|
);
|
|
expect(summary).toMatchObject({
|
|
kind: "turn-summary",
|
|
files: expect.arrayContaining([
|
|
expect.objectContaining({ path: "/workspace/demo/src/App.tsx" })
|
|
])
|
|
});
|
|
});
|
|
|
|
it("derives pending approval and ask-user prompts from typed tool rows", () => {
|
|
const detail = detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Ship it" },
|
|
userMessages: [{ id: "user-1", body: "Ship it" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 2,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-1",
|
|
toolCalls: [
|
|
{
|
|
id: "call:approval-1",
|
|
name: "Run command",
|
|
toolName: "Approval",
|
|
callType: "approval",
|
|
status: "waiting_approval",
|
|
statusKind: "waiting",
|
|
summary: "Approval required",
|
|
payload: {
|
|
input: {
|
|
requestId: "approval-request-1",
|
|
options: [
|
|
{
|
|
id: "allow_once",
|
|
label: "Legacy allow label",
|
|
name: "Allow once",
|
|
kind: "allow_once"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
id: "call:ask-1",
|
|
name: "Ask user",
|
|
toolName: "AskUserQuestion",
|
|
callType: "tool",
|
|
status: "waiting_input",
|
|
statusKind: "waiting",
|
|
summary: "",
|
|
payload: {
|
|
input: {
|
|
requestId: "ask-request-1",
|
|
questions: [
|
|
{
|
|
id: "approach",
|
|
header: "Approach",
|
|
question: "Which path should we take?",
|
|
options: [
|
|
{
|
|
label: "Typed renderer",
|
|
description: "Keep going"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
],
|
|
toolCallCount: 2,
|
|
hasFailedToolCall: false
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
const conversation = projectAgentConversationVM(detail);
|
|
|
|
expect("pendingApproval" in conversation).toBe(false);
|
|
expect("pendingInteractivePrompt" in conversation).toBe(false);
|
|
expect(
|
|
conversation.rows.flatMap((row) =>
|
|
row.kind === "tool-group" ? row.calls : []
|
|
)
|
|
).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "Approval" })
|
|
])
|
|
);
|
|
});
|
|
|
|
it("hides a pending approval nested inside a delegated subagent's steps", () => {
|
|
const detail = detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Delegate it" },
|
|
userMessages: [{ id: "user-1", body: "Delegate it" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-1",
|
|
toolCalls: [
|
|
{
|
|
id: "call:task-1",
|
|
name: "Run subagent",
|
|
toolName: "Task",
|
|
callType: "tool",
|
|
status: "in_progress",
|
|
statusKind: "working",
|
|
summary: "Delegating work",
|
|
payload: {
|
|
metadata: {
|
|
steps: [
|
|
{
|
|
toolUseId: "step-approval-1",
|
|
toolName: "Bash",
|
|
callType: "approval",
|
|
status: "waiting_approval",
|
|
toolInput: {
|
|
requestId: "nested-approval-1",
|
|
options: [
|
|
{
|
|
id: "allow_once",
|
|
name: "Allow once",
|
|
kind: "allow_once"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
const conversation = projectAgentConversationVM(detail);
|
|
|
|
expect("pendingApproval" in conversation).toBe(false);
|
|
const taskCall = conversation.rows
|
|
.flatMap((row) => (row.kind === "tool-group" ? row.calls : []))
|
|
.find((call) => call.toolName === "Task");
|
|
expect(taskCall?.task?.steps).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
["working", "working"],
|
|
["completed", "completed"],
|
|
["failed", "failed"]
|
|
] as const)(
|
|
"hides %s approval tool calls without hiding neighboring tools",
|
|
(status, statusKind) => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Write it" },
|
|
userMessages: [{ id: "user-1", body: "Write it" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 2,
|
|
hasFailedToolCall: statusKind === "failed",
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-1",
|
|
toolCalls: [
|
|
{
|
|
id: "call:write-1",
|
|
name: "Write file",
|
|
toolName: "Write",
|
|
callType: "tool",
|
|
status: "completed",
|
|
statusKind: "completed",
|
|
summary: "hello.md",
|
|
payload: null
|
|
},
|
|
{
|
|
id: `call:approval-${status}`,
|
|
name: "Approval",
|
|
toolName: "Approval",
|
|
callType: "approval",
|
|
status,
|
|
statusKind,
|
|
summary: "/workspace/hello.md",
|
|
payload: null
|
|
}
|
|
],
|
|
toolCallCount: 2,
|
|
hasFailedToolCall: statusKind === "failed"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
);
|
|
|
|
const visibleCalls = conversation.rows.flatMap((row) =>
|
|
row.kind === "tool-group" ? row.calls : []
|
|
);
|
|
expect(visibleCalls.map((call) => call.toolName)).toEqual(["Write"]);
|
|
}
|
|
);
|
|
|
|
it("surfaces a pending ask-user prompt nested inside a delegated subagent's steps", () => {
|
|
const detail = detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Delegate it" },
|
|
userMessages: [{ id: "user-1", body: "Delegate it" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-1",
|
|
toolCalls: [
|
|
{
|
|
id: "call:task-1",
|
|
name: "Run subagent",
|
|
toolName: "Task",
|
|
callType: "tool",
|
|
status: "in_progress",
|
|
statusKind: "working",
|
|
summary: "Delegating work",
|
|
payload: {
|
|
metadata: {
|
|
steps: [
|
|
{
|
|
toolUseId: "step-ask-1",
|
|
toolName: "AskUserQuestion",
|
|
name: "Ask user",
|
|
callType: "tool",
|
|
status: "waiting_input",
|
|
toolInput: {
|
|
requestId: "nested-ask-1",
|
|
questions: [
|
|
{
|
|
id: "approach",
|
|
header: "Approach",
|
|
question: "Which path should we take?",
|
|
options: [
|
|
{
|
|
label: "Typed renderer",
|
|
description: "Keep going"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
const conversation = projectAgentConversationVM(detail);
|
|
|
|
expect("pendingInteractivePrompt" in conversation).toBe(false);
|
|
});
|
|
|
|
it("carries runtime exit-plan options through the pending interactive prompt", () => {
|
|
const detail = detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Plan it" },
|
|
userMessages: [{ id: "user-1", body: "Plan it" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-1",
|
|
toolCalls: [
|
|
{
|
|
id: "call:plan-1",
|
|
name: "Exit plan mode",
|
|
toolName: "Approval",
|
|
callType: "approval",
|
|
status: "waiting_approval",
|
|
statusKind: "waiting",
|
|
summary: "",
|
|
payload: {
|
|
input: {
|
|
requestId: "plan-request-1",
|
|
toolCall: {
|
|
kind: "switch_mode",
|
|
title: "Exit plan mode"
|
|
},
|
|
options: [
|
|
{
|
|
optionId: "acceptEdits",
|
|
name: "Yes, and auto-accept edits",
|
|
kind: "acceptEdits"
|
|
},
|
|
{
|
|
optionId: "auto",
|
|
name: "Yes, and use auto mode",
|
|
kind: "auto"
|
|
},
|
|
{
|
|
optionId: "plan",
|
|
name: "No, keep planning",
|
|
kind: "plan"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
],
|
|
toolCallCount: 1,
|
|
hasFailedToolCall: false
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
const conversation = projectAgentConversationVM(detail);
|
|
|
|
expect("pendingInteractivePrompt" in conversation).toBe(false);
|
|
});
|
|
|
|
it("does not append the processing row when canonical detail suppresses it", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it("preserves the latest transcript turn as processing fallback identity", () => {
|
|
const firstTurn = detailViewModel().turns[0]!;
|
|
const secondTurn = {
|
|
id: "turn-2",
|
|
userMessage: {
|
|
id: "user-2",
|
|
body: "Follow-up request",
|
|
turnId: "turn-2"
|
|
},
|
|
userMessages: [
|
|
{ id: "user-2", body: "Follow-up request", turnId: "turn-2" }
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
};
|
|
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [firstTurn, secondTurn],
|
|
showProcessingIndicator: true
|
|
})
|
|
);
|
|
|
|
const processing = conversation.rows.find(
|
|
(row) => row.kind === "processing"
|
|
);
|
|
|
|
expect(processing).toEqual(
|
|
expect.objectContaining({
|
|
id: "processing:turn-2",
|
|
turnId: "turn-2"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("lets latest-turn semantic progress suppress legacy processing fallback", () => {
|
|
const firstTurn = detailViewModel().turns[0]!;
|
|
const compactNotice = compactNoticeMessage("turn-2", "running");
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
firstTurn,
|
|
{
|
|
id: "turn-2",
|
|
userMessage: {
|
|
id: "user-2",
|
|
body: "Follow-up request",
|
|
turnId: "turn-2"
|
|
},
|
|
userMessages: [
|
|
{ id: "user-2", body: "Follow-up request", turnId: "turn-2" }
|
|
],
|
|
agentMessages: [compactNotice],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [{ kind: "message", message: compactNotice }]
|
|
}
|
|
],
|
|
showProcessingIndicator: true
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it("scopes transient processing to the canonical active turn", () => {
|
|
const baseDetail = detailViewModel();
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...baseDetail.session,
|
|
activeTurnId: "turn-2"
|
|
},
|
|
turns: [
|
|
baseDetail.turns[0]!,
|
|
{
|
|
id: "turn-2",
|
|
userMessage: null,
|
|
userMessages: [],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: true
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.find((row) => row.kind === "processing")).toEqual(
|
|
expect.objectContaining({
|
|
id: "processing:turn-2",
|
|
turnId: "turn-2"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("uses canonical live turn timing instead of generic processing", () => {
|
|
const baseDetail = detailViewModel();
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...baseDetail.session,
|
|
activeTurnId: "turn-1"
|
|
},
|
|
sessionTurns: [
|
|
{
|
|
agentSessionId: "session-1",
|
|
origin: "user_prompt",
|
|
phase: "running",
|
|
startedAtUnixMs: 5_000,
|
|
turnId: "turn-1",
|
|
updatedAtUnixMs: 6_000
|
|
}
|
|
],
|
|
showProcessingIndicator: true
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it("uses active semantic progress instead of appending generic processing", () => {
|
|
const baseDetail = detailViewModel();
|
|
const baseTurn = baseDetail.turns[0]!;
|
|
const compactNotice = compactNoticeMessage("turn-1", "running");
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...baseDetail.session,
|
|
activeTurnId: "turn-1"
|
|
},
|
|
turns: [
|
|
{
|
|
...baseTurn,
|
|
agentMessages: [compactNotice],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
agentItems: [{ kind: "message", message: compactNotice }]
|
|
}
|
|
],
|
|
showProcessingIndicator: true
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
|
|
false
|
|
);
|
|
expect(
|
|
conversation.rows.some(
|
|
(row) =>
|
|
row.kind === "message" &&
|
|
row.messages.some(
|
|
(message) => message.presentationKind === "specific-progress"
|
|
)
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each(["completed", "failed", "canceled"] as const)(
|
|
"fails open to generic processing after compact is %s",
|
|
(commandStatus) => {
|
|
const baseTurn = detailViewModel().turns[0]!;
|
|
const compactNotice = compactNoticeMessage("turn-1", commandStatus);
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
...baseTurn,
|
|
agentMessages: [compactNotice],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
agentItems: [{ kind: "message", message: compactNotice }]
|
|
}
|
|
],
|
|
showProcessingIndicator: true
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
|
|
true
|
|
);
|
|
}
|
|
);
|
|
|
|
it("does not let progress from an older turn suppress current processing", () => {
|
|
const baseDetail = detailViewModel();
|
|
const firstTurn = baseDetail.turns[0]!;
|
|
const compactNotice = compactNoticeMessage("turn-1", "running");
|
|
const secondTurn = {
|
|
id: "turn-2",
|
|
userMessage: { id: "user-2", body: "Follow-up", turnId: "turn-2" },
|
|
userMessages: [{ id: "user-2", body: "Follow-up", turnId: "turn-2" }],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
};
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...baseDetail.session,
|
|
activeTurnId: "turn-2"
|
|
},
|
|
turns: [
|
|
{
|
|
...firstTurn,
|
|
agentMessages: [compactNotice],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
agentItems: [{ kind: "message", message: compactNotice }]
|
|
},
|
|
secondTurn
|
|
],
|
|
showProcessingIndicator: true
|
|
})
|
|
);
|
|
|
|
expect(conversation.rows.find((row) => row.kind === "processing")).toEqual(
|
|
expect.objectContaining({ turnId: "turn-2" })
|
|
);
|
|
});
|
|
|
|
it("keeps Edit and Write tool calls as standalone rows when avoidGroupingEdits is enabled", () => {
|
|
const detail = detailViewModel();
|
|
|
|
const conversation = projectAgentConversationVM(detail, {
|
|
avoidGroupingEdits: true
|
|
});
|
|
|
|
expect(conversation.rows.map((row) => row.kind)).toEqual([
|
|
"message",
|
|
"message",
|
|
"tool-group",
|
|
"message",
|
|
"tool-group",
|
|
"processing"
|
|
]);
|
|
|
|
const toolRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "tool-group" }
|
|
> => row.kind === "tool-group"
|
|
);
|
|
expect(toolRows).toHaveLength(2);
|
|
expect(toolRows.map((row) => row.grouped)).toEqual([true, false]);
|
|
expect(
|
|
conversation.rows.some(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> =>
|
|
row.kind === "message" &&
|
|
row.speaker === "assistant" &&
|
|
row.thinking.some(
|
|
(thinking) => thinking.body === "Need to inspect before editing."
|
|
)
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("merges adjacent assistant message rows within the same turn when no boundary item separates them", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Launch the app" },
|
|
userMessages: [{ id: "user-1", body: "Launch the app" }],
|
|
agentMessages: [
|
|
{ id: "assistant-1", body: "现在可直接访问:`http://" },
|
|
{ id: "assistant-2", body: "0.0.0.0:4173`" }
|
|
],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-1",
|
|
body: "现在可直接访问:`http://"
|
|
}
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-2",
|
|
body: "0.0.0.0:4173`",
|
|
statusKind: "working"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
|
|
expect(assistantRows).toHaveLength(1);
|
|
expect(assistantRows[0]?.messages.map((message) => message.body)).toEqual([
|
|
"现在可直接访问:`http://0.0.0.0:4173`"
|
|
]);
|
|
});
|
|
|
|
it("keeps a line boundary when merging settled adjacent assistant messages so Markdown fences never fuse", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Explain the fix" },
|
|
userMessages: [{ id: "user-1", body: "Explain the fix" }],
|
|
agentMessages: [
|
|
{ id: "assistant-1", body: "```ts\nconst x = 1;\n```" },
|
|
{ id: "assistant-2", body: "现在运行 pnpm test" }
|
|
],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-1", body: "```ts\nconst x = 1;\n```" }
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-2",
|
|
body: "现在运行 pnpm test",
|
|
statusKind: "completed"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
|
|
expect(assistantRows).toHaveLength(1);
|
|
expect(assistantRows[0]?.messages.map((message) => message.body)).toEqual([
|
|
"```ts\nconst x = 1;\n```\n现在运行 pnpm test"
|
|
]);
|
|
});
|
|
|
|
it("keeps a line boundary when merging settled adjacent messages so list markers stay on their own lines", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "List the steps" },
|
|
userMessages: [{ id: "user-1", body: "List the steps" }],
|
|
agentMessages: [
|
|
{ id: "assistant-1", body: "- step one\n- step two" },
|
|
{ id: "assistant-2", body: "- step three" }
|
|
],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-1",
|
|
body: "- step one\n- step two"
|
|
}
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-2",
|
|
body: "- step three",
|
|
statusKind: "completed"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
|
|
expect(assistantRows).toHaveLength(1);
|
|
expect(assistantRows[0]?.messages.map((message) => message.body)).toEqual([
|
|
"- step one\n- step two\n- step three"
|
|
]);
|
|
});
|
|
|
|
it("merges adjacent assistant messages without inserting a boundary when one side already provides a newline", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Go" },
|
|
userMessages: [{ id: "user-1", body: "Go" }],
|
|
agentMessages: [
|
|
{ id: "assistant-1", body: "First line.\n" },
|
|
{ id: "assistant-2", body: "Second line." }
|
|
],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-1", body: "First line.\n" }
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-2",
|
|
body: "Second line.",
|
|
statusKind: "completed"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
|
|
expect(assistantRows).toHaveLength(1);
|
|
expect(assistantRows[0]?.messages.map((message) => message.body)).toEqual([
|
|
"First line.\nSecond line."
|
|
]);
|
|
});
|
|
|
|
it("does not keep a previous working status when the merged follow-up carries no status", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Go" },
|
|
userMessages: [{ id: "user-1", body: "Go" }],
|
|
agentMessages: [
|
|
{ id: "assistant-1", body: "Part one" },
|
|
{ id: "assistant-2", body: "Part two" }
|
|
],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-1",
|
|
body: "Part one",
|
|
statusKind: "working"
|
|
}
|
|
},
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-2", body: "Part two" }
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
|
|
expect(assistantRows).toHaveLength(1);
|
|
const mergedMessage = assistantRows[0]?.messages[0];
|
|
expect(mergedMessage?.body).toBe("Part one\nPart two");
|
|
expect(mergedMessage?.statusKind).toBeNull();
|
|
});
|
|
|
|
it("marks user text and each settled turn's latest assistant text reply as copyable", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
activeTurn: null,
|
|
activeTurnId: null,
|
|
latestTurn: {
|
|
agentSessionId: "session-1",
|
|
outcome: "completed",
|
|
origin: "user_prompt",
|
|
phase: "settled",
|
|
settledAtUnixMs: 10,
|
|
startedAtUnixMs: 1,
|
|
turnId: "turn-1",
|
|
updatedAtUnixMs: 10
|
|
}
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "First request" },
|
|
userMessages: [{ id: "user-1", body: "First request" }],
|
|
agentMessages: [{ id: "assistant-1", body: "Older answer" }],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-1", body: "Older answer" }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: "turn-2",
|
|
userMessage: { id: "user-2", body: "Second request" },
|
|
userMessages: [{ id: "user-2", body: "Second request" }],
|
|
agentMessages: [{ id: "assistant-2", body: "Latest answer" }],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-2", body: "Latest answer" }
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const messageRows = conversation.rows.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message"
|
|
);
|
|
const userMessages = messageRows
|
|
.filter((row) => row.speaker === "user")
|
|
.flatMap((row) => row.messages);
|
|
const assistantMessages = messageRows
|
|
.filter((row) => row.speaker === "assistant")
|
|
.flatMap((row) => row.messages);
|
|
|
|
expect(userMessages.map((message) => message.copyText)).toEqual([
|
|
"First request",
|
|
"Second request"
|
|
]);
|
|
expect(
|
|
assistantMessages.map((message) => ({
|
|
id: message.id,
|
|
copyText: message.copyText ?? null,
|
|
isTurnFinalText: message.isTurnFinalText === true
|
|
}))
|
|
).toEqual([
|
|
{
|
|
id: "assistant-1",
|
|
copyText: "Older answer",
|
|
isTurnFinalText: true
|
|
},
|
|
{
|
|
id: "assistant-2",
|
|
copyText: "Latest answer",
|
|
isTurnFinalText: true
|
|
}
|
|
]);
|
|
});
|
|
|
|
it("marks prior turn assistant replies copyable while the latest turn is still working", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
activeTurn: {
|
|
agentSessionId: "session-1",
|
|
outcome: null,
|
|
origin: "user_prompt",
|
|
phase: "running",
|
|
settledAtUnixMs: null,
|
|
startedAtUnixMs: 1,
|
|
turnId: "turn-2",
|
|
updatedAtUnixMs: 10
|
|
},
|
|
activeTurnId: "turn-2"
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "First request" },
|
|
userMessages: [{ id: "user-1", body: "First request" }],
|
|
agentMessages: [{ id: "assistant-1", body: "Prior answer" }],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-1", body: "Prior answer" }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: "turn-2",
|
|
userMessage: { id: "user-2", body: "Keep going" },
|
|
userMessages: [{ id: "user-2", body: "Keep going" }],
|
|
agentMessages: [{ id: "assistant-2", body: "Still working" }],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: { id: "assistant-2", body: "Still working" }
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantMessages = conversation.rows
|
|
.filter(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
)
|
|
.flatMap((row) => row.messages);
|
|
|
|
expect(
|
|
assistantMessages.map((message) => ({
|
|
id: message.id,
|
|
copyText: message.copyText ?? null,
|
|
isTurnFinalText: message.isTurnFinalText === true
|
|
}))
|
|
).toEqual([
|
|
{
|
|
id: "assistant-1",
|
|
copyText: "Prior answer",
|
|
isTurnFinalText: true
|
|
},
|
|
{
|
|
id: "assistant-2",
|
|
copyText: null,
|
|
isTurnFinalText: false
|
|
}
|
|
]);
|
|
});
|
|
|
|
it("does not mark assistant replies copyable while the session is working", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
activeTurn: {
|
|
agentSessionId: "session-1",
|
|
outcome: null,
|
|
origin: "user_prompt",
|
|
phase: "running",
|
|
settledAtUnixMs: null,
|
|
startedAtUnixMs: 1,
|
|
turnId: "turn-1",
|
|
updatedAtUnixMs: 10
|
|
},
|
|
activeTurnId: "turn-1"
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Stream a response" },
|
|
userMessages: [{ id: "user-1", body: "Stream a response" }],
|
|
agentMessages: [
|
|
{
|
|
id: "assistant-working",
|
|
body: "Still streaming"
|
|
}
|
|
],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message",
|
|
message: {
|
|
id: "assistant-working",
|
|
body: "Still streaming"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const assistantRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "assistant"
|
|
);
|
|
|
|
expect(assistantRow?.messages[0]?.copyText).toBeUndefined();
|
|
});
|
|
|
|
it("projects user prompt images before text and keeps the session workspace id", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
workspaceId: "room-1"
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: null,
|
|
userMessages: [
|
|
{
|
|
id: "user-1",
|
|
body: "你能看到这个图吗",
|
|
sourceTimelineItems: [
|
|
{
|
|
id: 1,
|
|
agentSessionId: "session-1",
|
|
eventId: "event-1",
|
|
actorType: "user",
|
|
actorId: "user",
|
|
itemType: "message",
|
|
role: "user",
|
|
payload: {
|
|
content: [
|
|
{ type: "text", text: "你能看到这个图吗" },
|
|
{
|
|
type: "image",
|
|
mimeType: "image/png",
|
|
attachmentId: "attachment-1",
|
|
url: "https://objects.example.test/signed/screen.png",
|
|
name: "screen.png"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages.map((message) => message.contentKind)).toEqual([
|
|
"image-grid",
|
|
"text"
|
|
]);
|
|
expect(userRow?.messages[0]?.images?.[0]).toMatchObject({
|
|
workspaceId: "room-1",
|
|
agentSessionId: "session-1",
|
|
attachmentId: "attachment-1",
|
|
url: "https://objects.example.test/signed/screen.png",
|
|
mimeType: "image/png",
|
|
name: "screen.png"
|
|
});
|
|
});
|
|
|
|
it("keeps a signed image URL when its MIME type is absent", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
workspaceId: "room-1"
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: null,
|
|
userMessages: [
|
|
{
|
|
id: "user-1",
|
|
body: "",
|
|
sourceTimelineItems: [
|
|
{
|
|
id: 1,
|
|
agentSessionId: "session-1",
|
|
eventId: "event-1",
|
|
actorType: "user",
|
|
actorId: "user",
|
|
itemType: "message",
|
|
role: "user",
|
|
payload: {
|
|
content: [
|
|
{
|
|
type: "image",
|
|
url: "https://objects.example.test/signed/screen.png"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages.map((message) => message.contentKind)).toEqual([
|
|
"image-grid"
|
|
]);
|
|
expect(userRow?.messages[0]?.images?.[0]).toMatchObject({
|
|
url: "https://objects.example.test/signed/screen.png",
|
|
mimeType: ""
|
|
});
|
|
});
|
|
|
|
it("replaces rich user prompt text blocks with displayPrompt while preserving images", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
workspaceId: "room-1"
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: null,
|
|
userMessages: [
|
|
{
|
|
id: "user-1",
|
|
body: "Run Automation",
|
|
sourceTimelineItems: [
|
|
{
|
|
id: 1,
|
|
agentSessionId: "session-1",
|
|
eventId: "event-1",
|
|
actorType: "user",
|
|
actorId: "user",
|
|
itemType: "message",
|
|
role: "user",
|
|
payload: {
|
|
displayPrompt: "Run Automation",
|
|
content: [
|
|
{ type: "text", text: "long automation prompt" },
|
|
{
|
|
type: "image",
|
|
mimeType: "image/png",
|
|
attachmentId: "attachment-1",
|
|
name: "screen.png"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages.map((message) => message.contentKind)).toEqual([
|
|
"image-grid",
|
|
"text"
|
|
]);
|
|
expect(userRow?.messages[0]?.images?.[0]?.attachmentId).toBe(
|
|
"attachment-1"
|
|
);
|
|
expect(userRow?.messages[1]?.body).toBe("Run Automation");
|
|
expect(userRow?.messages.map((message) => message.body)).not.toContain(
|
|
"long automation prompt"
|
|
);
|
|
});
|
|
|
|
it("projects selected prompt text as a reference part instead of flattening it into the bubble", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: null,
|
|
userMessages: [
|
|
{
|
|
id: "user-1",
|
|
body: "这里说了什么\n> 对 tutti-lab/tutti 的影响是:它需要把 Go 依赖从旧的 tsh-server module 更新到新的 tutti-server module。",
|
|
sourceTimelineItems: [
|
|
{
|
|
id: 1,
|
|
agentSessionId: "session-1",
|
|
eventId: "event-1",
|
|
actorType: "user",
|
|
actorId: "user",
|
|
itemType: "message",
|
|
role: "user",
|
|
payload: {
|
|
displayPrompt:
|
|
"这里说了什么\n> 对 tutti-lab/tutti 的影响是:它需要把 Go 依赖从旧的 tsh-server module 更新到新的 tutti-server module。",
|
|
content: [
|
|
{ type: "text", text: "这里说了什么" },
|
|
{
|
|
type: "text",
|
|
text: "> 对 tutti-lab/tutti 的影响是:它需要把 Go 依赖从旧的 tsh-server module 更新到新的 tutti-server module。"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages.map((message) => message.contentKind)).toEqual([
|
|
"selected-text",
|
|
"text"
|
|
]);
|
|
expect(userRow?.messages[0]).toMatchObject({
|
|
body: "",
|
|
selectedText: {
|
|
count: 1,
|
|
texts: [
|
|
"对 tutti-lab/tutti 的影响是:它需要把 Go 依赖从旧的 tsh-server module 更新到新的 tutti-server module。"
|
|
]
|
|
}
|
|
});
|
|
expect(userRow?.messages[1]?.body).toBe("这里说了什么");
|
|
});
|
|
|
|
it("projects every legacy quote after a typed prompt into one reference part", () => {
|
|
const conversation = projectUserPromptForTest({
|
|
body: "Ask about both excerpts\n> first excerpt\n> second excerpt",
|
|
displayPrompt:
|
|
"Ask about both excerpts\n> first excerpt\n> second excerpt",
|
|
content: [
|
|
{ type: "text", text: "Ask about both excerpts" },
|
|
{ type: "text", text: "> first excerpt" },
|
|
{ type: "text", text: "> second excerpt" }
|
|
]
|
|
});
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages.map((message) => message.contentKind)).toEqual([
|
|
"selected-text",
|
|
"text"
|
|
]);
|
|
expect(userRow?.messages[0]?.selectedText).toEqual({
|
|
count: 2,
|
|
texts: ["first excerpt", "second excerpt"]
|
|
});
|
|
expect(userRow?.messages[1]?.body).toBe("Ask about both excerpts");
|
|
});
|
|
|
|
it("keeps ambiguous quote-only legacy content as ordinary Markdown", () => {
|
|
const conversation = projectUserPromptForTest({
|
|
body: "> first authored line\n> second authored line",
|
|
displayPrompt: "> first authored line\n> second authored line",
|
|
content: [
|
|
{ type: "text", text: "> first authored line" },
|
|
{ type: "text", text: "> second authored line" }
|
|
]
|
|
});
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages.map((message) => message.contentKind)).toEqual([
|
|
"text"
|
|
]);
|
|
expect(userRow?.messages[0]?.body).toBe(
|
|
"> first authored line\n> second authored line"
|
|
);
|
|
});
|
|
|
|
it("preserves an explicit selected-text kind already present in persisted content", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: null,
|
|
userMessages: [
|
|
{
|
|
id: "user-1",
|
|
body: "Ask about the selection",
|
|
sourceTimelineItems: [
|
|
{
|
|
id: 1,
|
|
agentSessionId: "session-1",
|
|
eventId: "event-1",
|
|
actorType: "user",
|
|
actorId: "user",
|
|
itemType: "message",
|
|
role: "user",
|
|
payload: {
|
|
displayPrompt: "Ask about the selection",
|
|
content: [
|
|
{ type: "text", text: "Ask about the selection" },
|
|
{
|
|
type: "text",
|
|
kind: "selected-text",
|
|
text: "Selected source text"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages[0]?.selectedText).toEqual({
|
|
count: 1,
|
|
texts: ["Selected source text"]
|
|
});
|
|
expect(userRow?.messages[1]?.body).toBe("Ask about the selection");
|
|
});
|
|
|
|
it("prefers materialized content text over legacy composer-file displayPrompt", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
workspaceId: "room-1"
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: null,
|
|
userMessages: [
|
|
{
|
|
id: "user-1",
|
|
body: "summarize",
|
|
sourceTimelineItems: [
|
|
{
|
|
id: 1,
|
|
agentSessionId: "session-1",
|
|
eventId: "event-1",
|
|
actorType: "user",
|
|
actorId: "user",
|
|
itemType: "message",
|
|
role: "user",
|
|
payload: {
|
|
displayPrompt:
|
|
"summarize [diagnostics.md](mention://composer-file/file-1?status=ready)",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "summarize [@diagnostics.md](/runtime/diagnostics.md)"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages[0]?.body).toBe(
|
|
"summarize [@diagnostics.md](/runtime/diagnostics.md)"
|
|
);
|
|
expect(userRow?.messages[0]?.body).not.toContain(
|
|
"mention://composer-file/"
|
|
);
|
|
});
|
|
|
|
it("does not render a synthetic image-only displayPrompt below the image", () => {
|
|
const conversation = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
workspaceId: "room-1"
|
|
},
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: null,
|
|
userMessages: [
|
|
{
|
|
id: "user-1",
|
|
body: "[Image]",
|
|
sourceTimelineItems: [
|
|
{
|
|
id: 1,
|
|
agentSessionId: "session-1",
|
|
eventId: "event-1",
|
|
actorType: "user",
|
|
actorId: "user",
|
|
itemType: "message",
|
|
role: "user",
|
|
payload: {
|
|
displayPrompt: "[Image]",
|
|
content: [
|
|
{
|
|
type: "image",
|
|
mimeType: "image/png",
|
|
attachmentId: "attachment-1",
|
|
name: "screen.png"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const userRow = conversation.rows.find(
|
|
(
|
|
row
|
|
): row is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => row.kind === "message" && row.speaker === "user"
|
|
);
|
|
|
|
expect(userRow?.messages.map((message) => message.contentKind)).toEqual([
|
|
"image-grid"
|
|
]);
|
|
expect(userRow?.messages.map((message) => message.body)).not.toContain(
|
|
"[Image]"
|
|
);
|
|
});
|
|
|
|
it("reuses unchanged transcript row references across incremental updates", () => {
|
|
const firstTurn = detailViewModel().turns[0]!;
|
|
const secondTurn = {
|
|
id: "turn-2",
|
|
userMessage: {
|
|
id: "user-2",
|
|
body: "Follow-up request",
|
|
turnId: "turn-2"
|
|
},
|
|
userMessages: [
|
|
{ id: "user-2", body: "Follow-up request", turnId: "turn-2" }
|
|
],
|
|
agentMessages: [
|
|
{ id: "assistant-2", body: "Working on it", turnId: "turn-2" }
|
|
],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{
|
|
kind: "message" as const,
|
|
message: {
|
|
id: "assistant-2",
|
|
body: "Working on it",
|
|
turnId: "turn-2"
|
|
}
|
|
}
|
|
]
|
|
};
|
|
const previous = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
latestTurn: {
|
|
...detailViewModel().session.latestTurn!,
|
|
turnId: "turn-2",
|
|
fileChanges: null
|
|
}
|
|
},
|
|
turns: [firstTurn, secondTurn],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
const next = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...detailViewModel().session,
|
|
latestTurn: {
|
|
...detailViewModel().session.latestTurn!,
|
|
turnId: "turn-2",
|
|
fileChanges: null
|
|
}
|
|
},
|
|
turns: [
|
|
firstTurn,
|
|
{
|
|
...secondTurn,
|
|
agentMessages: [
|
|
...secondTurn.agentMessages,
|
|
{ id: "assistant-3", body: "Done now", turnId: "turn-2" }
|
|
],
|
|
agentItems: [
|
|
...secondTurn.agentItems,
|
|
{
|
|
kind: "message" as const,
|
|
message: {
|
|
id: "assistant-3",
|
|
body: "Done now",
|
|
turnId: "turn-2"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
|
|
const reconciled = reconcileProjectedAgentConversationVM(previous, next);
|
|
const previousFirstTurnRows = previous.rows.filter(
|
|
(row) => row.turnId === firstTurn.id
|
|
);
|
|
const reconciledFirstTurnRows = reconciled.rows.filter(
|
|
(row) => row.turnId === firstTurn.id
|
|
);
|
|
|
|
expect(reconciledFirstTurnRows).toHaveLength(previousFirstTurnRows.length);
|
|
reconciledFirstTurnRows.forEach((row, index) => {
|
|
expect(row).toBe(previousFirstTurnRows[index]);
|
|
});
|
|
expect(reconciled.rows.at(-1)).not.toBe(previous.rows.at(-1));
|
|
});
|
|
|
|
it("reuses transcript rows when only render-irrelevant timestamps change", () => {
|
|
const baseDetail = detailViewModel();
|
|
const previous = projectAgentConversationVM(baseDetail);
|
|
const next = projectAgentConversationVM(
|
|
detailViewModel({
|
|
session: {
|
|
...baseDetail.session,
|
|
updatedAtUnixMs: 99
|
|
}
|
|
})
|
|
);
|
|
|
|
const reconciled = reconcileProjectedAgentConversationVM(previous, next);
|
|
|
|
expect(reconciled.rows).toBe(previous.rows);
|
|
});
|
|
});
|
|
|
|
function detailViewModel(
|
|
overrides: Partial<WorkspaceAgentSessionDetailViewModel> = {}
|
|
): WorkspaceAgentSessionDetailViewModel {
|
|
return {
|
|
activity: {
|
|
id: "activity-1",
|
|
sessionId: "session-1",
|
|
agentName: "Codex",
|
|
agentProvider: "codex",
|
|
title: "Codex",
|
|
latestActivitySummary: "Working",
|
|
status: "working",
|
|
sortTimeUnixMs: 10,
|
|
changedFiles: [{ path: "src/App.tsx", label: "src/App.tsx" }],
|
|
userId: "user-1",
|
|
userName: "Taylor",
|
|
userAvatarUrl: ""
|
|
},
|
|
session: normalizeAgentActivitySession({
|
|
...{
|
|
activeTurnId: null,
|
|
latestTurnInteractions: [],
|
|
pendingInteractions: []
|
|
},
|
|
workspaceId: "workspace-1",
|
|
agentSessionId: "session-1",
|
|
userId: "user-1",
|
|
provider: "codex",
|
|
providerSessionId: "provider-session-1",
|
|
cwd: "/workspace/demo",
|
|
title: "Codex",
|
|
createdAtUnixMs: 1,
|
|
updatedAtUnixMs: 10,
|
|
latestTurn: {
|
|
agentSessionId: "session-1",
|
|
turnId: "turn-1",
|
|
phase: "settled",
|
|
origin: "user_prompt",
|
|
outcome: "completed",
|
|
startedAtUnixMs: 1,
|
|
settledAtUnixMs: 10,
|
|
updatedAtUnixMs: 10,
|
|
fileChanges: {
|
|
files: [
|
|
{
|
|
path: "/workspace/demo/src/App.tsx",
|
|
change: "modified"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}),
|
|
cwd: "/workspace/demo",
|
|
workspaceRoot: "/workspace/demo",
|
|
sessionTurns: [
|
|
{
|
|
agentSessionId: "session-1",
|
|
turnId: "turn-1",
|
|
phase: "settled",
|
|
origin: "user_prompt",
|
|
outcome: "completed",
|
|
startedAtUnixMs: 1,
|
|
settledAtUnixMs: 10,
|
|
updatedAtUnixMs: 10,
|
|
fileChanges: {
|
|
files: [
|
|
{
|
|
path: "/workspace/demo/src/App.tsx",
|
|
change: "modified"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
],
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessage: { id: "user-1", body: "Ship it" },
|
|
userMessages: [{ id: "user-1", body: "Ship it" }],
|
|
agentMessages: [{ id: "assistant-1", body: "On it" }],
|
|
toolCalls: [],
|
|
toolCallCount: 2,
|
|
hasFailedToolCall: false,
|
|
agentItems: [
|
|
{ kind: "message", message: { id: "assistant-1", body: "On it" } },
|
|
{
|
|
kind: "tool-calls",
|
|
id: "tools-1",
|
|
toolCalls: [
|
|
{
|
|
id: "call:1",
|
|
name: "Read file",
|
|
toolName: "read_file",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed",
|
|
summary: "/workspace/demo/README.md",
|
|
payload: null
|
|
},
|
|
{
|
|
id: "call:2",
|
|
name: "Edit file",
|
|
toolName: "edit_file",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed",
|
|
summary: "/workspace/demo/src/App.tsx",
|
|
payload: null
|
|
}
|
|
],
|
|
toolCallCount: 2,
|
|
hasFailedToolCall: false,
|
|
summary: "Read and edited files",
|
|
groupEntries: [
|
|
{
|
|
kind: "tool-call",
|
|
call: {
|
|
id: "call:1",
|
|
name: "Read file",
|
|
toolName: "read_file",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed",
|
|
summary: "/workspace/demo/README.md",
|
|
payload: null
|
|
}
|
|
},
|
|
{
|
|
kind: "thinking",
|
|
thinking: {
|
|
id: "thinking-1",
|
|
body: "Need to inspect before editing."
|
|
}
|
|
},
|
|
{
|
|
kind: "tool-call",
|
|
call: {
|
|
id: "call:2",
|
|
name: "Edit file",
|
|
toolName: "edit_file",
|
|
callType: "tool",
|
|
status: "Completed",
|
|
statusKind: "completed",
|
|
summary: "/workspace/demo/src/App.tsx",
|
|
payload: null
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
showProcessingIndicator: true,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function projectUserPromptForTest(input: {
|
|
body: string;
|
|
displayPrompt: string;
|
|
content: readonly unknown[];
|
|
}): ReturnType<typeof projectAgentConversationVM> {
|
|
const baseTurn = detailViewModel().turns[0]!;
|
|
return projectAgentConversationVM(
|
|
detailViewModel({
|
|
turns: [
|
|
{
|
|
...baseTurn,
|
|
id: "turn-user-prompt",
|
|
userMessage: null,
|
|
userMessages: [
|
|
{
|
|
id: "user-prompt",
|
|
body: input.body,
|
|
sourceTimelineItems: [
|
|
{
|
|
id: 1,
|
|
agentSessionId: "session-1",
|
|
eventId: "event-1",
|
|
actorType: "user",
|
|
actorId: "user",
|
|
itemType: "message",
|
|
role: "user",
|
|
payload: {
|
|
displayPrompt: input.displayPrompt,
|
|
content: input.content
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
}
|
|
],
|
|
showProcessingIndicator: false
|
|
})
|
|
);
|
|
}
|
|
|
|
function compactNoticeMessage(
|
|
turnId: string,
|
|
commandStatus: "running" | "completed" | "failed" | "canceled"
|
|
): WorkspaceAgentSessionDetailViewModel["turns"][number]["agentMessages"][number] {
|
|
return {
|
|
id: `compact:${turnId}`,
|
|
body: "Compact notice",
|
|
turnId,
|
|
systemNotice: {
|
|
noticeKind: "system_notice",
|
|
severity: null,
|
|
command: "compact",
|
|
commandStatus,
|
|
title: "Compact notice",
|
|
detail: null,
|
|
retryable: null
|
|
}
|
|
};
|
|
}
|