diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 6c4973cd3dc..1724c3baf6e 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2498,6 +2498,7 @@ export function agentRoutes(db: Db) { agentId: heartbeatRuns.agentId, agentName: agentsTable.name, adapterType: agentsTable.adapterType, + logBytes: heartbeatRuns.logBytes, livenessState: heartbeatRuns.livenessState, livenessReason: heartbeatRuns.livenessReason, continuationAttempt: heartbeatRuns.continuationAttempt, @@ -2778,6 +2779,7 @@ export function agentRoutes(db: Db) { agentId: heartbeatRuns.agentId, agentName: agentsTable.name, adapterType: agentsTable.adapterType, + logBytes: heartbeatRuns.logBytes, livenessState: heartbeatRuns.livenessState, livenessReason: heartbeatRuns.livenessReason, continuationAttempt: heartbeatRuns.continuationAttempt, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 24813eb077b..a5e89836d1b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -744,6 +744,7 @@ const heartbeatRunIssueSummaryColumns = { finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, + logBytes: heartbeatRuns.logBytes, processStartedAt: heartbeatRuns.processStartedAt, livenessState: heartbeatRuns.livenessState, livenessReason: heartbeatRuns.livenessReason, diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 72c8e0a700b..ea7ae5d6ed2 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -20,6 +20,8 @@ export interface ActiveRunForIssue { agentId: string; agentName: string; adapterType: string; + logBytes?: number | null; + lastOutputBytes?: number | null; issueId?: string | null; livenessState?: RunLivenessFields["livenessState"]; livenessReason?: string | null; @@ -39,6 +41,8 @@ export interface LiveRunForIssue { agentId: string; agentName: string; adapterType: string; + logBytes?: number | null; + lastOutputBytes?: number | null; issueId?: string | null; livenessState?: RunLivenessFields["livenessState"]; livenessReason?: string | null; diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index ea1d4d3aff0..746844ac544 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -2411,6 +2411,8 @@ export function IssueChatThread({ agentId: activeRun.agentId, agentName: activeRun.agentName, adapterType: activeRun.adapterType, + logBytes: activeRun.logBytes, + lastOutputBytes: activeRun.lastOutputBytes, }); } return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index e9c2fe7bff9..26a5a3acd7f 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -59,6 +59,8 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { agentId: activeRun.agentId, agentName: activeRun.agentName, adapterType: activeRun.adapterType, + logBytes: activeRun.logBytes, + lastOutputBytes: activeRun.lastOutputBytes, issueId, }); } diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx index 9fdb4794589..025d2f0ed0c 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx @@ -258,6 +258,34 @@ describe("useLiveRunTranscripts", () => { container.remove(); }); + it("starts persisted-log hydration from the newest bytes when the visible window is truncated", async () => { + function Harness() { + useLiveRunTranscripts({ + companyId: "company-1", + runs: [{ id: "run-1", status: "running", adapterType: "codex_local", lastOutputBytes: 100_000 }], + enableRealtimeUpdates: false, + logReadLimitBytes: 64_000, + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(logMock).toHaveBeenCalledWith("run-1", 36_000, 64_000); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("rebuilds only the transcript for the run that receives live output", async () => { function Harness() { useLiveRunTranscripts({ diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index 991b9658486..74f94256d8e 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -16,6 +16,8 @@ export interface RunTranscriptSource { status: string; adapterType: string; hasStoredOutput?: boolean; + logBytes?: number | null; + lastOutputBytes?: number | null; } interface UseLiveRunTranscriptsOptions { @@ -35,6 +37,19 @@ function isTerminalStatus(status: string): boolean { return status === "failed" || status === "timed_out" || status === "cancelled" || status === "succeeded"; } +function runKnownLogBytes(run: RunTranscriptSource): number | null { + const bytes = run.status === "queued" + ? run.logBytes + : run.lastOutputBytes ?? run.logBytes; + return typeof bytes === "number" && Number.isFinite(bytes) && bytes > 0 ? bytes : null; +} + +export function resolveInitialLogOffset(run: RunTranscriptSource, limitBytes: number): number { + const knownBytes = runKnownLogBytes(run); + if (knownBytes === null) return 0; + return Math.max(0, knownBytes - Math.max(0, limitBytes)); +} + function parsePersistedLogContent( runId: string, content: string, @@ -82,7 +97,11 @@ export function useLiveRunTranscripts({ const runsKey = useMemo( () => runs - .map((run) => `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}`) + .map((run) => { + const logBytes = typeof run.logBytes === "number" ? run.logBytes : ""; + const lastOutputBytes = typeof run.lastOutputBytes === "number" ? run.lastOutputBytes : ""; + return `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}:${logBytes}:${lastOutputBytes}`; + }) .sort((a, b) => a.localeCompare(b)) .join(","), [runs], @@ -197,7 +216,7 @@ export function useLiveRunTranscripts({ if (missingTerminalLogRunIdsRef.current.has(run.id)) { return; } - const offset = logOffsetByRunRef.current.get(run.id) ?? 0; + const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes); try { const result = await heartbeatsApi.log(run.id, offset, logReadLimitBytes); if (cancelled) return; diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index b63d15a6650..aa2285d1447 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -43,6 +43,7 @@ export interface IssueChatLinkedRun { startedAt: Date | string | null; finishedAt?: Date | string | null; hasStoredOutput?: boolean; + logBytes?: number | null; } export interface IssueChatTranscriptEntry { diff --git a/ui/src/lib/issueChatTranscriptRuns.ts b/ui/src/lib/issueChatTranscriptRuns.ts index 1832e864817..a2b5c1e754a 100644 --- a/ui/src/lib/issueChatTranscriptRuns.ts +++ b/ui/src/lib/issueChatTranscriptRuns.ts @@ -15,6 +15,8 @@ export function resolveIssueChatTranscriptRuns(args: { id: run.id, status: run.status, adapterType: run.adapterType, + logBytes: run.logBytes, + lastOutputBytes: run.lastOutputBytes, }); } @@ -23,6 +25,8 @@ export function resolveIssueChatTranscriptRuns(args: { id: activeRun.id, status: activeRun.status, adapterType: activeRun.adapterType, + logBytes: activeRun.logBytes, + lastOutputBytes: activeRun.lastOutputBytes, }); } @@ -35,6 +39,7 @@ export function resolveIssueChatTranscriptRuns(args: { status: run.status, adapterType, hasStoredOutput: run.hasStoredOutput, + logBytes: run.logBytes, }); }