Nucleic-Session: FFDE42E0-22AB-4478-B2E2-FF69B2753CEE Co-authored-by: Nucleic <[email protected]>
516 lines
26 KiB
Swift
516 lines
26 KiB
Swift
import Foundation
|
|
import NucleicProtocol
|
|
|
|
/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at
|
|
/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle;
|
|
/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble
|
|
/// per message, one card per tool call, a single contiguous block for a run of consecutive
|
|
/// calls, and a subagent spawn carrying its own nested activity — so the view layer stays dumb.
|
|
/// (Mirrors the desktop's `TranscriptProjection` at phone fidelity, including its event
|
|
/// partitioning, tool-run coalescing, and subagent nesting.)
|
|
struct TranscriptItem: Identifiable, Equatable {
|
|
let id: String
|
|
/// The seq of the first event that created this item — stable scroll anchor + ordering.
|
|
let seq: UInt64
|
|
var kind: Kind
|
|
|
|
enum Role: Equatable { case user, assistant }
|
|
|
|
enum Kind: Equatable {
|
|
case message(role: Role, text: String)
|
|
case thinking(text: String)
|
|
/// A single tool call. A subagent spawn (`Task`/`Agent`) carries its inner activity in
|
|
/// `group.children` and renders as the gold subagent card; every other tool is a plain
|
|
/// collapsible card.
|
|
case tool(ToolGroup)
|
|
/// A run of ≥2 consecutive tool calls coalesced into one contiguous block — the phone
|
|
/// echo of the Mac's `.toolGroup`. A run made entirely of subagent spawns is a
|
|
/// multi-agent fan-out (the gold orchestration card); any other run is a tool block.
|
|
case toolBlock([ToolGroup])
|
|
case sessionStarted(model: String, cwd: String)
|
|
case usage(Usage)
|
|
case rateLimit(RateLimit)
|
|
case turnBoundary
|
|
case approval(toolName: String)
|
|
case runFinished(outcome: RunFinished.Outcome)
|
|
case error(message: String)
|
|
/// A passthrough note. `lock` carries the structured lock detail when this is a file-lock
|
|
/// lifecycle moment, so the projection can fold it onto the edit card it brackets; `nil`
|
|
/// for every non-lock note (and lock notes with no paths).
|
|
case note(text: String, icon: String?, lockEvent: Bool, lock: NoteLock? = nil)
|
|
case raw(type: String, body: String)
|
|
}
|
|
|
|
/// A row that draws nothing visible — an empty/redacted `.thinking` block (the agent streams
|
|
/// a finalized empty thinking item whenever the reasoning itself is redacted). Held aside
|
|
/// during tool-run coalescing so it doesn't split an otherwise-contiguous run of calls into
|
|
/// two cards with a gap. Mirrors the desktop projection's `rendersNothing`.
|
|
var rendersNothing: Bool {
|
|
if case .thinking(let text) = kind {
|
|
return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
|
|
struct ToolGroup: Equatable {
|
|
var toolCallID: String
|
|
var name: String
|
|
var input: JSONValue
|
|
var result: JSONValue?
|
|
var isError: Bool = false
|
|
var finished: Bool = false
|
|
/// Paths the call touched (from `fileChange` events tagged with this tool call).
|
|
var fileChanges: [FilePatch] = []
|
|
/// File-lock lifecycle moments (waiting/acquired/released) folded onto this call's card from
|
|
/// the note stream (LOCKING §4), newest last. A lock note is matched to the nearest preceding
|
|
/// edit whose paths overlap, so the lock reads as part of the edit rather than as a
|
|
/// free-floating row that would otherwise sort after the call. Empty when nothing locked this
|
|
/// call, or the "Show lock events" setting is off. Mirrors the Mac's folded `lockLines`.
|
|
var lockLines: [NoteLock] = []
|
|
/// For a subagent spawn (`Task`/`Agent`): the inner activity it produced — its own tool
|
|
/// calls, thinking, and prose — projected the same way and nested here, so the card can show
|
|
/// its live Read/Bash/Edit stream. Empty for every ordinary (non-subagent) tool.
|
|
var children: [TranscriptItem] = []
|
|
|
|
struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind }
|
|
|
|
/// Subagent orchestration (`Task`/`Agent`) gets the gold card treatment, like the Mac.
|
|
var isOrchestration: Bool { name == "Task" || name == "Agent" }
|
|
var isAskUserQuestion: Bool { name == "AskUserQuestion" }
|
|
|
|
/// The literal shell command a `Bash` call ran, if any (nil for every other tool). Used to
|
|
/// detect a git-commit pipeline that should read as a structured commit card at the row level,
|
|
/// exactly as the Mac promotes it out of the generic tool row.
|
|
var bashCommand: String? {
|
|
(name == "Bash" || name == "Shell") ? input["command"]?.stringValue : nil
|
|
}
|
|
}
|
|
|
|
enum TranscriptProjection {
|
|
/// How deep subagent nesting is followed before deeper descendants are left unattached — a
|
|
/// safety bound on the recursion (a subagent spawning a subagent spawning a subagent …), far
|
|
/// past any real fan-out depth. Mirrors the desktop projection.
|
|
private static let maxSubagentDepth = 4
|
|
|
|
/// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough
|
|
/// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed).
|
|
///
|
|
/// Work that happens *inside* a spawned subagent arrives in this same stream tagged with the
|
|
/// parent `Task`'s id (`parentToolCallID`). We partition events by which subagent (if any)
|
|
/// owns them, project the main agent's own events at the top level, and recursively project
|
|
/// each subagent's events into the `children` of its spawn — so a subagent's inner work nests
|
|
/// under its card instead of leaking (and interleaving) into the main transcript.
|
|
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
|
|
let (topLevel, byParent) = partition(events)
|
|
// Fold lock-lifecycle notes onto the edit cards they bracket, exactly as the Mac's
|
|
// `items(_:worktreeRoot:)` does — matched against the session's working directory so an
|
|
// edit's absolute `file_path` compares against the note's repo-relative paths. Folding
|
|
// happens only at the top level (a subagent's inner edits are literal, unlocked); the
|
|
// subagent recursion below stays plain, matching the desktop projection.
|
|
let root = worktreeRoot(in: topLevel)
|
|
let flat = foldLockNotes(flatItems(topLevel, showRaw: showRaw, showLockEvents: showLockEvents),
|
|
worktreeRoot: root)
|
|
return coalesceToolRuns(flat).map {
|
|
attachSubagentChildren($0, byParent: byParent, depth: 0,
|
|
showRaw: showRaw, showLockEvents: showLockEvents)
|
|
}
|
|
}
|
|
|
|
/// The session's working directory, read from its `sessionStarted` event, so an edit's
|
|
/// absolute `file_path` can be made repo-relative to compare against a lock note's
|
|
/// repo-relative paths. `nil` before the start event is seen (nothing to fold against yet).
|
|
private static func worktreeRoot(in events: [AgentEvent]) -> String? {
|
|
for event in events {
|
|
if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty {
|
|
return started.cwd
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Lock-note folding (LOCKING §4)
|
|
|
|
/// Match each lock-lifecycle note in `flat` to the nearest preceding tool item whose edited
|
|
/// paths overlap, attaching the matched `NoteLock`s to that call's `ToolGroup.lockLines` and
|
|
/// dropping the note from the list. Overlap is directory-aware (`pathsOverlap`); a tool call's
|
|
/// `file_path` is stripped of `worktreeRoot` and normalized so it compares against the note's
|
|
/// repo-relative paths. A note overlapping no preceding edit is left in place (renders
|
|
/// standalone), matching the Mac's `foldLockNotes`.
|
|
private static func foldLockNotes(_ flat: [TranscriptItem], worktreeRoot: String?) -> [TranscriptItem] {
|
|
// Each tool item's repo-relative edited paths (only edit-class tools have any), by index.
|
|
var editsByIndex: [Int: (id: String, paths: [String])] = [:]
|
|
for (i, item) in flat.enumerated() {
|
|
guard case .tool(let group) = item.kind else { continue }
|
|
let paths = editedPaths(toolName: group.name, input: group.input)
|
|
.map { normalizeForLock($0, worktreeRoot: worktreeRoot) }
|
|
.filter { !$0.isEmpty }
|
|
if !paths.isEmpty { editsByIndex[i] = (group.toolCallID, paths) }
|
|
}
|
|
guard !editsByIndex.isEmpty else { return flat }
|
|
|
|
var locksByCall: [String: [NoteLock]] = [:]
|
|
var folded = Set<Int>()
|
|
for (i, item) in flat.enumerated() {
|
|
guard case .note(_, _, true, let noteLock) = item.kind,
|
|
let lock = noteLock, !lock.paths.isEmpty else { continue }
|
|
// Route each path to the nearest preceding edit card that touches it, so a multi-file
|
|
// note brackets each file's own card. Fold only when *every* path lands on a card; a
|
|
// partial match stays a single standalone row rather than splitting across cards.
|
|
var perCard: [(id: String, paths: [String])] = []
|
|
var indexByID: [String: Int] = [:]
|
|
var matchedAll = true
|
|
for path in lock.paths {
|
|
var hitID: String?
|
|
for j in stride(from: i - 1, through: 0, by: -1) {
|
|
guard let edit = editsByIndex[j] else { continue }
|
|
if edit.paths.contains(where: { pathsOverlap($0, path) }) { hitID = edit.id; break }
|
|
}
|
|
guard let hitID else { matchedAll = false; break }
|
|
if let k = indexByID[hitID] { perCard[k].paths.append(path) }
|
|
else { indexByID[hitID] = perCard.count; perCard.append((hitID, [path])) }
|
|
}
|
|
guard matchedAll, !perCard.isEmpty else { continue }
|
|
for card in perCard {
|
|
locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths))
|
|
}
|
|
folded.insert(i)
|
|
}
|
|
guard !folded.isEmpty else { return flat }
|
|
|
|
return flat.enumerated().compactMap { i, item in
|
|
if folded.contains(i) { return nil }
|
|
guard case .tool(var group) = item.kind, let locks = locksByCall[group.toolCallID] else { return item }
|
|
group.lockLines = locks
|
|
return TranscriptItem(id: item.id, seq: item.seq, kind: .tool(group))
|
|
}
|
|
}
|
|
|
|
/// The paths an edit-class tool writes (mirrors `RiskClassifier.editedPaths`). Only Edit /
|
|
/// Write / MultiEdit / NotebookEdit carry a lockable path; everything else has none.
|
|
private static func editedPaths(toolName: String, input: JSONValue) -> [String] {
|
|
switch toolName {
|
|
case "Edit", "Write", "MultiEdit":
|
|
return [input["file_path"]?.stringValue].compactMap { $0 }
|
|
case "NotebookEdit":
|
|
return [input["notebook_path"]?.stringValue].compactMap { $0 }
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// Make a raw tool-call path comparable to a lock note's repo-relative path: strip the worktree
|
|
/// root prefix and normalize. A path not under the root is left as-is (it simply won't overlap).
|
|
/// Mirrors the Mac's `normalizeForLock`.
|
|
private static func normalizeForLock(_ path: String, worktreeRoot: String?) -> String {
|
|
var p = path
|
|
if let root = worktreeRoot, !root.isEmpty, p.hasPrefix(root) { p = String(p.dropFirst(root.count)) }
|
|
return normalizePath(p)
|
|
}
|
|
|
|
/// Trim `./`, surrounding slashes, and whitespace to a bare repo-relative path
|
|
/// (mirrors `ConflictDetector.normalize`).
|
|
private static func normalizePath(_ path: String) -> String {
|
|
var p = path.trimmingCharacters(in: .whitespaces)
|
|
while p.hasPrefix("./") { p.removeFirst(2) }
|
|
while p.hasPrefix("/") { p.removeFirst() }
|
|
while p.hasSuffix("/") { p.removeLast() }
|
|
return p
|
|
}
|
|
|
|
/// True when two repo-relative paths refer to the same file or one is a directory ancestor of
|
|
/// the other — compared componentwise so `src` never matches `src2/x` (mirrors
|
|
/// `ConflictDetector.pathsOverlap`).
|
|
private static func pathsOverlap(_ a: String, _ b: String) -> Bool {
|
|
guard !a.isEmpty, !b.isEmpty else { return false }
|
|
let ca = a.split(separator: "/")
|
|
let cb = b.split(separator: "/")
|
|
let n = min(ca.count, cb.count)
|
|
for i in 0..<n where ca[i] != cb[i] { return false }
|
|
return true
|
|
}
|
|
|
|
// MARK: - Subagent partitioning
|
|
|
|
/// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by
|
|
/// the spawning `Task`'s id.
|
|
private static func partition(_ events: [AgentEvent])
|
|
-> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]])
|
|
{
|
|
let parentOf = toolParentMap(events)
|
|
var topLevel: [AgentEvent] = []
|
|
var byParent: [String: [AgentEvent]] = [:]
|
|
for event in events {
|
|
if let owner = subagentOwner(of: event, parentOf: parentOf) {
|
|
byParent[owner, default: []].append(event)
|
|
} else {
|
|
topLevel.append(event)
|
|
}
|
|
}
|
|
return (topLevel, byParent)
|
|
}
|
|
|
|
/// Maps each tool-call id to its parent subagent's id, built from the call start/complete
|
|
/// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent
|
|
/// scope from the call it belongs to via this map.
|
|
private static func toolParentMap(_ events: [AgentEvent]) -> [String: String] {
|
|
var map: [String: String] = [:]
|
|
for event in events {
|
|
switch event.kind {
|
|
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
|
if let parent = call.parentToolCallID { map[call.toolCallID] = parent }
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return map
|
|
}
|
|
|
|
/// The id of the subagent whose scope this event belongs to, or nil for the main agent's own
|
|
/// turn. Tool calls and the subagent's prose/thinking carry the parent link directly; a tool
|
|
/// result or file change inherits it from the call it references.
|
|
private static func subagentOwner(of event: AgentEvent, parentOf: [String: String]) -> String? {
|
|
switch event.kind {
|
|
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
|
return call.parentToolCallID
|
|
case .toolResult(let result):
|
|
return parentOf[result.toolCallID]
|
|
case .fileChange(let change):
|
|
return change.toolCallID.flatMap { parentOf[$0] }
|
|
case .assistantText(let chunk), .thinking(let chunk):
|
|
return chunk.parentToolCallID
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Projects one scope's events into items, coalesces runs of tool calls, then nests each
|
|
/// subagent spawn's own activity (drawn from `byParent`) as its `children`, recursing for
|
|
/// subagents-within-subagents up to `maxSubagentDepth`.
|
|
private static func project(
|
|
_ events: [AgentEvent], byParent: [String: [AgentEvent]], depth: Int,
|
|
showRaw: Bool, showLockEvents: Bool
|
|
) -> [TranscriptItem] {
|
|
let items = coalesceToolRuns(flatItems(events, showRaw: showRaw, showLockEvents: showLockEvents))
|
|
guard depth < maxSubagentDepth else { return items }
|
|
return items.map {
|
|
attachSubagentChildren($0, byParent: byParent, depth: depth,
|
|
showRaw: showRaw, showLockEvents: showLockEvents)
|
|
}
|
|
}
|
|
|
|
/// For a subagent spawn (`Task`/`Agent`) — lone or inside a fan-out block — projects the
|
|
/// events it owns into its `children`. Leaves every ordinary tool untouched.
|
|
private static func attachSubagentChildren(
|
|
_ item: TranscriptItem, byParent: [String: [AgentEvent]], depth: Int,
|
|
showRaw: Bool, showLockEvents: Bool
|
|
) -> TranscriptItem {
|
|
func childrenFor(_ group: ToolGroup) -> ToolGroup {
|
|
guard group.isOrchestration else { return group }
|
|
var g = group
|
|
g.children = project(byParent[group.toolCallID] ?? [], byParent: byParent, depth: depth + 1,
|
|
showRaw: showRaw, showLockEvents: showLockEvents)
|
|
return g
|
|
}
|
|
switch item.kind {
|
|
case .tool(let group):
|
|
guard group.isOrchestration else { return item }
|
|
return TranscriptItem(id: item.id, seq: item.seq, kind: .tool(childrenFor(group)))
|
|
case .toolBlock(let groups):
|
|
return TranscriptItem(id: item.id, seq: item.seq, kind: .toolBlock(groups.map(childrenFor)))
|
|
default:
|
|
return item
|
|
}
|
|
}
|
|
|
|
// MARK: - Tool-run coalescing
|
|
|
|
/// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`.
|
|
///
|
|
/// Items that draw nothing (empty `.thinking` blocks — see `rendersNothing`) never break a
|
|
/// run: an invisible row landing between two tool calls must not split them into two cards
|
|
/// with a gap. Such items are held aside and re-emitted right after the block, so they still
|
|
/// render in place while the calls stay one contiguous block. Mirrors the desktop projection.
|
|
static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
|
|
var out: [TranscriptItem] = []
|
|
var run: [TranscriptItem] = [] // consecutive `.tool` items
|
|
var held: [TranscriptItem] = [] // non-rendering rows seen mid-run, held so they don't split it
|
|
|
|
func flush() {
|
|
if run.count == 1 {
|
|
out.append(run[0])
|
|
} else if run.count >= 2 {
|
|
let groups = run.compactMap { item -> ToolGroup? in
|
|
if case .tool(let g) = item.kind { return g } else { return nil }
|
|
}
|
|
out.append(TranscriptItem(id: "toolblock-\(groups[0].toolCallID)",
|
|
seq: run[0].seq, kind: .toolBlock(groups)))
|
|
}
|
|
run.removeAll(keepingCapacity: true)
|
|
out.append(contentsOf: held)
|
|
held.removeAll(keepingCapacity: true)
|
|
}
|
|
|
|
for item in flat {
|
|
if case .tool = item.kind {
|
|
run.append(item)
|
|
} else if item.rendersNothing, !run.isEmpty {
|
|
held.append(item)
|
|
} else {
|
|
flush()
|
|
out.append(item)
|
|
}
|
|
}
|
|
flush()
|
|
return out
|
|
}
|
|
|
|
// MARK: - Flat projection (one scope)
|
|
|
|
/// Fold one scope's raw events into interleaved, display-ready rows: streaming assistant /
|
|
/// thinking deltas accumulate into one growing row, and a tool call shows the moment it starts
|
|
/// (`toolCallStarted`), with its full input/result/file-changes filled in as they arrive.
|
|
private static func flatItems(
|
|
_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool
|
|
) -> [TranscriptItem] {
|
|
var items: [TranscriptItem] = []
|
|
var messageIndex: [String: Int] = [:] // messageID → items index (text coalescing)
|
|
var thinkingIndex: [String: Int] = [:]
|
|
var toolIndex: [String: Int] = [:] // toolCallID → items index
|
|
|
|
for event in events {
|
|
switch event.kind {
|
|
case .userText(let chunk):
|
|
coalesceText(.user, chunk, seq: event.seq, into: &items, index: &messageIndex)
|
|
case .assistantText(let chunk):
|
|
coalesceText(.assistant, chunk, seq: event.seq, into: &items, index: &messageIndex)
|
|
case .thinking(let chunk):
|
|
coalesceThinking(chunk, seq: event.seq, into: &items, index: &thinkingIndex)
|
|
|
|
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
|
let finished: Bool = { if case .toolCallCompleted = event.kind { return true } else { return false } }()
|
|
upsertTool(call, finished: finished, seq: event.seq, into: &items, index: &toolIndex)
|
|
case .toolCallInputDelta:
|
|
break // the final `toolCallCompleted` carries the assembled input
|
|
case .toolResult(let result):
|
|
attachResult(result, into: &items, index: toolIndex)
|
|
case .fileChange(let change):
|
|
attachFileChange(change, seq: event.seq, into: &items, index: toolIndex)
|
|
|
|
case .sessionStarted(let started):
|
|
items.append(.init(id: "start-\(event.seq)", seq: event.seq,
|
|
kind: .sessionStarted(model: started.model, cwd: started.cwd)))
|
|
case .usage(let usage):
|
|
items.append(.init(id: "usage-\(event.seq)", seq: event.seq, kind: .usage(usage)))
|
|
case .rateLimit(let limit):
|
|
items.append(.init(id: "rate-\(event.seq)", seq: event.seq, kind: .rateLimit(limit)))
|
|
case .turnCompleted:
|
|
items.append(.init(id: "turn-\(event.seq)", seq: event.seq, kind: .turnBoundary))
|
|
case .approvalRequested(let req):
|
|
items.append(.init(id: "appr-\(event.seq)", seq: event.seq, kind: .approval(toolName: req.toolName)))
|
|
case .approvalResolved:
|
|
break // dismissal is reflected in the live approval card, not the transcript
|
|
case .runFinished(let finished):
|
|
items.append(.init(id: "fin-\(event.seq)", seq: event.seq,
|
|
kind: .runFinished(outcome: finished.outcome)))
|
|
case .error(let err):
|
|
items.append(.init(id: "err-\(event.seq)", seq: event.seq, kind: .error(message: err.message)))
|
|
case .note(let note):
|
|
if note.lockEvent && !showLockEvents { break }
|
|
items.append(.init(id: "note-\(event.seq)", seq: event.seq,
|
|
kind: .note(text: note.text, icon: note.icon,
|
|
lockEvent: note.lockEvent, lock: note.lock)))
|
|
case .raw(let raw):
|
|
guard showRaw else { break }
|
|
items.append(.init(id: "raw-\(event.seq)", seq: event.seq,
|
|
kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary)))
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
// MARK: - Coalescing helpers
|
|
|
|
private static func coalesceText(
|
|
_ role: TranscriptItem.Role, _ chunk: TextChunk, seq: UInt64,
|
|
into items: inout [TranscriptItem], index: inout [String: Int]
|
|
) {
|
|
if let i = index[chunk.messageID], case .message(let r, let existing) = items[i].kind {
|
|
// A non-partial chunk is the authoritative full text; partials accumulate.
|
|
let text = chunk.isPartial ? existing + chunk.text : chunk.text
|
|
items[i].kind = .message(role: r, text: text)
|
|
} else {
|
|
index[chunk.messageID] = items.count
|
|
items.append(.init(id: "msg-\(chunk.messageID)", seq: seq,
|
|
kind: .message(role: role, text: chunk.text)))
|
|
}
|
|
}
|
|
|
|
private static func coalesceThinking(
|
|
_ chunk: TextChunk, seq: UInt64, into items: inout [TranscriptItem], index: inout [String: Int]
|
|
) {
|
|
if let i = index[chunk.messageID], case .thinking(let existing) = items[i].kind {
|
|
items[i].kind = .thinking(text: chunk.isPartial ? existing + chunk.text : chunk.text)
|
|
} else {
|
|
index[chunk.messageID] = items.count
|
|
items.append(.init(id: "think-\(chunk.messageID)", seq: seq, kind: .thinking(text: chunk.text)))
|
|
}
|
|
}
|
|
|
|
private static func upsertTool(
|
|
_ call: ToolCall, finished: Bool, seq: UInt64,
|
|
into items: inout [TranscriptItem], index: inout [String: Int]
|
|
) {
|
|
if let i = index[call.toolCallID], var group = currentGroup(items[i]) {
|
|
group.name = call.name
|
|
if !call.input.isEmptyValue { group.input = call.input }
|
|
group.finished = group.finished || finished
|
|
items[i].kind = .tool(group)
|
|
} else {
|
|
index[call.toolCallID] = items.count
|
|
let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished)
|
|
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: .tool(group)))
|
|
}
|
|
}
|
|
|
|
private static func attachResult(
|
|
_ result: ToolResult, into items: inout [TranscriptItem], index: [String: Int]
|
|
) {
|
|
guard let i = index[result.toolCallID], var group = currentGroup(items[i]) else { return }
|
|
group.result = result.content
|
|
group.isError = result.isError
|
|
group.finished = true
|
|
items[i].kind = .tool(group)
|
|
}
|
|
|
|
private static func attachFileChange(
|
|
_ change: FileChange, seq: UInt64, into items: inout [TranscriptItem], index: [String: Int]
|
|
) {
|
|
if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) {
|
|
group.fileChanges.append(.init(path: change.path, change: change.kind))
|
|
items[i].kind = .tool(group)
|
|
}
|
|
// Untagged file changes are folded into the diff stat, not the transcript.
|
|
}
|
|
|
|
/// Extract a tool group from a `.tool` item (the only kind `flatItems` produces for a call;
|
|
/// blocks/children are formed later, after coalescing).
|
|
private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? {
|
|
if case .tool(let g) = item.kind { return g }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
extension JSONValue {
|
|
/// Whether this value carries nothing worth keeping (so a later, fuller input wins).
|
|
var isEmptyValue: Bool {
|
|
switch self {
|
|
case .null: return true
|
|
case .object(let o): return o.isEmpty
|
|
case .string(let s): return s.isEmpty
|
|
default: return false
|
|
}
|
|
}
|
|
}
|