Files
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

497 lines
14 KiB
TypeScript

import { act, fireEvent, render, screen } from "@testing-library/react";
import type { AgentActivityTurn } from "@tutti-os/agent-activity-core";
import type { JSX } from "react";
import { describe, expect, it, vi } from "vitest";
import type { AgentTranscriptRowVM } from "../contracts/agentTranscriptRowVM";
import type { AgentTranscriptTurnGroup } from "./agentTranscriptModel";
import { AgentTurnWorkSection as AgentTurnWorkSectionView } from "./AgentTurnWorkSection";
import type { AgentTurnDisclosureStore } from "./AgentTurnDisclosureContext";
import { buildAgentTurnWorkSectionModel } from "./agentTurnWorkSectionModel";
vi.mock("../../../i18n/index", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../i18n/index")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) =>
key === "agentHost.agentGui.turnProcessedSeconds"
? `Processed for ${String(options?.seconds)}s`
: key === "agentHost.agentGui.turnPeerDeviceOfflinePendingSync"
? "Other device offline · Progress pending sync"
: key === "agentHost.agentGui.turnPeerDeviceProgressSynchronizing"
? "Synchronizing progress from other device…"
: key
})
};
});
const disclosureStore: AgentTurnDisclosureStore = {
expandedOverrides: {},
setExpandedOverride: () => {}
};
describe("AgentTurnWorkSection", () => {
it("ticks only the duration label without rerendering transcript rows", () => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
const renderRow = vi.fn((row: AgentTranscriptRowVM, rowIndex: number) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
));
try {
render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn()}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(screen.getByText("Processed for 45s")).toBeTruthy();
expect(vi.getTimerCount()).toBe(1);
renderRow.mockClear();
act(() => vi.advanceTimersByTime(5_000));
expect(screen.getByText("Processed for 50s")).toBeTruthy();
expect(renderRow).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it.each([
["peer-offline" as const, "Other device offline · Progress pending sync"],
["synchronizing" as const, "Synchronizing progress from other device…"]
])("shows %s observation-gap copy instead of a live timer", (state, copy) => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
try {
render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn()}
isActiveTurn
observationGapPresentationState={state}
disclosureStore={disclosureStore}
renderRow={(row, rowIndex) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
)}
/>
);
expect(screen.getByText(copy)).toBeTruthy();
expect(screen.queryByText("Processed for 45s")).toBeNull();
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("shows and ticks wall-clock time while the active turn is waiting", () => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
try {
render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn({ phase: "waiting" })}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={(row, rowIndex) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
)}
/>
);
expect(screen.getByText("Processed for 45s")).toBeTruthy();
expect(vi.getTimerCount()).toBe(1);
act(() => vi.advanceTimersByTime(5_000));
expect(screen.getByText("Processed for 50s")).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("keeps the live timer when a running turn transitions to waiting", () => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
const renderRow = (row: AgentTranscriptRowVM, rowIndex: number) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
);
try {
const { rerender } = render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn({ phase: "running" })}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(screen.getByText("Processed for 45s")).toBeTruthy();
expect(vi.getTimerCount()).toBe(1);
rerender(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn({ phase: "waiting" })}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(screen.getByText("Processed for 45s")).toBeTruthy();
expect(vi.getTimerCount()).toBe(1);
act(() => vi.advanceTimersByTime(5_000));
expect(screen.getByText("Processed for 50s")).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("clears the live timer and freezes timing when the turn settles", () => {
vi.useFakeTimers();
vi.setSystemTime(50_000);
const renderRow = (row: AgentTranscriptRowVM, rowIndex: number) => (
<div key={`${row.id}:${rowIndex}`}>{row.id}</div>
);
try {
const { rerender } = render(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn()}
isActiveTurn
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(vi.getTimerCount()).toBe(1);
rerender(
<AgentTurnWorkSection
group={turnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 12_000
})}
isActiveTurn={false}
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("renders interleaved turn rows in their original chronology", () => {
const expandedStore: AgentTurnDisclosureStore = {
expandedOverrides: { "session-1:turn-1": true },
setExpandedOverride: () => {}
};
const { container } = render(
<AgentTurnWorkSection
group={interleavedTurnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={expandedStore}
renderRow={(row) => (
<div key={row.id} data-test-row-id={row.id}>
{row.kind === "message" ? row.messages[0]?.body : row.id}
</div>
)}
/>
);
expect(
[...container.querySelectorAll("[data-test-row-id]")].map(
(element) => element.textContent
)
).toEqual([
"First request",
"Earlier answer",
"Follow-up",
"tools",
"Final answer"
]);
});
it("collapses ordinary assistant replies while keeping follow-up guidance visible", () => {
render(
<AgentTurnWorkSection
group={interleavedTurnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={disclosureStore}
renderRow={(row) => (
<div key={row.id} data-test-row-id={row.id}>
{row.kind === "message" ? row.messages[0]?.body : row.id}
</div>
)}
/>
);
expect(screen.queryByText("Earlier answer")).toBeNull();
expect(screen.getByText("Follow-up")).toBeTruthy();
expect(screen.getByText("Final answer")).toBeTruthy();
expect(screen.queryByText("tools")).toBeNull();
});
it("keeps the response-tail file diff panel visible when turn work is collapsed", () => {
const group = interleavedTurnGroup();
const summary: AgentTranscriptRowVM = {
kind: "turn-summary",
id: "file-diff",
turnId: "turn-1",
files: [],
fileCount: 0,
modifiedCount: 0,
createdCount: 0,
occurredAtUnixMs: 15_000
};
group.rows.push({ row: summary, rowIndex: group.rows.length });
const { container } = render(
<AgentTurnWorkSection
group={group}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={disclosureStore}
renderRow={(row) => (
<div key={row.id} data-test-row-id={row.id}>
{row.kind === "message" ? row.messages[0]?.body : row.id}
</div>
)}
/>
);
expect(screen.queryByText("tools")).toBeNull();
expect(screen.getByText("Final answer")).toBeTruthy();
expect(screen.getByText("file-diff")).toBeTruthy();
expect(
[...container.querySelectorAll("[data-test-row-id]")].map(
(element) => element.textContent
)
).toEqual(["First request", "Follow-up", "Final answer", "file-diff"]);
});
it("makes the duration text part of the turn disclosure button", () => {
const setExpandedOverride = vi.fn();
render(
<AgentTurnWorkSection
group={interleavedTurnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={{
expandedOverrides: {},
setExpandedOverride
}}
renderRow={(row) => <div key={row.id}>{row.id}</div>}
/>
);
const duration = screen.getByText("agentHost.agentGui.turnTotalSeconds");
const toggle = screen.getByRole("button", {
name: "agentHost.agentGui.expandTurnWork"
});
expect(toggle).toContainElement(duration);
fireEvent.click(duration);
expect(setExpandedOverride).toHaveBeenCalledWith("session-1:turn-1", true);
});
it("keeps dynamic section spacing inside the animated height", () => {
const expandedStore: AgentTurnDisclosureStore = {
expandedOverrides: { "session-1:turn-1": true },
setExpandedOverride: () => {}
};
const { container } = render(
<AgentTurnWorkSection
group={interleavedTurnGroup()}
sessionId="session-1"
turn={canonicalTurn({
phase: "settled",
outcome: "completed",
settledAtUnixMs: 15_000
})}
isActiveTurn={false}
disclosureStore={expandedStore}
renderRow={(row) => <div key={row.id}>{row.id}</div>}
/>
);
expect(
container.querySelector("[data-agent-turn-work-section]")
).toHaveClass("grid");
expect(
container.querySelector("[data-agent-turn-work-section]")
).not.toHaveClass("gap-4");
expect(
container.querySelector(".agent-collapsible-reveal__inner")
).toHaveClass("grid", "gap-4", "pt-4");
});
});
function AgentTurnWorkSection({
group,
sessionId,
turn,
isActiveTurn,
observationGapPresentationState,
disclosureStore,
renderRow
}: {
group: AgentTranscriptTurnGroup;
sessionId: string;
turn: AgentActivityTurn;
isActiveTurn: boolean;
observationGapPresentationState?: "peer-offline" | "synchronizing";
disclosureStore: AgentTurnDisclosureStore;
renderRow: (
row: AgentTranscriptRowVM,
rowIndex: number,
renderKey?: string
) => JSX.Element;
}): JSX.Element {
const model = buildAgentTurnWorkSectionModel(group, turn, isActiveTurn, {
collapseIntermediateAssistantReplies: true,
liveObservationGapPresentationState: observationGapPresentationState
});
if (!model) {
throw new Error("Test expected a timing-enabled turn disclosure model");
}
return (
<AgentTurnWorkSectionView
model={model}
sessionId={sessionId}
turnKey={group.turnId ?? group.key}
disclosureStore={disclosureStore}
renderRow={renderRow}
/>
);
}
function canonicalTurn(
overrides: Partial<AgentActivityTurn> = {}
): AgentActivityTurn {
return {
agentSessionId: "session-1",
origin: "user_prompt",
phase: "running",
startedAtUnixMs: 5_000,
turnId: "turn-1",
updatedAtUnixMs: 6_000,
...overrides
};
}
function turnGroup(): AgentTranscriptTurnGroup {
const row: AgentTranscriptRowVM = {
kind: "message",
id: "user-row",
turnId: "turn-1",
speaker: "user",
messages: [
{
kind: "message-content",
id: "user-message",
turnId: "turn-1",
body: "Please fix it",
presentationKind: "content",
occurredAtUnixMs: 5_000
}
],
thinking: [],
occurredAtUnixMs: 5_000
};
return {
key: "turn-1",
turnId: "turn-1",
rows: [{ row, rowIndex: 0 }]
};
}
function interleavedTurnGroup(): AgentTranscriptTurnGroup {
const messageRow = (
id: string,
speaker: "user" | "assistant",
body: string,
isTurnFinalText = false
): AgentTranscriptRowVM => ({
kind: "message",
id,
turnId: "turn-1",
speaker,
messages: [
{
kind: "message-content",
id: `${id}:message`,
turnId: "turn-1",
body,
presentationKind: "content",
copyText: isTurnFinalText ? body : null,
...(isTurnFinalText ? { isTurnFinalText: true as const } : {}),
occurredAtUnixMs: 5_000
}
],
thinking: [],
occurredAtUnixMs: 5_000
});
const rows: AgentTranscriptRowVM[] = [
messageRow("user-1", "user", "First request"),
messageRow("assistant-1", "assistant", "Earlier answer"),
messageRow("user-2", "user", "Follow-up"),
{
kind: "tool-group",
id: "tools",
turnId: "turn-1",
grouped: true,
calls: [],
entries: [],
occurredAtUnixMs: 8_000
},
messageRow("assistant-2", "assistant", "Final answer", true)
];
return {
key: "turn-1",
turnId: "turn-1",
rows: rows.map((row, rowIndex) => ({ row, rowIndex }))
};
}