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:
parent
f95efe6292
commit
85e36aaefb
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue