Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 120 additions & 1 deletion packages/opencode/src/altimate/observability/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export interface TraceSpan {
spanId: string
parentSpanId: string | null
name: string
kind: "session" | "generation" | "tool" | "text" | "span"
kind: "session" | "generation" | "tool" | "text" | "span" | "user-message"
startTime: number
endTime?: number
status: "ok" | "error"
Expand Down Expand Up @@ -490,6 +490,86 @@ export class Trace {
this.snapshot()
}

// altimate_change start — rehydrate a Trace from an existing on-disk file.
// Used by `getOrCreateTrace` on cache miss for a session whose trace file
// already exists (worker restart, MAX_TRACES eviction). Without this, the
// fresh Trace.create() + startTrace() path would push a single root span
// into an empty `this.spans` and the immediate snapshot would clobber the
// rich on-disk trace. Returns true if a usable trace was loaded; false
// otherwise (the caller should fall back to startTrace).
//
// Does NOT call snapshot — the file is already correct on disk; an
// unnecessary write here would compete with concurrent flushSync paths.
rehydrateFromFile(sessionId: string): boolean {
if (!this.snapshotDir) return false
const safeId = sessionId.replace(/[/\\.:]/g, "_") || "unknown"
const filePath = path.join(this.snapshotDir, `${safeId}.json`)
let raw: string
try {
raw = fsSync.readFileSync(filePath, "utf-8")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
} catch {
return false
}
let trace: TraceFile
try {
trace = JSON.parse(raw) as TraceFile
} catch {
return false
}
// `buildTraceFile` writes the sanitized form of `sessionId` (see ~line 808
// `.replace(/[/\\.:]/g, "_")`), so compare the same way — otherwise valid
// trace files with `/`, `\`, `.`, `:` in the session id would be rejected
// and the caller would fall back to `startTrace`, clobbering them.
const normalizedSessionId = sessionId.replace(/[/\\.:]/g, "_") || "unknown"
if (
!trace ||
trace.sessionId !== normalizedSessionId ||
!Array.isArray(trace.spans) ||
trace.spans.length === 0
) {
return false
}
const root = trace.spans.find((s) => s.parentSpanId === null && s.kind === "session")
if (!root) return false

this.sessionId = sessionId
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// Restore the original traceId so post-rehydrate snapshots/exports keep
// the same trace identity (downstream OTLP/Jaeger-style consumers and the
// trace viewer URL both depend on it being stable across rehydration).
if (typeof trace.traceId === "string" && trace.traceId.length > 0) {
this.traceId = trace.traceId
}
this.spans = trace.spans.map((s) => ({ ...s }))
this.rootSpanId = root.spanId
this.metadata = { ...(trace.metadata ?? {}) }
this.startTime = root.startTime
if (trace.summary) {
this.totalTokens = trace.summary.totalTokens ?? 0
this.totalCost = trace.summary.totalCost ?? 0
this.toolCallCount = trace.summary.totalToolCalls ?? 0
this.generationCount = trace.summary.totalGenerations ?? 0
if (trace.summary.tokens) {
this.tokensBreakdown = {
input: trace.summary.tokens.input ?? 0,
output: trace.summary.tokens.output ?? 0,
reasoning: trace.summary.tokens.reasoning ?? 0,
cacheRead: trace.summary.tokens.cacheRead ?? 0,
cacheWrite: trace.summary.tokens.cacheWrite ?? 0,
}
}
}
// Mid-session rehydration: the root span's endTime (if any) was set by a
// prior endTrace; clear it so the trace doesn't render as "completed."
const r = this.spans.find((s) => s.spanId === this.rootSpanId)
if (r) {
delete (r as { endTime?: number }).endTime
r.status = "ok"
}
this.endTraceStarted = false
return true
}
// altimate_change end

/**
* Enrich the trace with model/provider info from the first assistant message.
* Called when the message.updated event fires with assistant role.
Expand All @@ -515,6 +595,45 @@ export class Trace {
if (prompt) this.metadata.prompt = prompt
}

// altimate_change start — set only the user prompt without mutating title.
// The bus emits an auto-generated session title (Path C, `session.updated`)
// separately from the user's actual prompt text (Path B, `message.part.updated`).
// Capturing the prompt via `setTitle(text, text)` would race the title-agent:
// if the user text part arrived after `session.updated`, the nice generated
// title ("Greeting") would regress to the raw user input ("hi"). Use this
// method for prompt-only capture.
setPrompt(prompt: string) {
if (prompt) this.metadata.prompt = prompt
}
// altimate_change end

// altimate_change start — record an individual user message as a span so the
// chat tab can render multi-turn conversations. Without this, the viewer's
// chat tab can only display `metadata.prompt` at the top — a single string —
// and every later user message is silently dropped from the conversation
// rendering. Tracked as `kind: "user-message"` so the viewer can interleave
// these with `kind: "generation"` spans by startTime.
logUserMessage(text: string) {
if (!this.rootSpanId) return
if (!text) return
try {
this.spans.push({
spanId: randomUUIDv7(),
parentSpanId: this.rootSpanId,
name: "user-message",
kind: "user-message",
startTime: Date.now(),
endTime: Date.now(),
status: "ok",
input: text.slice(0, 4000),
})
this.snapshot()
} catch {
// best-effort
}
}
// altimate_change end

/**
* Open a generation span from a step-start event.
*/
Expand Down
42 changes: 37 additions & 5 deletions packages/opencode/src/altimate/observability/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1310,13 +1310,45 @@ function showDetail(span) {
(function() {
var el = document.getElementById('v-chat');
var html = '';
// Build a chronologically sorted list of conversation turns by interleaving
// user-message spans with generation spans. We render \`metadata.prompt\` at
// the top as a fallback whenever it carries a value not already represented
// by a user-message span — that covers three cases:
// 1. Pre-fix traces with no user-message spans (just the legacy prompt).
// 2. Mixed traces: an older session rehydrated with only metadata.prompt,
// then continued under the new code which added user-message spans for
// later turns. Without this, the legacy first turn would be dropped.
// 3. Brand-new traces where the first user-message span input equals
// metadata.prompt — we skip the duplicate to avoid double-rendering.
var userMsgs = spans.filter(function(s){return s.kind==='user-message';});
var gens = spans.filter(function(s){return s.kind==='generation';});
if (t.metadata.prompt) {
html += '<div class="chat-msg user"><div class="chat-role">\\u25B6 You</div>';
html += '<div class="chat-bubble">' + e(t.metadata.prompt) + '</div></div>';
// \`logUserMessage\` truncates span input to 4000 chars (see tracing.ts);
// \`metadata.prompt\` stores the full string. For prompts longer than the
// truncation length, strict equality would miss the dedupe and the same
// text would render twice. Match against the truncated form as well.
var promptStr = String(t.metadata.prompt);
var promptTruncated = promptStr.slice(0, 4000);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
var promptAlreadyInSpan = userMsgs.some(function(u){
return typeof u.input === 'string' && (u.input === promptStr || u.input === promptTruncated);
});
if (!promptAlreadyInSpan) {
html += '<div class="chat-msg user"><div class="chat-role">\\u25B6 You</div>';
html += '<div class="chat-bubble">' + e(promptStr) + '</div></div>';
}
}
var gens = spans.filter(function(s){return s.kind==='generation';});
gens.forEach(function(gen) {
var tools = spans.filter(function(s){return s.parentSpanId===gen.spanId && s.kind==='tool';});
var turns = userMsgs.concat(gens).sort(function(a, b) { return (a.startTime||0) - (b.startTime||0); });
turns.forEach(function(s) {
if (s.kind === 'user-message') {
var utxt = typeof s.input === 'string' ? s.input : (s.input != null ? JSON.stringify(s.input) : '');
if (!utxt) return;
html += '<div class="chat-msg user"><div class="chat-role">\\u25B6 You</div>';
html += '<div class="chat-bubble">' + e(utxt) + '</div></div>';
return;
}
// Generation: render its tool children first, then the agent response.
var gen = s;
var tools = spans.filter(function(c){return c.parentSpanId===gen.spanId && c.kind==='tool';});
if (tools.length) {
tools.forEach(function(tool) {
html += '<div class="chat-tool' + (tool.status === 'error' ? ' err' : '') + '">';
Expand Down
23 changes: 18 additions & 5 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,24 @@ export function Session() {
return new CustomSpeedScroll(3)
})

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

createEffect(async () => {
await sync.session
Expand Down
78 changes: 59 additions & 19 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,15 @@ function getOrCreateTrace(sessionID: string): Trace | null {
const trace = tracingExporters
? Trace.withExporters([...tracingExporters], { maxFiles: tracingMaxFiles })
: Trace.create()
trace.startTrace(sessionID, {})
// altimate_change start — prefer disk-rehydration on cache miss for an
// existing session (worker restart, MAX_TRACES eviction). startTrace would
// push a fresh root span into empty `this.spans` and the immediate
// snapshot would clobber the rich on-disk file. Defense in depth in
// addition to keeping the cache alive across turns.
if (!trace.rehydrateFromFile(sessionID)) {
trace.startTrace(sessionID, {})
}
// altimate_change end
Trace.setActive(trace)
sessionTraces.set(sessionID, trace)
return trace
Expand Down Expand Up @@ -203,16 +211,32 @@ const startEventStream = (input: { directory: string; workspaceID?: string }) =>
if (trace) {
if (part.type === "step-start") trace.logStepStart(part)
if (part.type === "step-finish") trace.logStepFinish(part)
if (part.type === "text" && part.time?.end) {
// altimate_change start — split the user-vs-assistant text routes.
// User text parts arrive without `time.end` set (it's a meaningful
// concept only for processing-end of assistant chunks), so the old
// `&& part.time?.end` gate dropped the prompt entirely. We trust
// `sessionUserMsgIds.has(messageID)` as the user-text signal and
// call `setPrompt(text)` only — never `setTitle` — to avoid racing
// the auto-generated title from `session.updated` (Path C).
if (part.type === "text") {
if (part.messageID && sessionUserMsgIds.get(part.sessionID)?.has(part.messageID)) {
// This is user prompt text — capture as title/prompt
const text = String(part.text || "")
if (text) trace.setTitle(text.slice(0, 80), text)
} else {
// This is assistant response text
if (text) {
trace.setPrompt(text)
// altimate_change start — record each user message as a span
// so the chat tab can render multi-turn conversations.
// Without a span, the viewer can only display `metadata.prompt`
// (singular) and every subsequent user message is silently
// dropped from the conversation rendering.
trace.logUserMessage(text)
// altimate_change end
}
} else if (part.time?.end) {
// Assistant response text (only counts when processing-end fires)
trace.logText(part)
}
}
// altimate_change end
if (part.type === "tool" && (part.state?.status === "completed" || part.state?.status === "error")) {
trace.logToolCall(part)
}
Expand All @@ -229,19 +253,21 @@ const startEventStream = (input: { directory: string; workspaceID?: string }) =>
if (trace) trace.setTitle(String(info.title))
}
}
// Finalize trace when session reaches idle (completed)
if (event.type === "session.status") {
const sid = (event as any).properties?.sessionID
const status = (event as any).properties?.status?.type
if (status === "idle" && sid) {
const trace = sessionTraces.get(sid)
if (trace) {
void trace.endTrace().catch(() => {})
sessionTraces.delete(sid)
sessionUserMsgIds.delete(sid)
}
}
}
// altimate_change start — DO NOT finalize the trace on session.status=idle.
// `idle` fires after every turn (busy → idle transition), not at session end.
// Calling `endTrace` + `sessionTraces.delete` here treats each turn as the
// end of the session: the next event for the same session in a later turn
// hits a cache miss in getOrCreateTrace, constructs a fresh Trace.create()
// with empty `this.spans`, and the immediate `snapshot()` clobbers the
// rich on-disk `ses_<id>.json` with a single root-span file. Symptoms:
// - waterfall view collapses to the system-prompt span after every turn
// - "What was asked / No prompt recorded" because metadata.prompt was
// captured on the destroyed instance, never on the replacement
// Sessions in altimate-code are long-lived across many turns; the Trace
// should live as long as the worker has the session in cache. Finalization
// happens on `shutdown` (worker.ts:312) and on MAX_TRACES eviction
// (worker.ts:87). No per-turn finalization is correct.
// altimate_change end
} catch {
// Trace must never interrupt event forwarding
}
Expand All @@ -261,6 +287,16 @@ const startEventStream = (input: { directory: string; workspaceID?: string }) =>
})
}

// altimate_change start — track the last workspaceID used to start the event stream
// so `setWorkspace` becomes idempotent on unchanged values. SolidJS effects in the
// session route can fire on every `session()` signal change (including agent-finish);
// without this guard, every fire propagates to `startEventStream` which clears
// `sessionTraces`, which causes the next snapshot from a freshly-created Trace to
// overwrite the rich on-disk trace with a near-empty one. Symptom: waterfall view
// collapses to the system-prompt span after every turn.
let currentWorkspaceID: string | undefined
// altimate_change end

startEventStream({ directory: process.cwd() })

export const rpc = {
Expand Down Expand Up @@ -306,6 +342,10 @@ export const rpc = {
await Instance.disposeAll()
},
async setWorkspace(input: { workspaceID?: string }) {
// altimate_change start — idempotency guard; see currentWorkspaceID comment above
if (input.workspaceID === currentWorkspaceID) return
currentWorkspaceID = input.workspaceID
// altimate_change end
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
},
async shutdown() {
Expand Down
Loading
Loading