Skip to content

Commit 89699f6

Browse files
sahrizviHaider
andauthored
fix(tracing): trace waterfall + summary prompt + chat tab all lose data after each agent turn (#895)
* fix(tracing): waterfall view collapses to system-prompt span on agent-finish Symptom: opening `/traces` mid-session and watching the waterfall view during agent execution shows the rich trace populating correctly, but the moment the agent finishes its turn the view collapses to a single "system-prompt" span — and the data is genuinely lost on disk, not just from the viewer. Chain (verified by reading worker.ts + routes/session/index.tsx + tracing.ts): 1. `routes/session/index.tsx` has `createEffect(() => session()?.workspaceID && sdk.setWorkspace(...))`. SolidJS dirty-tracks any signal read inside the effect — so it re-runs on EVERY `session()` change (message count, status, parts updates), including the cascade at agent-finish. 2. Each fire calls `worker.setWorkspace` via RPC. 3. `worker.setWorkspace` unconditionally calls `startEventStream`. 4. `startEventStream`: for (const [, trace] of sessionTraces) { void trace.endTrace().catch(() => {}) // fire-and-forget } sessionTraces.clear() 5. On the next event for the same session, `getOrCreateTrace` hits a cache miss, calls `Trace.create()` + `startTrace(sessionID, {})`, which pushes a single root span into a freshly-empty `this.spans` and calls `this.snapshot()`. The snapshot path is derived purely from sessionID (`tracing.ts:836`), so it overwrites the rich `ses_<id>.json` with a 1-span file. Distinct from #867 — that PR fixed intra-Trace-instance concurrency (snapshot debounce M2; FileExporter ↔ flushSync race M3). This bug is at the worker-level cache lifecycle: a new Trace instance gets constructed and its near-empty initial state clobbers the previous instance's rich state on disk. Two minimal fixes lock the contract: * `worker.ts` — make `setWorkspace` idempotent. Track `currentWorkspaceID` at module scope; early-return when the incoming value matches. Spurious calls from the UI no longer destroy traces. * `routes/session/index.tsx` — switch the workspaceID effect to `createEffect(on(() => session()?.workspaceID, ...))`. The `on()` projector restricts SolidJS dirty-tracking to that one field, so the effect only fires when workspaceID actually changes — defense in depth at the upstream trigger. Regression test in `test/cli/tui/worker-trace-clearing.test.ts` locks both contracts via source-grep (the worker-import side has top-level side effects that make in-process unit testing awkward). Out of scope (follow-up): `getOrCreateTrace` on cache miss does not rehydrate `this.spans` from the existing `ses_<id>.json` file. After the two fixes above, this matters only on worker restart or MAX_TRACES=100 eviction — both uncommon. Worth tracking as defense in depth so the disk file is always authoritative. Typecheck clean. 152 TUI tests pass; 35 existing tracing tests pass unchanged. * fix(tracing): real root cause — session.status=idle was destroying the Trace every turn Previous commit on this branch was correct but not load-bearing. The actual hot path that collapsed the on-disk trace after every agent turn is in `worker.ts`: if (event.type === "session.status") { if (status === "idle" && sid) { const trace = sessionTraces.get(sid) if (trace) { void trace.endTrace().catch(() => {}) sessionTraces.delete(sid) // ← every turn sessionUserMsgIds.delete(sid) } } } `session.status === "idle"` fires after every busy→idle transition, which happens once per turn — not once per session. Each fire ended the trace AND deleted the cache entry. The next event for the same session in the next turn hit a cache miss, constructed a fresh `Trace.create()`, called `startTrace(sessionID, {})` (which pushes a single root span into empty `this.spans`), and the immediate `snapshot()` clobbered the rich on-disk `ses_<id>.json` with a 1-span file. This also explains the "What was asked / No prompt recorded" symptom: `metadata.prompt` was captured on the now-destroyed first instance and never persisted into the replacement. Fixes in this commit: * `worker.ts`: removed the destructive `session.status === "idle"` handler. Sessions in altimate-code are long-lived; the Trace lives as long as the worker has the session in cache. Finalization happens on `shutdown` and on MAX_TRACES eviction only — both already correct. * `tracing.ts`: new `Trace.rehydrateFromFile(sessionId)` that reads the existing on-disk file and restores `this.spans`, `this.metadata`, `this.rootSpanId`, `this.startTime`, counters, and clears the root's endTime so the trace renders as still-in-progress. Returns true on success; false on missing/mismatched/malformed file. * `worker.ts:getOrCreateTrace`: on cache miss, calls `rehydrateFromFile` before falling back to `startTrace`. Defense in depth for the worker-restart / MAX_TRACES-eviction paths — even if some future path destroys the in-memory instance, the new instance loads disk state instead of overwriting. Verification * Behavioral test (`test/altimate/tracing-rehydrate.test.ts`, 4 cases): proves end-to-end that rehydrate preserves spans+metadata across Trace-instance reconstruction, returns false on missing/mismatched files, and clears the root endTime so re-opened traces accept new events. * Source-grep regression tests for worker.ts continue to lock the no-idle-clobber and rehydrate-before-startTrace contracts. * 449 pass / 0 fail across the full tracing + TUI suites (1 network flake in tracing-adversarial-2 unrelated to this change, passes on re-run). What I traced before declaring done * Inventoried every `sessionTraces.delete`, `sessionTraces.clear`, `endTrace()`, `Trace.create()`, `Trace.withExporters()`, and direct trace-file write across the whole repo. * Confirmed `this.spans` is never reassigned mid-instance (only pushed) except by the new `rehydrateFromFile`. * Confirmed no external callers of `trace.snapshot()` outside `tracing.ts`. Post-fix, the only ways a new `Trace` instance can replace an existing session's in-memory Trace are: worker boot (once), `setWorkspace` with an actually-changed workspaceID (rare, also idempotency-guarded), and MAX_TRACES eviction (uncommon at 100 sessions). All three now go through `rehydrateFromFile` first. * fix(tracing): capture user prompt via setPrompt; drop time.end gate Per codex review (2026-06-05): the previous user-text branch in worker.ts gated on `part.time?.end` (which user-input parts never have set) AND called `trace.setTitle(text, text)` which would have regressed the auto-generated session title from Path C (`session.updated`) back to the raw user input ('Greeting' → 'hi') if the ordering went sideways. * New `Trace.setPrompt(prompt)` method that only mutates `metadata.prompt`. Decouples prompt capture from title mutation. * Path B in worker.ts now: (a) accepts user text parts without `part.time?.end` (it's an assistant-side concept only); (b) calls `setPrompt(text)` only — never `setTitle` — for user-identified messages. Assistant text still requires `time.end` for `logText`. * Source-grep regression tests lock both contracts: no more `part.time?.end` on the user branch, no `setTitle` from the user branch, `setPrompt` exists and doesn't touch title. * fix(tracing): record user messages as spans so chat tab renders multi-turn The chat tab in the trace viewer renders 'metadata.prompt' as a single 'You' bubble at the top, then iterates 'kind: generation' spans for assistant replies. There's no place for any user message beyond the first to land — `setPrompt` overwrites on every call, and the viewer only reads the latest value. Symptom: a 3-turn session shows only the LAST user prompt followed by all earlier assistant responses, with the older user messages dropped. Fix splits the data and the rendering: * New `Trace.logUserMessage(text)` pushes a `kind: 'user-message'` span with the user text as input. Snapshots immediately like other log* methods. * New 'user-message' variant added to the TraceSpan.kind union. * worker.ts Path B: now also calls `logUserMessage(text)` alongside `setPrompt(text)` for user-identified text parts (the first one populates metadata.prompt for the Summary tab; all of them populate per-turn spans for the Chat tab). * viewer.ts chat-view: builds a chronologically sorted list of user-message + generation spans and walks it in startTime order. Older traces without user-message spans fall back to rendering metadata.prompt as before. * Behavioral test in tracing-rehydrate.test.ts proves the spans are written, ordered chronologically, and preserve the user text. 10 unit tests in worker-trace-clearing + 8 in tracing-rehydrate pass; 35 existing tracing tests pass unchanged; typecheck clean. * docs: trace-bugs followup to #867 — what this branch actually fixes * fix(tracing): address PR #895 bot review feedback * Trace.rehydrateFromFile (cubic P2): restore traceId from the on-disk file so post-rehydrate snapshots/exports preserve trace identity across instance lifetimes. Without this, every snapshot after rehydration writes a fresh random traceId. * Trace.rehydrateFromFile (cubic P2): normalize sessionId via the same sanitization buildTraceFile applies before comparing to trace.sessionId. Previously, sessions with /, \, ., or : in the id would be falsely rejected and recreated. * viewer chat-view (cubic P2): always render metadata.prompt at the top unless an existing user-message span carries the same text. Pre-fix traces (only metadata.prompt) and mixed traces (rehydrated pre-fix data + new turns) now render the first user turn correctly. Previously, the fallback was gated on userMsgs.length === 0 and dropped the legacy first turn in mixed traces. * worker-trace-clearing.test.ts (CodeRabbit, cubic P3): broaden the negative regression guards to catch all three flagged bug spellings — inline expression, inline ternary, block body with if — for the workspaceID effect; and to reject part.time?.end nested inside the user-text branch (identified by sessionUserMsgIds.get(...).has(...)). * routes/session/index.tsx: paraphrased the bug-shape literal that was in a comment so the broadened test regex doesn't catch our own documentation as the bug. * tracing-rehydrate.test.ts: behavioral tests for the traceId preservation and sanitized-sessionId match. Skipped CodeRabbit's tmpdir-fixture-style suggestion — the convention isn't followed by the existing tracing tests (tracing-display-crash, tracing-rename-race) so changing this one file alone would be inconsistent and out of scope for this PR. 48 affected tests pass; typecheck clean. * test(tracing): migrate tracing-rehydrate to tmpdir() fixture CodeRabbit pointed out that `packages/opencode/test/AGENTS.md` documents `tmpdir()` from `fixture/fixture.ts` as the project convention. My earlier reply skipped the migration on the grounds that sibling tracing tests don't follow the convention — but for code I'm adding fresh, the right move is to follow the documented standard. The sibling tests predate it and should be swept in a separate cleanup PR. Replaces the manual `os.tmpdir() + beforeEach/afterEach` pattern with `await using tmp = await tmpdir()` per test. Helpers `makeTrace` and `readTraceFile` now take `dir` as a first parameter so each test threads its own tmp directory through. 46 affected tests pass. * fix(viewer): dedupe metadata.prompt against truncated user-message input cubic flagged that `Trace.logUserMessage` slices user text at 4000 chars when persisting to the span, while `metadata.prompt` keeps the full string. The strict equality check in viewer chat-view (`u.input === t.metadata.prompt`) misses the dedupe for prompts longer than 4000 chars and renders the same text twice — once as the top-level "You" fallback bubble, once as the first user-message span. Match against both the full and the truncated form. Same fix shape cubic suggested. * docs: remove redundant spec/trace-bugs-followup-867.md The PR description already carries the bug-by-bug origin table and the explanation of why #867's scope was disjoint from these bugs. Keeping a 295-line spec file in the repo to say the same thing again is bloat — the PR is the right place for this content. * fix(tracing): synthetic-part gate, in-flight gen interrupt, shared truncation constant Three follow-ups from the multi-LLM consensus review of PR #895, codex-validated: * Worker user-text branch now skips parts with `synthetic` or `ignored` set (Major #1 in the review, codex-confirmed). `Session.createUserMessage` in prompt.ts attaches many synthetic text parts to the user messageID for MCP resource banners, decoded file contents, retry/reminder text, plan-mode reminders, and agent-handoff tags. Without the gate they pass the `sessionUserMsgIds.has(messageID)` check, `metadata.prompt` ends up holding the LAST synthetic part (typically a file blob), and the chat tab renders one fake "▶ You" bubble per synthetic span — defeating the two display surfaces this PR fixes. Gated on an `isAuthoredText` predicate so the symmetric assistant-text branch is also protected. Continue via predicate rather than `continue` keyword so the outer event loop still forwards the event downstream via `Rpc.emit`. * `Trace.rehydrateFromFile` now marks any open generation span as interrupted (Major #2). The transient state `logStepStart` populated (`currentGenerationSpanId`, `generationText`, `generationToolCalls`, `pendingToolResults`) is memory-only and can't be reconstructed from disk. If we leave open spans open, the next `step-finish` for that turn drops at the `!this.currentGenerationSpanId` guard and follow-up `logToolCall` mis-parents tool spans to the root, silently degrading the trace shape. Closing the span with `status: "error"` and a `statusMessage` describing the interruption preserves the partial data and makes the boundary visible in the viewer. * Extracted the 4000-char truncation cap as `USER_MESSAGE_INPUT_MAX_CHARS` exported from tracing.ts (new cubic P3 on viewer.ts:1331). The viewer's chat-tab dedupe now interpolates the same constant so the two sides can't drift if the truncation cap ever changes. Also reused a single `Date.now()` call for the user-message span's start/end timestamps (cosmetic, addresses review nit #16). Skipped from the review: - Major #3 (no per-messageID dedupe in logUserMessage): codex confirmed user text doesn't stream/chunk — message.part.delta is assistant-only — so the symptom the review described is subsumed by Major #1's synthetic gate. No separate fix needed. - Major #5 (Path A `setTitle(text, text)` couples title and prompt): codex grep-verified that no in-repo code path populates `userMsg.summary.title` or `summary.body`; the branch is inert. Cleanup risk only, tracked in #896. Tests - New behavioral test in tracing-rehydrate.test.ts asserts that an open generation span (no `step-finish` before reconstruction) ends up with `endTime` set, `status: "error"`, and a statusMessage matching `/interrupted/i` after rehydrate + a snapshot-triggering call. - New source-grep test in worker-trace-clearing.test.ts locks both the synthetic-gate literal (`!part.synthetic && !part.ignored`) and the requirement that both `trace.setPrompt` and `trace.logUserMessage` sit inside the `isAuthoredText &&` guard. 42 affected tests pass; typecheck clean. * test: tighten worker-trace-clearing regex to scope-bounded match (CodeRabbit) CodeRabbit flagged that the previous source-grep assertions matched across the entire `worker.ts` file, so unrelated code positioning could satisfy them without the synthetic-gate fix being present in the actual user-text branch. * The `isAuthoredText` declaration check now asserts the const is built from BOTH flags (`!part.synthetic && !part.ignored`), not just that the literal exists somewhere. * The two write-path checks now require `trace.setPrompt` and `trace.logUserMessage` to sit inside the same `if (text) { ... }` body within the `sessionUserMsgIds...has(part.messageID)` branch. The `[^{}]` bounds on the inner spans prevent the match from extending past the closing brace of that body, so calls elsewhere in the file can't false-green the assertion. * fix(tracing): make rehydrateFromFile async to unblock the event-loop hot path cubic flagged that `fsSync.readFileSync` in `rehydrateFromFile` blocks the worker event loop during a trace cache miss. The hot path is bounded (cache miss only fires on worker restart, MAX_TRACES eviction, or initial-boot resumption of a stale session), but a multi-MB trace file makes the pause visible. * `Trace.rehydrateFromFile` now returns `Promise<boolean>` and uses the async `fs.readFile`. * `getOrCreateTrace` in worker.ts becomes `async` and the three call sites in the event-stream loop now `await` it. The loop body was already async (`for await (const event of events.stream)`), so the conversion is local. * Behavioural tests in `tracing-rehydrate.test.ts` converted to `await` the now-async method (7 call sites). * The source-grep contract test for `getOrCreateTrace`'s rehydrate-before-startTrace shape now matches the `await` form. Not addressed in this commit (will reply on PR): - cubic also flagged a theoretical event-ordering race where `message.part.updated` could arrive before `message.updated`, leaving `sessionUserMsgIds` empty when the part handler runs. The producer (`Session.createUserMessage` → `updateMessage` THEN `updatePart` for each part) emits in order, and the consumer reads the event stream sequentially. The race is theoretical given the current producer; if we ever reorder, the defensive fix is to buffer unrouted parts by messageID. Out of scope for this PR. --------- Co-authored-by: Haider <haider@altimate.ai>
1 parent 7e909a9 commit 89699f6

6 files changed

Lines changed: 749 additions & 36 deletions

File tree

packages/opencode/src/altimate/observability/tracing.ts

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export interface TraceSpan {
4242
spanId: string
4343
parentSpanId: string | null
4444
name: string
45-
kind: "session" | "generation" | "tool" | "text" | "span"
45+
kind: "session" | "generation" | "tool" | "text" | "span" | "user-message"
4646
startTime: number
4747
endTime?: number
4848
status: "ok" | "error"
@@ -345,6 +345,12 @@ function formatDurationShort(ms: number): string {
345345
}
346346
// altimate_change end
347347

348+
// altimate_change start — shared truncation cap for `logUserMessage` span input.
349+
// Exported so the viewer's chat-tab dedupe can compare against the same boundary
350+
// (otherwise it'd silently drift if either side changes the magic number).
351+
export const USER_MESSAGE_INPUT_MAX_CHARS = 4000
352+
// altimate_change end
353+
348354
export class Trace {
349355
// Global active trace — set when a session starts, cleared on end.
350356
private static _active: Trace | null = null
@@ -490,6 +496,109 @@ export class Trace {
490496
this.snapshot()
491497
}
492498

499+
// altimate_change start — rehydrate a Trace from an existing on-disk file.
500+
// Used by `getOrCreateTrace` on cache miss for a session whose trace file
501+
// already exists (worker restart, MAX_TRACES eviction). Without this, the
502+
// fresh Trace.create() + startTrace() path would push a single root span
503+
// into an empty `this.spans` and the immediate snapshot would clobber the
504+
// rich on-disk trace. Returns true if a usable trace was loaded; false
505+
// otherwise (the caller should fall back to startTrace).
506+
//
507+
// Does NOT call snapshot — the file is already correct on disk; an
508+
// unnecessary write here would compete with concurrent flushSync paths.
509+
//
510+
// Async: read the file off the event-loop hot path so a worker-restart
511+
// cache miss with a large existing trace doesn't head-of-line-block all
512+
// session events. The actual hot path was bounded (cache-miss only) but
513+
// the sync `readFileSync` could still pause the worker noticeably for a
514+
// multi-MB trace.
515+
async rehydrateFromFile(sessionId: string): Promise<boolean> {
516+
if (!this.snapshotDir) return false
517+
const safeId = sessionId.replace(/[/\\.:]/g, "_") || "unknown"
518+
const filePath = path.join(this.snapshotDir, `${safeId}.json`)
519+
let raw: string
520+
try {
521+
raw = await fs.readFile(filePath, "utf-8")
522+
} catch {
523+
return false
524+
}
525+
let trace: TraceFile
526+
try {
527+
trace = JSON.parse(raw) as TraceFile
528+
} catch {
529+
return false
530+
}
531+
// `buildTraceFile` writes the sanitized form of `sessionId` (see ~line 808
532+
// `.replace(/[/\\.:]/g, "_")`), so compare the same way — otherwise valid
533+
// trace files with `/`, `\`, `.`, `:` in the session id would be rejected
534+
// and the caller would fall back to `startTrace`, clobbering them.
535+
const normalizedSessionId = sessionId.replace(/[/\\.:]/g, "_") || "unknown"
536+
if (
537+
!trace ||
538+
trace.sessionId !== normalizedSessionId ||
539+
!Array.isArray(trace.spans) ||
540+
trace.spans.length === 0
541+
) {
542+
return false
543+
}
544+
const root = trace.spans.find((s) => s.parentSpanId === null && s.kind === "session")
545+
if (!root) return false
546+
547+
this.sessionId = sessionId
548+
// Restore the original traceId so post-rehydrate snapshots/exports keep
549+
// the same trace identity (downstream OTLP/Jaeger-style consumers and the
550+
// trace viewer URL both depend on it being stable across rehydration).
551+
if (typeof trace.traceId === "string" && trace.traceId.length > 0) {
552+
this.traceId = trace.traceId
553+
}
554+
this.spans = trace.spans.map((s) => ({ ...s }))
555+
this.rootSpanId = root.spanId
556+
this.metadata = { ...(trace.metadata ?? {}) }
557+
this.startTime = root.startTime
558+
if (trace.summary) {
559+
this.totalTokens = trace.summary.totalTokens ?? 0
560+
this.totalCost = trace.summary.totalCost ?? 0
561+
this.toolCallCount = trace.summary.totalToolCalls ?? 0
562+
this.generationCount = trace.summary.totalGenerations ?? 0
563+
if (trace.summary.tokens) {
564+
this.tokensBreakdown = {
565+
input: trace.summary.tokens.input ?? 0,
566+
output: trace.summary.tokens.output ?? 0,
567+
reasoning: trace.summary.tokens.reasoning ?? 0,
568+
cacheRead: trace.summary.tokens.cacheRead ?? 0,
569+
cacheWrite: trace.summary.tokens.cacheWrite ?? 0,
570+
}
571+
}
572+
}
573+
// Mid-session rehydration: the root span's endTime (if any) was set by a
574+
// prior endTrace; clear it so the trace doesn't render as "completed."
575+
const r = this.spans.find((s) => s.spanId === this.rootSpanId)
576+
if (r) {
577+
delete (r as { endTime?: number }).endTime
578+
r.status = "ok"
579+
}
580+
// Close any in-flight generation spans as interrupted. The transient state
581+
// these spans depended on (`this.currentGenerationSpanId`, `generationText`,
582+
// `generationToolCalls`, `pendingToolResults`) lives only in memory and
583+
// can't be reconstructed from the on-disk file. If we leave open spans
584+
// open, the next `step-finish` event for that turn drops at the
585+
// `if (!this.currentGenerationSpanId) return` guard in `logStepFinish`
586+
// and any later `logToolCall` falls back to attaching tool spans to
587+
// `rootSpanId`, silently degrading the trace shape. Marking them
588+
// interrupted preserves the partial data and makes the boundary visible.
589+
const now = Date.now()
590+
for (const s of this.spans) {
591+
if (s.kind === "generation" && s.endTime === undefined) {
592+
s.endTime = now
593+
s.status = "error"
594+
s.statusMessage = "interrupted (worker restart / cache eviction before step-finish)"
595+
}
596+
}
597+
this.endTraceStarted = false
598+
return true
599+
}
600+
// altimate_change end
601+
493602
/**
494603
* Enrich the trace with model/provider info from the first assistant message.
495604
* Called when the message.updated event fires with assistant role.
@@ -515,6 +624,46 @@ export class Trace {
515624
if (prompt) this.metadata.prompt = prompt
516625
}
517626

627+
// altimate_change start — set only the user prompt without mutating title.
628+
// The bus emits an auto-generated session title (Path C, `session.updated`)
629+
// separately from the user's actual prompt text (Path B, `message.part.updated`).
630+
// Capturing the prompt via `setTitle(text, text)` would race the title-agent:
631+
// if the user text part arrived after `session.updated`, the nice generated
632+
// title ("Greeting") would regress to the raw user input ("hi"). Use this
633+
// method for prompt-only capture.
634+
setPrompt(prompt: string) {
635+
if (prompt) this.metadata.prompt = prompt
636+
}
637+
// altimate_change end
638+
639+
// altimate_change start — record an individual user message as a span so the
640+
// chat tab can render multi-turn conversations. Without this, the viewer's
641+
// chat tab can only display `metadata.prompt` at the top — a single string —
642+
// and every later user message is silently dropped from the conversation
643+
// rendering. Tracked as `kind: "user-message"` so the viewer can interleave
644+
// these with `kind: "generation"` spans by startTime.
645+
logUserMessage(text: string) {
646+
if (!this.rootSpanId) return
647+
if (!text) return
648+
try {
649+
const now = Date.now()
650+
this.spans.push({
651+
spanId: randomUUIDv7(),
652+
parentSpanId: this.rootSpanId,
653+
name: "user-message",
654+
kind: "user-message",
655+
startTime: now,
656+
endTime: now,
657+
status: "ok",
658+
input: text.length > USER_MESSAGE_INPUT_MAX_CHARS ? text.slice(0, USER_MESSAGE_INPUT_MAX_CHARS) : text,
659+
})
660+
this.snapshot()
661+
} catch {
662+
// best-effort
663+
}
664+
}
665+
// altimate_change end
666+
518667
/**
519668
* Open a generation span from a step-start event.
520669
*/

packages/opencode/src/altimate/observability/viewer.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* Branded with Altimate Trace colors. Includes share/export features for virality.
1313
*/
1414

15-
import type { TraceFile } from "./tracing"
15+
import { USER_MESSAGE_INPUT_MAX_CHARS, type TraceFile } from "./tracing"
1616

1717
export function renderTraceViewer(trace: TraceFile, options?: { live?: boolean; apiPath?: string }): string {
1818
const traceJSON = JSON.stringify(trace).replace(/<\//g, "<\\/")
@@ -1310,13 +1310,48 @@ function showDetail(span) {
13101310
(function() {
13111311
var el = document.getElementById('v-chat');
13121312
var html = '';
1313+
// Build a chronologically sorted list of conversation turns by interleaving
1314+
// user-message spans with generation spans. We render \`metadata.prompt\` at
1315+
// the top as a fallback whenever it carries a value not already represented
1316+
// by a user-message span — that covers three cases:
1317+
// 1. Pre-fix traces with no user-message spans (just the legacy prompt).
1318+
// 2. Mixed traces: an older session rehydrated with only metadata.prompt,
1319+
// then continued under the new code which added user-message spans for
1320+
// later turns. Without this, the legacy first turn would be dropped.
1321+
// 3. Brand-new traces where the first user-message span input equals
1322+
// metadata.prompt — we skip the duplicate to avoid double-rendering.
1323+
var userMsgs = spans.filter(function(s){return s.kind==='user-message';});
1324+
var gens = spans.filter(function(s){return s.kind==='generation';});
13131325
if (t.metadata.prompt) {
1314-
html += '<div class="chat-msg user"><div class="chat-role">\\u25B6 You</div>';
1315-
html += '<div class="chat-bubble">' + e(t.metadata.prompt) + '</div></div>';
1326+
// \`logUserMessage\` truncates span input to USER_MESSAGE_INPUT_MAX_CHARS
1327+
// (see tracing.ts); \`metadata.prompt\` stores the full string. For prompts
1328+
// longer than the truncation length, strict equality would miss the dedupe
1329+
// and the same text would render twice. Match against the truncated form as
1330+
// well. The constant is interpolated at render time so the two sides can't
1331+
// drift.
1332+
var USER_MSG_TRUNCATE = ${USER_MESSAGE_INPUT_MAX_CHARS};
1333+
var promptStr = String(t.metadata.prompt);
1334+
var promptTruncated = promptStr.slice(0, USER_MSG_TRUNCATE);
1335+
var promptAlreadyInSpan = userMsgs.some(function(u){
1336+
return typeof u.input === 'string' && (u.input === promptStr || u.input === promptTruncated);
1337+
});
1338+
if (!promptAlreadyInSpan) {
1339+
html += '<div class="chat-msg user"><div class="chat-role">\\u25B6 You</div>';
1340+
html += '<div class="chat-bubble">' + e(promptStr) + '</div></div>';
1341+
}
13161342
}
1317-
var gens = spans.filter(function(s){return s.kind==='generation';});
1318-
gens.forEach(function(gen) {
1319-
var tools = spans.filter(function(s){return s.parentSpanId===gen.spanId && s.kind==='tool';});
1343+
var turns = userMsgs.concat(gens).sort(function(a, b) { return (a.startTime||0) - (b.startTime||0); });
1344+
turns.forEach(function(s) {
1345+
if (s.kind === 'user-message') {
1346+
var utxt = typeof s.input === 'string' ? s.input : (s.input != null ? JSON.stringify(s.input) : '');
1347+
if (!utxt) return;
1348+
html += '<div class="chat-msg user"><div class="chat-role">\\u25B6 You</div>';
1349+
html += '<div class="chat-bubble">' + e(utxt) + '</div></div>';
1350+
return;
1351+
}
1352+
// Generation: render its tool children first, then the agent response.
1353+
var gen = s;
1354+
var tools = spans.filter(function(c){return c.parentSpanId===gen.spanId && c.kind==='tool';});
13201355
if (tools.length) {
13211356
tools.forEach(function(tool) {
13221357
html += '<div class="chat-tool' + (tool.status === 'error' ? ' err' : '') + '">';

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,24 @@ export function Session() {
183183
return new CustomSpeedScroll(3)
184184
})
185185

186-
createEffect(() => {
187-
if (session()?.workspaceID) {
188-
sdk.setWorkspace(session()?.workspaceID)
189-
}
190-
})
186+
// altimate_change start — gate setWorkspace on actual workspaceID change.
187+
// A plain inline `createEffect` callback that reads the session signal would
188+
// re-fire whenever ANY field on the signal changes (message count, status,
189+
// parts) — including the cascade of updates at agent-finish. Every spurious
190+
// fire propagates into `worker.setWorkspace` → `startEventStream` →
191+
// `sessionTraces.clear()` → next snapshot overwrites the rich on-disk trace
192+
// with a near-empty one. The `on()` projector below restricts SolidJS dirty-
193+
// tracking to the workspaceID value alone, so the effect only fires when
194+
// that field actually changes.
195+
createEffect(
196+
on(
197+
() => session()?.workspaceID,
198+
(workspaceID) => {
199+
if (workspaceID) sdk.setWorkspace(workspaceID)
200+
},
201+
),
202+
)
203+
// altimate_change end
191204

192205
createEffect(async () => {
193206
await sync.session

0 commit comments

Comments
 (0)