Show heartbeat progress in run logs (#8965)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The agent detail page is where operators inspect heartbeat runs and
watch live execution output.
> - Heartbeat runs can publish progress events while longer operations
are happening.
> - The live log viewer already appended streamed log and structured run
events, but it ignored progress events for the same run.
> - That made useful progress text invisible in the run log until
another event type arrived or the operator inspected other surfaces.
> - This pull request renders run progress events as system log lines in
the live agent run viewer.
> - The benefit is clearer live feedback during long-running heartbeat
operations without changing the backend event contract.

## Linked Issues or Issue Description

No public GitHub issue exists, so this PR describes the issue inline
following the bug report template.

### What happened

The agent detail run log subscribed to company live events and handled
`heartbeat.run.log` plus structured `heartbeat.run.event` payloads, but
it ignored `heartbeat.run.progress` events for the active run.

### Expected behavior

When a heartbeat run emits a progress message, the active run log should
show that message immediately as operator-visible system output.

### Steps to reproduce

1. Open an agent detail page for a live heartbeat run.
2. Trigger a run operation that emits `heartbeat.run.progress` events
with a `message` and optional `phase`.
3. Watch the live log viewer.

Before this change, the progress event was ignored by the log viewer.
After this change, it appears as a system log line, prefixed by
`[phase]` when a phase is present.

### Paperclip version / deployment mode

Current `master`; local development and normal board UI deployments.

### Related work search

Searched public GitHub issues and PRs in `paperclipai/paperclip` for
`heartbeat.run.progress AgentDetail` and `run progress log viewer`; no
duplicate issue or PR was found.

## What Changed

- Added live handling for `heartbeat.run.progress` events in
`AgentDetail`'s run `LogViewer`.
- Render progress messages as `system` log lines for the matching run.
- Include the optional progress phase in the displayed line as `[phase]
message`.
- Prefer the event's `updatedAt` timestamp when provided, falling back
to the live event timestamp.
- Added a replay key for progress log lines so WebSocket reconnect
replay does not duplicate the same rendered progress line.
- Added focused formatter/key tests covering phased progress, unphased
progress, empty messages, and replay-key output.

### Visual output example

The rendered log text is covered by the new formatter test:

```text
[workspace] Syncing issue history
Preparing workspace
```

No layout or styling changes are included; this PR only makes existing
log-line UI receive one more live event type.

## Verification

- `pnpm install --frozen-lockfile` — completed; emitted non-fatal
bin-link warnings for the unbuilt plugin SDK dev CLI.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/AgentDetail.progress.test.ts
src/context/LiveUpdatesProvider.test.ts` — passed, 2 files / 26 tests.
- `git diff --check` — passed.
- Local sensitive-content scan over the PR diff using patterns for API
keys, tokens, secrets, passwords, auth headers, private keys,
localhost/private paths, internal ticket ids, agent links, and tailnet
markers — no findings.

## Risks

Low risk. This is a UI-only live-event handling change for an existing
event type. The replay guard is intentionally scoped to progress lines
and uses the rendered timestamp, stream, and chunk as the key, so
repeated progress events with distinct timestamps or messages still
appear.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent with repository tool use, shell
execution, GitHub CLI access, and local test execution. Context window
size was not exposed in this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-04 06:33:49 -07:00 committed by GitHub
parent f95efe6292
commit 85e36aaefb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 94 additions and 1 deletions

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildHeartbeatProgressLogLine,
heartbeatProgressLogLineKey,
} from "./AgentDetail";
describe("buildHeartbeatProgressLogLine", () => {
it("renders progress messages with phase prefixes as system log lines", () => {
expect(
buildHeartbeatProgressLogLine(
{
message: "Syncing issue history",
phase: "workspace",
updatedAt: "2026-07-04T05:00:00.000Z",
},
"2026-07-04T04:59:00.000Z",
),
).toEqual({
ts: "2026-07-04T05:00:00.000Z",
stream: "system",
chunk: "[workspace] Syncing issue history",
});
});
it("renders progress messages without phases using the live event timestamp", () => {
expect(
buildHeartbeatProgressLogLine(
{ message: "Preparing workspace" },
"2026-07-04T05:01:00.000Z",
),
).toEqual({
ts: "2026-07-04T05:01:00.000Z",
stream: "system",
chunk: "Preparing workspace",
});
});
it("ignores empty progress messages", () => {
expect(
buildHeartbeatProgressLogLine(
{ message: " ", phase: "workspace" },
"2026-07-04T05:02:00.000Z",
),
).toBeNull();
});
});
describe("heartbeatProgressLogLineKey", () => {
it("uses the rendered log line fields as the replay key", () => {
const line = {
ts: "2026-07-04T05:03:00.000Z",
stream: "system" as const,
chunk: "[workspace] Syncing issue history",
};
expect(heartbeatProgressLogLineKey(line)).toBe(
"2026-07-04T05:03:00.000Z\u0000system\u0000[workspace] Syncing issue history",
);
});
});

View File

@ -301,7 +301,11 @@ function runMetrics(run: HeartbeatRun) {
};
}
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
export type RunLogChunk = {
ts: string;
stream: "stdout" | "stderr" | "system";
chunk: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
@ -314,6 +318,22 @@ function asNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
export function buildHeartbeatProgressLogLine(
payload: Record<string, unknown>,
fallbackTimestamp: string,
): RunLogChunk | null {
const message = asNonEmptyString(payload.message);
if (!message) return null;
const phase = asNonEmptyString(payload.phase);
const ts = asNonEmptyString(payload.updatedAt) ?? fallbackTimestamp;
const chunk = phase ? `[${phase}] ${message}` : message;
return { ts, stream: "system", chunk };
}
export function heartbeatProgressLogLineKey(line: RunLogChunk): string {
return `${line.ts}\u0000${line.stream}\u0000${line.chunk}`;
}
export function RunInvocationCard({
payload,
censorUsernameInLogs,
@ -3591,6 +3611,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const [transcriptMode, setTranscriptMode] = useState<TranscriptMode>("nice");
const logEndRef = useRef<HTMLDivElement>(null);
const pendingLogLineRef = useRef("");
const seenProgressLogLineKeysRef = useRef<Set<string>>(new Set());
const scrollContainerRef = useRef<ScrollContainer | null>(null);
const isFollowingRef = useRef(false);
const lastMetricsRef = useRef<{ scrollHeight: number; distanceFromBottom: number }>({
@ -3738,6 +3759,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
useEffect(() => {
let cancelled = false;
pendingLogLineRef.current = "";
seenProgressLogLineKeysRef.current = new Set();
setLogLines([]);
setLogOffset(0);
setHasMoreLog(false);
@ -3885,6 +3907,16 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return;
}
if (event.type === "heartbeat.run.progress") {
const line = buildHeartbeatProgressLogLine(payload, event.createdAt);
if (!line) return;
const key = heartbeatProgressLogLineKey(line);
if (seenProgressLogLineKeysRef.current.has(key)) return;
seenProgressLogLineKeysRef.current.add(key);
setLogLines((prev) => [...prev, line]);
return;
}
if (event.type !== "heartbeat.run.event") return;
const seq = typeof payload.seq === "number" ? payload.seq : null;