Tool Call Card Alignment
Nucleic-Session: FFDE42E0-22AB-4478-B2E2-FF69B2753CEE Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -94,7 +94,140 @@ enum TranscriptProjection {
|
|||||||
/// under its card instead of leaking (and interleaving) into the main transcript.
|
/// under its card instead of leaking (and interleaving) into the main transcript.
|
||||||
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
|
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
|
||||||
let (topLevel, byParent) = partition(events)
|
let (topLevel, byParent) = partition(events)
|
||||||
return project(topLevel, byParent: byParent, depth: 0, showRaw: showRaw, showLockEvents: showLockEvents)
|
// 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) = item.kind,
|
||||||
|
case .note(let note) = noteEvent(item)?.kind ?? .turnCompleted(TurnCompleted()),
|
||||||
|
let lock = note.lock, !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 `NoteEvent`-carrying source event for a `.note` item, recovered so its structured `lock`
|
||||||
|
/// detail is reachable (the projected `.note` kind keeps only text/icon/lockEvent).
|
||||||
|
private static func noteEvent(_ item: TranscriptItem) -> AgentEvent? {
|
||||||
|
// `.note` items retain the note's fields directly; rebuild a matching NoteEvent isn't
|
||||||
|
// possible from the projected kind, so the fold reads the lock off the note kind below.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
// MARK: - Subagent partitioning
|
||||||
|
|||||||
Reference in New Issue
Block a user