mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task chat shows each native runner turn while it streams, while it settles, and after it settles. > - During the onboarding interview, the agent streams a reply, posts a question card, and ends its turn as `yielded`. > - The live tail hides that reply when the card's pending interaction arrives. The settled turn also hides it, because a yielded turn never fills the final-response slot. > - The user sees the reply appear and then disappear. The reply is still in run history, so this is a rendering regression, not data loss. > - This pull request hides only the control-plane wait stub of a yielded turn. It keeps prose that the agent wrote for the user. > - The benefit is a stable conversation while responses stream and finish. ## Linked Issues or Issue Description **Bug description** During a hands-on onboarding test on staging, interview messages in the first-task chat appeared and then disappeared. **Steps to reproduce** 1. Start onboarding and pick "Interview me and propose a plan". 2. Watch the agent's turn. It streams a reply that describes the question card, for example "I've posted a question card on this task that asks four things...". 3. The question card appears. The reply disappears and does not come back after the run settles or after a reload. **Expected behavior** The conversation stays stable while responses stream and finish. **Related** Refs #18977. That PR changes server-side final-response selection for native standing conversations and leaves yielded waits and cards in control. This PR changes only how the task chat renders a yielded turn's authored reply. The two PRs do not overlap. **Evidence and root cause** - Persisted history is complete. The run's events contain the `agentMessage` `item.completed` with `channel: "final"` and the full reply text. The `run.result.accepted` result has `reportedWorkDisposition: "yielded"` with a `response_wake` continuation. Its summary is different from the reply. - The rendered thread does not contain that reply, also after "More messages" expands the history. - Classification: rendering/replacement, not persistence. Three gates drop the reply. `TaskChatThread` sets `suppressFinal` on the live tail when a pending interaction for that run exists. `TaskChatRunnerTurn` then clears the latched final. `paperclipRunnerFinalResponse` and the settled-turn path (`!sourceYielded`) return no final response for yielded turns. - Those gates were added on purpose so the mechanical "Waiting for <card>." prose that some providers echo does not show as an answer. This change keeps that behavior. ## What Changed - `transcript-adapter.ts`: add `isYieldedWaitStub`. A wait stub is text that echoes the yielded result summary, or a single-line "Waiting for ..." text. For a yielded turn, `paperclipRunnerFinalResponse` now returns the last authored final-channel message. It still returns nothing for a wait stub. New `yielded` and `waitSummary` options let callers state the yield from run metadata. - `TaskChatRunnerTurn.tsx`: `suppressFinal` hides only a wait stub. It no longer hides the agent's authored reply. A new `waitSummary` prop carries the accepted summary. - `TaskChatThread.tsx`: a settled yielded turn renders the authored reply once, in the last populated segment. The reply also counts as a native response for the "no user-facing response" marker and for progress de-duplication. The live tail passes the accepted summary to the runner turn. - Review round 1 (commitc134d72620): a run that posted a question card stays out of the accepted response-wake path while the card is pending and after it is answered. Before, an answered card let the wait summary replace the reply. The backward scan now skips wait stubs, so a stub echoed after the reply does not hide it. The accepted response-wake summary still has precedence for runs that posted no card. The existing contract test for that still passes. - Review round 2 (commit4f300fbb3e): when a steering anchor splits a yielded turn, the reply renders in the segment that contains it. The settled final-response choice moves from a nested ternary into a helper. `TaskChatRunnerTurn`'s `suppressFinal` prop is renamed `awaitingContinuation`. - Tests: a `TaskChatThread` red/green test feeds native run events through the three states: live stream, live with the pending card, and settled as yielded. An adapter unit test covers authored prose versus the two wait-stub forms. ## Verification Red (current `master` source with the new test). The reply disappears while the run is still live, at the moment the question card arrives: ``` RUN v5.0.3 /Users/dotta/paperclipai/paperclip/.claude/worktrees/fix-bug02-vanishing-messages-3e49c5/ui ❯ src/components/TaskChatThread.test.tsx (365 tests | 1 failed | 364 skipped) 664ms ❯ TaskChatThread runtime transcript selection (169) × keeps a streamed interview reply visible after its run yields to a question card 662ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL src/components/TaskChatThread.test.tsx > TaskChatThread runtime transcript selection > keeps a streamed interview reply visible after its run yields to a question card AssertionError: expected 'Unanswered questionWhat does your org…' to contain 'I\'ve posted a question card' Expected: "I've posted a question card" Received: "Unanswered questionWhat does your organization do?RunnerWorking for 103767m 18sThinkingWhat does your organization do?What does your organization do?YesNoOther CancelSubmit answers" ❯ src/components/TaskChatThread.test.tsx:3215:35 3213| linkedRuns: [linkedRun("running")], 3214| }); 3215| expect(container.textContent).toContain("I've posted a question ca… | ^ 3216| 3217| // 3. Settled: the run yielded to the question card it posted. ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed | 364 skipped (365) Start at 13:26:47 Duration 31.59s (transform 71%, import 19%, environment 8%, tests 2%) ``` The first red run failed one step later, at the settled step (before I added the live-with-card step). Both steps fail on `master`: ``` AssertionError: expected 'RunnerWorked for 30sUnanswered questi…' to contain 'I\'ve posted a question card' Received: "RunnerWorked for 30sUnanswered questionWhat does your organization do?...Submit answers" ``` Green (same test, with the fix): ``` Test Files 2 passed (2) Tests 2 passed | 479 skipped (481) ``` Neighbouring suites (`TaskChatThread*`, all of `src/components/task-chat/`, `IssueChatThread`, `issue-chat-messages`): ``` Test Files 59 passed (59) Tests 1411 passed | 1 skipped (1412) ``` This includes the existing tests "keeps a yielded question turn out of the final-response slot", "keeps a yielded run-result and provider wait prose out of the durable final slot", and every "renders only an authorized terminal response-wake answer" mode. They still pass, so wait stubs stay hidden. Review round 1 red/green. I added a fourth test step that answers the card. With the old pending-only check, that step fails: ``` × keeps a streamed interview reply visible after its run yields to a question card Received: "RunnerWorked for 30sWaiting for user answers on the interview question card.Questions answered..." ``` Withc134d72620, the step passes. The neighbouring suites pass again: `Test Files 59 passed (59)`, `Tests 1411 passed | 1 skipped (1412)`. Review round 2 red/green. A new steered-yielded test fails with the old last-segment placement (`expected 163 to be less than 52`: the reply rendered after the steering message). It passes with the fix. Neighbouring suites, now including `design-sandbox`: `Test Files 61 passed (61)`, `Tests 1426 passed | 1 skipped (1427)`. Other checks: - `node scripts/check-token-gates.mjs`: all gates clean. The change has no styling edits. - `ui` `tsc --noEmit`: no errors in the changed files. The remaining errors are already on `master` in this worktree: missing generated storybook catalog JSON, and workspace packages that are not built. - I did not run the full `pnpm -r typecheck`, `pnpm test:run`, or `pnpm build` locally. CI runs them. ## Risks - A yielded turn can now show a final reply above its question card. This is the text that the user already saw while the turn streamed. The text now stays instead of flashing away. - The wait-stub check is heuristic. A one-line authored reply that starts with "Waiting for" stays hidden, as before. A multi-line provider stub that does not match the summary now shows. - No data, API, or schema changes. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, file edit, browser inspection of the staging run history). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1651 lines
50 KiB
TypeScript
1651 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={Date.now() - 2_000}
|
||
awaitingContinuation={awaitingContinuation}
|
||
continuedAfterSteering={continuedAfterSteering}
|
||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||
/>
|
||
</TaskChatRequestHistoryContext.Provider>
|
||
</ThemeProvider>
|
||
</MemoryRouter>,
|
||
),
|
||
);
|
||
|
||
it("shows admission waiting instead of Thinking for a queued turn", () => {
|
||
render([], "queued");
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain("Queued");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Queued");
|
||
expect(container.textContent).not.toContain("Working");
|
||
expect(container.textContent).not.toContain("Thinking");
|
||
});
|
||
|
||
it("shows queued startup before a run ID or preparation label exists", () => {
|
||
render([], "queued", null);
|
||
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain("Queued");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Queued");
|
||
expect(container.textContent).not.toContain("Thinking");
|
||
});
|
||
|
||
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("shows the server's maintenance wait while admission is held", () => {
|
||
render([], "queued", "run-1", undefined, false, false, {
|
||
phase: "queued", label: "Waiting for maintenance", cause: "task_drain",
|
||
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("Waiting for maintenance");
|
||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Waiting for maintenance");
|
||
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.");
|
||
});
|
||
});
|