diff --git a/DESIGN.md b/DESIGN.md index c3e53e4db36..ef852568573 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index fa4a17ed06c..c960d3e9c7f 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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(); + render(); + 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('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(); + 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(); + 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( {}} issueStatus="blocked" linkedRuns={linkedRuns} onRetryFailedRun={retry} />); + expect(container.textContent?.match(/Run failed/g)).toHaveLength(1); + const button = container.querySelector('[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( {}} 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( {}} 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( {}} 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(); }); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 51a027fd5ab..45800935a12 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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(); - 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(); @@ -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, diff --git a/ui/src/components/task-chat/TaskChatTurn.test.tsx b/ui/src/components/task-chat/TaskChatTurn.test.tsx index 5b256850fb0..bfdc95d4d0d 100644 --- a/ui/src/components/task-chat/TaskChatTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.test.tsx @@ -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). diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index 3377ef40ed6..71c081cf2a6 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -88,7 +88,7 @@ export function TaskChatTurn({ data-testid="task-chat-turn" data-settled={item.settled ? "true" : "false"} > -
) : null} - + {!item.historical ? {item.continuedAfterSteering ? "Continued after steering ยท " : ""} {item.summary.durationLabel ? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}` : item.summary.failed ? "Stopped" : "Worked"} - -
+ : null} + : null} {item.items.length > 0 ? (
setOpen((o) => !o)} @@ -201,11 +202,11 @@ export function TaskChatTurn({ ) : null} - {!item.standaloneHeader ? ( + {!item.standaloneHeader && !item.historical ? ( ) : null} - {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" diff --git a/ui/src/components/task-chat/status-relevance.test.ts b/ui/src/components/task-chat/status-relevance.test.ts new file mode 100644 index 00000000000..a80f31ccf84 --- /dev/null +++ b/ui/src/components/task-chat/status-relevance.test.ts @@ -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); +}); diff --git a/ui/src/components/task-chat/status-relevance.ts b/ui/src/components/task-chat/status-relevance.ts new file mode 100644 index 00000000000..81eafde7580 --- /dev/null +++ b/ui/src/components/task-chat/status-relevance.ts @@ -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 | 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 | null, +) { + const terminal = isTerminalIssueStatus(issueStatus); + const byId = new Map(); + 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(); + const latestAttemptByAgent = new Map(); + 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); + }); +} diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index f903a17f008..e2919721e1f 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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; diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 999cf904cbf..5f84863768a 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -1959,6 +1959,7 @@ export function coalesceSettledTurns( held && meta && heldMeta && + Boolean(item.historical) === Boolean(held.historical) && meta.agentKey && meta.agentKey === heldMeta.agentKey ) {