diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c1b45e3b77..ad8948503a 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -12306,19 +12306,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); const ts = new Date().toISOString(); + outputSeq += 1; + const chunkSeq = outputSeq; let appendedBytes = 0; if (handle) { appendedBytes = await runLogStore.append(handle, { stream, chunk: sanitizedChunk, ts, + seq: chunkSeq, }); persistedLogBytes += appendedBytes; } - outputSeq += 1; outputProgressState.pending = { at: new Date(ts), - seq: outputSeq, + seq: chunkSeq, stream, bytes: persistedLogBytes, }; @@ -12359,6 +12361,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agentId: run.agentId, issueId, ts, + seq: chunkSeq, stream, chunk: payloadChunk, truncated: payloadChunk.length !== sanitizedChunk.length, diff --git a/server/src/services/run-log-store.ts b/server/src/services/run-log-store.ts index 9c6a814287..0d3b508c9d 100644 --- a/server/src/services/run-log-store.ts +++ b/server/src/services/run-log-store.ts @@ -31,7 +31,7 @@ export interface RunLogStore { begin(input: { companyId: string; agentId: string; runId: string }): Promise; append( handle: RunLogHandle, - event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string }, + event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string; seq?: number }, ): Promise; finalize(handle: RunLogHandle): Promise; read(handle: RunLogHandle, opts?: RunLogReadOptions): Promise; @@ -113,6 +113,10 @@ function createLocalFileRunLogStore(basePath: string): RunLogStore { ts: event.ts, stream: event.stream, chunk: event.chunk, + // Monotonic per-run sequence so readers can dedupe and order records + // even when several identical chunks share the same millisecond ts + // (common for ACP-style token deltas). + ...(typeof event.seq === "number" && Number.isFinite(event.seq) ? { seq: event.seq } : {}), }); const persisted = `${line}\n`; await fs.appendFile(absPath, persisted, "utf8"); diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 95a81707dd..70f9c5369f 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -1,7 +1,7 @@ import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils"; import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types"; -export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; +export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string; seq?: number }; type TranscriptBuildOptions = { censorUsernameInLogs?: boolean }; type RedactionOptions = { enabled: boolean }; diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 3774624e32..bdcfd28393 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -158,6 +158,46 @@ describe("MarkdownBody", () => { expect(html).toContain("Plain text"); }); + it("hides markdown HTML comments instead of rendering placeholder text", () => { + const html = renderMarkdown("Before\n\n\n\nAfter"); + + expect(html).toContain("Before"); + expect(html).toContain("After"); + expect(html).not.toContain("<!--"); + expect(html).not.toContain("-->"); + }); + + it("hides escaped HTML comment placeholders before attachment images", () => { + const html = renderMarkdown("\\ ![](/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content)"); + + expect(html).toContain(''); + expect(html).not.toContain("<!--"); + expect(html).not.toContain("-->"); + }); + + it("hides incomplete streamed HTML comment placeholders before attachment images", () => { + const html = renderMarkdown("\\` as a literal."); + const blockHtml = renderMarkdown("```html\n\n```"); + + expect(inlineHtml).toContain("<!-- -->"); + expect(blockHtml).toContain("<!-- keep this example -->"); + }); + it("uses soft-break styling by default", () => { const html = renderMarkdown("First line\nSecond line"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index cdcd49ed46..99fa9a6a2f 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -247,6 +247,30 @@ const tableCellWrapStyle: React.CSSProperties = { wordBreak: "normal", }; +function isHtmlCommentNode(node: MarkdownAstNode) { + return node.type === "html" && typeof node.value === "string" && /^$/.test(node.value.trim()); +} + +function isEscapedHtmlCommentPlaceholder(node: MarkdownAstNode) { + if (node.type !== "text" || typeof node.value !== "string") return false; + const value = node.value.trim(); + return /^\\?