Files
tutti/packages/agent/gui/shared/agentConversation/projection/workspaceAgentTimelineProjection.spec.ts
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

787 lines
22 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
normalizeAgentActivitySession,
type AgentActivitySession
} from "@tutti-os/agent-activity-core";
import type { WorkspaceAgentActivityTimelineItem } from "../../workspaceAgentTimelineTypes";
import type { WorkspaceAgentActivityCard } from "../../workspaceAgentActivityListViewModel";
import { buildWorkspaceAgentSessionDetailViewModel } from "../../workspaceAgentSessionDetailViewModel";
import {
buildCanonicalWorkspaceAgentDetailView,
projectWorkspaceAgentTimelineToConversationVM
} from "./workspaceAgentTimelineProjection";
describe("projectWorkspaceAgentTimelineToConversationVM", () => {
it("groups adjacent tool calls and bridge thinking directly from timeline items", () => {
const conversation = projectWorkspaceAgentTimelineToConversationVM({
activity: activity(),
session: session(),
workspaceRoot: "/workspace/demo",
timelineItems: timelineItems()
});
expect(conversation.rows.map((row) => row.kind)).toEqual([
"message",
"message",
"tool-group",
"tool-group"
]);
const groupedRows = conversation.rows.filter(
(
row
): row is Extract<
(typeof conversation.rows)[number],
{ kind: "tool-group" }
> => row.kind === "tool-group" && row.grouped
);
expect(groupedRows).toHaveLength(1);
expect(groupedRows[0]?.entries.map((entry) => entry.kind)).toEqual([
"tool-call",
"thinking",
"tool-call"
]);
expect(groupedRows[0]?.entries[1]?.kind).toBe("thinking");
expect(
groupedRows[0]?.entries[1]?.kind === "thinking"
? groupedRows[0].entries[1].thinking.body
: null
).toBe("Need to inspect before editing.");
expect("pendingApproval" in conversation).toBe(false);
expect("pendingInteractivePrompt" in conversation).toBe(false);
const summaryRow = conversation.rows.find(
(
row
): row is Extract<
(typeof conversation.rows)[number],
{ kind: "turn-summary" }
> => row.kind === "turn-summary"
);
expect(summaryRow).toBeUndefined();
});
it("builds canonical detail turns directly from timeline items without the legacy detail builder", () => {
const detail = buildCanonicalWorkspaceAgentDetailView({
activity: activity(),
session: session({ effectiveStatus: "working", turnPhase: "working" }),
workspaceRoot: "/workspace/demo",
timelineItems: timelineItems()
});
expect(detail.turns).toHaveLength(1);
expect(detail.turns[0]?.rawAgentItems?.map((item) => item.kind)).toEqual([
"message",
"tool-calls",
"thinking",
"tool-calls",
"tool-calls",
"tool-calls"
]);
expect(detail.turns[0]?.agentItems.map((item) => item.kind)).toEqual([
"message",
"tool-calls",
"thinking",
"tool-calls",
"tool-calls",
"tool-calls"
]);
expect(detail.showProcessingIndicator).toBe(false);
});
it("omits Claude synthetic interrupt messages from left detail conversation rows", () => {
const conversation = projectWorkspaceAgentTimelineToConversationVM({
activity: activity(),
session: session({
provider: "claude-code",
title: "[Request interrupted by user]"
}),
workspaceRoot: "/workspace/demo",
timelineItems: [
{
...timelineItems()[0]!,
content: "[Request interrupted by user]"
},
{
...timelineItems()[0]!,
id: 11,
eventId: "event-11",
content: "[Request interrupted by user for tool use]",
occurredAtUnixMs: 11,
createdAtUnixMs: 11
},
{
...timelineItems()[0]!,
id: 12,
eventId: "event-12",
content: "继续正常请求",
occurredAtUnixMs: 12,
createdAtUnixMs: 12
},
timelineItems()[1]!
]
});
const userMessages = conversation.rows
.filter(
(
row
): row is Extract<
(typeof conversation.rows)[number],
{ kind: "message" }
> => row.kind === "message" && row.speaker === "user"
)
.flatMap((row) => row.messages.map((message) => message.body));
expect(userMessages).toEqual(["继续正常请求"]);
});
it("prefers durable sequence order over drifted timestamps", () => {
const conversation = projectWorkspaceAgentTimelineToConversationVM({
activity: activity(),
session: session(),
workspaceRoot: "/workspace/demo",
timelineItems: timelineItems().map((item) => ({
...item,
occurredAtUnixMs:
item.seq === 3
? 300
: item.seq === 4
? 40
: item.seq === 5
? 50
: item.seq,
createdAtUnixMs:
item.seq === 3
? 300
: item.seq === 4
? 40
: item.seq === 5
? 50
: item.seq
}))
});
expect(conversation.rows.map((row) => row.kind)).toEqual([
"message",
"message",
"tool-group",
"tool-group"
]);
});
it("keeps the legacy detail builder aligned with the canonical timeline projection helper", () => {
const input = {
activity: activity(),
session: session({ effectiveStatus: "working", turnPhase: "working" }),
workspaceRoot: "/workspace/demo",
timelineItems: timelineItems()
};
const canonical = buildCanonicalWorkspaceAgentDetailView(input);
const legacy = buildWorkspaceAgentSessionDetailViewModel(input);
expect(legacy).toEqual(canonical);
});
it("respects avoidGroupingEdits when rebuilding rows directly from timeline items", () => {
const conversation = projectWorkspaceAgentTimelineToConversationVM(
{
activity: activity(),
session: session({
effectiveStatus: "completed",
turnPhase: "completed"
}),
workspaceRoot: "/workspace/demo",
timelineItems: timelineItems()
},
{ avoidGroupingEdits: true }
);
const groupedRows = conversation.rows.filter(
(
row
): row is Extract<
(typeof conversation.rows)[number],
{ kind: "tool-group" }
> => row.kind === "tool-group" && row.grouped
);
expect(groupedRows).toHaveLength(1);
expect(groupedRows[0]?.calls.map((call) => call.id)).toEqual([
"call:read-1"
]);
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("projects transport notices without merging them into assistant content", () => {
const conversation = projectWorkspaceAgentTimelineToConversationVM({
activity: activity(),
session: session(),
workspaceRoot: "/workspace/demo",
timelineItems: [
timelineItems()[0]!,
{
id: 98,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 98,
eventId: "notice:event-98",
actorType: "agent",
actorId: "codex",
itemType: "message.assistant",
role: "assistant",
status: "completed",
payload: {
kind: "agent_system_notice",
noticeKind: "transport_retry",
severity: "warning",
title: "Codex connection interrupted. Reconnecting...",
detail:
"ResponseStreamDisconnected: websocket IO error: Broken pipe",
retryable: true,
content: "Codex connection interrupted. Reconnecting...",
text: "Codex connection interrupted. Reconnecting..."
},
occurredAtUnixMs: 98,
createdAtUnixMs: 98
},
{
id: 99,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 99,
eventId: "event-99",
actorType: "agent",
actorId: "codex",
itemType: "message.assistant",
role: "assistant",
status: "completed",
content: "Done.",
occurredAtUnixMs: 99,
createdAtUnixMs: 99
}
]
});
const assistantRows = conversation.rows.filter(
(
row
): row is Extract<
(typeof conversation.rows)[number],
{ kind: "message" }
> => row.kind === "message" && row.speaker === "assistant"
);
expect(assistantRows).toHaveLength(2);
expect(assistantRows[0]?.messages[0]?.systemNotice).toEqual({
noticeKind: "transport_retry",
severity: "warning",
title: "Codex connection interrupted. Reconnecting...",
detail: "ResponseStreamDisconnected: websocket IO error: Broken pipe",
retryable: true
});
expect(assistantRows[1]?.messages[0]?.body).toBe("Done.");
});
it("maps review-process thinking timeline items to assistant message rows", () => {
const detail = buildCanonicalWorkspaceAgentDetailView({
activity: activity(),
session: session(),
workspaceRoot: "/workspace/demo",
timelineItems: [
{
id: 1,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-review",
seq: 1,
eventId: "event-1",
actorType: "user",
actorId: "user-1",
itemType: "message.user",
role: "user",
content: "/review",
occurredAtUnixMs: 1,
createdAtUnixMs: 1
},
{
id: 2,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-review",
seq: 2,
eventId: "event-2",
actorType: "agent",
actorId: "codex",
itemType: "message.assistant_thinking",
role: "assistant_thinking",
status: "completed",
content:
"**Considering workspace registration order**\n\nInspecting the auth flow.",
payload: { messageKind: "review-process" },
occurredAtUnixMs: 2,
createdAtUnixMs: 2
}
]
});
expect(detail.turns[0]?.agentItems).toEqual([
expect.objectContaining({
kind: "message",
message: expect.objectContaining({
body: "Inspecting the auth flow."
})
})
]);
});
it("does not append processing after a terminal assistant message even if the session patch is still working", () => {
const completedReplyTimelineItems: WorkspaceAgentActivityTimelineItem[] = [
timelineItems()[0]!,
{
id: 99,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 99,
eventId: "event-99",
actorType: "agent",
actorId: "codex",
itemType: "message.assistant",
role: "assistant",
status: "completed",
content: "Done.",
occurredAtUnixMs: 99,
createdAtUnixMs: 99
}
];
const detail = buildCanonicalWorkspaceAgentDetailView({
activity: activity(),
session: session({
effectiveStatus: "working",
turnPhase: "working"
}),
workspaceRoot: "/workspace/demo",
timelineItems: completedReplyTimelineItems
});
const conversation = projectWorkspaceAgentTimelineToConversationVM({
activity: activity(),
session: session({
effectiveStatus: "working",
turnPhase: "working"
}),
workspaceRoot: "/workspace/demo",
timelineItems: completedReplyTimelineItems
});
expect(detail.showProcessingIndicator).toBe(false);
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
false
);
});
it("keeps processing after an interim assistant message while the turn lifecycle reports an active turn", () => {
const interimReplyTimelineItems: WorkspaceAgentActivityTimelineItem[] = [
timelineItems()[0]!,
{
id: 99,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 99,
eventId: "event-99",
actorType: "agent",
actorId: "codex",
itemType: "message.assistant",
role: "assistant",
status: "completed",
content: "I will now dispatch the sub-agents.",
occurredAtUnixMs: 99,
createdAtUnixMs: 99
}
];
const workingSession = {
...session({
effectiveStatus: "working",
turnPhase: "working"
}),
activeTurn: activeTurn("running")
};
const detail = buildCanonicalWorkspaceAgentDetailView({
activity: activity(),
session: workingSession,
workspaceRoot: "/workspace/demo",
timelineItems: interimReplyTimelineItems
});
const conversation = projectWorkspaceAgentTimelineToConversationVM({
activity: activity(),
session: workingSession,
workspaceRoot: "/workspace/demo",
timelineItems: interimReplyTimelineItems
});
expect(detail.showProcessingIndicator).toBe(true);
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
true
);
});
it("keeps processing when session status is stale ready but turn lifecycle is still running", () => {
const completedToolTimelineItems = timelineItems().slice(0, 5);
const staleReadySession = {
...session({
effectiveStatus: "ready",
turnPhase: "idle"
}),
activeTurn: activeTurn("running")
};
const detail = buildCanonicalWorkspaceAgentDetailView({
activity: activity(),
session: staleReadySession,
workspaceRoot: "/workspace/demo",
timelineItems: completedToolTimelineItems
});
const conversation = projectWorkspaceAgentTimelineToConversationVM({
activity: activity(),
session: staleReadySession,
workspaceRoot: "/workspace/demo",
timelineItems: completedToolTimelineItems
});
expect(detail.showProcessingIndicator).toBe(true);
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
true
);
});
it("does not append processing after a terminal assistant message once the turn lifecycle is settling", () => {
const settlingReplyTimelineItems: WorkspaceAgentActivityTimelineItem[] = [
timelineItems()[0]!,
{
id: 99,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 99,
eventId: "event-99",
actorType: "agent",
actorId: "codex",
itemType: "message.assistant",
role: "assistant",
status: "completed",
content: "Done.",
occurredAtUnixMs: 99,
createdAtUnixMs: 99
}
];
const settlingSession = {
...session({
effectiveStatus: "working",
turnPhase: "working"
}),
activeTurn: activeTurn("settled")
};
const detail = buildCanonicalWorkspaceAgentDetailView({
activity: activity(),
session: settlingSession,
workspaceRoot: "/workspace/demo",
timelineItems: settlingReplyTimelineItems
});
expect(detail.showProcessingIndicator).toBe(false);
});
it("keeps processing after settle until authoritative terminal transcript content arrives", () => {
const userOnlyTimelineItems: WorkspaceAgentActivityTimelineItem[] = [
timelineItems()[0]!
];
const settledWithoutFinal = {
...session({
effectiveStatus: "working",
turnPhase: "working"
}),
activeTurn: activeTurn("settled")
};
const detail = buildCanonicalWorkspaceAgentDetailView({
activity: activity(),
session: settledWithoutFinal,
workspaceRoot: "/workspace/demo",
timelineItems: userOnlyTimelineItems
});
const conversation = projectWorkspaceAgentTimelineToConversationVM({
activity: activity(),
session: settledWithoutFinal,
workspaceRoot: "/workspace/demo",
timelineItems: userOnlyTimelineItems
});
expect(detail.showProcessingIndicator).toBe(true);
expect(conversation.rows.some((row) => row.kind === "processing")).toBe(
true
);
});
});
function activity(
overrides: Partial<WorkspaceAgentActivityCard> = {}
): WorkspaceAgentActivityCard {
return {
id: "activity-1",
sessionId: "session-1",
agentName: "Codex",
agentProvider: "codex",
status: "working",
title: "Codex",
latestActivitySummary: "Working",
sortTimeUnixMs: 10,
changedFiles: [],
userId: "user-1",
userName: "Taylor",
userAvatarUrl: "",
...overrides
};
}
function session(
overrides: Partial<AgentActivitySession> & {
effectiveStatus?: string;
turnPhase?: string;
} = {}
): AgentActivitySession {
const {
effectiveStatus: _effectiveStatus,
turnPhase: _turnPhase,
...canonical
} = overrides;
return 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"
}
]
}
},
...canonical
});
}
function activeTurn(phase: "running" | "settled") {
return {
agentSessionId: "session-1",
origin: "user_prompt" as const,
phase,
startedAtUnixMs: 1,
turnId: "turn-1",
updatedAtUnixMs: 10
};
}
function timelineItems(): WorkspaceAgentActivityTimelineItem[] {
return [
{
id: 1,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 1,
eventId: "event-1",
actorType: "user",
actorId: "user-1",
itemType: "message.user",
role: "user",
content: "Ship the patch",
occurredAtUnixMs: 1,
createdAtUnixMs: 1
},
{
id: 2,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 2,
eventId: "event-2",
actorType: "agent",
actorId: "codex",
itemType: "message.assistant",
role: "assistant",
content: "On it",
occurredAtUnixMs: 2,
createdAtUnixMs: 2
},
{
id: 3,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 3,
eventId: "event-3",
actorType: "agent",
actorId: "codex",
itemType: "call.completed",
role: "assistant",
callType: "tool",
callId: "read-1",
name: "read_file",
status: "completed",
payload: {
tool_state: {
name: "read_file",
input: { path: "/workspace/demo/README.md" },
output: { text: "Read README.md" }
}
},
occurredAtUnixMs: 3,
createdAtUnixMs: 3
},
{
id: 4,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 4,
eventId: "event-4",
actorType: "agent",
actorId: "codex",
itemType: "message.assistant_thinking",
role: "assistant_thinking",
content: "Need to inspect before editing.",
occurredAtUnixMs: 4,
createdAtUnixMs: 4
},
{
id: 5,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 5,
eventId: "event-5",
actorType: "agent",
actorId: "codex",
itemType: "call.completed",
role: "assistant",
callType: "tool",
callId: "edit-1",
name: "edit_file",
status: "completed",
payload: {
tool_state: {
name: "edit_file",
input: {
file_path: "/workspace/demo/src/App.tsx",
old_string: "old",
new_string: "new"
},
output: {}
}
},
occurredAtUnixMs: 5,
createdAtUnixMs: 5
},
{
id: 6,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 6,
eventId: "event-6",
actorType: "agent",
actorId: "codex",
itemType: "approval.requested",
role: "assistant",
callType: "approval",
callId: "approval-1",
name: "Approval",
status: "waiting_approval",
payload: {
input: {
requestId: "approval-request-1",
options: [
{ id: "allow_once", label: "Allow once", kind: "allow_once" }
]
}
},
occurredAtUnixMs: 6,
createdAtUnixMs: 6
},
{
id: 7,
workspaceId: "room-1",
agentSessionId: "session-1",
turnId: "turn-1",
seq: 7,
eventId: "event-7",
actorType: "agent",
actorId: "codex",
itemType: "interactive.requested",
role: "assistant",
callType: "interactive",
callId: "ask-1",
name: "AskUserQuestion",
status: "waiting_input",
payload: {
input: {
requestId: "ask-request-1",
questions: [
{
id: "approach",
header: "Approach",
question: "Which direction should we take?",
options: [
{ label: "Use typed renderer", description: "Keep going" }
]
}
]
}
},
occurredAtUnixMs: 7,
createdAtUnixMs: 7
}
];
}