From c48feee190f0f79c4988a082cd0344640ac8ab79 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 2 Jul 2026 22:21:56 -0700 Subject: [PATCH] Improve live agent feedback during sandboxed runs (#8915) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A core part of that experience is watching active agent runs without dropping into raw logs first > - Local and sandbox-backed adapters already record useful run output, progress, and tool activity > - But active issue threads could sit visually stale while the agent was syncing workspaces, tailing sandbox output, or emitting incremental tool-call updates > - Operators need timely, human-readable progress while preserving the raw transcript underneath > - This pull request streams sandbox run-log progress into runtime status, keeps visible issue threads refreshed, and folds repeated ACPX tool updates into stable transcript cards > - The benefit is that long-running agent work becomes easier to supervise without changing the task/comment control-plane model ## Linked Issues or Issue Description No public GitHub issue exists for this exact change. Problem/motivation: - During long-running sandboxed agent work, the issue UI can appear idle even though the agent is actively syncing, running tools, or producing incremental output. - Operators need realtime feedback at the issue-thread layer, not only after opening raw logs or waiting for the final heartbeat result. - Related public context: #1808 previously added live-run status dots to Projects; #4362 touches heartbeat wakeup behavior but is not a duplicate of this runtime/UI feedback change. ## What Changed - Added sandbox run-log streaming support and defaulted sandbox-capable local adapters into the richer live-feedback path. - Surfaced environment/sandbox sync progress through heartbeat runtime status with bounded, redacted snippets. - Added live issue-thread cache patching so visible active runs update as progress events arrive. - Folded repeated ACPX `tool_call` updates into one transcript card instead of stacking duplicate cards. - Updated adapter docs and added focused regression coverage for sandbox log streaming, runtime status, ACPX parsing, live updates, transcript rendering, and issue chat messages. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/context/LiveUpdatesProvider.test.ts` - `pnpm exec vitest run server/src/services/heartbeat-run-runtime-status.test.ts server/src/__tests__/heartbeat-runtime-state.test.ts ui/src/context/LiveUpdatesProvider.test.ts` - `pnpm exec vitest run packages/adapter-utils/src/execution-target-sandbox.test.ts packages/adapter-utils/src/sandbox-managed-runtime.test.ts server/src/services/heartbeat-run-runtime-status.test.ts server/src/__tests__/agent-live-run-routes.test.ts server/src/__tests__/heartbeat-runtime-state.test.ts packages/adapters/acpx-local/src/ui/parse-stdout.test.ts ui/src/context/LiveUpdatesProvider.test.ts ui/src/components/transcript/RunTranscriptView.test.tsx ui/src/lib/issue-chat-messages.test.ts ui/src/components/IssueChatThread.test.tsx` - GitHub PR workflow on head `8397953e7b41ccd42e5d9457ee7e4dfb996e4ec5`: `verify`, build, typecheck/release-registry, e2e, general shards, serialized server shards, and canary dry run passed. - Greptile Review on head `8397953e7b41ccd42e5d9457ee7e4dfb996e4ec5`: Confidence Score 5/5, no unresolved review threads. ## Risks - Live issue-thread cache patching could miss an edge case for a route shape not covered by tests. - Surfacing active-run snippets needs continued care around redaction; this PR keeps snippets bounded and adds redaction-focused coverage. - More frequent active-run UI refreshes could expose performance issues on very large issue threads, though updates are scoped to visible run/query caches. ## Model Used OpenAI GPT-5 via Codex, operating as a tool-enabled coding agent with shell, git, and repository-editing capabilities. Context window size is 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 --- docs/adapters/overview.md | 16 +- .../src/execution-target-sandbox.test.ts | 267 +++++++++++++++++ .../adapter-utils/src/execution-target.ts | 82 +++++- .../adapter-utils/src/runtime-progress.ts | 3 + .../src/sandbox-managed-runtime.test.ts | 40 ++- .../src/sandbox-managed-runtime.ts | 84 ++++-- .../src/sandbox-run-log-stream.ts | 278 ++++++++++++++++++ packages/adapters/acpx-local/src/index.ts | 8 +- .../adapters/acpx-local/src/server/execute.ts | 3 + .../acpx-local/src/ui/parse-stdout.test.ts | 54 ++++ .../acpx-local/src/ui/parse-stdout.ts | 24 +- .../claude-local/src/server/execute.ts | 1 + .../codex-local/src/server/execute.ts | 1 + .../cursor-local/src/server/execute.ts | 1 + .../gemini-local/src/server/execute.ts | 1 + .../opencode-local/src/server/execute.ts | 1 + .../adapters/pi-local/src/server/execute.ts | 1 + packages/shared/src/types/environment.ts | 4 + packages/shared/src/types/heartbeat.ts | 6 +- .../__tests__/agent-live-run-routes.test.ts | 6 + .../__tests__/heartbeat-runtime-state.test.ts | 12 + server/src/services/environment-config.ts | 2 + .../services/environment-execution-target.ts | 4 + .../heartbeat-run-runtime-status.test.ts | 105 +++++++ .../services/heartbeat-run-runtime-status.ts | 85 +++++- server/src/services/heartbeat.ts | 211 ++++++++++++- ui/src/api/heartbeats.ts | 6 + ui/src/components/IssueChatThread.test.tsx | 7 +- ui/src/components/IssueChatThread.tsx | 78 +++-- .../transcript/RunTranscriptView.test.tsx | 96 ++++++ .../transcript/RunTranscriptView.tsx | 28 +- ui/src/context/LiveUpdatesProvider.test.ts | 127 ++++++++ ui/src/context/LiveUpdatesProvider.tsx | 164 +++++++++++ ui/src/lib/issue-chat-messages.test.ts | 6 + ui/src/lib/issue-chat-messages.ts | 8 + ui/src/pages/CompanyEnvironments.tsx | 10 + 36 files changed, 1742 insertions(+), 88 deletions(-) create mode 100644 packages/adapter-utils/src/sandbox-run-log-stream.ts diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 43a3963155..ac153d2937 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip: |---------|----------|-------------| | [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| ACPX Local | `acpx_local` | Runs Claude, Codex, or a custom ACP agent through ACPX with live structured event streaming | | [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | | OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | Cursor | `cursor` | Runs Cursor in background mode | @@ -92,12 +93,25 @@ my-adapter/ ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin +- **Need a coding agent?** Use `claude_local`, `codex_local`, `acpx_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin +- **Need the richest live run feedback (especially for sandbox workers)?** Use `acpx_local` — see [Feedback granularity](#feedback-granularity) - **Need Hermes on another host or already running as a service?** Use `hermes_gateway` - **Need to run a script or command?** Use `process` - **Need to call a custom external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters) +## Feedback Granularity + +Adapter choice determines how much structured, live detail a run's transcript can show while the agent is still working. Every adapter's stdout is streamed to the run log and rendered live in the UI — including runs on sandbox execution targets, whose logs are tailed and delivered incrementally — but the *granularity* of what you see depends on the event stream the adapter emits. + +Rough tiers, richest first: + +1. **`acpx_local` — full structured event stream.** ACPX emits a JSONL event per meaningful runtime moment: `acpx.session` (agent, mode, session identity), `acpx.status` (progress text plus context-window usage), `acpx.text_delta` (assistant/thinking token deltas), `acpx.tool_call` (tool title, call id, and status updates as the call progresses), `acpx.result` (stop reason summary), and `acpx.error` (code, message, retryability). The transcript renders these as live-updating message, thinking, tool, and status blocks, and repeated `acpx.tool_call` status updates fold into a single tool card instead of stacking duplicates. +2. **CLI wrappers (`claude_local`, `codex_local`, `cursor`, `opencode_local`, …).** These parse each CLI's own streaming JSON output. You get assistant text, tool calls/results, and a final usage/cost summary, but granularity is limited to what the CLI prints — some emit tool progress, others only call/finish pairs. +3. **Generic adapters (`process`, `http`).** Plain stdout/stderr lines with no structured transcript — you see raw output only. + +**Recommendation:** for sandbox workers, prefer `acpx_local`. Sandbox run logs are streamed live, so the richer the event stream, the more useful the live transcript and status line are while a remote run is in flight. ACPX's status events (including context usage) and incremental tool-call updates give the closest thing to watching the agent work locally. + ## UI Parser Contract External adapters can ship a self-contained UI parser that tells the Paperclip web UI how to render their stdout. Without it, the UI uses a generic shell parser. See the [UI Parser Contract](/adapters/adapter-ui-parser) for details. diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 0e7fac90fb..f977922171 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -16,6 +16,7 @@ import { startAdapterExecutionTargetPaperclipBridge, type AdapterSandboxExecutionTarget, } from "./execution-target.js"; +import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js"; import { runChildProcess } from "./server-utils.js"; import { shellQuote } from "./ssh.js"; @@ -75,6 +76,33 @@ describe("sandbox adapter execution targets", () => { return contents; } + function encodeTailTick(stdout: Buffer, stderr: Buffer): string { + return [ + "__PAPERCLIP_RUN_LOG_STDOUT__", + stdout.toString("base64"), + "__PAPERCLIP_RUN_LOG_STDERR__", + stderr.toString("base64"), + "__PAPERCLIP_RUN_LOG_END__", + "", + ].join("\n"); + } + + async function waitForCondition(predicate: () => boolean, message: string): Promise { + const deadline = Date.now() + 1000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(message); + } + + function combinedStream( + events: Array<{ stream: "stdout" | "stderr"; chunk: string }>, + stream: "stdout" | "stderr", + ): string { + return events.filter((event) => event.stream === stream).map((event) => event.chunk).join(""); + } + it("executes through the provider-neutral runner without a remote spec", async () => { const runner = { execute: vi.fn(async () => ({ @@ -464,6 +492,245 @@ describe("sandbox adapter execution targets", () => { } }); + it("creates a sandbox run log tail factory when bridge streaming is enabled", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-stream-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); + await mkdir(runtimeRootDir, { recursive: true }); + + const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd, + runner: createLocalSandboxRunner(), + streamRunLogs: true, + timeoutMs: 30_000, + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-bridge-stream", + target, + runtimeRootDir, + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: "http://127.0.0.1:9", + onLog: async (stream, chunk) => { + logs.push({ stream, chunk }); + }, + }); + try { + expect(bridge?.runLogTail).toBeTruthy(); + expect(combinedStream(logs, "stdout")).toContain("Sandbox run log streaming enabled"); + + const wrapped = bridge!.runLogTail!.create().wrapCommand("agent-cli", ["--message", "hello world"]); + expect(wrapped.command).toBe("sh"); + expect(wrapped.args.join("\n")).toContain("tee -a"); + expect(wrapped.args.join("\n")).toContain("agent-cli"); + } finally { + await bridge?.stop(); + } + }); + + it("defaults sandbox run log streaming on and honors the explicit opt-out", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-stream-default-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); + await mkdir(runtimeRootDir, { recursive: true }); + + const baseTarget: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd, + runner: createLocalSandboxRunner(), + timeoutMs: 30_000, + }; + + const defaultBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-bridge-stream-default", + target: baseTarget, + runtimeRootDir, + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: "http://127.0.0.1:9", + }); + try { + expect(defaultBridge?.runLogTail).toBeTruthy(); + } finally { + await defaultBridge?.stop(); + } + + const optOutBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-bridge-stream-opt-out", + target: { ...baseTarget, streamRunLogs: false }, + runtimeRootDir, + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: "http://127.0.0.1:9", + }); + try { + expect(optOutBridge?.runLogTail ?? null).toBeNull(); + } finally { + await optOutBridge?.stop(); + } + }); + + it("tails sandbox run log chunks with byte offsets and dedupes the final batch", async () => { + const stdoutText = "stdout-abc\n"; + const stderrText = "stderr-xyz\n"; + const stdoutBytes = Buffer.from(stdoutText, "utf8"); + const stderrBytes = Buffer.from(stderrText, "utf8"); + const stdoutOffsets: number[] = []; + const stderrOffsets: number[] = []; + const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + + const runner = { + execute: vi.fn(async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + timeoutMs?: number; + }) => { + const script = input.args?.[1] ?? ""; + const offsets = [...script.matchAll(/tail -c \+(\d+) /g)].map((match) => Number(match[1])); + const stdoutStart = Math.max(0, (offsets[0] ?? 1) - 1); + const stderrStart = Math.max(0, (offsets[1] ?? 1) - 1); + stdoutOffsets.push(stdoutStart + 1); + stderrOffsets.push(stderrStart + 1); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: encodeTailTick( + stdoutBytes.subarray(stdoutStart, stdoutStart + 4), + stderrBytes.subarray(stderrStart, stderrStart + 4), + ), + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }; + }), + }; + + const tail = createSandboxRunLogTailFactory({ + runner, + remoteCwd: "/workspace", + logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", + pollIntervalMs: 1, + maxChunkBytesPerTick: 4, + tickTimeoutMs: 50, + }).create(); + + tail.start(async (stream, chunk) => { + events.push({ stream, chunk }); + }); + + await waitForCondition( + () => combinedStream(events, "stdout") === stdoutText && combinedStream(events, "stderr") === stderrText, + "run log tail did not stream expected stdout/stderr chunks", + ); + + await tail.finish({ stdout: stdoutText, stderr: stderrText }); + + expect(combinedStream(events, "stdout")).toBe(stdoutText); + expect(combinedStream(events, "stderr")).toBe(stderrText); + expect(stdoutOffsets.slice(0, 3)).toEqual([1, 5, 9]); + expect(stderrOffsets.slice(0, 3)).toEqual([1, 5, 9]); + expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ + command: "sh", + cwd: "/workspace", + env: { PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge" }, + timeoutMs: 50, + })); + }); + + it("emits only the unstreamed final suffix when the tail loop stops early", async () => { + const finalStdout = "prefix suffix\n"; + const finalBytes = Buffer.from(finalStdout, "utf8"); + const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + + const runner = { + execute: vi.fn(async (input: { args?: string[] }) => { + const script = input.args?.[1] ?? ""; + const offsets = [...script.matchAll(/tail -c \+(\d+) /g)].map((match) => Number(match[1])); + const stdoutStart = Math.max(0, (offsets[0] ?? 1) - 1); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: encodeTailTick(finalBytes.subarray(stdoutStart, stdoutStart + 7), Buffer.alloc(0)), + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }; + }), + }; + + const tail = createSandboxRunLogTailFactory({ + runner, + remoteCwd: "/workspace", + logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", + pollIntervalMs: 1, + maxChunkBytesPerTick: 7, + tickTimeoutMs: 50, + }).create(); + + tail.start(async (stream, chunk) => { + events.push({ stream, chunk }); + }); + await waitForCondition(() => combinedStream(events, "stdout").length >= 7, "run log tail did not emit prefix"); + await tail.finish({ stdout: finalStdout, stderr: "" }); + + expect(combinedStream(events, "stdout")).toBe(finalStdout); + expect(events.filter((event) => event.stream === "stdout").map((event) => event.chunk).join("|")) + .toBe("prefix |suffix\n"); + }); + + it("delivers the final batch and a warning when run log polling degrades", async () => { + const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const runner = { + execute: vi.fn(async () => ({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "tail failed", + pid: null, + startedAt: new Date().toISOString(), + })), + }; + + const tail = createSandboxRunLogTailFactory({ + runner, + remoteCwd: "/workspace", + logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", + pollIntervalMs: 1, + tickTimeoutMs: 50, + maxConsecutiveFailures: 1, + }).create(); + + tail.start(async (stream, chunk) => { + events.push({ stream, chunk }); + }); + await waitForCondition(() => runner.execute.mock.calls.length >= 1, "run log tail did not poll before finish"); + await new Promise((resolve) => setTimeout(resolve, 5)); + await tail.finish({ stdout: "final out\n", stderr: "final err\n" }); + + expect(combinedStream(events, "stdout")).toBe("final out\n"); + expect(combinedStream(events, "stderr")).toBe( + "final err\n[paperclip] Run log streaming degraded during the run; remaining output was delivered at completion.\n", + ); + }); + it("exposes the Paperclip bridge to the sandbox shell surface", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-shell-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 6978e8e903..466c00a96c 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -15,9 +15,14 @@ import { createSandboxCallbackBridgeAsset, createSandboxCallbackBridgeToken, DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, + sandboxCallbackBridgeDirectories, startSandboxCallbackBridgeServer, startSandboxCallbackBridgeWorker, } from "./sandbox-callback-bridge.js"; +import { + createSandboxRunLogTailFactory, + type SandboxRunLogTailFactory, +} from "./sandbox-run-log-stream.js"; import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; import { ensureCommandResolvable, @@ -57,6 +62,13 @@ export interface AdapterSandboxExecutionTarget { remoteCwd: string; timeoutMs?: number | null; runner?: CommandManagedRuntimeRunner; + /** + * Sandbox-backed adapter runs stream the agent CLI's stdout/stderr + * incrementally via a log-tail loop beside the callback bridge instead of + * waiting for the batched provider result. Streaming is ON by default; + * set to `false` to explicitly opt out back to batch-at-end delivery. + */ + streamRunLogs?: boolean | null; } export type AdapterExecutionTarget = @@ -86,6 +98,12 @@ export interface AdapterExecutionTargetProcessOptions { onRuntimeProgress?: RuntimeStatusSink; onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; terminalResultCleanup?: TerminalResultCleanupOptions; + /** + * Sandbox-only: factory from the Paperclip bridge handle that streams the + * CLI's stdout/stderr during the run. When provided, the batched provider + * onLog is suppressed and incremental chunks flow through `onLog` instead. + */ + runLogTail?: SandboxRunLogTailFactory | null; } export interface AdapterExecutionTargetShellOptions { @@ -98,6 +116,12 @@ export interface AdapterExecutionTargetShellOptions { export interface AdapterExecutionTargetPaperclipBridgeHandle { env: Record; + /** + * Present when the sandbox target opted into run-log streaming + * (`streamRunLogs`). Create one handle per CLI attempt and pass it to + * `runAdapterExecutionTargetProcess` via `options.runLogTail`. + */ + runLogTail?: SandboxRunLogTailFactory | null; stop(): Promise; } @@ -412,18 +436,38 @@ export async function runAdapterExecutionTargetProcess( phase: "adapter_startup", message: "Starting adapter in sandbox", }); - return await runner.execute({ - command, - args, - cwd: target.remoteCwd, - env, - stdin: options.stdin, - timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined, - onLog: options.onLog, - onSpawn: options.onSpawn - ? async (meta) => options.onSpawn?.({ ...meta, processGroupId: null }) - : undefined, - }); + const runLogTail = options.runLogTail?.create() ?? null; + let execCommand = command; + let execArgs = args; + if (runLogTail) { + ({ command: execCommand, args: execArgs } = runLogTail.wrapCommand(command, args)); + runLogTail.start(options.onLog); + } + try { + const result = await runner.execute({ + command: execCommand, + args: execArgs, + cwd: target.remoteCwd, + env, + stdin: options.stdin, + timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined, + // The tail loop already streams incremental chunks; suppress the + // runner's end-of-run batched onLog to avoid duplicate log bytes. + onLog: runLogTail ? undefined : options.onLog, + onSpawn: options.onSpawn + ? async (meta) => options.onSpawn?.({ ...meta, processGroupId: null }) + : undefined, + }); + if (runLogTail) { + await runLogTail.finish({ stdout: result.stdout, stderr: result.stderr }); + } + return result; + } catch (error) { + if (runLogTail) { + await runLogTail.abort(); + } + throw error; + } } const env = @@ -886,6 +930,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar leaseId: readStringMeta(parsed, "leaseId"), remoteCwd, timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null, + streamRunLogs: typeof parsed.streamRunLogs === "boolean" ? parsed.streamRunLogs : null, }; } @@ -1194,12 +1239,25 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { throw error; } + let runLogTail: SandboxRunLogTailFactory | null = null; + if (target.transport === "sandbox" && target.streamRunLogs !== false) { + runLogTail = createSandboxRunLogTailFactory({ + runner, + remoteCwd: target.remoteCwd, + logsDir: sandboxCallbackBridgeDirectories(queueDir).logsDir, + shellCommand, + }); + await onLog("stdout", "[paperclip] Sandbox run log streaming enabled for this run.\n"); + } + return { env: { PAPERCLIP_API_URL: server.baseUrl, PAPERCLIP_API_KEY: bridgeToken, PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + PAPERCLIP_BRIDGE_QUEUE_DIR: queueDir, }, + runLogTail, stop: async () => { await Promise.allSettled([ server?.stop(), diff --git a/packages/adapter-utils/src/runtime-progress.ts b/packages/adapter-utils/src/runtime-progress.ts index 11beabda63..e217927675 100644 --- a/packages/adapter-utils/src/runtime-progress.ts +++ b/packages/adapter-utils/src/runtime-progress.ts @@ -32,6 +32,9 @@ export type RuntimeStatusPhase = export interface RuntimeStatusUpdate { phase: RuntimeStatusPhase; message: string; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: Date | string | null; } export type RuntimeStatusSink = (update: RuntimeStatusUpdate) => void | Promise; diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 5338a8e768..a101fc817c 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -148,12 +148,18 @@ describe("sandbox managed runtime", () => { await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).resolves.toBe("remove\n"); await expect(readFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"local\":true}\n"); await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n"); - expect(runtimeStatuses).toEqual([ + expect(runtimeStatuses).toEqual(expect.arrayContaining([ "config_sync:Syncing workspace to sandbox", "config_sync:Syncing runtime assets to sandbox", "restore:Restoring workspace from sandbox", "finalize:Finalizing sandbox workspace", - ]); + ])); + expect(runtimeStatuses).toEqual(expect.arrayContaining([ + expect.stringMatching(/^config_sync:Syncing workspace to sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/), + expect.stringMatching(/^config_sync:Syncing skills to sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/), + expect.stringMatching(/^restore:Restoring workspace from sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/), + ])); + expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing sandbox workspace"); }); it("syncs git-backed workspaces through a shallow standalone clone and keeps .git out of archives", async () => { @@ -185,19 +191,29 @@ describe("sandbox managed runtime", () => { const uploadedTars: { remotePath: string; bytes: Buffer }[] = []; const downloadedTars: { remotePath: string; bytes: Buffer }[] = []; + const driveProgress = async ( + total: number, + onProgress: ((done: number, total: number | null) => void | Promise) | undefined, + ) => { + if (!onProgress) return; + await onProgress(Math.max(1, Math.floor(total / 2)), total); + await onProgress(total, total); + }; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, - writeFile: async (remotePath, bytes) => { + writeFile: async (remotePath, bytes, options) => { await mkdir(path.dirname(remotePath), { recursive: true }); const buffer = Buffer.from(bytes); if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer }); await writeFile(remotePath, buffer); + await driveProgress(buffer.byteLength, options?.onProgress); }, - readFile: async (remotePath) => { + readFile: async (remotePath, options) => { const buffer = await readFile(remotePath); if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer }); + await driveProgress(buffer.byteLength, options?.onProgress); return buffer; }, listFiles: async () => [], @@ -208,7 +224,7 @@ describe("sandbox managed runtime", () => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, }; - const runtimeStatusPhases: string[] = []; + const runtimeStatuses: Array<{ phase: string; message: string }> = []; const prepared = await prepareSandboxManagedRuntime({ spec: { @@ -223,7 +239,7 @@ describe("sandbox managed runtime", () => { client, workspaceLocalDir: localWorkspaceDir, onRuntimeProgress: async (status) => { - runtimeStatusPhases.push(status.phase); + runtimeStatuses.push({ phase: status.phase, message: status.message }); }, }); @@ -269,13 +285,21 @@ describe("sandbox managed runtime", () => { const downloadMembers = await listTarMembers(rootDir, "workspace-download-list.tar", downloadedTars[0]!.bytes); expect(downloadMembers.some((entry) => entry === ".git" || entry.startsWith(".git/"))).toBe(false); expect(downloadMembers.some((entry) => entry === "node_modules" || entry.startsWith("node_modules/"))).toBe(false); - expect(runtimeStatusPhases).toEqual([ + expect(runtimeStatuses.map((status) => status.phase)).toEqual(expect.arrayContaining([ "git_sync", "config_sync", "export", "restore", "finalize", - ]); + ])); + expect(runtimeStatuses.some((status) => ( + status.phase === "git_sync" && + /^Syncing git history to sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/.test(status.message) + ))).toBe(true); + expect(runtimeStatuses.some((status) => ( + status.phase === "export" && + /^Exporting git history from sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/.test(status.message) + ))).toBe(true); }); it("repairs stale host index deletions when the sandbox restores a clean git worktree", async () => { diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index cbaed7298e..9edb59703c 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -382,10 +382,28 @@ function makeTransferProgress( phase: RuntimeProgressPhase, direction: RuntimeProgressDirection, label?: string, -): { options: SandboxTransferProgressOptions | undefined; finish: () => Promise } { - if (!sink) return { options: undefined, finish: async () => {} }; + runtimeStatus?: { + sink: RuntimeStatusSink | undefined; + phase: RuntimeStatusPhase; + }, +): { + options: SandboxTransferProgressOptions | undefined; + finish: (doneBytes?: number, totalBytes?: number | null) => Promise; +} { + if (!sink && !runtimeStatus?.sink) { + return { options: undefined, finish: async () => {} }; + } const reporter = createRuntimeProgressReporter({ - sink, + sink: async (line) => { + await sink?.(line); + if (runtimeStatus?.sink) { + await emitRuntimeStatus( + runtimeStatus.sink, + runtimeStatus.phase, + line.replace(/^\[paperclip\]\s*/, "").trim(), + ); + } + }, phase, direction, target: "sandbox", @@ -397,8 +415,8 @@ function makeTransferProgress( await reporter.report(transferredBytes, totalBytes); }, }, - finish: async () => { - await reporter.complete(); + finish: async (doneBytes, totalBytes) => { + await reporter.complete(doneBytes, totalBytes); }, }; } @@ -460,9 +478,15 @@ export async function prepareSandboxManagedRuntime(input: { const gitTarBytes = await fs.readFile(gitTarPath); const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar"); await input.client.makeDir(runtimeRootDir); - const gitUpload = makeTransferProgress(input.onProgress, "Syncing", "to", "git history"); + const gitUpload = makeTransferProgress( + input.onProgress, + "Syncing", + "to", + "git history", + { sink: input.onRuntimeProgress, phase: "git_sync" }, + ); await input.client.writeFile(remoteGitTar, toArrayBuffer(gitTarBytes), gitUpload.options); - await gitUpload.finish(); + await gitUpload.finish(gitTarBytes.byteLength, gitTarBytes.byteLength); await input.client.run( `sh -c ${shellQuote( `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` + @@ -494,13 +518,19 @@ export async function prepareSandboxManagedRuntime(input: { const workspaceTarBytes = await fs.readFile(workspaceTarPath); const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar"); await input.client.makeDir(runtimeRootDir); - const workspaceUpload = makeTransferProgress(input.onProgress, "Syncing", "to", "workspace"); + const workspaceUpload = makeTransferProgress( + input.onProgress, + "Syncing", + "to", + "workspace", + { sink: input.onRuntimeProgress, phase: "config_sync" }, + ); await input.client.writeFile( remoteWorkspaceTar, toArrayBuffer(workspaceTarBytes), workspaceUpload.options, ); - await workspaceUpload.finish(); + await workspaceUpload.finish(workspaceTarBytes.byteLength, workspaceTarBytes.byteLength); const extractWorkspaceTarCommand = gitSnapshot ? `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` + `tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` + @@ -534,9 +564,15 @@ export async function prepareSandboxManagedRuntime(input: { const assetTarBytes = await fs.readFile(assetTarPath); const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key); const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`); - const assetUpload = makeTransferProgress(input.onProgress, "Syncing", "to", asset.key); + const assetUpload = makeTransferProgress( + input.onProgress, + "Syncing", + "to", + asset.key, + { sink: input.onRuntimeProgress, phase: "config_sync" }, + ); await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes), assetUpload.options); - await assetUpload.finish(); + await assetUpload.finish(assetTarBytes.byteLength, assetTarBytes.byteLength); await input.client.run( `sh -c ${shellQuote( `rm -rf ${shellQuote(remoteAssetDir)} && ` + @@ -582,9 +618,16 @@ export async function prepareSandboxManagedRuntime(input: { }))}`, { timeoutMs: input.spec.timeoutMs }, ); - const gitExport = makeTransferProgress(restoreSink, "Exporting git history", "from"); + const gitExport = makeTransferProgress( + restoreSink, + "Exporting git history", + "from", + undefined, + { sink: input.onRuntimeProgress, phase: "export" }, + ); const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options); - await gitExport.finish(); + const bundleBuffer = toBuffer(bundleBytes); + await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength); await input.client.remove(remoteGitBundle).catch(() => undefined); remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath) .then((bytes) => toBuffer(bytes).toString("utf8").trim()) @@ -592,7 +635,7 @@ export async function prepareSandboxManagedRuntime(input: { remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty"; await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined); const bundlePath = path.join(tempDir, "git-delta.bundle"); - await fs.writeFile(bundlePath, toBuffer(bundleBytes)); + await fs.writeFile(bundlePath, bundleBuffer); importedHead = await fetchGitBundleIntoLocalRef({ localDir: input.workspaceLocalDir, bundlePath, @@ -612,13 +655,20 @@ export async function prepareSandboxManagedRuntime(input: { }))}`, { timeoutMs: input.spec.timeoutMs }, ); - const workspaceRestore = makeTransferProgress(restoreSink, "Restoring", "from", "workspace"); + const workspaceRestore = makeTransferProgress( + restoreSink, + "Restoring", + "from", + "workspace", + { sink: input.onRuntimeProgress, phase: "restore" }, + ); const archiveBytes = await input.client.readFile(remoteWorkspaceTar, workspaceRestore.options); - await workspaceRestore.finish(); + const archiveBuffer = toBuffer(archiveBytes); + await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength); await input.client.remove(remoteWorkspaceTar).catch(() => undefined); const localArchivePath = path.join(tempDir, "workspace.tar"); const extractedDir = path.join(tempDir, "workspace"); - await fs.writeFile(localArchivePath, toBuffer(archiveBytes)); + await fs.writeFile(localArchivePath, archiveBuffer); await extractTarballToDirectory({ archivePath: localArchivePath, localDir: extractedDir, diff --git a/packages/adapter-utils/src/sandbox-run-log-stream.ts b/packages/adapter-utils/src/sandbox-run-log-stream.ts new file mode 100644 index 0000000000..013ab43048 --- /dev/null +++ b/packages/adapter-utils/src/sandbox-run-log-stream.ts @@ -0,0 +1,278 @@ +import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; +import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js"; +import { shellQuote } from "./ssh.js"; + +// Sandbox providers execute commands through batch RPCs, so agent CLI output +// normally only reaches the host when the process exits. This module streams +// that output during the run instead: `wrapCommand` tees the CLI's +// stdout/stderr into log files under the bridge runtime directory inside the +// sandbox, and a host-side poll loop tails those files (byte offsets + +// base64 transport, mirroring the callback-bridge queue client) and emits +// incremental `onLog` chunks through the existing run-log pipeline. + +const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL"; +const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge"; + +const DEFAULT_TAIL_POLL_INTERVAL_MS = 250; +const DEFAULT_TAIL_MAX_CHUNK_BYTES = 64 * 1024; +const DEFAULT_TAIL_TICK_TIMEOUT_MS = 15_000; +const DEFAULT_TAIL_MAX_CONSECUTIVE_FAILURES = 3; + +const TAIL_MARKER_STDOUT = "__PAPERCLIP_RUN_LOG_STDOUT__"; +const TAIL_MARKER_STDERR = "__PAPERCLIP_RUN_LOG_STDERR__"; +const TAIL_MARKER_END = "__PAPERCLIP_RUN_LOG_END__"; + +export type SandboxRunLogSink = (stream: "stdout" | "stderr", chunk: string) => Promise; + +export interface SandboxRunLogTailHandle { + /** + * Wrap the agent CLI invocation in a shell script that tees stdout/stderr + * into tailable log files while preserving the original streams (the + * provider result must keep the full stdout for adapter parsing) and the + * original exit code. + */ + wrapCommand(command: string, args: string[]): { command: string; args: string[] }; + /** Start the host-side poll loop that tails the log files via the runner. */ + start(onLog: SandboxRunLogSink): void; + /** + * Stop the poll loop and emit any bytes of the final batched output that + * were not already streamed. Emitting the suffix past the streamed byte + * offset both dedupes the final batch and guarantees full coverage when + * the tail loop degraded mid-run. + */ + finish(finalBatch: { stdout: string; stderr: string }): Promise; + /** Stop the poll loop without emitting anything further (error path). */ + abort(): Promise; +} + +export interface SandboxRunLogTailFactory { + create(): SandboxRunLogTailHandle; +} + +export interface SandboxRunLogTailFactoryOptions { + runner: CommandManagedRuntimeRunner; + remoteCwd: string; + /** Remote directory the log files live in (bridge queue `logs/` dir). */ + logsDir: string; + shellCommand?: "bash" | "sh" | null; + pollIntervalMs?: number | null; + maxChunkBytesPerTick?: number | null; + tickTimeoutMs?: number | null; + maxConsecutiveFailures?: number | null; +} + +function normalizePositiveInt(value: number | null | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.trunc(value) + : fallback; +} + +interface TailStreamState { + stream: "stdout" | "stderr"; + logFile: string; + offset: number; + decoder: StringDecoder; +} + +function decodeBase64Section(lines: string[]): Buffer { + const joined = lines.join("").replace(/\s+/g, ""); + if (joined.length === 0) return Buffer.alloc(0); + return Buffer.from(joined, "base64"); +} + +export function createSandboxRunLogTailFactory( + options: SandboxRunLogTailFactoryOptions, +): SandboxRunLogTailFactory { + const shellCommand = preferredShellForSandbox(options.shellCommand); + const pollIntervalMs = normalizePositiveInt(options.pollIntervalMs, DEFAULT_TAIL_POLL_INTERVAL_MS); + const maxChunkBytes = normalizePositiveInt(options.maxChunkBytesPerTick, DEFAULT_TAIL_MAX_CHUNK_BYTES); + const tickTimeoutMs = normalizePositiveInt(options.tickTimeoutMs, DEFAULT_TAIL_TICK_TIMEOUT_MS); + const maxConsecutiveFailures = normalizePositiveInt( + options.maxConsecutiveFailures, + DEFAULT_TAIL_MAX_CONSECUTIVE_FAILURES, + ); + + let sequence = 0; + + function createHandle(): SandboxRunLogTailHandle { + sequence += 1; + const baseName = `run-${sequence}`; + const stdoutLog = path.posix.join(options.logsDir, `${baseName}-stdout.log`); + const stderrLog = path.posix.join(options.logsDir, `${baseName}-stderr.log`); + const statusFile = path.posix.join(options.logsDir, `${baseName}-status`); + + const streams: [TailStreamState, TailStreamState] = [ + { stream: "stdout", logFile: stdoutLog, offset: 0, decoder: new StringDecoder("utf8") }, + { stream: "stderr", logFile: stderrLog, offset: 0, decoder: new StringDecoder("utf8") }, + ]; + + let sink: SandboxRunLogSink | null = null; + let stopped = false; + let degraded = false; + let loopPromise: Promise | null = null; + let wakeSleep: (() => void) | null = null; + + function sleep(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + wakeSleep = null; + resolve(); + }, ms); + wakeSleep = () => { + clearTimeout(timer); + wakeSleep = null; + resolve(); + }; + }); + } + + function buildTickScript(): string { + const lines: string[] = [`printf '%s\\n' ${shellQuote(TAIL_MARKER_STDOUT)}`]; + for (const state of streams) { + if (state.stream === "stderr") { + lines.push(`printf '%s\\n' ${shellQuote(TAIL_MARKER_STDERR)}`); + } + lines.push( + `if [ -f ${shellQuote(state.logFile)} ]; then tail -c +${state.offset + 1} ${shellQuote(state.logFile)} | head -c ${maxChunkBytes} | base64; fi`, + ); + } + lines.push(`printf '%s\\n' ${shellQuote(TAIL_MARKER_END)}`); + return lines.join("\n"); + } + + function parseTickOutput(stdout: string): { stdout: Buffer; stderr: Buffer } | null { + const lines = stdout.split(/\r?\n/); + const stdoutIndex = lines.indexOf(TAIL_MARKER_STDOUT); + const stderrIndex = lines.indexOf(TAIL_MARKER_STDERR); + const endIndex = lines.indexOf(TAIL_MARKER_END); + if (stdoutIndex < 0 || stderrIndex < stdoutIndex || endIndex < stderrIndex) { + return null; + } + return { + stdout: decodeBase64Section(lines.slice(stdoutIndex + 1, stderrIndex)), + stderr: decodeBase64Section(lines.slice(stderrIndex + 1, endIndex)), + }; + } + + async function emitBytes(state: TailStreamState, bytes: Buffer): Promise { + if (bytes.length === 0) return; + state.offset += bytes.length; + const text = state.decoder.write(bytes); + if (text.length > 0 && sink) { + await sink(state.stream, text); + } + } + + async function tick(): Promise { + const result = await options.runner.execute({ + command: shellCommand, + args: shellCommandArgs(buildTickScript()), + cwd: options.remoteCwd, + env: { [SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE }, + timeoutMs: tickTimeoutMs, + }); + if (result.timedOut || (result.exitCode ?? 1) !== 0) { + throw new Error( + `Run log tail tick failed (exit ${result.exitCode ?? "null"}${result.timedOut ? ", timed out" : ""}).`, + ); + } + const sections = parseTickOutput(result.stdout); + if (!sections) { + throw new Error("Run log tail tick returned unparseable output."); + } + await emitBytes(streams[0], sections.stdout); + await emitBytes(streams[1], sections.stderr); + } + + async function loop(): Promise { + let consecutiveFailures = 0; + while (!stopped) { + await sleep(pollIntervalMs); + if (stopped) break; + try { + await tick(); + consecutiveFailures = 0; + } catch { + consecutiveFailures += 1; + if (consecutiveFailures >= maxConsecutiveFailures) { + degraded = true; + break; + } + } + } + } + + async function stopLoop(): Promise { + stopped = true; + wakeSleep?.(); + if (loopPromise) { + await loopPromise.catch(() => undefined); + loopPromise = null; + } + } + + return { + wrapCommand(command, args) { + const quotedInvocation = [command, ...args].map(shellQuote).join(" "); + // Tee stdout/stderr into tailable log files while keeping both + // streams flowing to the provider result. fd 3 carries the real + // stdout out of the inner group so stderr can ride the inner pipe + // into its own tee. The exit status survives the pipeline through + // the status file. + const script = [ + `out_log=${shellQuote(stdoutLog)}`, + `err_log=${shellQuote(stderrLog)}`, + `status_file=${shellQuote(statusFile)}`, + `mkdir -p ${shellQuote(options.logsDir)}`, + `: > "$out_log"`, + `: > "$err_log"`, + `rm -f "$status_file"`, + `{`, + ` { ${quotedInvocation} 3>&-; printf '%s' "$?" > "$status_file"; } 2>&1 1>&3 | tee -a "$err_log" >&2`, + `} 3>&1 | tee -a "$out_log"`, + `if [ -s "$status_file" ]; then exit "$(cat "$status_file")"; fi`, + `exit 1`, + ].join("\n"); + return { command: shellCommand, args: shellCommandArgs(script) }; + }, + start(onLog) { + if (loopPromise || stopped) return; + sink = onLog; + loopPromise = loop(); + }, + async finish(finalBatch) { + await stopLoop(); + if (!sink) return; + for (const state of streams) { + const finalBytes = Buffer.from( + state.stream === "stdout" ? finalBatch.stdout : finalBatch.stderr, + "utf8", + ); + if (finalBytes.length > state.offset) { + const text = state.decoder.write(finalBytes.subarray(state.offset)); + if (text.length > 0) { + await sink(state.stream, text); + } + } + const rest = state.decoder.end(); + if (rest.length > 0) { + await sink(state.stream, rest); + } + } + if (degraded) { + await sink( + "stderr", + "[paperclip] Run log streaming degraded during the run; remaining output was delivered at completion.\n", + ); + } + }, + async abort() { + await stopLoop(); + }, + }; + } + + return { create: createHandle }; +} diff --git a/packages/adapters/acpx-local/src/index.ts b/packages/adapters/acpx-local/src/index.ts index ead70e617c..8298069166 100644 --- a/packages/adapters/acpx-local/src/index.ts +++ b/packages/adapters/acpx-local/src/index.ts @@ -25,10 +25,14 @@ Adapter: acpx_local Use when: - The agent should run through Agent Client Protocol via ACPX on the Paperclip host or a managed execution environment. - You want one built-in adapter that can target Claude, Codex, or a custom ACP server command. -- You need Paperclip-managed session identity and live streamed ACP events in later ACPX runtime phases. +- You want the richest live run feedback. acpx_local streams structured JSONL events (acpx.session, acpx.status, acpx.text_delta, acpx.tool_call, acpx.result, acpx.error) that the UI renders as live message, thinking, tool, and status blocks. +- The agent runs on a sandbox execution target. Sandbox run logs stream live, so + acpx_local's granular events make remote runs as observable as local ones. + Prefer acpx_local for sandbox workers, especially when watching progress in + real time. Don't use when: -- You need today's stable Claude Code or Codex CLI wrapper behavior. Use claude_local or codex_local until acpx_local runtime execution is enabled. +- You depend on CLI-wrapper-specific behavior of claude_local or codex_local (their session files, CLI flags, or CLI-version-specific output). - The host cannot satisfy ACPX's Node >=22.12.0 prerequisite. - The agent runtime is not an ACP server and cannot be launched through ACPX. diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index d465b02c2e..dc56b35d03 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -1096,6 +1096,8 @@ async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeE return; } if (event.type === "tool_call") { + const eventRecord = event as Record; + const toolInput = eventRecord.input; await emitAcpxLog(ctx, { type: "acpx.tool_call", name: event.title ?? "acp_tool", @@ -1103,6 +1105,7 @@ async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeE status: event.status, text: event.text, tag: event.tag, + ...(toolInput !== undefined ? { input: toolInput } : {}), }); return; } diff --git a/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts b/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts index 80fb267160..932ffbe74e 100644 --- a/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts +++ b/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts @@ -96,6 +96,60 @@ describe("parseAcpxStdoutLine", () => { ]); }); + it("merges explicit tool_call input payload with status text", () => { + const entries = parseAcpxStdoutLine( + emit({ + type: "acpx.tool_call", + name: "read", + toolCallId: "tool-3", + status: "in_progress", + text: "reading README.md", + input: { file: "README.md" }, + }), + TS, + ); + expect(entries).toEqual([ + { + kind: "tool_call", + ts: TS, + name: "read", + toolUseId: "tool-3", + input: { file: "README.md", status: "in_progress", text: "reading README.md" }, + }, + ]); + }); + + it("keeps terminal tool_call status while preserving existing input", () => { + const entries = parseAcpxStdoutLine( + emit({ + type: "acpx.tool_call", + name: "read", + toolCallId: "tool-4", + status: "completed", + text: "ok", + input: { file: "README.md", status: "running" }, + }), + TS, + ); + expect(entries).toEqual([ + { + kind: "tool_call", + ts: TS, + name: "read", + toolUseId: "tool-4", + input: { file: "README.md", status: "running", text: "ok" }, + }, + { + kind: "tool_result", + ts: TS, + toolUseId: "tool-4", + toolName: "read", + content: "ok", + isError: false, + }, + ]); + }); + it("emits a paired tool_result entry when a tool_call reports terminal status", () => { const completed = parseAcpxStdoutLine( emit({ diff --git a/packages/adapters/acpx-local/src/ui/parse-stdout.ts b/packages/adapters/acpx-local/src/ui/parse-stdout.ts index 019e8f3322..1818a51a4c 100644 --- a/packages/adapters/acpx-local/src/ui/parse-stdout.ts +++ b/packages/adapters/acpx-local/src/ui/parse-stdout.ts @@ -36,6 +36,11 @@ function pickToolUseId(parsed: Record): string { ); } +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + function statusText(parsed: Record): string { const text = asString(parsed.text).trim(); const tag = asString(parsed.tag).trim(); @@ -86,12 +91,19 @@ export function parseAcpxStdoutLine(line: string, ts: string): TranscriptEntry[] const text = asString(parsed.text); const name = asString(parsed.name, "acp_tool"); const toolUseId = pickToolUseId(parsed); - const input = - parsed.input !== undefined - ? parsed.input - : text || status - ? { ...(text ? { text } : {}), ...(status ? { status } : {}) } - : {}; + const parsedInput = parsed.input; + const input = (() => { + if (parsedInput === undefined) { + return text || status ? { ...(text ? { text } : {}), ...(status ? { status } : {}) } : {}; + } + const inputRecord = asRecord(parsedInput); + if (!inputRecord) return parsedInput; + return { + ...inputRecord, + ...(status && inputRecord.status === undefined ? { status } : {}), + ...(text && inputRecord.text === undefined ? { text } : {}), + }; + })(); const entries: TranscriptEntry[] = [ { kind: "tool_call", diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 2fd71a6c1b..3bfec50c62 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -793,6 +793,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise parseClaudeStreamJson(stdout).resultJson !== null, diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 193febc6de..ae5e759125 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -893,6 +893,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { ...run, currentStatusMessage: "Syncing workspace to sandbox", currentStatusUpdatedAt: new Date("2026-04-10T09:30:05.000Z"), + currentToolName: "bash", + lastAssistantSnippet: "Inspecting files", + lastEventAt: new Date("2026-04-10T09:30:06.000Z"), })); const res = await requestApp( @@ -331,6 +334,9 @@ describe("agent live run routes", () => { expect(res.body).toMatchObject({ currentStatusMessage: "Syncing workspace to sandbox", currentStatusUpdatedAt: "2026-04-10T09:30:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting files", + lastEventAt: "2026-04-10T09:30:06.000Z", }); }); diff --git a/server/src/__tests__/heartbeat-runtime-state.test.ts b/server/src/__tests__/heartbeat-runtime-state.test.ts index e6c182b3ce..24ae9173f1 100644 --- a/server/src/__tests__/heartbeat-runtime-state.test.ts +++ b/server/src/__tests__/heartbeat-runtime-state.test.ts @@ -148,6 +148,9 @@ describeEmbeddedPostgres("heartbeat runtime state deduplication", () => { const status = await heartbeat.recordRuntimeProgress(run, { phase: "config_sync", message: "Syncing workspace to sandbox", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting the repository", + lastEventAt: "2026-06-24T00:00:05.000Z", }, issueId); expect(status).toMatchObject({ @@ -157,6 +160,9 @@ describeEmbeddedPostgres("heartbeat runtime state deduplication", () => { runId, phase: "config_sync", message: "Syncing workspace to sandbox", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting the repository", + lastEventAt: new Date("2026-06-24T00:00:05.000Z"), }); expect(heartbeat.decorateActiveRunStatus({ id: runId, @@ -166,6 +172,9 @@ describeEmbeddedPostgres("heartbeat runtime state deduplication", () => { status: "running", })).toMatchObject({ currentStatusMessage: "Syncing workspace to sandbox", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting the repository", + lastEventAt: new Date("2026-06-24T00:00:05.000Z"), }); expect(liveEvents).toContainEqual(expect.objectContaining({ companyId, @@ -176,6 +185,9 @@ describeEmbeddedPostgres("heartbeat runtime state deduplication", () => { issueId, phase: "config_sync", message: "Syncing workspace to sandbox", + currentToolName: "bash", + lastAssistantSnippet: "Inspecting the repository", + lastEventAt: "2026-06-24T00:00:05.000Z", }), })); diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 05878ed276..0fe977fca5 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -75,6 +75,7 @@ const fakeSandboxEnvironmentConfigSchema = z.object({ .min(1, "Fake sandbox environments require an image.") .default("ubuntu:24.04"), reuseLease: z.boolean().optional().default(false), + streamRunLogs: z.boolean().optional(), }).strict(); const pluginSandboxProviderKeySchema = z.string() @@ -89,6 +90,7 @@ const pluginSandboxEnvironmentConfigSchema = z.object({ provider: pluginSandboxProviderKeySchema, timeoutMs: z.coerce.number().int().min(1).max(86_400_000).optional(), reuseLease: z.boolean().optional().default(false), + streamRunLogs: z.boolean().optional(), }).catchall(z.unknown()); const pluginEnvironmentConfigSchema = z.object({ diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 0e50bd7b6a..c88c25cc3f 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -73,6 +73,10 @@ export async function resolveEnvironmentExecutionTarget(input: { environmentId: input.environment.id ?? null, leaseId: input.leaseId ?? null, timeoutMs, + // Run-log streaming defaults ON for sandbox environments so agent CLI + // output reaches the UI mid-run; `streamRunLogs: false` is an explicit + // opt-out back to batch-at-end delivery. + streamRunLogs: parsed.config.streamRunLogs !== false, runner: input.environmentRuntime && input.lease ? { supportsSingleStreamStdinProgress: false, diff --git a/server/src/services/heartbeat-run-runtime-status.test.ts b/server/src/services/heartbeat-run-runtime-status.test.ts index 457ce76804..f603900127 100644 --- a/server/src/services/heartbeat-run-runtime-status.test.ts +++ b/server/src/services/heartbeat-run-runtime-status.test.ts @@ -6,6 +6,7 @@ import { MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS, setHeartbeatRunRuntimeStatus, sweepExpiredHeartbeatRunRuntimeStatuses, + touchHeartbeatRunRuntimeStatus, } from "./heartbeat-run-runtime-status.js"; describe("heartbeat run runtime status store", () => { @@ -22,11 +23,17 @@ describe("heartbeat run runtime status store", () => { runId: "run-1", phase: "config_sync", message: `Syncing workspace with apiKey: "sk-test-secret" ${"x".repeat(300)}`, + currentToolName: `bash apiKey: "sk-tool-secret" ${"x".repeat(120)}`, + lastAssistantSnippet: `Reading apiKey: "sk-snippet-secret" ${"x".repeat(300)}`, + lastEventAt: new Date("2026-06-24T00:00:05.000Z"), updatedAt, }); expect(status?.message).toContain("***REDACTED***"); expect(status?.message.length).toBeLessThanOrEqual(MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS); + expect(status?.currentToolName).toContain("***REDACTED***"); + expect(status?.lastAssistantSnippet).toContain("***REDACTED***"); + expect(status?.lastEventAt).toEqual(new Date("2026-06-24T00:00:05.000Z")); expect(getHeartbeatRunRuntimeStatus("run-1", { companyId: "company-1", issueId: "issue-1", @@ -38,6 +45,9 @@ describe("heartbeat run runtime status store", () => { agentId: "agent-1", runId: "run-1", phase: "config_sync", + currentToolName: expect.stringContaining("***REDACTED***"), + lastAssistantSnippet: expect.stringContaining("***REDACTED***"), + lastEventAt: new Date("2026-06-24T00:00:05.000Z"), }); expect(getHeartbeatRunRuntimeStatus("run-1", { companyId: "other-company" })).toBeNull(); expect(getHeartbeatRunRuntimeStatus("run-1", { @@ -61,6 +71,101 @@ describe("heartbeat run runtime status store", () => { expect(getHeartbeatRunRuntimeStatus("run-1")).toBeNull(); }); + it("touch refreshes timestamps while preserving the existing status context", () => { + setHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: "issue-1", + agentId: "agent-1", + runId: "run-1", + phase: "run_activity", + message: "Using Bash", + currentToolName: "Bash", + lastAssistantSnippet: "Running the tests", + updatedAt: new Date("2026-06-24T00:00:00.000Z"), + lastEventAt: new Date("2026-06-24T00:00:00.000Z"), + }); + + const touched = touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: "issue-1", + agentId: "agent-1", + runId: "run-1", + at: new Date("2026-06-24T00:00:45.000Z"), + }); + + expect(touched).toMatchObject({ + runId: "run-1", + phase: "run_activity", + message: "Using Bash", + currentToolName: "Bash", + lastAssistantSnippet: "Running the tests", + updatedAt: new Date("2026-06-24T00:00:45.000Z"), + lastEventAt: new Date("2026-06-24T00:00:45.000Z"), + }); + expect(getHeartbeatRunRuntimeStatus("run-1", { + companyId: "company-1", + now: new Date("2026-06-24T00:02:00.000Z"), + })).toMatchObject({ message: "Using Bash" }); + }); + + it("touch does not move timestamps backwards", () => { + setHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: null, + agentId: "agent-1", + runId: "run-1", + phase: "run_activity", + message: "Using Bash", + updatedAt: new Date("2026-06-24T00:01:00.000Z"), + lastEventAt: new Date("2026-06-24T00:01:00.000Z"), + }); + + const touched = touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: null, + agentId: "agent-1", + runId: "run-1", + at: new Date("2026-06-24T00:00:30.000Z"), + }); + + expect(touched).toMatchObject({ + updatedAt: new Date("2026-06-24T00:01:00.000Z"), + lastEventAt: new Date("2026-06-24T00:01:00.000Z"), + }); + }); + + it("touch creates a fallback run_activity status when none is live", () => { + const created = touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: "issue-1", + agentId: "agent-1", + runId: "run-1", + at: new Date("2026-06-24T00:00:00.000Z"), + }); + + expect(created).toMatchObject({ + runId: "run-1", + phase: "run_activity", + message: "Receiving agent output", + updatedAt: new Date("2026-06-24T00:00:00.000Z"), + lastEventAt: new Date("2026-06-24T00:00:00.000Z"), + }); + + // An expired entry is replaced with a fresh fallback rather than revived. + const expiredTouch = touchHeartbeatRunRuntimeStatus({ + companyId: "company-1", + issueId: "issue-1", + agentId: "agent-1", + runId: "run-1", + at: new Date("2026-06-24T00:05:00.000Z"), + }); + expect(expiredTouch).toMatchObject({ + phase: "run_activity", + message: "Receiving agent output", + updatedAt: new Date("2026-06-24T00:05:00.000Z"), + }); + }); + it("sweeps expired statuses without touching fresh entries", () => { setHeartbeatRunRuntimeStatus({ companyId: "company-1", diff --git a/server/src/services/heartbeat-run-runtime-status.ts b/server/src/services/heartbeat-run-runtime-status.ts index fd198751f4..d2995337e2 100644 --- a/server/src/services/heartbeat-run-runtime-status.ts +++ b/server/src/services/heartbeat-run-runtime-status.ts @@ -3,6 +3,8 @@ import { redactSensitiveText } from "../redaction.js"; export const HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS = 90_000; export const MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS = 180; +export const MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS = 80; +export const MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS = 220; export interface HeartbeatRunRuntimeStatus { companyId: string; @@ -12,6 +14,9 @@ export interface HeartbeatRunRuntimeStatus { phase: HeartbeatRunStatusPhase; message: string; updatedAt: Date; + currentToolName: string | null; + lastAssistantSnippet: string | null; + lastEventAt: Date | null; } const runtimeStatusesByRunId = new Map(); @@ -20,14 +25,27 @@ function cloneStatus(status: HeartbeatRunRuntimeStatus): HeartbeatRunRuntimeStat return { ...status, updatedAt: new Date(status.updatedAt), + lastEventAt: status.lastEventAt ? new Date(status.lastEventAt) : null, }; } -export function sanitizeHeartbeatRunRuntimeStatusMessage(message: string): string { - const normalized = message.replace(/\s+/g, " ").trim(); +function sanitizeRuntimeStatusText(value: string, maxChars: number): string { + const normalized = value.replace(/\s+/g, " ").trim(); const redacted = redactSensitiveText(normalized); - if (redacted.length <= MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS) return redacted; - return `${redacted.slice(0, MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS - 3)}...`; + if (redacted.length <= maxChars) return redacted; + return `${redacted.slice(0, maxChars - 3)}...`; +} + +export function sanitizeHeartbeatRunRuntimeStatusMessage(message: string): string { + return sanitizeRuntimeStatusText(message, MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS); +} + +export function sanitizeHeartbeatRunRuntimeToolName(toolName: string): string { + return sanitizeRuntimeStatusText(toolName, MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS); +} + +export function sanitizeHeartbeatRunRuntimeAssistantSnippet(snippet: string): string { + return sanitizeRuntimeStatusText(snippet, MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS); } function isExpired(status: HeartbeatRunRuntimeStatus, now: Date, ttlMs: number) { @@ -35,9 +53,15 @@ function isExpired(status: HeartbeatRunRuntimeStatus, now: Date, ttlMs: number) } export function setHeartbeatRunRuntimeStatus( - input: Omit & { + input: Omit< + HeartbeatRunRuntimeStatus, + "message" | "updatedAt" | "currentToolName" | "lastAssistantSnippet" | "lastEventAt" + > & { message: string; updatedAt?: Date; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: Date | null; }, ): HeartbeatRunRuntimeStatus | null { const message = sanitizeHeartbeatRunRuntimeStatusMessage(input.message); @@ -54,11 +78,62 @@ export function setHeartbeatRunRuntimeStatus( phase: input.phase, message, updatedAt: input.updatedAt ? new Date(input.updatedAt) : new Date(), + currentToolName: input.currentToolName + ? sanitizeHeartbeatRunRuntimeToolName(input.currentToolName) + : null, + lastAssistantSnippet: input.lastAssistantSnippet + ? sanitizeHeartbeatRunRuntimeAssistantSnippet(input.lastAssistantSnippet) + : null, + lastEventAt: input.lastEventAt ? new Date(input.lastEventAt) : null, }; runtimeStatusesByRunId.set(status.runId, status); return cloneStatus(status); } +/** + * Refresh the activity timestamps of an existing runtime status without + * discarding its message/tool context, so streamed run-log output keeps the + * "Working... / X ago" line fresh between structured events. When no live + * status exists (first output, or the previous one expired past the TTL), a + * fallback `run_activity` status is created instead. + */ +export function touchHeartbeatRunRuntimeStatus(input: { + companyId: string; + issueId: string | null; + agentId: string; + runId: string; + at?: Date; + fallbackPhase?: HeartbeatRunStatusPhase; + fallbackMessage?: string; +}): HeartbeatRunRuntimeStatus | null { + const at = input.at ?? new Date(); + const existing = runtimeStatusesByRunId.get(input.runId); + if ( + existing && + !isExpired(existing, at, HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS) && + existing.companyId === input.companyId && + existing.agentId === input.agentId + ) { + if (at.getTime() > existing.updatedAt.getTime()) { + existing.updatedAt = new Date(at); + } + if (!existing.lastEventAt || at.getTime() > existing.lastEventAt.getTime()) { + existing.lastEventAt = new Date(at); + } + return cloneStatus(existing); + } + return setHeartbeatRunRuntimeStatus({ + companyId: input.companyId, + issueId: input.issueId, + agentId: input.agentId, + runId: input.runId, + phase: input.fallbackPhase ?? "run_activity", + message: input.fallbackMessage ?? "Receiving agent output", + updatedAt: at, + lastEventAt: at, + }); +} + export function getHeartbeatRunRuntimeStatus( runId: string, expected?: { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bd963ada97..00f9f3ea26 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -204,8 +204,11 @@ import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js"; import { clearHeartbeatRunRuntimeStatus, getHeartbeatRunRuntimeStatus, + MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS, + MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS, setHeartbeatRunRuntimeStatus, sweepExpiredHeartbeatRunRuntimeStatuses, + touchHeartbeatRunRuntimeStatus, } from "./heartbeat-run-runtime-status.js"; import { assertLowTrustRuntimeServicesAllowed, @@ -266,6 +269,7 @@ export { ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS, } from "./recovery/service.js"; export const ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS = 60 * 1000; +export const ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS = 5 * 1000; export const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS = [ 2 * 60 * 1000, 10 * 60 * 1000, @@ -3985,6 +3989,9 @@ function decorateHeartbeatRunRuntimeStatus, update: RuntimeStatusUpdate, @@ -4023,24 +4062,121 @@ function recordHeartbeatRunRuntimeProgress( runId: run.id, phase: update.phase as HeartbeatRunStatusPhase, message: update.message, + currentToolName: readNonEmptyString(update.currentToolName) ?? null, + lastAssistantSnippet: readNonEmptyString(update.lastAssistantSnippet) ?? null, + lastEventAt: update.lastEventAt ? new Date(update.lastEventAt) : new Date(), }); if (!status) return null; - publishLiveEvent({ - companyId: status.companyId, - type: "heartbeat.run.progress", - payload: { - runId: status.runId, - agentId: status.agentId, - issueId: status.issueId, - phase: status.phase, - message: status.message, - updatedAt: status.updatedAt.toISOString(), - }, - }); + publishHeartbeatRunRuntimeProgress(status); return status; } +function sanitizeLiveRunProgressText(value: string, maxChars: number): string | null { + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized) return null; + const redacted = redactSensitiveText(normalized); + if (redacted.length <= maxChars) return redacted; + return `${redacted.slice(0, maxChars - 3)}...`; +} + +function readLiveRunProgressString(value: unknown, maxChars: number): string | null { + return typeof value === "string" ? sanitizeLiveRunProgressText(value, maxChars) : null; +} + +function readFirstLiveRunProgressString(maxChars: number, values: unknown[]): string | null { + for (const value of values) { + const text = readLiveRunProgressString(value, maxChars); + if (text) return text; + } + return null; +} + +function readLiveRunToolName(payload: Record | null, eventType: string): string | null { + const toolCall = parseObject(payload?.tool_call) ?? parseObject(payload?.toolCall); + const message = parseObject(payload?.message); + const direct = readFirstLiveRunProgressString(MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS, [ + payload?.toolName, + payload?.tool_name, + payload?.tool, + payload?.name, + payload?.title, + toolCall?.name, + toolCall?.toolName, + message?.name, + message?.toolName, + ]); + if (direct) return direct; + + const normalizedEventType = eventType.toLowerCase(); + if (!normalizedEventType.includes("tool")) return null; + return readLiveRunProgressString(eventType.replace(/[._-]+/g, " "), MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS); +} + +function readLiveRunAssistantSnippet( + payload: Record | null, + eventType: string, + message: string | null, +): string | null { + const normalizedEventType = eventType.toLowerCase(); + const messagePayload = parseObject(payload?.message); + const direct = readFirstLiveRunProgressString(MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS, [ + payload?.text, + payload?.delta, + payload?.text_delta, + payload?.content, + payload?.summary, + messagePayload?.text, + messagePayload?.content, + ]); + if (direct) return direct; + + if ( + normalizedEventType.includes("assistant") || + normalizedEventType.includes("text_delta") || + normalizedEventType.includes("message.delta") || + normalizedEventType.includes("message_delta") + ) { + return message ? sanitizeLiveRunProgressText(message, MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS) : null; + } + + return null; +} + +function buildRunEventRuntimeProgress(input: { + eventType: string; + message: string | null; + payload: Record | null; + at: Date; +}) { + const normalizedEventType = input.eventType.toLowerCase(); + if (normalizedEventType === "lifecycle" || normalizedEventType === "adapter.invoke") { + return null; + } + + const currentToolName = readLiveRunToolName(input.payload, input.eventType); + const lastAssistantSnippet = readLiveRunAssistantSnippet(input.payload, input.eventType, input.message); + const fallbackMessage = + readLiveRunProgressString(input.message, MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS) ?? + sanitizeLiveRunProgressText( + input.eventType.replace(/[._-]+/g, " "), + MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS, + ); + const message = + currentToolName + ? `Using ${currentToolName}` + : lastAssistantSnippet ?? fallbackMessage; + + if (!message) return null; + return { + phase: "run_activity" as const, + message, + currentToolName, + lastAssistantSnippet, + lastEventAt: input.at, + }; +} + export function buildPaperclipTaskMarkdown(input: { issue: { id: string; @@ -6552,6 +6688,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload?: Record; }, ) { + const eventAt = new Date(); const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); const sanitizedMessage = event.message ? redactCurrentUserText(event.message, currentUserRedactionOptions) @@ -6563,6 +6700,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const sanitizedPayload = secretSanitizedPayload ? redactCurrentUserValue(secretSanitizedPayload, currentUserRedactionOptions) : secretSanitizedPayload; + const issueId = readRuntimeStatusIssueIdCandidate(run) ?? null; + const progress = buildRunEventRuntimeProgress({ + eventType: event.eventType, + message: sanitizedMessage ?? null, + payload: sanitizedPayload ?? null, + at: eventAt, + }); await db.insert(heartbeatRunEvents).values({ companyId: run.companyId, @@ -6583,15 +6727,34 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload: { runId: run.id, agentId: run.agentId, + issueId, seq, eventType: event.eventType, stream: event.stream ?? null, level: event.level ?? null, color: event.color ?? null, message: sanitizedMessage ?? null, + currentToolName: progress?.currentToolName ?? null, + lastAssistantSnippet: progress?.lastAssistantSnippet ?? null, + lastEventAt: (progress?.lastEventAt ?? eventAt).toISOString(), payload: sanitizedPayload ?? null, }, }); + if (progress && isHeartbeatRunRuntimeStatusActive(run.status)) { + const status = setHeartbeatRunRuntimeStatus({ + companyId: run.companyId, + issueId, + agentId: run.agentId, + runId: run.id, + phase: progress.phase, + message: progress.message, + updatedAt: eventAt, + currentToolName: progress.currentToolName, + lastAssistantSnippet: progress.lastAssistantSnippet, + lastEventAt: progress.lastEventAt, + }); + if (status) publishHeartbeatRunRuntimeProgress(status); + } } async function nextRunEventSeq(runId: string) { @@ -10489,6 +10652,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) let stderrExcerpt = ""; let outputSeq = Number(run.lastOutputSeq ?? 0); let lastOutputFlushAt: Date | null = run.lastOutputAt ?? null; + let lastLogRuntimeStatusTouchMs = 0; const outputProgressState: { pending: { at: Date; @@ -10629,6 +10793,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; await flushOutputProgress(); + // Streamed CLI output is real run activity: keep the in-memory + // runtime status ("Working... / X ago") fresh between structured + // events so sandbox runs with mid-run log streaming never show a + // minutes-stale timestamp. Throttled to avoid churning the live + // event stream on every 250ms tail chunk. + const logActivityAt = new Date(ts); + if ( + isHeartbeatRunRuntimeStatusActive(run.status) && + logActivityAt.getTime() - lastLogRuntimeStatusTouchMs >= + ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS + ) { + lastLogRuntimeStatusTouchMs = logActivityAt.getTime(); + const touchedStatus = touchHeartbeatRunRuntimeStatus({ + companyId: run.companyId, + issueId, + agentId: run.agentId, + runId: run.id, + at: logActivityAt, + }); + if (touchedStatus) publishHeartbeatRunRuntimeProgress(touchedStatus); + } + const payloadChunk = sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES ? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES) @@ -10640,6 +10826,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload: { runId: run.id, agentId: run.agentId, + issueId, ts, stream, chunk: payloadChunk, diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 9153bc4503..c3a9f5ac6f 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -38,6 +38,9 @@ export interface ActiveRunForIssue { outputSilence?: HeartbeatRun["outputSilence"]; currentStatusMessage?: string | null; currentStatusUpdatedAt?: string | Date | null; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: string | Date | null; } export interface LiveRunForIssue { @@ -64,6 +67,9 @@ export interface LiveRunForIssue { outputSilence?: HeartbeatRun["outputSilence"]; currentStatusMessage?: string | null; currentStatusUpdatedAt?: string | null; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: string | null; } export interface WatchdogDecisionInput { diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 29285804d0..21d6213be9 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -3464,6 +3464,8 @@ describe("IssueChatThread", () => { adapterType: "codex_local", currentStatusMessage: "Syncing git worktree to sandbox", currentStatusUpdatedAt: "2026-04-06T12:00:05.000Z", + currentToolName: "bash", + lastEventAt: new Date(Date.now() - 2000).toISOString(), }} onAdd={async () => {}} enableLiveTranscriptPolling={false} @@ -3473,8 +3475,9 @@ describe("IssueChatThread", () => { }); expect(container.textContent).toContain("Working..."); - expect(container.textContent).toContain("Syncing git worktree to sandbox"); - expect(container.querySelector('[title="Syncing git worktree to sandbox"]')).not.toBeNull(); + expect(container.textContent).toContain("Using bash"); + expect(container.textContent).not.toContain("last activity"); + expect(container.textContent).toMatch(/\d+ seconds? ago/); act(() => { root.unmount(); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 3aafbf53be..ab9d88df6e 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -301,6 +301,59 @@ function useLiveElapsed(startMs: number | null | undefined, active: boolean): st return formatDurationWords(Date.now() - startMs); } +function readCustomString(custom: Record, key: string): string { + return typeof custom[key] === "string" ? custom[key].trim() : ""; +} + +function toTimestampOrNull(value: string): number | null { + if (!value) return null; + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function IssueChatLiveRunStatusLine({ + custom, + active, + className, +}: { + custom: Record; + active: boolean; + className?: string; +}) { + const currentStatusMessage = readCustomString(custom, "currentStatusMessage"); + const currentToolName = readCustomString(custom, "currentToolName"); + const lastAssistantSnippet = readCustomString(custom, "lastAssistantSnippet"); + const lastEventAt = readCustomString(custom, "lastEventAt"); + const lastEventAtMs = toTimestampOrNull(lastEventAt); + const lastActivityElapsed = useLiveElapsed(lastEventAtMs, active); + const lastActivityAgeMs = lastEventAtMs ? Date.now() - lastEventAtMs : null; + + if (!active) return null; + + const primary = + currentToolName + ? `Using ${currentToolName}` + : lastAssistantSnippet + ? lastAssistantSnippet + : currentStatusMessage; + const activityText = lastActivityElapsed + ? lastActivityAgeMs !== null && lastActivityAgeMs >= 15_000 + ? `no output for ${lastActivityElapsed} - still running` + : `${lastActivityElapsed} ago` + : ""; + const text = [primary, activityText].filter(Boolean).join(" · "); + if (!text) return null; + + return ( + + {text} + + ); +} + function useStableEvent unknown>(callback: T | undefined): T | undefined { const callbackRef = useRef(callback); useLayoutEffect(() => { @@ -873,8 +926,6 @@ function IssueChatChainOfThought({ const rawSegments = Array.isArray(custom.chainOfThoughtSegments) ? (custom.chainOfThoughtSegments as SegmentTiming[]) : []; - const currentStatusMessage = - typeof custom.currentStatusMessage === "string" ? custom.currentStatusMessage.trim() : ""; const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null; const isActive = isCoTSegmentActive({ isMessageRunning, @@ -937,14 +988,7 @@ function IssueChatChainOfThought({ · {toolSummary} ) : null} - {isActive && currentStatusMessage ? ( - - {currentStatusMessage} - - ) : null} + {hasContent ? ( @@ -1592,8 +1636,6 @@ function IssueChatAssistantMessage({ ? custom.notices.filter((notice): notice is string => typeof notice === "string" && notice.length > 0) : []; const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : ""; - const currentStatusMessage = - typeof custom.currentStatusMessage === "string" ? custom.currentStatusMessage.trim() : ""; const isRunning = message.role === "assistant" && message.status?.type === "running"; const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null; const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running"); @@ -1881,14 +1923,7 @@ function IssueChatAssistantMessage({ {waitingText} - {isRunning && currentStatusMessage ? ( -
- {currentStatusMessage} -
- ) : null} + ) : null} {notices.length > 0 ? ( @@ -4215,6 +4250,9 @@ export function IssueChatThread({ outputSilence: activeRun.outputSilence, currentStatusMessage: activeRun.currentStatusMessage ?? null, currentStatusUpdatedAt: toIsoString(activeRun.currentStatusUpdatedAt), + currentToolName: activeRun.currentToolName ?? null, + lastAssistantSnippet: activeRun.lastAssistantSnippet ?? null, + lastEventAt: toIsoString(activeRun.lastEventAt), }); } return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); diff --git a/ui/src/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx index 46f9f3d956..15e1d5aed1 100644 --- a/ui/src/components/transcript/RunTranscriptView.test.tsx +++ b/ui/src/components/transcript/RunTranscriptView.test.tsx @@ -2,11 +2,107 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { parseAcpxStdoutLine } from "@paperclipai/adapter-acpx-local/ui"; import type { TranscriptEntry } from "../../adapters"; +import { buildTranscript, type RunLogChunk } from "../../adapters"; import { ThemeProvider } from "../../context/ThemeContext"; import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView"; describe("RunTranscriptView", () => { + it("folds repeated tool_call status updates for the same toolUseId into one block", () => { + const entries: TranscriptEntry[] = [ + { + kind: "tool_call", + ts: "2026-03-12T00:00:00.000Z", + name: "read", + toolUseId: "tool-1", + input: { text: "read README.md", status: "pending" }, + }, + { + kind: "tool_call", + ts: "2026-03-12T00:00:01.000Z", + name: "read", + toolUseId: "tool-1", + input: { status: "in_progress" }, + }, + { + kind: "tool_call", + ts: "2026-03-12T00:00:02.000Z", + name: "search", + toolUseId: "tool-2", + input: { text: "grep TODO", status: "pending" }, + }, + { + kind: "tool_result", + ts: "2026-03-12T00:00:03.000Z", + toolUseId: "tool-1", + toolName: "read", + content: "ok", + isError: false, + }, + ]; + + const blocks = normalizeTranscript(entries, false); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "tool_group", + items: [ + { + name: "Read", + status: "completed", + result: "ok", + // Later status updates merge into the original input instead of + // spawning duplicate "Running" cards. + input: { text: "read README.md", status: "in_progress" }, + }, + { name: "Search", status: "running" }, + ], + }); + }); + + it("renders a streamed acpx tool call as a single card once completed", () => { + const ts = "2026-03-12T00:00:00.000Z"; + const jsonLines = [ + { type: "acpx.tool_call", name: "read", toolCallId: "tool-1", status: "pending", text: "read README.md" }, + { type: "acpx.tool_call", name: "read", toolCallId: "tool-1", status: "in_progress", text: "read README.md" }, + { type: "acpx.tool_call", name: "read", toolCallId: "tool-1", status: "completed", text: "ok" }, + ]; + const chunks: RunLogChunk[] = [ + { ts, stream: "stdout", chunk: jsonLines.map((line) => JSON.stringify(line)).join("\n") + "\n" }, + ]; + + const entries = buildTranscript(chunks, parseAcpxStdoutLine); + const blocks = normalizeTranscript(entries, false); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "tool_group", + items: [{ name: "Read", status: "completed", result: "ok" }], + }); + }); + + it("renders streamed acpx tool calls with input payloads as readable status cards", () => { + const ts = "2026-03-12T00:00:00.000Z"; + const jsonLines = [ + { type: "acpx.tool_call", name: "read", toolCallId: "tool-2", status: "running", input: { file: "README.md" } }, + { type: "acpx.tool_call", name: "read", toolCallId: "tool-2", status: "in_progress", text: "opening file", input: { line: 1 } }, + { type: "acpx.tool_call", name: "read", toolCallId: "tool-2", status: "completed", text: "ok" }, + ]; + const chunks: RunLogChunk[] = [ + { ts, stream: "stdout", chunk: jsonLines.map((line) => JSON.stringify(line)).join("\n") + "\n" }, + ]; + + const entries = buildTranscript(chunks, parseAcpxStdoutLine); + const blocks = normalizeTranscript(entries, false); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "tool_group", + items: [{ name: "Read", status: "completed", result: "ok" }], + }); + }); + it("keeps running command stdout inside the command fold instead of a standalone stdout block", () => { const entries: TranscriptEntry[] = [ { diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index 52e9f6bfc8..7b7ac350f1 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -206,6 +206,21 @@ function summarizeRecord(record: Record, keys: string[]): strin return null; } +/** Merge a streamed tool_call status update into the input captured so far. */ +function mergeToolInput(previous: unknown, incoming: unknown): unknown { + if (incoming === null || incoming === undefined) return previous; + if (typeof incoming === "string") { + return incoming.trim().length > 0 ? incoming : previous; + } + const previousRecord = asRecord(previous); + const incomingRecord = asRecord(incoming); + if (incomingRecord) { + if (Object.keys(incomingRecord).length === 0) return previous; + return previousRecord ? { ...previousRecord, ...incomingRecord } : incoming; + } + return incoming; +} + function summarizeToolInput(name: string, input: unknown, density: TranscriptDensity): string { const compactMax = density === "compact" ? 72 : 120; if (typeof input === "string") { @@ -229,7 +244,7 @@ function summarizeToolInput(name: string, input: unknown, density: TranscriptDen const direct = summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"]) - ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"]) + ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool", "text"]) ?? null; if (direct) return truncate(direct, compactMax); @@ -454,11 +469,20 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole } if (entry.kind === "tool_call") { + const toolUseId = entry.toolUseId ?? extractToolUseId(entry.input); + // Streaming runtimes (e.g. ACPX) re-emit the same tool call as its + // status progresses. Fold updates into the existing running card + // instead of stacking duplicate "Running" blocks. + const pending = toolUseId ? pendingToolBlocks.get(toolUseId) : undefined; + if (pending && pending.status === "running") { + pending.input = mergeToolInput(pending.input, entry.input); + continue; + } const toolBlock: Extract = { type: "tool", ts: entry.ts, name: displayToolName(entry.name, entry.input), - toolUseId: entry.toolUseId ?? extractToolUseId(entry.input), + toolUseId, input: entry.input, status: "running", }; diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 1625fbfc68..1fe52db88c 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -143,6 +143,133 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); + it("applies heartbeat progress payloads directly to cached visible issue runs", () => { + const cache = new Map([ + [JSON.stringify(queryKeys.liveRuns("company-1")), [{ id: "run-1", currentStatusMessage: null }]], + [JSON.stringify(queryKeys.issues.detail("DEMO-759")), { + id: "issue-1", + identifier: "DEMO-759", + assigneeAgentId: "agent-1", + }], + [JSON.stringify(queryKeys.issues.detail("issue-1")), { + id: "issue-1", + identifier: "DEMO-759", + assigneeAgentId: "agent-1", + }], + [JSON.stringify(queryKeys.issues.activeRun("DEMO-759")), { + id: "run-1", + currentStatusMessage: null, + }], + [JSON.stringify(queryKeys.issues.activeRun("issue-1")), { + id: "run-1", + currentStatusMessage: null, + }], + [JSON.stringify(queryKeys.issues.liveRuns("DEMO-759")), [{ id: "run-1", currentStatusMessage: null }]], + [JSON.stringify(queryKeys.issues.liveRuns("issue-1")), [{ id: "run-1", currentStatusMessage: null }]], + [JSON.stringify(queryKeys.issues.runs("DEMO-759")), [{ runId: "run-1" }]], + ]); + const queryClient = { + getQueryData: (key: unknown) => cache.get(JSON.stringify(key)), + setQueryData: (key: unknown, updater: unknown) => { + const cacheKey = JSON.stringify(key); + const current = cache.get(cacheKey); + cache.set(cacheKey, typeof updater === "function" ? updater(current) : updater); + }, + }; + + const changed = __liveUpdatesTestUtils.applyRunLiveStatusPatchToCaches( + queryClient as never, + "company-1", + "/DEMO/issues/DEMO-759", + { + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + message: "Syncing workspace", + updatedAt: "2026-04-06T12:00:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Reading package.json", + lastEventAt: "2026-04-06T12:00:08.000Z", + }, + { isForegrounded: true }, + ); + + expect(changed).toBe(true); + expect(cache.get(JSON.stringify(queryKeys.liveRuns("company-1")))).toEqual([ + expect.objectContaining({ + id: "run-1", + currentStatusMessage: "Syncing workspace", + currentStatusUpdatedAt: "2026-04-06T12:00:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Reading package.json", + lastEventAt: "2026-04-06T12:00:08.000Z", + }), + ]); + expect(cache.get(JSON.stringify(queryKeys.issues.activeRun("DEMO-759")))).toMatchObject({ + currentToolName: "bash", + lastAssistantSnippet: "Reading package.json", + }); + expect(cache.get(JSON.stringify(queryKeys.issues.liveRuns("issue-1")))).toEqual([ + expect.objectContaining({ + currentStatusMessage: "Syncing workspace", + currentToolName: "bash", + }), + ]); + }); + + it("uses the heartbeat event timestamp for run event status patches", () => { + expect( + __liveUpdatesTestUtils.readRunLiveStatusPatchFromPayload( + { + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + message: "Tool started", + currentToolName: "bash", + lastAssistantSnippet: "Checking workspace", + }, + "2026-04-06T12:00:09.000Z", + "heartbeat.run.event", + ), + ).toEqual({ + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + message: "Tool started", + updatedAt: "2026-04-06T12:00:09.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Checking workspace", + lastEventAt: "2026-04-06T12:00:09.000Z", + }); + }); + + it("does not clear run tool context from null heartbeat event fields", () => { + const patch = __liveUpdatesTestUtils.readRunLiveStatusPatchFromPayload( + { + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + message: null, + currentToolName: null, + lastAssistantSnippet: null, + lastEventAt: "2026-04-06T12:00:10.000Z", + }, + "2026-04-06T12:00:09.000Z", + "heartbeat.run.event", + ); + + expect(patch).toEqual({ + runId: "run-1", + agentId: "agent-1", + issueId: "issue-1", + updatedAt: "2026-04-06T12:00:09.000Z", + lastEventAt: "2026-04-06T12:00:10.000Z", + }); + expect(patch).not.toHaveProperty("message"); + expect(patch).not.toHaveProperty("currentToolName"); + expect(patch).not.toHaveProperty("lastAssistantSnippet"); + }); + it("refreshes issue document caches when a document activity event arrives", () => { const invalidations: unknown[] = []; const queryClient = { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 74d66d8708..c480f781b8 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -306,6 +306,164 @@ function invalidateVisibleIssueRunQueries( return true; } +interface RunLiveStatusPatch { + runId: string; + agentId: string | null; + issueId: string | null; + message?: string | null; + updatedAt?: string | null; + currentToolName?: string | null; + lastAssistantSnippet?: string | null; + lastEventAt?: string | null; +} + +function hasPatchKey( + patch: RunLiveStatusPatch, + key: K, +): patch is RunLiveStatusPatch & Required> { + return Object.prototype.hasOwnProperty.call(patch, key); +} + +function applyRunLiveStatusPatch( + run: T, + patch: RunLiveStatusPatch, +): T { + if (run.id !== patch.runId) return run; + return { + ...run, + ...(hasPatchKey(patch, "message") + ? { currentStatusMessage: patch.message } + : {}), + ...(hasPatchKey(patch, "updatedAt") + ? { currentStatusUpdatedAt: patch.updatedAt } + : {}), + ...(hasPatchKey(patch, "currentToolName") + ? { currentToolName: patch.currentToolName } + : {}), + ...(hasPatchKey(patch, "lastAssistantSnippet") + ? { lastAssistantSnippet: patch.lastAssistantSnippet } + : {}), + ...(hasPatchKey(patch, "lastEventAt") + ? { lastEventAt: patch.lastEventAt } + : {}), + }; +} + +function applyRunLiveStatusPatchToArray( + runs: T[] | undefined, + patch: RunLiveStatusPatch, +): T[] | undefined { + if (!runs) return runs; + let changed = false; + const nextRuns = runs.map((run) => { + if (run.id !== patch.runId) return run; + changed = true; + return applyRunLiveStatusPatch(run, patch); + }); + return changed ? nextRuns : runs; +} + +function readRunLiveStatusPatchFromPayload( + payload: Record, + eventCreatedAt: string, + eventType: string, +): RunLiveStatusPatch | null { + const runId = readString(payload.runId); + if (!runId) return null; + + const patch: RunLiveStatusPatch = { + runId, + agentId: readString(payload.agentId), + issueId: readString(payload.issueId), + }; + + if (eventType === "heartbeat.run.progress") { + patch.message = readString(payload.message); + patch.updatedAt = readString(payload.updatedAt) ?? eventCreatedAt; + patch.currentToolName = readString(payload.currentToolName); + patch.lastAssistantSnippet = readString(payload.lastAssistantSnippet); + patch.lastEventAt = readString(payload.lastEventAt) ?? patch.updatedAt; + return patch; + } + + if (eventType === "heartbeat.run.log") { + patch.lastEventAt = readString(payload.ts) ?? eventCreatedAt; + return patch; + } + + if (eventType === "heartbeat.run.event") { + const message = readString(payload.message); + if (message) patch.message = message; + patch.updatedAt = readString(payload.updatedAt) ?? eventCreatedAt; + const currentToolName = readString(payload.currentToolName); + if (currentToolName) patch.currentToolName = currentToolName; + const lastAssistantSnippet = readString(payload.lastAssistantSnippet); + if (lastAssistantSnippet) patch.lastAssistantSnippet = lastAssistantSnippet; + patch.lastEventAt = readString(payload.lastEventAt) ?? eventCreatedAt; + return patch; + } + + return null; +} + +function applyRunLiveStatusPatchToCaches( + queryClient: QueryClient, + companyId: string, + pathname: string, + patch: RunLiveStatusPatch, + options?: VisibleRouteOptions, +): boolean { + let changed = false; + queryClient.setQueryData( + queryKeys.liveRuns(companyId), + (current: LiveRunForIssue[] | undefined) => { + const nextRuns = applyRunLiveStatusPatchToArray(current, patch); + if (nextRuns !== current) changed = true; + return nextRuns; + }, + ); + + const issueRefs = new Set(); + if (patch.issueId) { + for (const ref of resolveIssueQueryRefs(queryClient, companyId, patch.issueId, null)) { + issueRefs.add(ref); + } + } + + const context = resolveVisibleIssueRouteContext(queryClient, pathname, options); + if ( + context && + ( + context.runIds.has(patch.runId) || + (!!patch.issueId && context.issueRefs.has(patch.issueId)) || + (!!patch.agentId && !!context.assigneeAgentId && patch.agentId === context.assigneeAgentId) + ) + ) { + for (const ref of context.issueRefs) issueRefs.add(ref); + } + + for (const issueRef of issueRefs) { + queryClient.setQueryData( + queryKeys.issues.activeRun(issueRef), + (current: ActiveRunForIssue | null | undefined) => { + if (!current || current.id !== patch.runId) return current; + changed = true; + return applyRunLiveStatusPatch(current, patch); + }, + ); + queryClient.setQueryData( + queryKeys.issues.liveRuns(issueRef), + (current: LiveRunForIssue[] | undefined) => { + const nextRuns = applyRunLiveStatusPatchToArray(current, patch); + if (nextRuns !== current) changed = true; + return nextRuns; + }, + ); + } + + return changed; +} + function shouldSuppressAgentStatusToastForVisibleIssue( queryClient: QueryClient, pathname: string, @@ -870,6 +1028,10 @@ function handleLiveEvent( const nameOf = (id: string) => resolveAgentName(queryClient, expectedCompanyId, id); const payload = event.payload ?? {}; + const liveStatusPatch = readRunLiveStatusPatchFromPayload(payload, event.createdAt, event.type); + if (liveStatusPatch) { + applyRunLiveStatusPatchToCaches(queryClient, expectedCompanyId, pathname, liveStatusPatch); + } if (event.type === "heartbeat.run.log") { return; } @@ -979,10 +1141,12 @@ export const __liveUpdatesTestUtils = { buildAgentStatusToast, buildRunStatusToast, closeSocketQuietly, + applyRunLiveStatusPatchToCaches, hydrateVisibleIssueComment, invalidateActivityQueries, invalidateHeartbeatProgressQueries, invalidateVisibleIssueRunQueries, + readRunLiveStatusPatchFromPayload, resolveLiveCompanyId, shouldDeferIssueRefetchForVisibleAgentActivity, shouldDeferVisibleIssueCommentActivity, diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 1f8a5959c1..0dfc2f1e8a 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -688,6 +688,9 @@ describe("buildIssueChatMessages", () => { adapterType: "codex_local", currentStatusMessage: "Syncing git worktree to sandbox", currentStatusUpdatedAt: "2026-04-06T12:03:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Checking repository status", + lastEventAt: "2026-04-06T12:03:08.000Z", }; const messages = buildIssueChatMessages({ @@ -709,6 +712,9 @@ describe("buildIssueChatMessages", () => { runId: "run-active-1", currentStatusMessage: "Syncing git worktree to sandbox", currentStatusUpdatedAt: "2026-04-06T12:03:05.000Z", + currentToolName: "bash", + lastAssistantSnippet: "Checking repository status", + lastEventAt: "2026-04-06T12:03:08.000Z", }, }, }); diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 78351d6be5..75f7b9a211 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -871,6 +871,11 @@ function normalizeLiveRuns( currentStatusUpdatedAt: activeRun.currentStatusUpdatedAt ? toDate(activeRun.currentStatusUpdatedAt).toISOString() : null, + currentToolName: activeRun.currentToolName ?? null, + lastAssistantSnippet: activeRun.lastAssistantSnippet ?? null, + lastEventAt: activeRun.lastEventAt + ? toDate(activeRun.lastEventAt).toISOString() + : null, }); } return [...deduped.values()].sort((a, b) => toTimestamp(a.createdAt) - toTimestamp(b.createdAt)); @@ -911,6 +916,9 @@ function createLiveRunMessage(args: { chainOfThoughtSegments: segments, currentStatusMessage: run.currentStatusMessage ?? null, currentStatusUpdatedAt: run.currentStatusUpdatedAt ?? null, + currentToolName: run.currentToolName ?? null, + lastAssistantSnippet: run.lastAssistantSnippet ?? null, + lastEventAt: run.lastEventAt ?? null, }), }; return message; diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 6fd86efef4..757ab83b33 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1231,6 +1231,16 @@ export function CompanyEnvironments() { This provider does not declare additional configuration fields. )} + + setEnvironmentForm((current) => ({ + ...current, + sandboxConfig: { ...current.sandboxConfig, streamRunLogs: checked }, + }))} + /> ) : null}