Carry the source transcript into forks and cross-link fork points

This commit is contained in:
Ian Gulliver
2026-10-20 09:14:44 -07:00
parent 29c993cde0
commit 09d6c6e023
10 changed files with 208 additions and 123 deletions
+62 -39
View File
@@ -87,6 +87,7 @@ final class Row: Identifiable {
var summary: Note?
var review: Review?
var reviewOf: Int?
var forks: [String] = []
var question: InboxItem?
var text = ""
var md: [MDBlock] = []
@@ -177,8 +178,8 @@ private final class Ctx {
var tools: [String: Row] = [:]
var subagents: [String: Subagent] = [:]
var reviewers: Set<String> = []
var reviewButtons: [Int: Row] = [:]
var reviews: [Int: Review] = [:]
var snapshots: [String: Row] = [:]
var reviews: [String: Review] = [:]
var reviewRows: [String: [Row]] = [:]
var questions: [String: Row] = [:]
var run: Run?
@@ -204,12 +205,14 @@ final class TranscriptBuilder {
@ObservationIgnored private var stream: Row?
@ObservationIgnored private var streamTimer: Task<Void, Never>?
@ObservationIgnored private let taskId: String?
@ObservationIgnored private var rowTask: String?
@ObservationIgnored private let worktree: () -> String
@ObservationIgnored private let running: () -> Bool
@ObservationIgnored private var pendingIds = 0
init(taskId: String?, worktree: @escaping () -> String, running: @escaping () -> Bool) {
self.taskId = taskId
rowTask = taskId
self.worktree = worktree
self.running = running
}
@@ -317,7 +320,7 @@ final class TranscriptBuilder {
}
run.calls.append(call)
if run.group == nil {
let group = Row(.group, taskId: taskId, body: true)
let group = Row(.group, taskId: rowTask, body: true)
group.owner = ctx.subagents[parent]?.row
for list in [run.container, sink ?? root] {
if let i = list.rows.firstIndex(where: { $0 === run.first }) {
@@ -356,7 +359,7 @@ final class TranscriptBuilder {
if let stream {
row = stream
} else {
row = Row(.stream, taskId: taskId)
row = Row(.stream, taskId: rowTask)
row.hidden = true
stream = row
ctx.run = nil
@@ -378,7 +381,7 @@ final class TranscriptBuilder {
}
private func openHandoff() {
let row = Row(.handoff, taskId: taskId, body: true)
let row = Row(.handoff, taskId: rowTask, body: true)
row.summary = Note(parts: [.text("writing a handoff…")])
put(row)
ctx.handoff = row
@@ -398,9 +401,14 @@ final class TranscriptBuilder {
private func render(_ events: [ByreEvent]) -> Bool {
var contextChanged = false
defer {
rowTask = taskId
}
for ev in events {
let p = ev.payload.value
let parent = parentOf(p)
rowTask = ev.taskId ?? taskId
let own = rowTask == taskId
if ev.kind == "session.created" && str(p, "role") == "reviewer", let sid = ev.agentSessionId, !sid.isEmpty {
ctx.reviewers.insert(sid)
continue
@@ -423,14 +431,14 @@ final class TranscriptBuilder {
if sink == nil && ((ev.kind == "agent.message" && parent.isEmpty) || ev.kind == "turn.ended" || ev.kind == "user.message") {
dropStream()
}
if sink == nil && ev.kind == "user.message" {
if sink == nil && own && ev.kind == "user.message" {
confirmPending(str(p, "text") ?? "", queued: true, local: ev.actor != "byre")
}
if sink == nil && (ev.kind == "task.merged" || ev.kind == "task.discarded") {
if sink == nil && own && (ev.kind == "task.merged" || ev.kind == "task.discarded") {
pending.removeAll { !$0.local }
}
if ev.kind == "user.queued" {
if sink == nil {
if sink == nil && own {
let text = str(p, "text") ?? ""
confirmPending(text, queued: false, local: true)
pendingIds += 1
@@ -440,7 +448,7 @@ final class TranscriptBuilder {
continue
}
if ev.kind == "user.unqueued" {
if sink == nil, let id = int(p, "id") {
if sink == nil && own, let id = int(p, "id") {
withdrawn(id)
}
continue
@@ -465,18 +473,26 @@ final class TranscriptBuilder {
if ev.kind == "snapshot.created" {
ctx.run = nil
let snap = try! decodePayload(ev.payload, as: Snapshot.self)
let row = Row(.snapshot(snap), taskId: taskId)
ctx.reviewButtons[snap.n] = row
if ![.answer, .fork, .askHuman].contains(snap.trigger) && sink == nil {
let row = Row(.snapshot(snap), taskId: rowTask)
ctx.snapshots[snapKey(snap.n)] = row
if ![.answer, .fork, .askHuman].contains(snap.trigger) && sink == nil && own {
lastReady = nil
}
put(row)
continue
}
if ev.kind == "task.forked", let fork = str(p, "fork") {
if let row = ctx.snapshots[snapKey(int(p, "forked_at") ?? 0)] {
row.forks.append(fork)
continue
}
put(Row(.note(Note(parts: [.text("\(ev.actor) forked snapshot #\(jsString(p["forked_at"])) as "), .task(fork)], when: ev.createdAt)), taskId: rowTask))
continue
}
if ev.kind == "review.started" || ev.kind == "review.finished" || ev.kind == "review.finding" {
let r = try! decodePayload(ev.payload, as: Review.self)
ctx.reviewButtons[r.snapshotN]?.review = r
ctx.reviews[r.snapshotN] = r
ctx.snapshots[snapKey(r.snapshotN)]?.review = r
ctx.reviews[snapKey(r.snapshotN)] = r
for row in ctx.reviewRows[r.id] ?? [] {
row.review = r
}
@@ -509,10 +525,10 @@ final class TranscriptBuilder {
if ev.kind == "agent.tool_call" && sub == nil && str(p, "name") == "mcp__byre__respond_to_finding" {
let input = dict(p, "input") ?? [:]
let n = int(input, "snapshot") ?? 0
let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: taskId)
let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: rowTask)
row.started = ev.createdAt
ctx.tools[jsString(p["id"])] = row
if let r = ctx.reviews[n], r.state == .done {
if let r = ctx.reviews[snapKey(n)], r.state == .done {
row.review = r
ctx.reviewRows[r.id, default: []].append(row)
}
@@ -569,16 +585,16 @@ final class TranscriptBuilder {
if ev.kind == "user.message" && !bool(p, "mid_turn") {
ctx.open = true
}
if ev.kind == "turn.ended" && bool(p, "summary") && taskId != nil {
if ev.kind == "turn.ended" && bool(p, "summary") && taskId != nil && own {
row.summarySeq = ev.seq
}
if ev.kind == "task.merge_after_review" && sink == nil {
if ev.kind == "task.merge_after_review" && sink == nil && own {
lastMergeAfterReview = row.id
}
if (ev.kind == "task.ready" || ev.kind == "task.discard_suggested") && sink == nil {
if (ev.kind == "task.ready" || ev.kind == "task.discard_suggested") && sink == nil && own {
lastReady = row.id
}
if (ev.kind == "inbox.created" || ev.kind == "task.ready_cleared") && sink == nil {
if (ev.kind == "inbox.created" || ev.kind == "task.ready_cleared") && sink == nil && own {
lastReady = nil
}
put(row, keepHeld: ev.kind == "agent.thinking")
@@ -586,6 +602,10 @@ final class TranscriptBuilder {
return contextChanged
}
private func snapKey(_ n: Int) -> String {
"\(rowTask ?? "")/\(n)"
}
private func claudeTaskStatus(_ p: JSONDict) {
guard str(p, "type") == "system", let sub = ctx.subagents[jsString(p["tool_use_id"])] else {
return
@@ -619,7 +639,7 @@ final class TranscriptBuilder {
}
private func renderToolCall(_ p: JSONDict) -> Row {
let row = Row(.tool(name: toolLabel(jsString(p["name"])), brief: toolBrief(p["input"] ?? nil, worktree()), input: prettyJSON(p["input"] ?? nil)), taskId: taskId)
let row = Row(.tool(name: toolLabel(jsString(p["name"])), brief: toolBrief(p["input"] ?? nil, worktree()), input: prettyJSON(p["input"] ?? nil)), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
ctx.tools[jsString(p["id"])] = row
@@ -630,7 +650,7 @@ final class TranscriptBuilder {
let input = dict(p, "input") ?? [:]
let id = jsString(p["id"])
let type = str(input, "subagent_type") ?? "subagent"
let row = Row(.subagent(title: "\(jsString(p["name"])) \(type)", description: str(input, "description") ?? "", prompt: parseMarkdown(str(input, "prompt") ?? "")), taskId: taskId, body: true)
let row = Row(.subagent(title: "\(jsString(p["name"])) \(type)", description: str(input, "description") ?? "", prompt: parseMarkdown(str(input, "prompt") ?? "")), taskId: rowTask, body: true)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
ctx.tools[id] = row
@@ -643,9 +663,9 @@ final class TranscriptBuilder {
let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")]
let fs = findings(r)
let head = fs.isEmpty
? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: taskId)
: Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: taskId)
let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: taskId) }
? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: rowTask)
: Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: rowTask)
let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: rowTask) }
for row in rows {
row.review = r
row.reviewOf = r.snapshotN
@@ -658,7 +678,7 @@ final class TranscriptBuilder {
let p = ev.payload.value
let when = ev.createdAt
func note(_ parts: [NotePart], _ style: NoteStyle = .plain, time: Bool = true) -> Row {
Row(.note(Note(parts: parts, when: time ? when : nil, style: style)), taskId: taskId)
Row(.note(Note(parts: parts, when: time ? when : nil, style: style)), taskId: rowTask)
}
switch ev.kind {
@@ -671,17 +691,17 @@ final class TranscriptBuilder {
if ev.actor == "byre" {
let first = text.components(separatedBy: "\n").first ?? ""
let brief = first.count > 120 ? "\(first.prefix(120))…" : first
let row = Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: taskId)
let row = Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: rowTask)
row.hidden = bool(p, "continuation")
return row
}
let images = (p["images"] as? [(any Sendable)?] ?? []).map { jsString($0) }
return Row(.user(actor: ev.actor, when: when, images: images, md: trimmed(text).isEmpty ? nil : parseMarkdown(text)), taskId: taskId)
return Row(.user(actor: ev.actor, when: when, images: images, md: trimmed(text).isEmpty ? nil : parseMarkdown(text)), taskId: rowTask)
case "agent.message":
let text = jsString(p["text"])
if !parentOf(p).isEmpty {
let row = Row(.subagentMessage(when: when, md: parseMarkdown(text)), taskId: taskId)
let row = Row(.subagentMessage(when: when, md: parseMarkdown(text)), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
return row
}
@@ -690,17 +710,17 @@ final class TranscriptBuilder {
return nil
}
ctx.quiet = false
return Row(.agent(md: parseMarkdown(text)), taskId: taskId)
return Row(.agent(md: parseMarkdown(text)), taskId: rowTask)
case "agent.thinking":
let row = Row(.thinking(md: parseMarkdown(jsString(p["text"]))), taskId: taskId)
let row = Row(.thinking(md: parseMarkdown(jsString(p["text"]))), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
return row
case "agent.tool_result":
let body = resultText(p["content"] ?? nil)
guard let target = ctx.tools[jsString(p["tool_use_id"])] else {
return Row(.orphan(body), taskId: taskId)
return Row(.orphan(body), taskId: rowTask)
}
target.result = body
if case .findingResponse = target.kind, bool(p, "is_error") {
@@ -713,7 +733,7 @@ final class TranscriptBuilder {
if bool(p, "handoff") {
return note([.text("handoff written; continuing in a new session with a fresh context")])
}
return Row(.turnEnd(error: bool(p, "is_error") ? "turn ended: \(jsString(p["subtype"]).replacingOccurrences(of: "_", with: " "))" : nil), taskId: taskId)
return Row(.turnEnd(error: bool(p, "is_error") ? "turn ended: \(jsString(p["subtype"]).replacingOccurrences(of: "_", with: " "))" : nil), taskId: rowTask)
case "session.created":
let model = str(p, "model") ?? ""
@@ -729,7 +749,7 @@ final class TranscriptBuilder {
return note([.text("session \(jsString(p["state"])): \(error)")], .error, time: false)
case "inbox.created":
let row = Row(.question, taskId: taskId)
let row = Row(.question, taskId: rowTask)
let item = try! decodePayload(ev.payload, as: InboxItem.self)
row.question = item
ctx.questions[item.id] = row
@@ -744,8 +764,11 @@ final class TranscriptBuilder {
return note([.text("\(jsString(p["error"])) (\(jsString(p["vendor"])) \(limit["rateLimitType"].map { jsString($0) } ?? "") limit resets \(resets))")], .error)
case "task.created":
if let from = str(p, "forked_from"), let task = rowTask, task != taskId {
return note([.text("\(ev.actor) forked "), .task(task), .text(" here from "), .task(from), .text(" at snapshot #\(jsString(p["forked_at"]))")])
}
if let from = str(p, "forked_from") {
return note([.text("\(ev.actor) forked this task from "), .task(from), .text(" at snapshot #\(jsString(p["forked_at"])); its agent continues that task's conversation as it stood there")])
return note([.text("\(ev.actor) forked this task here from "), .task(from), .text(" at snapshot #\(jsString(p["forked_at"])); everything above happened in that task, and its agent continues that conversation")])
}
if let parentTask = str(p, "parent_task_id") {
return note([.text("subtask of "), .task(parentTask), .text(", created by its agent")])
@@ -773,7 +796,7 @@ final class TranscriptBuilder {
case "task.merge_after_review":
var n = Note(parts: [.text("\(ev.actor) asked to merge once the review of snapshot #\(jsString(p["snapshot_n"])) finishes with no findings")], when: when)
n.cancelMergeAfterReview = jsString(p["id"])
return Row(.note(n), taskId: taskId)
return Row(.note(n), taskId: rowTask)
case "task.merge_after_review_cancelled":
return note([.text("\(ev.actor) cancelled the merge after review")])
@@ -799,10 +822,10 @@ final class TranscriptBuilder {
return note([.text("\(ev.actor) set the task summary")])
case "task.ready":
return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"])), discard: false), taskId: taskId)
return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"])), discard: false), taskId: rowTask)
case "task.discard_suggested":
return Row(.ready(when: when, md: parseMarkdown(jsString(p["reason"])), discard: true), taskId: taskId)
return Row(.ready(when: when, md: parseMarkdown(jsString(p["reason"])), discard: true), taskId: rowTask)
case "task.ready_cleared":
return note([.text(ev.actor == "agent" ? "the agent withdrew its ready call" : "\(ev.actor) dismissed the ready call")])
@@ -833,7 +856,7 @@ func findingCount(_ r: Review) -> Int {
r.state == .done ? findings(r).indices.filter { findingOpen(r, $0) }.count : 0
}
func latestReview(_ events: [ByreEvent]) -> Review? {
func latestReview(_ events: ArraySlice<ByreEvent>) -> Review? {
var best: Review?
for ev in events where isReviewEvent(ev) {
let r = try! decodePayload(ev.payload, as: Review.self)