mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix(chat): hide obsolete execution status and system notices (#14874)
## Thinking Path > - Paperclip lets people oversee agent work through task conversations. > - Conversations should show failures and waits that still affect the task. > - Old run errors and recovery notices remained visible after later work or task completion. > - These messages looked current even when no action remained. > - This change hides obsolete execution status while preserving the responses and activity. > - The full diagnostic record stays available in run history. ## Linked Issues or Issue Description **What happened?** Task chat kept showing “Run failed”, “Stopped”, and “Waiting to resume” after the execution had been superseded or the task had finished. Stored system notices also remained in the conversation. Completed tasks disabled Retry but kept the error message. **Expected behavior** Hide system status that no longer applies. Keep the latest unresolved failure, active recovery holds, and useful recovery actions visible. Preserve messages, files, questions, session boundaries, and inspectable activity. **Steps to reproduce** 1. Let a task run fail, then record a pre-start recovery wait. 2. Complete a later attempt or mark the task done. 3. Open the task conversation. Before this change, the old error and wait remain visible. **Paperclip version or commit** Reproduced in component tests against `0f9e9be408`. **Deployment mode** Task chat in local or hosted deployments; both legacy adapters and the native runner. Related: #14857 and #14869 address execution recovery. This PR addresses the remaining conversation presentation. Searched GitHub for historical chat status, historical errors, and “Waiting to resume”; no duplicate PR found. ## What Changed - Determine status relevance from task state, attempt order, successor evidence, and recovery state. Time alone does not hide errors. - Hide obsolete run markers and stored execution notices. Require run or recovery provenance, so unrelated system updates such as child-task blockers remain visible. Keep errors from the latest failed attempt actionable. - Keep unresolved execution holds visible. A refused pre-start retry does not replace a real attempt, and another agent’s work does not resolve a run-specific error. - Show historical activity without Worked/Stopped labels. Keep historical failures out of the current turn’s summary. - Anchor activity to visible comments so removing a notice cannot remove the response or activity with it. - Document the presentation rules and cover both runner modes and both task presentation modes. ## Verification - 334 focused component and status-policy tests passed across four files, including the child-task relay regressions. - `pnpm build` passed. The UI build also passed after the final presentation changes. - `pnpm exec vitest run --project @paperclipai/ui`: 667 files and 7,157 tests passed. Subsequent focused tests cover the final activity-anchor, live-successor, and notice-provenance changes. - `pnpm -r typecheck` passed. UI typecheck and build passed again after the review fix. - `pnpm test:run` completed its general-server phase with 14,716 tests passed, 17 failed, and 87 skipped; it stopped before later phases. The failures occurred in four unchanged server suites: chat channels, email channels, company skills, and runtime skill cache. A targeted rerun reproduced missing bundled skill paths and `EACCES` during cache-directory rename on macOS. All Linux CI suites pass for the final commit, including these server suites. - Design token gates and diff checks pass. - All 53 checks pass on commit `7af9753859`; two optional Storybook checks are skipped. [Final CI run](https://github.com/paperclipai/paperclip/actions/runs/36931968751) includes build, full typecheck, all server and workspace test shards, all eight end-to-end shards, runner verification, and the canary dry run. - Greptile scores the final commit at 5/5. No review threads remain unresolved. The branch is current with `master` and has no merge conflicts. ## Risks This changes presentation only. It does not change execution, recovery, stored comments, or run history. The main risk is hiding a current diagnostic too early. Tests cover active holds, refused retries, different agents, missing timestamps and provenance, live successors, preserved responses, and the current retry target. The base branch has a dependency override/lockfile mismatch. Local installation used the same resolution fallback as CI, then restored the tracked lockfile. No dependency changes are included. ## Model Used OpenAI Codex (GPT-6). The exact runtime model identifier and context window are not exposed in this session. Used reasoning, repository inspection, code execution, and regression tests. ## 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 - [ ] I have run tests locally and they pass — changed-code tests pass; unrelated full-suite failures are documented above - [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>
This commit is contained in:
1 parent
2a36e915ef
commit
4b2bd6563d
9 files changed
+434
-22
No files matched your search
@@ -45,6 +45,16 @@ alignment in every step and conditional state, not just the first screen.
|
||||
|
||||
## Contextual feedback
|
||||
|
||||
Task chat shows execution errors and waits only while they remain relevant.
|
||||
Completing or cancelling a task hides its old execution notices. A newer attempt by
|
||||
the same agent or an explicit successor supersedes earlier run notices; an
|
||||
unresolved execution hold remains visible. Historical turns keep their responses,
|
||||
files, questions and inspectable activity without a Worked/Stopped status label.
|
||||
Run history retains the full diagnostic record. Session reset boundaries remain
|
||||
in the conversation. Time passing or a new human comment alone does not resolve
|
||||
an error. Stored notices need run or recovery provenance before they can be hidden;
|
||||
child-task relays and other unrelated system updates stay visible.
|
||||
|
||||
Do not show a toast for task or run state already visible on the current screen.
|
||||
This includes descendant runs represented by the open subtree. Show local action
|
||||
results in place; keep failures actionable inline. Notifications for other work
|
||||
|
||||
@@ -152,6 +152,128 @@ function render(ui: ReactElement) {
|
||||
);
|
||||
}
|
||||
|
||||
describe.each(["legacy", "native"] as const)("historical %s system status", (runtimeMode) => {
|
||||
const run = (id: string, status: string, minute: number) => ({
|
||||
runId: id, status, runtimeMode, agentId: "agent", agentName: "Assistant",
|
||||
adapterType: runtimeMode === "native" ? "paperclip_runner" : "claude_local",
|
||||
createdAt: `2025-01-01T10:0${minute}:00Z`, startedAt: `2025-01-01T10:0${minute}:01Z`,
|
||||
finishedAt: `2025-01-01T10:0${minute}:30Z`,
|
||||
});
|
||||
const comment = (id: string, body: string, runId?: string) => ({
|
||||
id, body, runId, companyId: "company", issueId: "issue", authorType: "agent" as const,
|
||||
authorAgentId: "agent", authorUserId: null, presentation: null, metadata: null,
|
||||
createdAt: new Date("2025-01-01T10:00:20Z"), updatedAt: new Date("2025-01-01T10:00:20Z"),
|
||||
});
|
||||
const setTranscript = (id: string) => {
|
||||
const state = runtimeMode === "native" ? nativeTranscriptState : transcriptState;
|
||||
state.transcriptByRun.set(id, [
|
||||
{ kind: "assistant", channel: "progress", ts: "2025-01-01T10:00:02Z", text: "Inspected the source." },
|
||||
{ kind: "tool_call", ts: "2025-01-01T10:00:03Z", name: "Read", input: { path: "README.md" }, toolUseId: "read" },
|
||||
{ kind: "tool_result", ts: "2025-01-01T10:00:04Z", toolUseId: "read", content: "Source content" },
|
||||
{ kind: "assistant", channel: "final", ts: "2025-01-01T10:00:20Z", text: "Saved the useful response." },
|
||||
]);
|
||||
};
|
||||
|
||||
it.each([true, false].flatMap(streamlined => ["done", "cancelled"].map(issueStatus => ({ streamlined, issueStatus }))))("removes failures, stops, waits and stored notices on $issueStatus tasks (streamlined=$streamlined)", ({ streamlined, issueStatus }) => {
|
||||
streamlinedState.enabled = streamlined;
|
||||
setTranscript("failed");
|
||||
const props = {
|
||||
comments: [
|
||||
comment("response", "Saved the useful response.", "failed"),
|
||||
{ ...comment("notice", "The previous execution needs to be checked.", "failed"), authorType: "system" as const,
|
||||
presentation: { kind: "system_notice" as const, tone: "warning" as const, title: "Recovery pending", detailsDefaultOpen: true } },
|
||||
{ ...comment("human", "Please keep this message."), authorType: "user" as const, authorAgentId: null, authorUserId: "board" },
|
||||
{ ...comment("session", "/new"), conversationSessionGeneration: 1 },
|
||||
],
|
||||
linkedRuns: [run("failed", "failed", 0), run("stopped", "cancelled", 1),
|
||||
{ ...run("wait", "cancelled", 2), startedAt: null, errorCode: "execution_reconciliation_required" },
|
||||
run("empty-success", "succeeded", 3)],
|
||||
onAdd: async () => {},
|
||||
};
|
||||
render(<TaskChatThread {...props} issueStatus="blocked" />);
|
||||
render(<TaskChatThread {...props} issueStatus={issueStatus} />);
|
||||
expect(container.textContent).toContain("Saved the useful response.");
|
||||
expect(container.textContent).toContain("Please keep this message.");
|
||||
expect(container.textContent).toContain("New session");
|
||||
for (const status of ["Run failed", "Stopped", "Worked", "Run cancelled", "Waiting to resume", "Recovery pending", "Run completed", "previous execution needs"]) {
|
||||
expect(container.textContent).not.toContain(status);
|
||||
}
|
||||
if (runtimeMode === "legacy") {
|
||||
const activity = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.startsWith("Activity"));
|
||||
expect(activity).toBeDefined();
|
||||
flushSync(() => activity!.click());
|
||||
expect(container.textContent).toContain("Inspected the source.");
|
||||
} else {
|
||||
expect(container.textContent).toContain("Inspected the source.");
|
||||
}
|
||||
expect(props.linkedRuns[0].status).toBe("failed");
|
||||
expect(props.comments).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("replaces old failure and wait notices when a new attempt succeeds, regardless of fetch order", () => {
|
||||
setTranscript("old");
|
||||
const notice = { ...comment("notice", "Old recovery details."), authorType: "system" as const,
|
||||
metadata: { version: 1 as const, sourceRunId: "old", sections: [] } };
|
||||
const wait = { ...run("wait", "cancelled", 1), startedAt: null, errorCode: "execution_reconciliation_required" };
|
||||
const props = { comments: [notice, comment("response", "Saved the useful response.", "old")], onAdd: async () => {}, issueStatus: "in_progress" };
|
||||
render(<TaskChatThread {...props} linkedRuns={[run("old", "failed", 0), wait]} />);
|
||||
expect(container.textContent).toContain("Run failed");
|
||||
expect(container.textContent).toContain("Waiting to resume");
|
||||
for (const linkedRuns of [[run("new", "succeeded", 2), wait, run("old", "failed", 0)], [run("old", "failed", 0), wait, run("new", "succeeded", 2)]]) {
|
||||
render(<TaskChatThread {...props} linkedRuns={linkedRuns} />);
|
||||
expect(container.textContent).not.toContain("Run failed");
|
||||
expect(container.textContent).not.toContain("Waiting to resume");
|
||||
expect(container.textContent).not.toContain("Stopped");
|
||||
expect(container.querySelector('[data-testid="task-chat-system-notice"]')).toBeNull();
|
||||
expect(container.textContent).toContain("Saved the useful response.");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the latest failure actionable after hiding earlier errors", async () => {
|
||||
const retry = vi.fn();
|
||||
const linkedRuns = [run("new-failure", "failed", 2), run("old-failure", "failed", 0)];
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="blocked" linkedRuns={linkedRuns} onRetryFailedRun={retry} />);
|
||||
expect(container.textContent?.match(/Run failed/g)).toHaveLength(1);
|
||||
const button = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-run-failed-try-again"]');
|
||||
expect(button).not.toBeNull();
|
||||
flushSync(() => button!.click());
|
||||
await Promise.resolve();
|
||||
expect(retry).toHaveBeenCalledExactlyOnceWith("new-failure");
|
||||
});
|
||||
|
||||
it.each(["in_progress", "done"])("preserves a child's stop relay after parent work (%s)", (issueStatus) => {
|
||||
const relay = { ...comment("child-relay", "System relay: [Child task](/issues/child) transitioned to `blocked`."),
|
||||
authorType: "system" as const, authorAgentId: null };
|
||||
render(<TaskChatThread comments={[relay]} onAdd={async () => {}} issueStatus={issueStatus}
|
||||
linkedRuns={[run("parent-follow-up", "succeeded", 1)]} />);
|
||||
expect(container.querySelector('[data-thread-anchor="child-relay"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Child task");
|
||||
});
|
||||
|
||||
it("clears old notices as soon as a successor is live, before history refreshes", () => {
|
||||
const old = run("old", "failed", 0);
|
||||
const live = { ...run("live", "running", 1), id: "live", finishedAt: null,
|
||||
invocationSource: "on_demand", triggerDetail: null };
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress"
|
||||
linkedRuns={[old]} activeRun={live} liveRuns={[live]} />);
|
||||
expect(container.textContent).not.toContain("Run failed");
|
||||
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not carry an old failure into a successful same-agent activity summary", () => {
|
||||
setTranscript("old");
|
||||
setTranscript("new");
|
||||
const response = comment("new-response", "The follow-up succeeded.", "new");
|
||||
response.createdAt = new Date("2025-01-01T10:01:20Z");
|
||||
render(<TaskChatThread comments={[comment("old-response", "Saved the useful response.", "old"), response]}
|
||||
onAdd={async () => {}} issueStatus="in_progress" linkedRuns={[run("old", "failed", 0), run("new", "succeeded", 1)]} />);
|
||||
expect(container.textContent).toContain("The follow-up succeeded.");
|
||||
expect(container.textContent).toContain("Worked");
|
||||
expect(container.textContent).not.toContain("Stopped");
|
||||
expect(container.textContent).not.toContain("Run failed");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])("interleaves browser sessions with their requests and preserves position on status updates (streamlined=%s)", (streamlined) => {
|
||||
streamlinedState.enabled = streamlined;
|
||||
const onOpenBrowser = vi.fn();
|
||||
@@ -1384,7 +1506,7 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
lastConfirmedActivityAt: null, retryAt: null, attempt: 2, maxAttempts: 3, recoveryOwner: null, nextAction: null,
|
||||
permittedActions: ["inspect_run" as const], predecessorRunId: null, successorRunId: "fresh-run" } } : {}),
|
||||
}]} />);
|
||||
expect(container.textContent).toContain("Run failed");
|
||||
expect(container.textContent?.includes("Run failed")).toBe(!continued);
|
||||
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ import {
|
||||
taskChatTurnStatusModel,
|
||||
} from "@/components/task-chat/TaskChatTurnStatusIsland";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import {
|
||||
taskChatStatusRelevance,
|
||||
withoutHistoricalRunStatus,
|
||||
} from "@/components/task-chat/status-relevance";
|
||||
import {
|
||||
assembleThreadItems,
|
||||
attachSettledTurns,
|
||||
@@ -794,6 +798,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return map;
|
||||
}, [linkedRuns]);
|
||||
|
||||
const statusRelevance = useMemo(
|
||||
() => taskChatStatusRelevance(issueStatus, [
|
||||
...(linkedRuns ?? []).map((run) => ({ ...run, id: run.runId })),
|
||||
...(liveRuns ?? []),
|
||||
...(activeRun ? [activeRun] : []),
|
||||
], props.recoveryAction),
|
||||
[issueStatus, linkedRuns, liveRuns, activeRun, props.recoveryAction],
|
||||
);
|
||||
|
||||
const verificationCaveatsByRunId = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
@@ -812,6 +825,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const projectedComments = useMemo(
|
||||
() =>
|
||||
comments.flatMap((comment) => {
|
||||
if (statusRelevance.isHistoricalNotice(comment)) return [];
|
||||
if (isRedundantAiRecoveryNotice(comment, interactions)) return [];
|
||||
if (comment.body !== LEGACY_WITHHELD_RUN_COMMENT || !comment.runId)
|
||||
return [comment];
|
||||
@@ -825,7 +839,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const summary = acceptedSemanticResultSummary(resultJson);
|
||||
return [summary ? { ...comment, body: summary } : comment];
|
||||
}),
|
||||
[comments, interactions, linkedRunMetaById],
|
||||
[comments, interactions, linkedRunMetaById, statusRelevance],
|
||||
);
|
||||
|
||||
const commentItems = useMemo(
|
||||
@@ -1028,7 +1042,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// backbone chronologically at its start time (PAP-367).
|
||||
const lastCommentIdByRun = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const comment of comments) {
|
||||
for (const comment of projectedComments) {
|
||||
if (comment.deletedAt || !comment.runId || !comment.id) continue;
|
||||
map.set(comment.runId, comment.id);
|
||||
}
|
||||
@@ -1036,14 +1050,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// Same-turn steering splits that causal interval into timestamped segments,
|
||||
// so each segment must stay unanchored and interleave around the injected
|
||||
// human bubble through the chronological assembler.
|
||||
for (const comment of comments) {
|
||||
for (const comment of projectedComments) {
|
||||
const runId = comment.steeredIntoRunId;
|
||||
if (!comment.deletedAt && runId && comment.conversationAnchorAt) {
|
||||
map.delete(runId);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [comments]);
|
||||
}, [projectedComments]);
|
||||
|
||||
const steeringAnchorsByRun = useMemo(() => {
|
||||
const map = new Map<string, number[]>();
|
||||
@@ -1477,6 +1491,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
if (liveRun && source.id === liveRun.id) continue;
|
||||
const entries = transcriptByRun.get(source.id) ?? [];
|
||||
const meta = linkedRunMetaById.get(source.id);
|
||||
const historical = statusRelevance.isHistoricalRun(source.id);
|
||||
// A workspace admission attempt never started provider work. Its live
|
||||
// successor owns the waiting indicator; retain this attempt in the run log.
|
||||
if (source.status === "cancelled" && meta?.errorCode === "workspace_busy") {
|
||||
@@ -1508,11 +1523,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
entries.length === 0 &&
|
||||
!lastCommentIdByRun.has(source.id);
|
||||
if (executionWait) {
|
||||
settledRunIds.add(source.id);
|
||||
if (historical) continue;
|
||||
const wait = meta?.resultJson?.executionWait;
|
||||
const waitKey = wait && typeof wait === "object" && "recoveryActionId" in wait
|
||||
? String(wait.recoveryActionId)
|
||||
: "execution_reconciliation_required";
|
||||
settledRunIds.add(source.id);
|
||||
if (previousExecutionWaitKey !== waitKey) {
|
||||
const id = `${source.id}:execution-wait`;
|
||||
entriesWithFailures.push({
|
||||
@@ -1557,7 +1573,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const progressCommentIds = semanticProgressCommentIds(meta?.resultJson);
|
||||
const sourcePresentationCommentId =
|
||||
decidedCommentId === undefined
|
||||
? ([...comments]
|
||||
? ([...projectedComments]
|
||||
.reverse()
|
||||
.find(
|
||||
(comment) =>
|
||||
@@ -1566,7 +1582,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
!progressCommentIds.has(comment.id),
|
||||
)?.id ?? null)
|
||||
: decidedCommentId !== null &&
|
||||
comments.some(
|
||||
projectedComments.some(
|
||||
(comment) =>
|
||||
!comment.deletedAt &&
|
||||
comment.runId === source.id &&
|
||||
@@ -1576,7 +1592,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
: null;
|
||||
const sourceHasPresentationComment = sourcePresentationCommentId !== null;
|
||||
const sourcePresentationText = sourcePresentationCommentId
|
||||
? (comments.find(
|
||||
? (projectedComments.find(
|
||||
(comment) => comment.id === sourcePresentationCommentId,
|
||||
)?.body ?? null)
|
||||
: null;
|
||||
@@ -1643,7 +1659,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
: undefined;
|
||||
const finishedAt =
|
||||
meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt;
|
||||
entriesWithFailures.push({
|
||||
if (!historical) entriesWithFailures.push({
|
||||
ms: toMs(finishedAt),
|
||||
order: 3,
|
||||
id,
|
||||
@@ -1701,7 +1717,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
? `Workspace setup failed before the agent started. ${retryDetail}`
|
||||
: `The run failed (${code}). ${retryDetail}`;
|
||||
const id = `${source.id}:failure`;
|
||||
entriesWithFailures.push({
|
||||
if (!historical) entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
order: 3,
|
||||
id,
|
||||
@@ -1750,6 +1766,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
id: turnId,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
historical,
|
||||
agentName:
|
||||
meta?.agentName ??
|
||||
(meta?.agentId ? agentMap?.get(meta.agentId)?.name : undefined),
|
||||
@@ -1775,7 +1792,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
} else if (!sourceHasNativeStop && !sourceHasLegacyStop && !lastCommentIdByRun.has(source.id)) {
|
||||
settledRunIds.add(source.id);
|
||||
const id = `${source.id}:terminal-notice`;
|
||||
entriesWithFailures.push({
|
||||
if (!historical) entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
order: 3,
|
||||
id,
|
||||
@@ -1807,7 +1824,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
!sourceHasNativeResponse
|
||||
) {
|
||||
const id = `${source.id}:terminal-notice`;
|
||||
entriesWithFailures.push({
|
||||
if (!historical) entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
order: 3,
|
||||
id,
|
||||
@@ -1843,12 +1860,13 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
source.id === planDocumentSourceRunId && planTurnItem
|
||||
? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem)
|
||||
: parsedTranscript;
|
||||
const visibleActivity = historical ? withoutHistoricalRunStatus(parsed) : parsed;
|
||||
return {
|
||||
segment,
|
||||
parsed,
|
||||
timelineItems: sourceIsPaperclipRunner
|
||||
? paperclipRunnerTimelineItems(parsed)
|
||||
: parsed,
|
||||
? paperclipRunnerTimelineItems(visibleActivity)
|
||||
: visibleActivity,
|
||||
};
|
||||
});
|
||||
const sourceResponseText =
|
||||
@@ -1928,6 +1946,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
id: turnId,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
historical,
|
||||
agentName:
|
||||
meta?.agentName ??
|
||||
(meta?.agentId ? agentMap?.get(meta.agentId)?.name : undefined),
|
||||
@@ -2090,6 +2109,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
};
|
||||
}, [
|
||||
orderedEntries,
|
||||
statusRelevance,
|
||||
canRetryFailedRun,
|
||||
interactions,
|
||||
runs,
|
||||
@@ -2098,6 +2118,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
linkedRunMetaById,
|
||||
lastCommentIdByRun,
|
||||
comments,
|
||||
projectedComments,
|
||||
steeringAnchorsByRun,
|
||||
legacyTimelineAnchorsByRun,
|
||||
hasBrief,
|
||||
|
||||
@@ -171,6 +171,27 @@ describe("TaskChatTurn", () => {
|
||||
'[data-testid="task-chat-live-turn"]',
|
||||
);
|
||||
|
||||
it("keeps historical activity expandable without a failure or completion status", () => {
|
||||
renderTurn({ ...SETTLED, historical: true, summary: { ...SETTLED.summary, failed: true } });
|
||||
expect(summaryBtn()?.textContent).toContain("Activity");
|
||||
expect(container.textContent).not.toContain("Stopped");
|
||||
expect(container.textContent).not.toContain("Worked");
|
||||
flushSync(() => summaryBtn()!.click());
|
||||
expect(container.textContent).toContain("c1");
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
});
|
||||
|
||||
it("omits historical status while preserving native agent identity and response", () => {
|
||||
renderTurn({ ...SETTLED, historical: true, standaloneHeader: true, agentName: "Assistant",
|
||||
summary: { ...SETTLED.summary, failed: true },
|
||||
finalResponse: { id: "response", kind: "message", author: "agent", text: "Saved response" } });
|
||||
expect(container.textContent).toContain("Assistant");
|
||||
expect(container.textContent).toContain("Saved response");
|
||||
expect(container.textContent).toContain("c1");
|
||||
expect(container.textContent).not.toContain("Stopped");
|
||||
expect(container.textContent).not.toContain("Worked");
|
||||
});
|
||||
|
||||
it("renders a settled turn folded with its summary line", () => {
|
||||
renderTurn(SETTLED);
|
||||
// Label and mono metrics are adjacent spans (v7 runsum grammar).
|
||||
|
||||
@@ -88,7 +88,7 @@ export function TaskChatTurn({
|
||||
data-testid="task-chat-turn"
|
||||
data-settled={item.settled ? "true" : "false"}
|
||||
>
|
||||
<div
|
||||
{item.agentName || !item.historical ? <div
|
||||
className="flex min-h-8 w-full min-w-0 items-center gap-2 pb-1 pt-1.5 text-sm text-muted-foreground"
|
||||
data-testid="task-chat-turn-summary"
|
||||
data-turn-position="identity"
|
||||
@@ -99,15 +99,15 @@ export function TaskChatTurn({
|
||||
agentIcon={item.agentIcon} agent={item.agent}
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">
|
||||
{!item.historical ? <span className="min-w-0 truncate">
|
||||
{item.continuedAfterSteering ? "Continued after steering · " : ""}
|
||||
{item.summary.durationLabel
|
||||
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
|
||||
: item.summary.failed
|
||||
? "Stopped"
|
||||
: "Worked"}
|
||||
</span>
|
||||
</div>
|
||||
</span> : null}
|
||||
</div> : null}
|
||||
{item.items.length > 0 ? (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-2 py-1"
|
||||
@@ -179,7 +179,8 @@ export function TaskChatTurn({
|
||||
const folded = (item.settled || parentRow) && !open;
|
||||
const SummaryIcon = item.summary.failed ? X : Check;
|
||||
|
||||
const header = item.settled ? (
|
||||
const showSettledHeader = item.settled && (!item.historical || expandable);
|
||||
const header = showSettledHeader ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
@@ -201,11 +202,11 @@ export function TaskChatTurn({
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{!item.standaloneHeader ? (
|
||||
{!item.standaloneHeader && !item.historical ? (
|
||||
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
<span>
|
||||
{item.standaloneHeader && item.summary.durationLabel
|
||||
{item.historical ? "Activity" : item.standaloneHeader && item.summary.durationLabel
|
||||
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
|
||||
: item.summary.failed
|
||||
? "Stopped"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExecutionProjection } from "@paperclipai/shared";
|
||||
import type { IssueChatComment } from "@/lib/issue-chat-messages";
|
||||
import { taskChatStatusRelevance, withoutHistoricalRunStatus } from "./status-relevance";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
|
||||
const oldRun = {
|
||||
id: "old", agentId: "agent", status: "failed",
|
||||
createdAt: "2025-01-01T10:00:00Z", startedAt: "2025-01-01T10:00:01Z",
|
||||
};
|
||||
const nextRun = { ...oldRun, id: "next", status: "succeeded", createdAt: "2025-01-01T10:01:00Z", startedAt: "2025-01-01T10:01:01Z" };
|
||||
const hold: ExecutionProjection = {
|
||||
phase: "recovery_needed", label: "Stopped", cause: "uncertain_provider_action",
|
||||
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
|
||||
recoveryOwner: "board", nextAction: "Check the execution", permittedActions: ["inspect_recovery"],
|
||||
predecessorRunId: null, successorRunId: null,
|
||||
};
|
||||
const notice: IssueChatComment = {
|
||||
id: "notice", companyId: "company", issueId: "issue", authorType: "system",
|
||||
authorAgentId: null, authorUserId: null, body: "Execution needs attention.",
|
||||
presentation: null, metadata: { version: 1, sourceRunId: "old", sections: [] },
|
||||
createdAt: new Date("2025-01-01T10:00:30Z"), updatedAt: new Date("2025-01-01T10:00:30Z"),
|
||||
};
|
||||
|
||||
describe("task chat status relevance", () => {
|
||||
it("uses attempt creation order regardless of API order or a late finish", () => {
|
||||
for (const runs of [[oldRun, nextRun], [nextRun, oldRun]]) {
|
||||
const policy = taskChatStatusRelevance("in_progress", runs);
|
||||
expect(policy.isHistoricalRun("old")).toBe(true);
|
||||
expect(policy.isHistoricalRun("next")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an unresolved failure regardless of its age or missing provenance", () => {
|
||||
const policy = taskChatStatusRelevance("blocked", [oldRun]);
|
||||
expect(policy.isHistoricalRun("old")).toBe(false);
|
||||
expect(policy.isHistoricalRun("unloaded")).toBe(false);
|
||||
expect(policy.isHistoricalNotice(notice)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat another agent or a refused admission as a replacement", () => {
|
||||
for (const later of [
|
||||
{ ...nextRun, agentId: "other-agent" },
|
||||
{ ...nextRun, status: "cancelled", startedAt: null, errorCode: "execution_reconciliation_required" },
|
||||
]) {
|
||||
expect(taskChatStatusRelevance("blocked", [oldRun, later]).isHistoricalRun("old")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides earlier observations of a wait and clears the latest wait after work starts", () => {
|
||||
const wait = { ...oldRun, status: "cancelled", startedAt: null, errorCode: "execution_reconciliation_required" };
|
||||
const newerWait = { ...wait, id: "newer-wait", createdAt: "2025-01-01T10:00:30Z" };
|
||||
expect(taskChatStatusRelevance("blocked", [wait, newerWait]).isHistoricalRun("old")).toBe(true);
|
||||
expect(taskChatStatusRelevance("blocked", [wait, newerWait]).isHistoricalRun("newer-wait")).toBe(false);
|
||||
expect(taskChatStatusRelevance("in_progress", [wait, newerWait, nextRun]).isHistoricalRun("newer-wait")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an explicit hold until a successor or terminal task resolves it", () => {
|
||||
const held = { ...oldRun, execution: hold };
|
||||
expect(taskChatStatusRelevance("blocked", [held, nextRun]).isHistoricalRun("old")).toBe(false);
|
||||
expect(taskChatStatusRelevance("blocked", [held, oldRun, nextRun]).isHistoricalRun("old")).toBe(false);
|
||||
expect(taskChatStatusRelevance("blocked", [{ ...held, execution: { ...hold, successorRunId: "next" } }]).isHistoricalRun("old")).toBe(true);
|
||||
expect(taskChatStatusRelevance("done", [held]).isHistoricalRun("old")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses resolved recovery evidence even without a successor run", () => {
|
||||
const waiting = { ...oldRun, resultJson: { executionWait: { recoveryActionId: "action" } } };
|
||||
const recoveryNotice = { ...notice, metadata: { version: 1 as const, sections: [], recovery: {
|
||||
kind: "disposition_repair_escalated" as const, actionId: "action", attemptCount: 1,
|
||||
maxAttempts: 3, reason: "missing result", assigneeAgentId: "agent",
|
||||
} } };
|
||||
for (const status of ["active", "escalated", "resolved", "cancelled"] as const) {
|
||||
const action = { id: "action", status };
|
||||
const policy = taskChatStatusRelevance("blocked", [waiting, nextRun], action);
|
||||
expect(policy.isHistoricalRun("old")).toBe(status === "resolved" || status === "cancelled");
|
||||
expect(policy.isHistoricalNotice(recoveryNotice)).toBe(status === "resolved" || status === "cancelled");
|
||||
}
|
||||
});
|
||||
|
||||
it("hides old system notices by provenance without hiding authored messages or session boundaries", () => {
|
||||
const policy = taskChatStatusRelevance("in_progress", [oldRun, nextRun]);
|
||||
expect(policy.isHistoricalNotice({ ...notice, metadata: { version: 1, sourceRunId: "old", sections: [] } })).toBe(true);
|
||||
expect(policy.isHistoricalNotice({ ...notice, metadata: null, createdByRunId: "next" })).toBe(false);
|
||||
expect(policy.isHistoricalNotice({ ...notice, metadata: null, createdByRunId: "unknown" })).toBe(false);
|
||||
expect(policy.isHistoricalNotice(notice)).toBe(true);
|
||||
expect(policy.isHistoricalNotice({ ...notice, authorType: "agent" })).toBe(false);
|
||||
expect(policy.isHistoricalNotice({ ...notice, authorType: "user" })).toBe(false);
|
||||
expect(policy.isHistoricalNotice({ ...notice, conversationSessionGeneration: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["done", "cancelled"])("hides obsolete notices on %s tasks even without loaded runs", (status) => {
|
||||
const policy = taskChatStatusRelevance(status, []);
|
||||
expect(policy.isHistoricalRun("unloaded")).toBe(true);
|
||||
expect(policy.isHistoricalNotice(notice)).toBe(true);
|
||||
expect(policy.isHistoricalNotice({ ...notice, authorType: "agent" })).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["in_progress", "done", "cancelled"])("preserves unrelated system updates on %s tasks after later work", (status) => {
|
||||
const policy = taskChatStatusRelevance(status, [oldRun, nextRun]);
|
||||
const relay = { ...notice, metadata: null, body: "A child task is still blocked." };
|
||||
expect(policy.isHistoricalNotice(relay)).toBe(false);
|
||||
expect(policy.isHistoricalNotice({ ...relay, presentation: {
|
||||
kind: "system_notice", title: "Child task blocked", tone: "warning", detailsDefaultOpen: false,
|
||||
} })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps uncertainty visible when timestamps are missing or tied", () => {
|
||||
const invalid = { ...nextRun, createdAt: "invalid", startedAt: null };
|
||||
expect(taskChatStatusRelevance("blocked", [oldRun, invalid]).isHistoricalRun("old")).toBe(false);
|
||||
expect(taskChatStatusRelevance("blocked", [oldRun, { ...nextRun, createdAt: oldRun.createdAt }]).isHistoricalRun("old")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes run lifecycle diagnostics while retaining tool failures and pending permission requests", () => {
|
||||
const retained: TaskChatItem[] = [
|
||||
{ id: "reply", kind: "message", author: "agent", text: "Saved the result." },
|
||||
{ id: "tool", kind: "tool", name: "Read", status: "failed" },
|
||||
{ id: "approval", kind: "protocol", surface: "runtime_request", runId: "old", requestId: "permission",
|
||||
requestKind: "command_approval", turnId: "turn", requestType: "permission", choices: [], fields: [],
|
||||
status: "pending", prompt: "Allow the command?" },
|
||||
];
|
||||
const items: TaskChatItem[] = [
|
||||
...retained,
|
||||
{ id: "failed", kind: "marker", variant: "interrupted", label: "Run failed" },
|
||||
{ id: "system", kind: "message", author: "system", text: "Waiting to resume" },
|
||||
{ id: "wait", kind: "protocol", surface: "provider_activity", family: "wait", title: "Waiting", status: "completed",
|
||||
eventType: "wait", details: [], steps: [], links: [], children: [] },
|
||||
{ id: "terminal", kind: "protocol", surface: "run_terminal", turnState: "failed", runState: "failed", disposition: "blocked" },
|
||||
];
|
||||
expect(withoutHistoricalRunStatus(items)).toEqual(retained);
|
||||
expect(items).toHaveLength(7);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ExecutionProjection, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import type { IssueChatComment } from "@/lib/issue-chat-messages";
|
||||
import { isTerminalIssueStatus } from "@/lib/liveIssueIds";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
|
||||
interface StatusRun {
|
||||
id: string;
|
||||
agentId: string;
|
||||
status: string;
|
||||
errorCode?: string | null;
|
||||
createdAt: Date | string;
|
||||
startedAt?: Date | string | null;
|
||||
execution?: ExecutionProjection | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function timestamp(value: Date | string | null | undefined): number {
|
||||
return value == null ? NaN : new Date(value).getTime();
|
||||
}
|
||||
|
||||
function isAdmissionWait(run: StatusRun): boolean {
|
||||
return run.status === "cancelled" && !run.startedAt
|
||||
&& ["execution_reconciliation_required", "workspace_busy", "issue_not_in_progress", "issue_terminal_status"].includes(run.errorCode ?? "");
|
||||
}
|
||||
|
||||
/** Presentation only: retain every run and comment in the underlying history. */
|
||||
export function taskChatStatusRelevance(
|
||||
issueStatus: string | null | undefined,
|
||||
runs: readonly StatusRun[],
|
||||
recoveryAction?: Pick<IssueRecoveryAction, "id" | "status"> | null,
|
||||
) {
|
||||
const terminal = isTerminalIssueStatus(issueStatus);
|
||||
const byId = new Map<string, StatusRun>();
|
||||
for (const run of runs) {
|
||||
const previous = byId.get(run.id);
|
||||
byId.set(run.id, { ...previous, ...run, execution: run.execution ?? previous?.execution });
|
||||
}
|
||||
const latestByAgent = new Map<string, number>();
|
||||
const latestAttemptByAgent = new Map<string, number>();
|
||||
for (const run of byId.values()) {
|
||||
// Admission order, not finish order: an old attempt can finish late.
|
||||
const createdAt = timestamp(run.createdAt);
|
||||
const order = Number.isFinite(createdAt) ? createdAt : timestamp(run.startedAt);
|
||||
if (Number.isFinite(order)) {
|
||||
latestByAgent.set(run.agentId, Math.max(latestByAgent.get(run.agentId) ?? -Infinity, order));
|
||||
if (!isAdmissionWait(run)) {
|
||||
latestAttemptByAgent.set(run.agentId, Math.max(latestAttemptByAgent.get(run.agentId) ?? -Infinity, order));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isHistoricalRun = (runId: string): boolean => {
|
||||
if (terminal) return true;
|
||||
const run = byId.get(runId);
|
||||
if (!run) return false;
|
||||
if (run.execution?.successorRunId || run.execution?.phase === "completed") return true;
|
||||
const wait = run.resultJson?.executionWait;
|
||||
if (recoveryAction && wait && typeof wait === "object" && "recoveryActionId" in wait
|
||||
&& wait.recoveryActionId === recoveryAction.id) {
|
||||
return recoveryAction.status === "resolved" || recoveryAction.status === "cancelled";
|
||||
}
|
||||
// A newer admission attempt does not resolve an explicit execution hold.
|
||||
if (run.execution?.phase === "recovery_needed") return false;
|
||||
const createdAt = timestamp(run.createdAt);
|
||||
const order = Number.isFinite(createdAt) ? createdAt : timestamp(run.startedAt);
|
||||
const latest = isAdmissionWait(run) ? latestByAgent : latestAttemptByAgent;
|
||||
return order < (latest.get(run.agentId) ?? -Infinity);
|
||||
};
|
||||
|
||||
const isHistoricalNotice = (comment: IssueChatComment): boolean => {
|
||||
// Session resets are durable conversation boundaries, not execution status.
|
||||
if (comment.conversationSessionGeneration != null) return false;
|
||||
if (comment.authorType !== "system" && comment.presentation?.kind !== "system_notice") return false;
|
||||
const sourceRunId = comment.metadata?.sourceRunId ?? comment.createdByRunId
|
||||
?? comment.runId ?? comment.derivedCreatedByRunId;
|
||||
const recoveryActionId = comment.metadata?.recovery?.actionId;
|
||||
// System authorship alone does not make a comment execution status. Child
|
||||
// task relays and other unrelated updates have their own unresolved state.
|
||||
// Without run/recovery provenance, a later run cannot prove them obsolete.
|
||||
if (!sourceRunId && !recoveryActionId) return false;
|
||||
if (terminal) return true;
|
||||
if (recoveryActionId === recoveryAction?.id && recoveryAction) {
|
||||
return recoveryAction.status === "resolved" || recoveryAction.status === "cancelled";
|
||||
}
|
||||
if (sourceRunId) return isHistoricalRun(sourceRunId);
|
||||
return false;
|
||||
};
|
||||
|
||||
return { isHistoricalRun, isHistoricalNotice };
|
||||
}
|
||||
|
||||
/** Remove obsolete lifecycle diagnostics, preserving responses, tools and inputs. */
|
||||
export function withoutHistoricalRunStatus(items: readonly TaskChatItem[]): TaskChatItem[] {
|
||||
return items.filter((item) => {
|
||||
if (item.kind === "marker" || item.kind === "status") return false;
|
||||
if (item.kind === "message") return item.author !== "system";
|
||||
if (item.kind !== "protocol") return true;
|
||||
if (item.surface === "run_terminal" || item.surface === "run_result") return false;
|
||||
return item.surface !== "provider_activity"
|
||||
|| !["terminal", "wait", "provider_notice"].includes(item.family);
|
||||
});
|
||||
}
|
||||
@@ -508,6 +508,8 @@ export interface TaskChatTurnItem {
|
||||
kind: "turn";
|
||||
items: TaskChatTurnChildItem[];
|
||||
settled: boolean;
|
||||
/** Superseded execution status is omitted; activity remains inspectable. */
|
||||
historical?: boolean;
|
||||
/** Agent identity retained when a live runner turn becomes durable history. */
|
||||
agentName?: string;
|
||||
agentIcon?: string | null;
|
||||
|
||||
@@ -1959,6 +1959,7 @@ export function coalesceSettledTurns(
|
||||
held &&
|
||||
meta &&
|
||||
heldMeta &&
|
||||
Boolean(item.historical) === Boolean(held.historical) &&
|
||||
meta.agentKey &&
|
||||
meta.agentKey === heldMeta.agentKey
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user