fix(web): address the review of the workspace-file route
Isolation is restored on the premise the review corrected: a workspace file need not be agent-authored — a read row makes every file in a cloned repository openable — and a same-origin active document was measured driving /api/settings.describe to a 200 with full data. Script-capable documents go back into an opaque origin; the preview's lost localStorage is the known cost, and a separate serving origin is the way to retire it. - confine(): a workspace rooted at a filesystem root has a realpath already ending in the separator, and the doubled prefix 403'd every child. - turnDeliverables(): reset on the turn boundary, not only at a closing assistant, so an interrupted turn cannot spill into the next turn's row; and recognize a mutation by render intent (diff card, or generic with kind 'edit') so str_replace_editor's insert counts. - 405 answers name the methods it allows. - The e2e now cold-seeds a recorded WRITE turn, so the assembled application covers the Produced row, its chip's served URL, and the isolation header. - Agent Note matched to what shipped (the row is in this PR, not deferred); ui-conversation README documents the new destination and the row; the fixture lane's dead-tab quirk and the cold-path listing cost are recorded.
This commit is contained in:
@@ -32,6 +32,21 @@ function rendersNothing(node: ConversationNode): boolean {
|
||||
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths a call view reports having created or changed, by render intent rather
|
||||
* than tool name: a diff card, or a generic card whose kind is `edit` (the
|
||||
* shape `str_replace_editor`'s insert presents). Every other card produces
|
||||
* nothing to open — a read looked, a delete removed, a terminal ran.
|
||||
*/
|
||||
function producedPaths(view: ToolResultNode['callView']): readonly string[] {
|
||||
if (view === null) return []
|
||||
if (view.card === 'diff') return (view.locations ?? []).map(location => location.path)
|
||||
if (view.card === 'generic' && view.kind === 'edit') {
|
||||
return (view.locations ?? []).map(location => location.path)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Seq set of assistants that own IconActions: the last content-text assistant
|
||||
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
|
||||
@@ -54,10 +69,19 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
|
||||
*
|
||||
* The source is the mutation tools' own follow-along `locations`, not the
|
||||
* closing prose: a produced file must be listed whether or not the model
|
||||
* remembered to name it. Reads contribute nothing (looking at a file does not
|
||||
* produce it) and a failed mutation contributes nothing (there is no file to
|
||||
* open). Paths keep first-seen order and appear once, so a file written and
|
||||
* then edited in the same turn is one entry.
|
||||
* remembered to name it. A mutation is recognized by render intent, not by
|
||||
* tool name — a diff card, or a generic card whose `kind` is `edit` (the shape
|
||||
* `str_replace_editor`'s insert presents) — so a new mutation tool joins by
|
||||
* declaring what it does. Reads contribute nothing (looking at a file does not
|
||||
* produce it), and neither do deletes (there is nothing left to open) or
|
||||
* failed calls. Paths keep first-seen order and appear once, so a file written
|
||||
* and then edited in the same turn is one entry.
|
||||
*
|
||||
* Accumulation resets on the turn boundary, not merely at the closing
|
||||
* assistant: a turn that mutates files and then ends without content text
|
||||
* (interrupted mid-tool, or a turn whose last text precedes its last tool
|
||||
* result) must not spill its paths into the next turn's row, nor leave `seen`
|
||||
* suppressing a file the next turn legitimately rewrites.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
* @returns Per-closing-seq produced paths; a turn that produced none is absent.
|
||||
*/
|
||||
@@ -65,21 +89,37 @@ export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMa
|
||||
const closing = assistantActionsSeqs(nodes)
|
||||
const byClosingSeq = new Map<number, readonly string[]>()
|
||||
let pending: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let seen = new Set<string>()
|
||||
let turn: number | undefined
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-result') {
|
||||
if (node.isError || node.callView?.card !== 'diff') continue
|
||||
for (const location of node.callView.locations ?? []) {
|
||||
if (seen.has(location.path)) continue
|
||||
seen.add(location.path)
|
||||
pending.push(location.path)
|
||||
if (node.isError) continue
|
||||
for (const path of producedPaths(node.callView)) {
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
pending.push(path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Tool results carry no turn of their own, so the boundary is read off the
|
||||
// nodes that do. A user message opens a turn without reporting a number,
|
||||
// which is why the tracked turn goes back to undefined there: the next
|
||||
// node to report one is stating the current turn, not entering a new one.
|
||||
if (node.kind === 'user') {
|
||||
turn = undefined
|
||||
pending = []
|
||||
seen = new Set()
|
||||
} else if ('turn' in node) {
|
||||
if (turn !== undefined && node.turn !== turn) {
|
||||
pending = []
|
||||
seen = new Set()
|
||||
}
|
||||
turn = node.turn
|
||||
}
|
||||
if (node.kind !== 'assistant' || !closing.has(node.seq)) continue
|
||||
if (pending.length > 0) byClosingSeq.set(node.seq, pending)
|
||||
pending = []
|
||||
seen.clear()
|
||||
seen = new Set()
|
||||
}
|
||||
return byClosingSeq
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user