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
+4 -1
View File
@@ -561,7 +561,10 @@ paths:
- { name: taskId, in: path, required: true, schema: { type: string } }
get:
operationId: listTaskEvents
description: The task's events after a seq, oldest first. Page by passing the last seq back as after.
description: >
The task's transcript after a seq, oldest first. Page by passing the last seq back as after. A fork's
transcript starts with the task it was forked from, up to and including the snapshot.created event of the
snapshot it was forked at (and so on for a fork of a fork); those events keep their own task_id.
parameters:
- { name: after, in: query, required: false, schema: { type: integer, format: int64 } }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000 } }
+1 -1
View File
@@ -451,7 +451,7 @@ The cut point comes from `vendor_uuid`: every top-level `agent.message`, `agent.
A fork of a workflow stage task gets a copy of the source's frozen workflow, brief, run and stage, not the library's current workflow, so the fork is another attempt at that stage of the same run in its own worktree; when it finishes, the stages after it start from it unless they already have tasks (see "Workflows").
The fork's session guidance gets one more line (`forkGuidance` in `internal/agents/guidance.go`) naming the source task, the snapshot, the fork's branch and worktree and the source's worktree, telling the agent that everything before the fork happened in the other worktree and to work only in its own. Claude Code can reuse the system prompt recorded for the source conversation on a fork, the source's cwd included, so the `UserPromptSubmit` hook's copy is what reliably tells it. The fork's transcript starts with a note from its `task.created` event, "forked this task from <source> at snapshot #n", linking the source while it is active; the lead's signal for it reads "<task> was forked by <user> from <source> at snapshot n", and `list_tasks` notes the source. The fork's transcript shows only its own events, not the source's history.
The fork's session guidance gets one more line (`forkGuidance` in `internal/agents/guidance.go`) naming the source task, the snapshot, the fork's branch and worktree and the source's worktree, telling the agent that everything before the fork happened in the other worktree and to work only in its own. Claude Code can reuse the system prompt recorded for the source conversation on a fork, the source's cwd included, so the `UserPromptSubmit` hook's copy is what reliably tells it. A fork carries the source's transcript up to the fork point. `GET /api/tasks/{id}/events` and the task SSE stream's backfill (`Store.TranscriptEvents`) return, besides the task's own events, the source's events up to and including the `snapshot.created` event of the snapshot it was forked at, and so on up the chain for a fork of a fork; those events keep their own `task_id`, and the stream's live part sends only the task's own. The lead's `read_transcript`, `forkPoint` and everything else reading `Store.TaskEvents` see only the task's own events. In the app (`loadHistory` in `mac/Sources/Byre/Store.swift`) the inherited events are the history's leading run whose `task_id` isn't the task's (`Feed.inherited`); `Store.transcript`, which the Changes tab, the Board and the latest-review lookup read, is only the task's own (`Feed.own`). The transcript renders the inherited events like its own, each row carrying the event's task, so its links and its **fork** button act on the source; inherited snapshot rows have no **changes** button, an inherited question that was still open at the fork shows closed ("unanswered when this task was forked"), and inherited questions, findings and ready calls are never attention items. The fork point in the fork is the note from its `task.created` event right after the inherited part, "forked this task here from <source> at snapshot #n", linking the source while it is active. In the source, the same transaction records `task.forked` (`fork`, `forked_at`) on the source task, and its transcript shows "forked as <fork>" on that snapshot's row (a note, when the row isn't shown). The lead's signal for the fork reads "<task> was forked by <user> from <source> at snapshot n", and `list_tasks` notes the source.
An answered question can be answered again. Expanding an answered question's bubble in the transcript shows an **Answer again** button when the question is `answered` (the transcript's copy of the question comes from its `inbox.*` events, which carry no `snapshot_n`, so the server is what checks for one); it reopens the card's choices, Other box and "Let the agent decide", with Cancel in place of Ignore, and sending posts the new answer, an `InboxResponseCreate`, to `POST /api/inbox/answer-again` (`AnswerAgain` in `internal/agents/fork.go`) instead of `respondInbox`. byre checks the answer against the question as if it were open, forks the question's task at the answer's `snapshot_n` exactly as the **fork** button does, and files in the fork a copy of the question (same payload, its own id and thread) already `answered` with the new response, in one transaction (`Store.CreateAnsweredItem`), whose `inbox.created` event carries the answered item and the human as actor. `Deliver` then hands that answer to the fork's idle session as its first turn, the usual delivery message, so the forked conversation, cut just before the original answer reached the agent, continues with the new one. The app opens the fork; the original task and its question are untouched. An `inbox.created` for an item that isn't open raises no notification, and the lead's signal for it reads as an answer, not a question. Refused (409) when the question isn't `answered` or its answer has no `snapshot_n`, and for anything the fork refuses; 400 for an answer that doesn't fit the question.
+50 -49
View File
@@ -4368,55 +4368,56 @@ var swaggerSpec = []string{
"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",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+1 -1
View File
@@ -733,7 +733,7 @@ func (s *Server) ListTaskEvents(w http.ResponseWriter, r *http.Request, taskID s
limit = *params.Limit
}
events, err := s.store.TaskEvents(r.Context(), taskID, after, limit)
events, err := s.store.TranscriptEvents(r.Context(), taskID, after, limit)
if err != nil {
writeStoreError(w, err)
return
+1 -1
View File
@@ -35,7 +35,7 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api
case lead:
return s.store.LeadEvents(r.Context(), viewer, after, backfillPage)
case taskID != "":
return s.store.TaskEvents(r.Context(), taskID, after, backfillPage)
return s.store.TranscriptEvents(r.Context(), taskID, after, backfillPage)
}
return s.store.EventsAfter(r.Context(), viewer, after, backfillPage)
}
+40 -1
View File
@@ -360,7 +360,11 @@ func (s *Store) CreateTask(ctx context.Context, t api.Task, frozen *FrozenWorkfl
if err != nil {
return err
}
return emit(api.Event{Kind: "task.created", ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: actor, Payload: ToPayload(t)})
err = emit(api.Event{Kind: "task.created", ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: actor, Payload: ToPayload(t)})
if err != nil || forkedFrom == "" {
return err
}
return emit(api.Event{Kind: "task.forked", ProjectId: &t.ProjectId, TaskId: &forkedFrom, Actor: actor, Payload: map[string]any{"fork": t.Id, "forked_at": forkedAt}})
})
}
@@ -958,6 +962,41 @@ func (s *Store) TaskEvents(ctx context.Context, taskID string, after int64, limi
return scanEvents(rows)
}
func (s *Store) TranscriptEvents(ctx context.Context, taskID string, after int64, limit int) ([]api.Event, error) {
where := "task_id = ?"
args := []any{taskID}
for id := taskID; ; {
from := ""
at := 0
err := s.db.QueryRowContext(ctx, `SELECT forked_from, forked_at FROM tasks WHERE id = ?`, id).Scan(&from, &at)
if err != nil {
return nil, err
}
if from == "" {
break
}
cut := int64(0)
err = s.db.QueryRowContext(ctx, `
SELECT coalesce(max(seq), 0) FROM events
WHERE task_id = ? AND kind = 'snapshot.created' AND json_extract(payload, '$.n') = ?
`, from, at).Scan(&cut)
if err != nil {
return nil, err
}
where += " OR (task_id = ? AND seq <= ?)"
args = append(args, from, cut)
id = from
}
rows, err := s.db.QueryContext(ctx, `
SELECT `+eventColumns+`
FROM events WHERE (`+where+`) AND seq > ? ORDER BY seq LIMIT ?
`, append(args, after, limit)...)
if err != nil {
return nil, err
}
return scanEvents(rows)
}
func (s *Store) FirstPrompt(ctx context.Context, taskID string) (string, error) {
text := ""
err := s.db.QueryRowContext(ctx, `
+39 -26
View File
@@ -268,7 +268,7 @@ final class ChatModel {
store.jump = nil
scrollTo(row.id)
case .review(let n):
guard let row = reviewRow(n) else {
guard let row = reviewRow(n, taskId) else {
return
}
store.jump = nil
@@ -276,8 +276,8 @@ final class ChatModel {
}
}
private func reviewRow(_ n: Int) -> Row? {
let rows = builder.root.rows
private func reviewRow(_ n: Int, _ task: String?) -> Row? {
let rows = builder.root.rows.filter { $0.taskId == task }
return rows.first { $0.reviewOf == n } ?? rows.first { r in
if case .snapshot(let s) = r.kind {
return s.n == n
@@ -286,8 +286,8 @@ final class ChatModel {
}
}
func showReview(_ n: Int) {
if let row = reviewRow(n) {
func showReview(_ n: Int, _ task: String?) {
if let row = reviewRow(n, task) {
scrollTo(row.id)
}
}
@@ -317,7 +317,7 @@ final class ChatModel {
var discard = false
for row in builder.root.rows where !row.hidden {
switch row.kind {
case .question where row.question?.state == .open:
case .question where row.question?.state == .open && row.taskId == taskId:
questions.append(row.id)
case .finding(let i):
if let r = row.review, r.id == latest?.id, findingOpen(r, i) {
@@ -1300,13 +1300,13 @@ struct RowView: View {
}
case .snapshot(let snap):
SnapshotRow(snap: snap, review: row.review, taskId: row.taskId) {
model.showReview(snap.n)
SnapshotRow(snap: snap, review: row.review, taskId: row.taskId, inherited: row.taskId != model.taskId, forks: row.forks) {
model.showReview(snap.n, row.taskId)
}
case .question:
if let item = row.question {
QuestionBubble(item: item)
QuestionBubble(item: item, inherited: row.taskId != model.taskId)
}
}
}
@@ -1314,9 +1314,10 @@ struct RowView: View {
private struct QuestionBubble: View {
let item: InboxItem
let inherited: Bool
var body: some View {
let open = item.state == .open
let open = item.state == .open && !inherited
Group {
if open {
QuestionCard(item: item)
@@ -1357,7 +1358,7 @@ private struct QuestionBubble: View {
let answer = item.responses.last
switch item.state {
case .open:
return "waiting for your answer"
return inherited ? "unanswered when this task was forked" : "waiting for your answer"
case .dismissed:
return item.resolvedBy == nil ? "withdrawn by the agent" : "dismissed by \(item.resolvedBy!)"
default:
@@ -1594,16 +1595,7 @@ struct NoteView: View {
case .text(let s):
out += AttributedString(s)
case .task(let id):
guard let task = store.findTask(id) else {
out += AttributedString(id)
continue
}
var a = AttributedString(task.name)
if task.status == .active {
a.link = AppLink.task(task.id)
a.foregroundColor = taskPalette[task.color]
}
out += a
out += taskLink(store, id)
case .url(let port):
let url = previewURL(store.api.origin, Int(port) ?? 0)
var a = AttributedString(url.absoluteString)
@@ -1616,6 +1608,19 @@ struct NoteView: View {
}
}
@MainActor
func taskLink(_ store: Store, _ id: String) -> AttributedString {
guard let task = store.findTask(id) else {
return AttributedString(id)
}
var a = AttributedString(task.name)
if task.status == .active {
a.link = AppLink.task(task.id)
a.foregroundColor = taskPalette[task.color]
}
return a
}
func previewURL(_ origin: URL, _ port: Int) -> URL {
var c = URLComponents()
c.scheme = "https"
@@ -1629,6 +1634,8 @@ private struct SnapshotRow: View {
let snap: Snapshot
let review: Review?
let taskId: String?
let inherited: Bool
let forks: [String]
let showReview: () -> Void
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@@ -1640,12 +1647,14 @@ private struct SnapshotRow: View {
HStack(spacing: 8) {
Rectangle().fill(Palette.border).frame(height: 1)
Text("snapshot #\(snap.n)")
Button("changes") {
store.pinSnapshot(snap.n)
store.focusTab(.changes)
if !inherited {
Button("changes") {
store.pinSnapshot(snap.n)
store.focusTab(.changes)
}
.buttonStyle(SmallLinkButtonStyle())
.help("Show snapshot \(snap.n) in the Changes tab")
}
.buttonStyle(SmallLinkButtonStyle())
.help("Show snapshot \(snap.n) in the Changes tab")
Button("fork") {
guard let taskId else {
return
@@ -1667,6 +1676,10 @@ private struct SnapshotRow: View {
if ![.answer, .fork, .askHuman].contains(snap.trigger) {
reviewButton
}
ForEach(forks, id: \.self) { id in
Text(AttributedString("forked as ") + taskLink(store, id))
.fixedSize()
}
Rectangle().fill(Palette.border).frame(height: 1)
}
.font(Fonts.font(11))
+7 -1
View File
@@ -25,6 +25,7 @@ final class Listeners<T> {
final class Feed {
private(set) var events: [ByreEvent] = []
private(set) var ready = false
private(set) var inherited = 0
let appended = Listeners<[ByreEvent]>()
let streamed = Listeners<ByreEvent>()
@ObservationIgnored private var last: Int64 = 0
@@ -49,15 +50,20 @@ final class Feed {
}
}
func load(_ history: [ByreEvent]) {
func load(_ history: [ByreEvent], inherited: Int = 0) {
if ready {
return
}
ready = true
self.inherited = inherited
buffer = history + buffer
flush()
}
var own: ArraySlice<ByreEvent> {
events.dropFirst(inherited)
}
func prepend(_ older: [ByreEvent]) {
events.insert(contentsOf: older, at: 0)
}
+3 -3
View File
@@ -164,8 +164,8 @@ final class Store {
}
}
var transcript: [ByreEvent] {
selectedFeed?.events ?? []
var transcript: ArraySlice<ByreEvent> {
selectedFeed?.own ?? []
}
var selectedFeed: Feed? {
@@ -439,7 +439,7 @@ final class Store {
break
}
}
feed.load(got)
feed.load(got, inherited: got.prefix { $0.taskId != taskId }.count)
}
private func taskFeed(_ taskId: String) -> Feed {
+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)