Carry the source transcript into forks and cross-link fork points
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user