mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Chat uses a shared runner turn to show activity after Send. > - The turn currently shows Queued while the server admits the request. > - People need immediate activity feedback during this short wait. > - This change uses the existing Working header and Thinking activity during ordinary admission. > - Specific server holds stay visible so people can see setup or maintenance delays. ## Linked Issues or Issue Description **What existing behavior does this improve?** The activity indicator in task chat and agent chat after Send. **Current behavior** Ordinary admission shows Queued in the header and activity row. It changes to Working and Thinking when execution starts. **Proposed behavior** Show Working and Thinking from message submission through ordinary queued admission. Keep the same avatar, name, and activity row in place. Preserve specific server waits and the existing queue controls. **Subsystem affected** ui/ — React board UI. ## What Changed - Use the default activity presentation when a queued turn has no specific server hold. - Retain maintenance, agent setup, server restart, previous-run, provider, and preparation labels. - Omit the elapsed-time suffix until a start time is known. This prevents a frozen Working for 0s header before a run exists. - Update component tests for submission, acknowledgement, reassignment, and runtime discovery on desktop and mobile. - Update the activity guidance in DESIGN.md. - Keep this behavior in the shared runner turn. No API, queue, or execution contract changes are needed. ## Verification - Chat component suites: 582 passed, 1 existing skip. - Command: `pnpm exec vitest run ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx ui/src/components/TaskChatThread.test.tsx ui/src/components/IssueChatThread.test.tsx`. - `pnpm check:token-gates`: passed. - `pnpm verify`: exit 2. DESIGN.md is a global CI input, so the selector requires broader CI coverage. The three focused chat suites passed again on final commit `e85b8e43b007288f3ef41e0a42e801d5d2c41e54`. Required CI passed on this revision. See the CI result below. - The earlier CI attempt on `796fac80a06769d67af5b899fb186a875bfb228d` failed the unchanged Rust test `process_lineage::journal::tests::reaping_stops_a_dead_runners_provider_and_its_escaped_tool_job` with `unattributed_orphan`. This UI change does not alter its compiled inputs. The cause is not yet established. The current revision passed the Rust check. The earlier failure cause remains unknown. [Failed job](https://github.com/paperclipai/paperclip/actions/runs/42843806417/job/129926132490). - The workspace preflight installed dependencies and built shared and plugin SDK outputs. Its unrelated Runner build could not start because cargo is unavailable. The current revision passed CI typecheck and build. - Both current-head reviews are 5/5 with no remaining findings. There are no unresolved review threads. - Current-head typecheck, build, general tests, serialized server tests, all eight E2E shards, and Rust checks passed. [CI run](https://github.com/paperclipai/paperclip/actions/runs/42845383745). - The Runner support suite reported one 30-second timeout in `private-reporter-artifacts.test.ts`; 11,291 other tests passed. Both tests in that file passed locally in isolation (1.64 seconds for the full command). The one rerun passed in CI attempt 2. The timeout cause remains unknown. [Failed job](https://github.com/paperclipai/paperclip/actions/runs/42845383745/job/129932351496). - Final CI result: attempt 2 passed on `e85b8e43b007288f3ef41e0a42e801d5d2c41e54`. Typecheck, build, tests, Rust checks, and all eight E2E shards passed. [CI attempt 2](https://github.com/paperclipai/paperclip/actions/runs/42845383745/attempts/2). ## Risks - Low risk. This changes presentation only. A queued turn can show Thinking before provider execution starts. - Specific server holds remain visible. Scheduling, permissions, and saved message controls do not change. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The runtime does not expose a more specific deployment model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1662 lines
50 KiB
TypeScript
1662 lines
50 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import type { ExecutionProjection } from "@paperclipai/shared";
|
||
import { act } from "react";
|
||
import { createRoot, type Root } from "react-dom/client";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { ThemeProvider } from "@/context/ThemeContext";
|
||
import { MemoryRouter } from "@/lib/router";
|
||
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
|
||
import { TaskChatRequestHistoryContext } from "./TaskChatComposerTakeoverContext";
|
||
import { transcriptToTaskChatItems } from "./transcript-adapter";
|
||
import type {
|
||
TaskChatItem,
|
||
TaskChatProviderActivityFamily,
|
||
TaskChatProviderActivityItem,
|
||
TaskChatRuntimeRequestDecision,
|
||
TaskChatRuntimeRequestItem,
|
||
} from "./task-chat-model";
|
||
|
||
describe("TaskChatRunnerTurn", () => {
|
||
let container: HTMLDivElement;
|
||
let root: Root;
|
||
|
||
beforeEach(() => {
|
||
container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
root = createRoot(container);
|
||
});
|
||
|
||
afterEach(() => {
|
||
act(() => root.unmount());
|
||
container.remove();
|
||
});
|
||
|
||
const render = (
|
||
items: TaskChatItem[],
|
||
status = "running",
|
||
runId: string | null = "run-1",
|
||
onRuntimeRequestDecision?: (
|
||
item: TaskChatRuntimeRequestItem,
|
||
decision: TaskChatRuntimeRequestDecision,
|
||
) => void,
|
||
awaitingContinuation = false,
|
||
continuedAfterSteering = false,
|
||
execution?: ExecutionProjection,
|
||
preparationLabel?: string,
|
||
) =>
|
||
act(() =>
|
||
root.render(
|
||
<MemoryRouter>
|
||
<ThemeProvider>
|
||
<TaskChatRequestHistoryContext.Provider value="issue-1">
|
||
<TaskChatRunnerTurn
|
||
runId={runId}
|
||
agentName="Runner"
|
||
items={items}
|
||
status={status}
|
||
execution={execution}
|
||
preparationLabel={preparationLabel}
|
||
startedAtMs={runId ? Date.now() - 2_000 : null}
|
||
awaitingContinuation={awaitingContinuation}
|
||
continuedAfterSteering={continuedAfterSteering}
|
||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||
/>
|
||
</TaskChatRequestHistoryContext.Provider>
|
||
</ThemeProvider>
|
||
</MemoryRouter>,
|
||
),
|
||
);
|
||
|
||
it.each([null, "run-1"].flatMap(runId => [false, true].map(projected => ({ runId, projected }))))(
|
||
"shows Working and Thinking during ordinary admission (runId=$runId, projected=$projected)",
|
||
({ runId, projected }) => {
|
||
render([], "queued", runId, undefined, false, false, projected ? {
|
||
phase: "queued", label: "Queued", cause: null,
|
||
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
|
||
recoveryOwner: null, nextAction: null, permittedActions: ["inspect_run"],
|
||
predecessorRunId: null, successorRunId: null,
|
||
} : undefined);
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain("Working");
|
||
if (!runId) {
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toBe("Working");
|
||
}
|
||
const activity = container.querySelector('[data-testid="task-chat-current-activity-label"]');
|
||
expect(activity?.textContent).toBe("Thinking");
|
||
expect(activity?.classList.contains("shimmer-text")).toBe(true);
|
||
expect(container.textContent).not.toContain("Queued");
|
||
},
|
||
);
|
||
|
||
it("preserves a specific preparation label before a run ID exists", () => {
|
||
render([], "queued", null, undefined, false, false, undefined, "Preparing workspace");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Preparing workspace");
|
||
});
|
||
|
||
it.each([
|
||
["task_drain", "Waiting for maintenance"],
|
||
["agent_setup_pending", "Waiting for agent setup"],
|
||
["agent_setup_failed", "Agent setup needs attention"],
|
||
["server_restarting", "Waiting for server restart"],
|
||
["previous_run_settling", "Waiting for previous run to stop"],
|
||
])("shows the server's %s wait while admission is held", (cause, label) => {
|
||
render([], "queued", "run-1", undefined, false, false, {
|
||
phase: "queued", label, cause,
|
||
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
|
||
recoveryOwner: null, nextAction: "Starts automatically when maintenance finishes.",
|
||
permittedActions: ["inspect_run"], predecessorRunId: null, successorRunId: null,
|
||
});
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain(label);
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe(label);
|
||
expect(container.textContent).not.toContain("Thinking");
|
||
});
|
||
|
||
const resolvedQuestion = (
|
||
id: string,
|
||
prompt: string,
|
||
): TaskChatRuntimeRequestItem => ({
|
||
id,
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: id,
|
||
requestKind: "user_input",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "resolved",
|
||
prompt: "Codex needs your input.",
|
||
choices: [],
|
||
fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
questions: [
|
||
{ id: `${id}-field`, prompt, required: true, answerMode: "text" },
|
||
],
|
||
},
|
||
response: {
|
||
schema: "paperclip.question_response.v1",
|
||
answers: { [`${id}-field`]: { text: "Answered" } },
|
||
},
|
||
});
|
||
|
||
it("shows a stable Working header with Thinking at the turn tail", () => {
|
||
render([], "running");
|
||
expect(container.textContent).toContain("Thinking");
|
||
expect(container.textContent).toContain("Runner");
|
||
expect(
|
||
container.querySelector(
|
||
'[data-testid="task-chat-runner-disclosure-caret"]',
|
||
),
|
||
).toBeNull();
|
||
expect(
|
||
container.querySelector(
|
||
'[data-testid="task-chat-current-activity-icon"]',
|
||
),
|
||
).toBeNull();
|
||
expect(
|
||
container.querySelector(
|
||
'[data-testid="task-chat-current-activity-label"]',
|
||
)?.textContent,
|
||
).toBe("Thinking");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-current-activity"]')
|
||
?.tagName,
|
||
).toBe("DIV");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-turn-status-header"]')
|
||
?.textContent,
|
||
).toContain("Working for");
|
||
expect(container.textContent).not.toContain("Waiting for transcript");
|
||
const identity = container.querySelector(
|
||
'[data-testid="task-chat-agent-identity"]',
|
||
);
|
||
const status = container.querySelector(
|
||
'[data-testid="task-chat-turn-status-header"]',
|
||
);
|
||
const identityRow = container.querySelector(
|
||
'[data-testid="task-chat-runner-identity-row"]',
|
||
);
|
||
const activity = container.querySelector(
|
||
'[data-testid="task-chat-current-activity"]',
|
||
);
|
||
expect(identity?.parentElement).toBe(identityRow);
|
||
expect(status?.parentElement).toBe(identityRow);
|
||
expect(status?.getAttribute("data-turn-position")).toBe("identity");
|
||
expect(identity?.compareDocumentPosition(activity!)).toBe(
|
||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||
);
|
||
expect(activity?.getAttribute("data-turn-position")).toBe("tail");
|
||
expect(activity?.classList.contains("px-1")).toBe(true);
|
||
expect(activity?.firstElementChild?.hasAttribute("aria-hidden")).toBe(
|
||
false,
|
||
);
|
||
});
|
||
|
||
it.each(["reconnecting", "retry_scheduled"] as const)(
|
||
"keeps the active turn and Thinking tail visible with a %s projection",
|
||
(phase) => {
|
||
const execution = { phase } as ExecutionProjection;
|
||
render([], "running", "run-1", undefined, false, false, execution);
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
|
||
.toContain("Working for");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent)
|
||
.toBe("Thinking");
|
||
render([], "succeeded", "run-1", undefined, false, false, execution);
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
|
||
.toContain("Worked");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||
render([], "failed", "run-1", undefined, false, false, execution);
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
|
||
.toContain("Stopped");
|
||
expect(container.textContent).not.toContain("Reconnecting");
|
||
},
|
||
);
|
||
|
||
it.each(["Muse", "Dot"])("shows Waiting for %s until native acceptance, without a Thinking claim", provider => {
|
||
const waiting = { phase: "waiting_for_provider", label: `Waiting for ${provider}` } as ExecutionProjection;
|
||
render([], "running", "run-1", undefined, false, false, waiting);
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
|
||
.toContain(`Waiting for ${provider}`);
|
||
const tail = container.querySelector('[data-testid="task-chat-current-activity-label"]');
|
||
expect(tail?.textContent).toBe(`Waiting for ${provider}`);
|
||
expect(tail?.classList.contains("shimmer-text")).toBe(false);
|
||
expect(container.textContent).not.toContain("Working");
|
||
expect(container.textContent).not.toContain("Thinking");
|
||
|
||
// Re-render the same live turn after the native acceptance projection changes.
|
||
render([], "running", "run-1", undefined, false, false,
|
||
{ phase: "working", label: "Working" } as ExecutionProjection);
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
|
||
.toContain("Working for");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent)
|
||
.toBe("Thinking");
|
||
|
||
// A stale waiting projection must never override a terminal run outcome.
|
||
render([], "failed", "run-1", undefined, false, false, waiting);
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
|
||
.toContain("Stopped");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||
expect(container.textContent).not.toContain(`Waiting for ${provider}`);
|
||
});
|
||
|
||
it("labels the streaming tail as a continuation after steering", () => {
|
||
render([], "running", "run-1", undefined, false, true);
|
||
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-turn-status-header"]')
|
||
?.textContent,
|
||
).toContain("Continued after steering · Working for");
|
||
});
|
||
|
||
it("renders completed progress exactly once when the live run returns to Thinking", () => {
|
||
const text = "Understood—I’ll use a two-second wait instead.";
|
||
const items = transcriptToTaskChatItems(
|
||
[
|
||
{
|
||
kind: "thinking",
|
||
ts: "2026-09-04T13:39:20.000Z",
|
||
text: "",
|
||
lifecycle: "started",
|
||
channel: "summary",
|
||
itemId: "reasoning-1",
|
||
},
|
||
{
|
||
kind: "thinking",
|
||
ts: "2026-09-04T13:39:20.100Z",
|
||
text: "",
|
||
lifecycle: "completed",
|
||
channel: "summary",
|
||
itemId: "reasoning-1",
|
||
},
|
||
{
|
||
kind: "assistant",
|
||
ts: "2026-09-04T13:39:21.100Z",
|
||
text,
|
||
channel: "progress",
|
||
itemId: "message-1",
|
||
},
|
||
],
|
||
{ runId: "run-1", agentName: "Runner", running: true },
|
||
);
|
||
|
||
render(items);
|
||
|
||
expect(container.textContent?.split(text)).toHaveLength(2);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-phase-interstitial"]')
|
||
?.textContent,
|
||
).toContain(text);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-live-narration"]'),
|
||
).toBeNull();
|
||
expect(
|
||
container.querySelector(
|
||
'[data-testid="task-chat-current-activity-label"]',
|
||
)?.textContent,
|
||
).toBe("Thinking");
|
||
});
|
||
|
||
it("keeps progress mounted above its grouped current command", () => {
|
||
render([
|
||
{
|
||
id: "p1",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Running the exact command now.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
streaming: true,
|
||
},
|
||
{
|
||
id: "t1",
|
||
kind: "tool",
|
||
name: "command",
|
||
rawName: "Bash",
|
||
target: "for i in 1 2 3 4; do echo STREAM-$i; done",
|
||
status: "in_progress",
|
||
detail: "STREAM-1\n",
|
||
},
|
||
]);
|
||
const commentary = container.querySelector('[data-testid="task-chat-phase-interstitial"]');
|
||
const activity = container.querySelector('[data-testid="task-chat-activity-viewport"]');
|
||
expect(commentary?.textContent).toContain("Running the exact command now.");
|
||
expect(activity?.textContent).toContain("Running$ ");
|
||
expect(activity?.textContent).toContain("STREAM-$i");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||
expect(commentary?.compareDocumentPosition(activity!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||
|
||
});
|
||
|
||
it("keeps the timer at the top and moves resumed Thinking below completed activity", () => {
|
||
render([
|
||
{
|
||
id: "commentary",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "I’ll inspect the workspace.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "tool",
|
||
kind: "tool",
|
||
name: "Command",
|
||
rawName: "bash",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "thinking",
|
||
kind: "thinking",
|
||
lines: [],
|
||
lifecycleOnly: true,
|
||
streaming: true,
|
||
},
|
||
]);
|
||
|
||
const header = container.querySelector('[data-testid="task-chat-turn-status-header"]');
|
||
const activity = container.querySelector('[data-testid="task-chat-activity-viewport"]');
|
||
expect(header?.textContent).toContain("Working for");
|
||
expect(activity?.textContent).toBe("Thinking");
|
||
expect(header?.compareDocumentPosition(activity!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||
|
||
});
|
||
|
||
it("keeps earlier commentary mounted when later commentary streams", () => {
|
||
render([
|
||
{
|
||
id: "commentary",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "I’ll inspect the card.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
transcriptIndex: 1,
|
||
},
|
||
{
|
||
id: "reasoning",
|
||
kind: "thinking",
|
||
lines: ["The saved preview can be reused."],
|
||
streaming: true,
|
||
channel: "summary",
|
||
transcriptIndex: 2,
|
||
},
|
||
]);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-phase-interstitial"]')
|
||
?.textContent,
|
||
).toContain("I’ll inspect the card.");
|
||
|
||
render([
|
||
{
|
||
id: "commentary",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "I’ll inspect the card.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
transcriptIndex: 1,
|
||
},
|
||
{
|
||
id: "reasoning",
|
||
kind: "thinking",
|
||
lines: ["The saved preview can be reused."],
|
||
streaming: true,
|
||
channel: "summary",
|
||
transcriptIndex: 2,
|
||
},
|
||
{
|
||
id: "commentary-later",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "I’ve found the rendering seam.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
transcriptIndex: 3,
|
||
},
|
||
]);
|
||
const commentary = container.querySelectorAll(
|
||
'[data-testid="task-chat-phase-interstitial"]',
|
||
);
|
||
expect(commentary).toHaveLength(2);
|
||
expect(commentary[0]?.textContent).toContain("I’ll inspect the card.");
|
||
expect(commentary[1]?.textContent).toContain(
|
||
"I’ve found the rendering seam.",
|
||
);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-runner-activity-list"]'),
|
||
).toBeNull();
|
||
act(() =>
|
||
container
|
||
.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
)
|
||
?.click(),
|
||
);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-runner-activity-list"]')
|
||
?.textContent,
|
||
).toContain("Thought");
|
||
});
|
||
|
||
it("keeps the latest provider-authored reasoning line visible while activity is folded", () => {
|
||
render([
|
||
{
|
||
id: "reasoning",
|
||
kind: "thinking",
|
||
lines: ["Inspecting the task state.", "Checking the steering path."],
|
||
streaming: true,
|
||
channel: "summary",
|
||
transcriptIndex: 2,
|
||
},
|
||
]);
|
||
|
||
const ticker = container.querySelector(
|
||
'[data-testid="task-chat-activity-viewport"]',
|
||
);
|
||
expect(ticker?.textContent).toContain("Checking the steering path.");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-runner-activity-list"]'),
|
||
).toBeNull();
|
||
});
|
||
|
||
it("keeps a visible fallback when completion tools are filtered before the final reply", () => {
|
||
render([{ id: "finish", kind: "tool", name: "paperclip_finish", status: "in_progress" }]);
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Thinking");
|
||
expect(container.querySelector('[data-testid="task-chat-activity-phase"]')).toBeNull();
|
||
render([{ id: "finish-provider", kind: "protocol", surface: "provider_activity", family: "tool_execution", eventType: "tool.execution.started", status: "running", title: "Finish", details: [{ label: "Name", value: "paperclip_finish" }], steps: [], links: [], children: [] }]);
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Thinking");
|
||
expect(container.querySelector('[data-testid="task-chat-activity-phase"]')).toBeNull();
|
||
});
|
||
|
||
it("surfaces native activity transport failure while retrying", () => {
|
||
act(() =>
|
||
root.render(
|
||
<MemoryRouter>
|
||
<ThemeProvider>
|
||
<TaskChatRunnerTurn
|
||
runId="run-1"
|
||
agentName="Runner"
|
||
items={[]}
|
||
status="running"
|
||
startedAtMs={Date.now() - 2_000}
|
||
activityUnavailable
|
||
/>
|
||
</ThemeProvider>
|
||
</MemoryRouter>,
|
||
),
|
||
);
|
||
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-activity-unavailable"]')
|
||
?.textContent,
|
||
).toContain("temporarily unavailable");
|
||
});
|
||
|
||
it("starts a separate activity group at every commentary boundary", () => {
|
||
render([
|
||
{
|
||
id: "commentary-one",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "First phase.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "tool-one",
|
||
kind: "tool",
|
||
name: "Read",
|
||
rawName: "read_file",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "commentary-two",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Second phase.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "tool-two",
|
||
kind: "tool",
|
||
name: "Bash",
|
||
rawName: "bash",
|
||
status: "completed",
|
||
},
|
||
]);
|
||
const rows = container.querySelectorAll(
|
||
'[data-testid="task-chat-turn-timeline-row"]',
|
||
);
|
||
expect(rows).toHaveLength(2);
|
||
expect(rows[0]?.textContent).toContain("First phase.");
|
||
expect(rows[0]?.textContent).toContain("Read file");
|
||
expect(rows[1]?.textContent).toContain("Second phase.");
|
||
expect(rows[1]?.textContent).toContain("Ran a command");
|
||
act(() =>
|
||
rows[0]
|
||
?.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
)
|
||
?.click(),
|
||
);
|
||
expect(
|
||
rows[0]
|
||
?.querySelector('[data-activity-icon] svg')
|
||
?.parentElement?.classList.contains("size-5"),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("keeps internal reasoning nested inside its activity group", () => {
|
||
render([
|
||
{
|
||
id: "summary",
|
||
kind: "thinking",
|
||
lines: ["Inspect the current card."],
|
||
streaming: false,
|
||
channel: "summary",
|
||
transcriptIndex: 1,
|
||
},
|
||
{
|
||
id: "detail",
|
||
kind: "thinking",
|
||
lines: ["Keep the canonical revision atomic."],
|
||
streaming: true,
|
||
channel: "detail",
|
||
transcriptIndex: 2,
|
||
},
|
||
]);
|
||
expect(
|
||
container.querySelectorAll('[data-testid="task-chat-thinking"]'),
|
||
).toHaveLength(0);
|
||
act(() =>
|
||
container
|
||
.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
)
|
||
?.click(),
|
||
);
|
||
const history = container.querySelector('[data-testid="task-chat-runner-activity-list"]');
|
||
expect(container.querySelector('[data-testid="task-chat-runner-activity-rail"]')).toBeNull();
|
||
expect(history?.querySelectorAll("li")).toHaveLength(2);
|
||
expect(history?.textContent).toContain("ThoughtInspect the current card.");
|
||
expect(history?.textContent).toContain("ThinkingKeep the canonical revision atomic.");
|
||
expect(container.querySelector('[data-testid="task-chat-runner-activity-detail"]')).toBeNull();
|
||
|
||
});
|
||
|
||
it("renders only the current reasoning block as active", () => {
|
||
render([
|
||
{
|
||
id: "old-reasoning",
|
||
kind: "thinking",
|
||
lines: ["Inspect the current card."],
|
||
streaming: true,
|
||
transcriptIndex: 1,
|
||
},
|
||
{
|
||
id: "read",
|
||
kind: "tool",
|
||
name: "Read files",
|
||
rawName: "Read",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "current-reasoning",
|
||
kind: "thinking",
|
||
lines: ["Verify the updated state."],
|
||
streaming: true,
|
||
transcriptIndex: 3,
|
||
},
|
||
]);
|
||
|
||
act(() =>
|
||
container
|
||
.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
)
|
||
?.click(),
|
||
);
|
||
|
||
expect(container.querySelector('[data-activity-item-id="old-reasoning"]')?.textContent).toContain("ThoughtInspect the current card.");
|
||
expect(container.querySelector('[data-activity-item-id="current-reasoning"]')?.textContent).toContain("ThinkingVerify the updated state.");
|
||
|
||
});
|
||
|
||
it("does not let a textless reasoning lifecycle remove sticky commentary", () => {
|
||
render([
|
||
{
|
||
id: "commentary",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Old commentary",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
transcriptIndex: 1,
|
||
},
|
||
{
|
||
id: "reasoning",
|
||
kind: "thinking",
|
||
lines: [],
|
||
lifecycleOnly: true,
|
||
streaming: true,
|
||
transcriptIndex: 2,
|
||
},
|
||
]);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-phase-interstitial"]')
|
||
?.textContent,
|
||
).toContain("Old commentary");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-current-activity"]'),
|
||
).toBeNull();
|
||
expect(
|
||
container.querySelector(
|
||
'[data-testid="task-chat-activity-viewport"]',
|
||
)?.textContent,
|
||
).toBe("Thinking");
|
||
});
|
||
|
||
it("keeps the within-turn plan in activity history without rendering a Plan document preview", () => {
|
||
const activity: TaskChatProviderActivityItem = {
|
||
id: "live-plan",
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "plan",
|
||
eventType: "plan.updated",
|
||
status: "running",
|
||
title: "Plan",
|
||
details: [{ label: "Revision", value: "4" }],
|
||
steps: [
|
||
{
|
||
id: "one",
|
||
label: "Extract the shared preview",
|
||
status: "in_progress",
|
||
},
|
||
],
|
||
links: [],
|
||
children: [],
|
||
transcriptIndex: 1,
|
||
};
|
||
render([activity]);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-live-plan-preview"]'),
|
||
).toBeNull();
|
||
const disclosure = container.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
);
|
||
expect(disclosure?.getAttribute("aria-label")).toContain("Expand 1 activity");
|
||
act(() => disclosure?.click());
|
||
const history = container.querySelector(
|
||
'[data-testid="task-chat-runner-activity-list"]',
|
||
);
|
||
expect(history?.textContent).toContain("Updating the plan");
|
||
expect(
|
||
history?.querySelector('[data-activity-family="plan"]'),
|
||
).not.toBeNull();
|
||
expect(
|
||
history
|
||
?.querySelector('[data-activity-icon] svg')
|
||
?.parentElement?.classList.contains("size-5"),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("shows canonical web research as the current Codex-style activity", () => {
|
||
render([
|
||
{
|
||
id: "research-1",
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "research",
|
||
eventType: "research.started",
|
||
status: "running",
|
||
title: "Research",
|
||
summary: "site:openai.com model guide GPT-5.4",
|
||
details: [
|
||
{ label: "Query", value: "site:openai.com model guide GPT-5.4" },
|
||
],
|
||
steps: [],
|
||
links: [],
|
||
children: [],
|
||
},
|
||
]);
|
||
|
||
const activity = container.querySelector(
|
||
'[data-testid="task-chat-activity-viewport"]',
|
||
);
|
||
expect(activity?.textContent).toContain("Searching the web");
|
||
expect(activity?.textContent).toContain(
|
||
"site:openai.com model guide GPT-5.4",
|
||
);
|
||
expect(activity?.querySelector("[data-activity-family]")?.getAttribute("data-activity-family")).toBe("research");
|
||
});
|
||
|
||
it("has a purpose-built current-activity presentation for every provider family", () => {
|
||
const cases: Array<{
|
||
family: TaskChatProviderActivityFamily;
|
||
eventType: string;
|
||
status: TaskChatProviderActivityItem["status"];
|
||
expected: string;
|
||
details?: TaskChatProviderActivityItem["details"];
|
||
}> = [
|
||
{
|
||
family: "plan",
|
||
eventType: "plan.updated",
|
||
status: "running",
|
||
expected: "Updating the plan",
|
||
},
|
||
{
|
||
family: "tool_execution",
|
||
eventType: "tool.execution.started",
|
||
status: "running",
|
||
expected: "Running an unnamed tool",
|
||
},
|
||
{
|
||
family: "research",
|
||
eventType: "research.started",
|
||
status: "running",
|
||
expected: "Searching the web",
|
||
details: [{ label: "Action", value: "search" }],
|
||
},
|
||
{
|
||
family: "delegation",
|
||
eventType: "delegation.started",
|
||
status: "running",
|
||
expected: "Starting a subagent",
|
||
details: [{ label: "Action", value: "spawn" }],
|
||
},
|
||
{
|
||
family: "model_identity",
|
||
eventType: "model.route.changed",
|
||
status: "informational",
|
||
expected: "Switched models",
|
||
},
|
||
{
|
||
family: "context",
|
||
eventType: "context.compacted",
|
||
status: "completed",
|
||
expected: "Compacted context",
|
||
},
|
||
{
|
||
family: "artifact",
|
||
eventType: "artifact.generated",
|
||
status: "running",
|
||
expected: "Generating an artifact",
|
||
},
|
||
{
|
||
family: "review",
|
||
eventType: "review.mode.changed",
|
||
status: "informational",
|
||
expected: "Entered review mode",
|
||
details: [{ label: "State", value: "entered" }],
|
||
},
|
||
{
|
||
family: "hook",
|
||
eventType: "hook.started",
|
||
status: "running",
|
||
expected: "Running a hook",
|
||
},
|
||
{
|
||
family: "memory",
|
||
eventType: "memory.citation.referenced",
|
||
status: "informational",
|
||
expected: "Referenced memory",
|
||
},
|
||
{
|
||
family: "safety",
|
||
eventType: "safety.review.started",
|
||
status: "running",
|
||
expected: "Auto-reviewing",
|
||
},
|
||
{
|
||
family: "terminal",
|
||
eventType: "terminal.input.sent",
|
||
status: "informational",
|
||
expected: "Sent terminal input",
|
||
},
|
||
{
|
||
family: "wait",
|
||
eventType: "wait.started",
|
||
status: "running",
|
||
expected: "Waiting",
|
||
},
|
||
{
|
||
family: "provider_notice",
|
||
eventType: "provider.notice.recorded",
|
||
status: "informational",
|
||
expected: "Provider notice",
|
||
},
|
||
];
|
||
|
||
expect(cases.map((entry) => entry.family)).toEqual([
|
||
"plan",
|
||
"tool_execution",
|
||
"research",
|
||
"delegation",
|
||
"model_identity",
|
||
"context",
|
||
"artifact",
|
||
"review",
|
||
"hook",
|
||
"memory",
|
||
"safety",
|
||
"terminal",
|
||
"wait",
|
||
"provider_notice",
|
||
] satisfies TaskChatProviderActivityFamily[]);
|
||
|
||
for (const entry of cases) {
|
||
render([
|
||
{
|
||
id: `provider-${entry.family}`,
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: entry.family,
|
||
eventType: entry.eventType,
|
||
status: entry.status,
|
||
title: entry.family,
|
||
details: entry.details ?? [],
|
||
steps: [],
|
||
links: [],
|
||
children: [],
|
||
},
|
||
]);
|
||
const activity = container.querySelector(
|
||
'[data-testid="task-chat-activity-viewport"]',
|
||
);
|
||
expect(activity?.querySelector("[data-activity-family]")?.getAttribute("data-activity-family"), entry.family).toBe(
|
||
entry.family,
|
||
);
|
||
expect(activity?.textContent, entry.family).toContain(entry.expected);
|
||
}
|
||
});
|
||
|
||
it("shows provider failures and interruptions instead of a successful past-tense label", () => {
|
||
const provider = (
|
||
status: "failed" | "interrupted",
|
||
): TaskChatProviderActivityItem => ({
|
||
id: `research-${status}`,
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "research",
|
||
eventType: "research.completed",
|
||
status,
|
||
title: "Research",
|
||
details: [],
|
||
steps: [],
|
||
links: [],
|
||
children: [],
|
||
});
|
||
render([provider("failed")]);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-activity-viewport"]')
|
||
?.textContent,
|
||
).toContain("Web search failed");
|
||
render([provider("interrupted")]);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-activity-viewport"]')
|
||
?.textContent,
|
||
).toContain("Web search stopped");
|
||
});
|
||
|
||
it("surfaces workspace changes and file references as current activity", () => {
|
||
render([
|
||
{
|
||
id: "workspace-change",
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
changeSetId: "change-1",
|
||
revision: 1,
|
||
source: "harness_reported",
|
||
complete: false,
|
||
files: [],
|
||
totals: { files: 2, additions: 3, deletions: 1 },
|
||
patchArtifactRef: null,
|
||
},
|
||
]);
|
||
const card = container.querySelector(
|
||
'[data-testid="task-chat-workspace-change"]',
|
||
);
|
||
expect(card).not.toBeNull();
|
||
expect(
|
||
card?.closest('[data-testid="task-chat-activity-phase"]'),
|
||
).toBeNull();
|
||
|
||
render([
|
||
{
|
||
id: "workspace-file",
|
||
kind: "protocol",
|
||
surface: "workspace_file",
|
||
referenceId: "file-1",
|
||
source: "runner_verified",
|
||
path: "ui/src/App.tsx",
|
||
displayName: "App.tsx",
|
||
mediaType: "text/typescript",
|
||
presentation: "code",
|
||
line: 42,
|
||
preview: null,
|
||
previewTruncated: false,
|
||
},
|
||
]);
|
||
const activity = container.querySelector(
|
||
'[data-testid="task-chat-activity-viewport"]',
|
||
);
|
||
expect(activity?.textContent).toContain("Referenced a file");
|
||
expect(activity?.textContent).toContain("ui/src/App.tsx:42");
|
||
const icon = activity?.querySelector(
|
||
'[data-activity-icon] svg',
|
||
);
|
||
expect(icon).not.toBeNull();
|
||
expect(icon?.parentElement?.classList.contains("size-5")).toBe(true);
|
||
expect(icon?.parentElement?.classList.contains("justify-center")).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it("streams the final response in its durable slot and settles the disclosure", () => {
|
||
render(
|
||
[
|
||
{
|
||
id: "p1",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Checking.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "f1",
|
||
kind: "message",
|
||
author: "agent",
|
||
authorName: "Runner",
|
||
text: "Completed successfully.",
|
||
channel: "final",
|
||
streaming: true,
|
||
},
|
||
],
|
||
"succeeded",
|
||
);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]')
|
||
?.textContent,
|
||
).toContain("Completed successfully.");
|
||
expect(
|
||
container.querySelectorAll('[data-testid="task-chat-agent-avatar"]'),
|
||
).toHaveLength(1);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-turn-status-header"]')
|
||
?.textContent,
|
||
).toContain("Worked for");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-phase-interstitial"]')
|
||
?.textContent,
|
||
).toContain("Checking.");
|
||
});
|
||
|
||
it("collapses progress text repeated verbatim by the final response", () => {
|
||
const repeated = "BASELINE-DONE";
|
||
render(
|
||
[
|
||
{
|
||
id: "progress",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: repeated,
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "tool",
|
||
kind: "tool",
|
||
name: "Command",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "final",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: repeated,
|
||
channel: "final",
|
||
},
|
||
],
|
||
"succeeded",
|
||
);
|
||
|
||
expect(container.textContent?.split(repeated)).toHaveLength(2);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-phase-interstitial"]'),
|
||
).toBeNull();
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]')
|
||
?.textContent,
|
||
).toContain(repeated);
|
||
});
|
||
|
||
it("clears provider wait prose when the run is accepted as yielded", () => {
|
||
const providerWait: TaskChatItem = {
|
||
id: "provider-wait",
|
||
kind: "message",
|
||
author: "agent",
|
||
authorName: "Runner",
|
||
text: "Waiting for Review browser RTS plan.",
|
||
channel: "final",
|
||
streaming: false,
|
||
};
|
||
render([providerWait], "succeeded");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]'),
|
||
).not.toBeNull();
|
||
|
||
render(
|
||
[
|
||
providerWait,
|
||
{
|
||
id: "result",
|
||
kind: "protocol",
|
||
surface: "run_result",
|
||
disposition: "yielded",
|
||
summary: "Waiting for Review browser RTS plan.",
|
||
objectiveSatisfied: false,
|
||
verification: [],
|
||
remainingWork: [],
|
||
blocker: null,
|
||
artifacts: [],
|
||
},
|
||
],
|
||
"succeeded",
|
||
);
|
||
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]'),
|
||
).toBeNull();
|
||
});
|
||
|
||
it("clears provider wait prose as soon as interaction authority is known", () => {
|
||
const providerWait: TaskChatItem = {
|
||
id: "provider-wait-before-result",
|
||
kind: "message",
|
||
author: "agent",
|
||
authorName: "Runner",
|
||
text: "Waiting for structured input.",
|
||
channel: "final",
|
||
streaming: false,
|
||
};
|
||
render([providerWait], "running");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]'),
|
||
).not.toBeNull();
|
||
|
||
render([providerWait], "running", "run-1", undefined, true);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]'),
|
||
).toBeNull();
|
||
});
|
||
|
||
it("waits for settlement before using a structured result fallback", () => {
|
||
const items: TaskChatItem[] = [
|
||
{
|
||
id: "result",
|
||
kind: "protocol",
|
||
surface: "run_result",
|
||
disposition: "done",
|
||
summary: "Structured fallback.",
|
||
objectiveSatisfied: true,
|
||
verification: [],
|
||
remainingWork: [],
|
||
blocker: null,
|
||
artifacts: [],
|
||
},
|
||
];
|
||
|
||
render(items, "running");
|
||
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]'),
|
||
).toBeNull();
|
||
});
|
||
|
||
it("lets a shorter provider final replace a structured result fallback", () => {
|
||
const result: TaskChatItem = {
|
||
id: "result",
|
||
kind: "protocol",
|
||
surface: "run_result",
|
||
disposition: "done",
|
||
summary: "A much longer structured summary fallback.",
|
||
objectiveSatisfied: true,
|
||
verification: [],
|
||
remainingWork: [],
|
||
blocker: null,
|
||
artifacts: [],
|
||
};
|
||
|
||
render([result], "succeeded");
|
||
expect(container.textContent).toContain(
|
||
"A much longer structured summary fallback.",
|
||
);
|
||
|
||
render(
|
||
[
|
||
result,
|
||
{
|
||
id: "provider-final",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Done.",
|
||
channel: "final",
|
||
streaming: false,
|
||
},
|
||
],
|
||
"succeeded",
|
||
);
|
||
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]')
|
||
?.textContent,
|
||
).toContain("Done.");
|
||
expect(container.textContent).not.toContain(
|
||
"A much longer structured summary fallback.",
|
||
);
|
||
});
|
||
|
||
it("collapses terminal reasoning to the existing Worked summary", () => {
|
||
render(
|
||
[
|
||
{
|
||
id: "reasoning",
|
||
kind: "thinking",
|
||
lines: ["Provider-authored trace"],
|
||
channel: "summary",
|
||
transcriptIndex: 1,
|
||
},
|
||
],
|
||
"succeeded",
|
||
);
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-live-narration"]'),
|
||
).toBeNull();
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-turn-status-header"]')
|
||
?.textContent,
|
||
).toContain("Worked for");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
|
||
?.textContent,
|
||
).toContain("Thought");
|
||
});
|
||
|
||
it("keeps final text mounted through a transient replay gap", () => {
|
||
render([
|
||
{
|
||
id: "f1",
|
||
kind: "message",
|
||
author: "agent",
|
||
authorName: "Runner",
|
||
text: "Completed successfully.",
|
||
channel: "final",
|
||
streaming: true,
|
||
},
|
||
]);
|
||
render([
|
||
{ id: "t1", kind: "tool", name: "Paperclip_finish", status: "completed" },
|
||
]);
|
||
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]')
|
||
?.textContent,
|
||
).toContain("Completed successfully.");
|
||
expect(
|
||
container.querySelectorAll('[data-testid="task-chat-final-response"]'),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
it("clears replay-latched final text when the next run takes over the lane", () => {
|
||
render(
|
||
[
|
||
{
|
||
id: "f1",
|
||
kind: "message",
|
||
author: "agent",
|
||
authorName: "Runner",
|
||
text: "First answer.",
|
||
channel: "final",
|
||
},
|
||
],
|
||
"running",
|
||
"run-1",
|
||
);
|
||
render([], "running", "run-2");
|
||
|
||
expect(container.textContent).not.toContain("First answer.");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-current-activity"]')
|
||
?.textContent,
|
||
).toContain("Thinking");
|
||
});
|
||
|
||
it("omits runner lifecycle and token noise while preserving useful history", () => {
|
||
render([
|
||
{
|
||
id: "session",
|
||
kind: "marker",
|
||
variant: "session_start",
|
||
label: "Session started",
|
||
detail: "session-id",
|
||
},
|
||
{
|
||
id: "start",
|
||
kind: "marker",
|
||
variant: "turn_boundary",
|
||
label: "Turn started",
|
||
},
|
||
{
|
||
id: "usage",
|
||
kind: "usage",
|
||
usage: {
|
||
used: 10_520,
|
||
size: 0,
|
||
inputTokens: 10_254,
|
||
outputTokens: 266,
|
||
},
|
||
},
|
||
{
|
||
id: "tool",
|
||
kind: "tool",
|
||
name: "Paperclip_finish",
|
||
rawName: "paperclip_finish",
|
||
target: "reportedWorkDisposition: done",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "interrupt",
|
||
kind: "marker",
|
||
variant: "interrupted",
|
||
label: "Interrupted",
|
||
},
|
||
{
|
||
id: "complete",
|
||
kind: "marker",
|
||
variant: "turn_boundary",
|
||
label: "Turn completed",
|
||
},
|
||
]);
|
||
|
||
expect(container.textContent).not.toContain("Paperclip_finish");
|
||
expect(container.textContent).toContain("Interrupted");
|
||
expect(container.textContent).not.toContain("Session started");
|
||
expect(container.textContent).not.toContain("Turn started");
|
||
expect(container.textContent).not.toContain("Turn completed");
|
||
expect(container.textContent).not.toContain("10,520");
|
||
act(() =>
|
||
container
|
||
.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
)
|
||
?.click(),
|
||
);
|
||
expect(
|
||
container
|
||
.querySelector('[data-activity-icon] svg')
|
||
?.parentElement?.classList.contains("size-5"),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("keeps a compact pending runtime entry without duplicating composer controls", async () => {
|
||
const onDecision = vi.fn();
|
||
render(
|
||
[
|
||
{
|
||
id: "request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-1",
|
||
requestKind: "command_approval",
|
||
turnId: "turn-1",
|
||
requestType: "permission",
|
||
status: "pending",
|
||
prompt: "Allow command?",
|
||
choices: [{ key: "accept", label: "Allow once" }],
|
||
fields: [],
|
||
},
|
||
],
|
||
"running",
|
||
"run-1",
|
||
onDecision,
|
||
);
|
||
|
||
expect(
|
||
container.querySelector(
|
||
'[data-testid="task-chat-runtime-request-marker"]',
|
||
),
|
||
).toBeNull();
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-runtime-request"]'),
|
||
).toBeNull();
|
||
expect(container.querySelector('[data-testid="task-chat-pending-runtime-request"]')?.textContent).toContain("Allow command?");
|
||
expect(onDecision).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps a resolved question as a compact expandable receipt outside folded activity", () => {
|
||
render(
|
||
[
|
||
{
|
||
id: "resolved-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-1",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "resolved",
|
||
prompt: "Codex needs your input.",
|
||
choices: [],
|
||
fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
questions: [
|
||
{
|
||
id: "goal",
|
||
prompt: "What should the server do?",
|
||
required: true,
|
||
answerMode: "text",
|
||
},
|
||
],
|
||
},
|
||
response: {
|
||
schema: "paperclip.question_response.v1",
|
||
answers: { goal: { text: "Serve a small JSON API." } },
|
||
},
|
||
},
|
||
],
|
||
"succeeded",
|
||
);
|
||
|
||
const receipt = container.querySelector<HTMLDetailsElement>(
|
||
'[data-testid="task-chat-runtime-request"]',
|
||
);
|
||
const history = receipt?.querySelector(
|
||
'[data-testid="task-chat-runtime-request-history"]',
|
||
);
|
||
expect(receipt?.open).toBe(false);
|
||
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
||
"Questions answered",
|
||
);
|
||
expect(history?.closest(".tc-turn-fold")).toBeNull();
|
||
expect(history?.textContent).toContain("What should the server do?");
|
||
expect(history?.textContent).toContain("Serve a small JSON API.");
|
||
});
|
||
|
||
it("renders the DOT-217 mixed turn in exact transcript order", () => {
|
||
render(
|
||
[
|
||
{
|
||
id: "commentary-1",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "I’ll inspect the workspace first.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "tool-1",
|
||
kind: "tool",
|
||
name: "Read",
|
||
rawName: "read_file",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "tool-2",
|
||
kind: "tool",
|
||
name: "Read",
|
||
rawName: "read_file",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "commentary-2",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "I need the first product choices.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
resolvedQuestion("questions-1", "Which core loop?"),
|
||
{
|
||
id: "commentary-3",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Now I’ll pin down the simulation.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "tool-3",
|
||
kind: "tool",
|
||
name: "Search",
|
||
rawName: "web_search",
|
||
status: "completed",
|
||
},
|
||
resolvedQuestion("questions-2", "How should time work?"),
|
||
{
|
||
id: "commentary-4",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "The final implementation shape is clear.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "tool-4",
|
||
kind: "tool",
|
||
name: "Bash",
|
||
rawName: "bash",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "final",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "The plan is ready.",
|
||
channel: "final",
|
||
},
|
||
],
|
||
"succeeded",
|
||
);
|
||
|
||
const timeline = container.querySelector(
|
||
'[data-testid="task-chat-turn-timeline"]',
|
||
);
|
||
const rows = Array.from(
|
||
timeline?.querySelectorAll(
|
||
'[data-testid="task-chat-turn-timeline-row"]',
|
||
) ?? [],
|
||
);
|
||
expect(rows.map((row) => row.getAttribute("data-timeline-row-id"))).toEqual(
|
||
[
|
||
"commentary-1:phase",
|
||
"commentary-2:phase",
|
||
"questions-1",
|
||
"commentary-3:phase",
|
||
"questions-2",
|
||
"commentary-4:phase",
|
||
],
|
||
);
|
||
expect(rows[0]?.textContent).toContain("Read file");
|
||
expect(rows[2]?.textContent).toContain("Questions answered");
|
||
expect(rows[3]?.textContent).toContain("Searched the web");
|
||
expect(rows[5]?.textContent).toContain("Ran commands");
|
||
const worked = container.querySelector(
|
||
'[data-testid="task-chat-turn-status-header"]',
|
||
);
|
||
const identityRow = container.querySelector(
|
||
'[data-testid="task-chat-runner-identity-row"]',
|
||
);
|
||
const identity = container.querySelector(
|
||
'[data-testid="task-chat-agent-identity"]',
|
||
);
|
||
const final = container.querySelector(
|
||
'[data-testid="task-chat-final-response"]',
|
||
);
|
||
expect(worked?.getAttribute("data-turn-position")).toBe("identity");
|
||
expect(worked?.parentElement).toBe(identityRow);
|
||
expect(identity?.parentElement).toBe(identityRow);
|
||
expect(identityRow?.classList.contains("items-center")).toBe(true);
|
||
expect(worked?.classList.contains("border-b")).toBe(false);
|
||
expect(worked?.compareDocumentPosition(timeline!)).toBe(
|
||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||
);
|
||
expect(timeline?.compareDocumentPosition(final!)).toBe(
|
||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||
);
|
||
});
|
||
|
||
it("expands a grouped live phase without hiding its commentary", () => {
|
||
render([
|
||
{
|
||
id: "thinking-empty",
|
||
kind: "thinking",
|
||
lines: [],
|
||
streaming: false,
|
||
lifecycleOnly: true,
|
||
},
|
||
{
|
||
id: "commentary",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "Checking the current implementation.",
|
||
interstitial: true,
|
||
channel: "progress",
|
||
},
|
||
{
|
||
id: "tool",
|
||
kind: "tool",
|
||
name: "Read",
|
||
rawName: "read_file",
|
||
target: "ui/src/App.tsx",
|
||
status: "completed",
|
||
detail: "export function App() {}",
|
||
},
|
||
]);
|
||
|
||
const disclosure = container.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
);
|
||
expect(disclosure?.getAttribute("aria-expanded")).toBe("false");
|
||
expect(
|
||
container.querySelectorAll('[data-testid="task-chat-thinking"]'),
|
||
).toHaveLength(0);
|
||
|
||
act(() => disclosure?.click());
|
||
|
||
expect(disclosure?.getAttribute("aria-expanded")).toBe("true");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-runner-activity-list"]'),
|
||
).not.toBeNull();
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-phase-interstitial"]')
|
||
?.textContent,
|
||
).toContain("Checking the current implementation.");
|
||
expect(container.textContent).toContain("ui/src/App.tsx");
|
||
});
|
||
|
||
it("preserves an open phase while the live run becomes terminal", () => {
|
||
const items: TaskChatItem[] = [
|
||
{
|
||
id: "tool",
|
||
kind: "tool",
|
||
name: "Search",
|
||
rawName: "web_search",
|
||
target: "smoked pork shoulder",
|
||
status: "in_progress",
|
||
},
|
||
];
|
||
render(items);
|
||
const disclosure = container.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
);
|
||
act(() => disclosure?.click());
|
||
expect(disclosure?.getAttribute("aria-expanded")).toBe("true");
|
||
|
||
render(
|
||
[{ ...items[0], kind: "tool", status: "completed" } as TaskChatItem],
|
||
"succeeded",
|
||
);
|
||
|
||
const settled = container.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
);
|
||
expect(settled?.getAttribute("aria-expanded")).toBe("true");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-turn-status-header"]')
|
||
?.textContent,
|
||
).toContain("Worked for");
|
||
});
|
||
|
||
it("resets phase disclosure state when a different run takes over", () => {
|
||
const items: TaskChatItem[] = [
|
||
{
|
||
id: "tool",
|
||
kind: "tool",
|
||
name: "Read",
|
||
rawName: "read_file",
|
||
target: "ui/src/App.tsx",
|
||
status: "completed",
|
||
},
|
||
];
|
||
render(items, "running", "run-1");
|
||
act(() =>
|
||
container
|
||
.querySelector<HTMLButtonElement>(
|
||
'[data-testid="task-chat-activity-phase-toggle"]',
|
||
)
|
||
?.click(),
|
||
);
|
||
expect(
|
||
container
|
||
.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
|
||
?.getAttribute("aria-expanded"),
|
||
).toBe("true");
|
||
|
||
render(items, "running", "run-2");
|
||
|
||
expect(
|
||
container
|
||
.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
|
||
?.getAttribute("aria-expanded"),
|
||
).toBe("false");
|
||
});
|
||
|
||
it("renders a failed terminal disclosure without treating prose as status authority", () => {
|
||
render(
|
||
[
|
||
{
|
||
id: "final",
|
||
kind: "message",
|
||
author: "agent",
|
||
text: "I finished the draft.",
|
||
channel: "final",
|
||
},
|
||
{
|
||
id: "tool",
|
||
kind: "tool",
|
||
name: "Command",
|
||
rawName: "bash",
|
||
target: "pnpm test",
|
||
status: "failed",
|
||
},
|
||
],
|
||
"failed",
|
||
);
|
||
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-turn-status-header"]')
|
||
?.textContent,
|
||
).toContain("Stopped after");
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-final-response"]')
|
||
?.textContent,
|
||
).toContain("I finished the draft.");
|
||
});
|
||
});
|