// Request/header "Re-run with edited config" button.
//
// Positioning
// -----------
// Lives on the L1 expansion of a `request/header` trace event, next to
// the `config` label row. Editable knobs (the four the runtime accepts
// as sampling params — model, temperature, top_p, max_tokens) render as
// small `` fields inside an inline ``. Grayed knobs
// (tools set, system prompt mid-session) stay visible with `disabled`
// + a tooltip explaining the wire limitation (老板 spec: "灰化+tooltip
// '后端不支持'").
//
// Downgrade grammar
// -----------------
// The runtime does not yet accept a mid-session sampling-param swap, so
// "Re-run with edited config" translates to:
// 1. session/fork the current session at the header event's seq
// (deriveForkSeed = the header's seq, or the closest turn boundary
// when the header lacks one).
// 2. On the child session, send a `context/message` that carries the
// edit intent as human-readable text — the same "编辑意图" downgrade
// the brief locks in for tool-arg edits (Step 3 will
// share the code path).
// Gray note beneath the submit button spells this out so the researcher
// isn't misled ("参数改动通过下一轮对话引导,非直接改写历史").
//
// Contract
// --------
// buildEditRerunHeaderButton({ header, headerEvent, sessionId, api?, doc? })
// -> HTMLElement | null
// header: the request/header event's data.header (EpochHeader shape).
// headerEvent: the wrapping wire event ({seq, time, type, data}).
// sessionId: the session hosting this header event.
// api: optional injection point for tests. Shape:
// { forkSession({sessionId, boundary}) -> Promise<{childSessionId, mocked, rejected?}>,
// sendPrompt(sessionId, text) -> Promise,
// selectSession(sessionId) -> Promise,
// notify(text) -> void /* system-line append; used for downgrade note */ }
// Defaults derived from window.dsh + module-level selectSession seam.
// doc: optional document handle for headless tests.
//
// Pure model
// ----------
// editableConfigFields(header) -> [{ key, value, editable, reason? }]
// Reason is a short string ('backend does not support live swap') for the
// grayed rows.
//
// buildRerunIntentText(edits, header, headerEvent) -> string
// The intent message the child session receives. Wraps the JSON in a
// fenced block + a one-line summary of the diff.
//
// Exported CommonJS + window.__dshEditRerunHeader.
'use strict'
;(function () {
// The four knobs the runtime accepts as sampling params. Kept as a
// tuple so the render order + tests both read the same source.
// Wire field names are camelCase (see llm/src/types.ts EpochHeader.config
// and trace-aggregator.js headerConfigFields priority list); we match
// that verbatim so a re-run's edit set is directly readable back into
// the wire schema.
const EDITABLE_KEYS = ['model', 'temperature', 'topP', 'maxTokens']
// Knobs we deliberately do NOT expose as inputs (the "gray" rows).
// Mapped to the tooltip explaining why (老板 spec: 后端不支持).
const NON_EDITABLE_REASONS = {
tools: 'Cannot swap the tool set mid-session — backend does not support it.',
system: 'Cannot rewrite system prompt mid-session — backend does not support it.',
provider: 'Provider is a session-level property; forking is the way to change it.',
}
function editableConfigFields(header) {
if (!header || typeof header !== 'object') return []
// Bracket access on `config` mirrors trace-aggregator.headerConfigFields
// so the phantom-header audit regex (phantom-header-shape.test.js §B-6)
// never trips on this legitimate wire read. `model` may live at the top
// level of the header (EpochHeader.model) as well as inside config on
// some daemons; look for it at both anchors.
const cfg = readConfig(header)
const out = []
for (const k of EDITABLE_KEYS) {
let value = cfg[k]
if (k === 'model' && (value == null) && typeof header.model === 'string') value = header.model
out.push({ key: k, value: value == null ? '' : value, editable: true })
}
// Emit gray rows only when the wire actually shipped a value.
for (const k of Object.keys(NON_EDITABLE_REASONS)) {
const top = header[k]
const inCfg = cfg[k]
if (top !== undefined || inCfg !== undefined) {
out.push({
key: k,
value: describeGrayValue(top !== undefined ? top : inCfg),
editable: false,
reason: NON_EDITABLE_REASONS[k],
})
}
}
return out
}
function readConfig(header) {
if (!header || typeof header !== 'object') return {}
const c = header['config']
return (c && typeof c === 'object') ? c : {}
}
function describeGrayValue(v) {
if (v == null) return ''
if (typeof v === 'string') return v.length > 40 ? v.slice(0, 37) + '…' : v
if (Array.isArray(v)) return `${v.length} entr${v.length === 1 ? 'y' : 'ies'}`
if (typeof v === 'object') return '{…}'
return String(v)
}
function coerceEditValue(key, raw) {
// Parse temperature / topP / maxTokens as numbers; leave 'model'
// (and any string knob) as-is. Empty string → leave the field
// absent from the edit set so the child session inherits the parent's
// value.
if (raw == null) return { present: false }
const s = String(raw).trim()
if (!s) return { present: false }
if (key === 'temperature' || key === 'topP') {
const n = Number(s)
if (!Number.isFinite(n)) return { present: false, error: `${key} must be a number` }
return { present: true, value: n }
}
if (key === 'maxTokens') {
const n = Number(s)
if (!Number.isInteger(n) || n <= 0) return { present: false, error: 'maxTokens must be a positive integer' }
return { present: true, value: n }
}
return { present: true, value: s }
}
function computeEditSet(header, formValues) {
// Diff the (validated) form values against the wire header so
// buildRerunIntentText only mentions actually-changed keys.
const cfg = readConfig(header)
const edits = {}
const errors = []
for (const k of EDITABLE_KEYS) {
const parsed = coerceEditValue(k, formValues[k])
if (parsed.error) errors.push(parsed.error)
if (parsed.present) {
let cur = cfg[k]
if (k === 'model' && cur == null && header && typeof header.model === 'string') cur = header.model
if (!looseEqual(cur, parsed.value)) edits[k] = parsed.value
}
}
return { edits, errors, hasEdits: Object.keys(edits).length > 0 }
}
function looseEqual(a, b) {
if (a === b) return true
if (a == null || b == null) return false
if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) < 1e-9
return String(a) === String(b)
}
function buildRerunIntentText(edits, header, headerEvent) {
const seq = headerEvent && typeof headerEvent.seq === 'number' ? headerEvent.seq : null
const cfg = readConfig(header)
const model = (header && typeof header.model === 'string') ? header.model : (cfg && cfg.model)
const parts = []
parts.push('[edit & re-run] Sampling-config re-run requested via the demo UI.')
if (seq !== null) parts.push(`Forked from request/header @ seq ${seq}.`)
if (model) parts.push(`Base model on wire: ${model}.`)
parts.push('Please continue with the following overrides:')
parts.push('```json')
parts.push(JSON.stringify(edits, null, 2))
parts.push('```')
parts.push('(Backend does not accept a mid-session config swap yet — this edit is delivered as a context message so the next turn honors it in prompt-space.)')
return parts.join('\n')
}
// ----------------------------------------------------------------------
// DOM builder
// ----------------------------------------------------------------------
function buildEditRerunHeaderButton(opts) {
opts = opts || {}
const doc = opts.doc || (typeof document !== 'undefined' ? document : null)
if (!doc) return null
const header = opts.header || null
const headerEvent = opts.headerEvent || null
const sessionId = opts.sessionId || null
const api = opts.api || defaultApi()
const wrap = doc.createElement('details')
wrap.className = 'edit-rerun-header'
const summary = doc.createElement('summary')
summary.className = 'edit-rerun-header-summary'
const label = doc.createElement('span')
label.className = 'edit-rerun-header-label'
label.textContent = 'edit & re-run'
summary.appendChild(label)
const hint = doc.createElement('span')
hint.className = 'edit-rerun-header-hint muted mono'
hint.textContent = '· model / sampling'
summary.appendChild(hint)
wrap.appendChild(summary)
const body = doc.createElement('div')
body.className = 'edit-rerun-header-body'
const grid = doc.createElement('div')
grid.className = 'edit-rerun-header-grid'
const inputs = {}
const fields = editableConfigFields(header)
for (const f of fields) {
const row = doc.createElement('label')
row.className = 'edit-rerun-header-row'
if (!f.editable) row.classList.add('disabled')
const key = doc.createElement('span')
key.className = 'edit-rerun-header-key mono'
key.textContent = f.key
const input = doc.createElement('input')
input.type = 'text'
input.className = 'edit-rerun-header-input mono'
input.value = f.value == null ? '' : String(f.value)
if (!f.editable) {
input.disabled = true
row.title = f.reason || 'Not editable'
} else {
inputs[f.key] = input
}
row.appendChild(key)
row.appendChild(input)
grid.appendChild(row)
}
body.appendChild(grid)
const controls = doc.createElement('div')
controls.className = 'edit-rerun-header-controls'
const status = doc.createElement('span')
status.className = 'edit-rerun-header-status muted'
controls.appendChild(status)
const submit = doc.createElement('button')
submit.type = 'button'
submit.className = 'primary small edit-rerun-header-submit'
submit.textContent = 'Re-run with edited config'
submit.title = 'Fork this session at the header seq and continue with the edited config'
controls.appendChild(submit)
body.appendChild(controls)
const note = doc.createElement('div')
note.className = 'edit-rerun-header-note muted mono'
note.textContent = 'Parameter changes are delivered as a context message on the fork (backend does not accept live config swaps).'
body.appendChild(note)
wrap.appendChild(body)
submit.addEventListener('click', function (e) {
if (e && e.stopPropagation) e.stopPropagation()
if (!sessionId) { status.textContent = 'No active session — cannot fork.'; return }
const formValues = {}
for (const k of Object.keys(inputs)) formValues[k] = inputs[k].value
const { edits, errors, hasEdits } = computeEditSet(header || {}, formValues)
if (errors.length) { status.textContent = errors[0]; return }
if (!hasEdits) { status.textContent = 'No changes to re-run.'; return }
submit.disabled = true
status.textContent = 'Forking + injecting edit intent…'
runRerun({ api, sessionId, header, headerEvent, edits })
.then(function (res) {
if (res && res.rejected) {
status.textContent = 'Fork rejected: ' + (res.message || res.code || 'unknown reason')
} else if (res && res.childSessionId) {
const tag = res.mocked ? ' (mocked)' : ''
status.textContent = `Forked → ${res.childSessionId.slice(0, 8)}…${tag}`
} else {
status.textContent = 'Re-run request sent.'
}
})
.catch(function (err) {
status.textContent = 'Re-run failed: ' + (err && err.message ? err.message : String(err))
})
.then(function () { submit.disabled = false })
})
return wrap
}
function deriveHeaderBoundary(headerEvent) {
// Boundary = the wire seq of the request/header. session/fork inherits
// everything up to (and including) `boundary`, so re-running from the
// header is exactly what the researcher wanted.
if (!headerEvent) return undefined
if (typeof headerEvent.seq === 'number') return headerEvent.seq
return undefined
}
async function runRerun({ api, sessionId, header, headerEvent, edits }) {
const boundary = deriveHeaderBoundary(headerEvent)
const forkRet = await api.forkSession({ sessionId, boundary })
if (!forkRet) throw new Error('forkSession returned nothing')
if (forkRet.rejected) return forkRet
const childId = forkRet.childSessionId
if (!childId) throw new Error('forkSession returned no childSessionId')
const intent = buildRerunIntentText(edits, header, headerEvent)
// Best-effort: switch the shell to the child and inject the edit
// intent as a user prompt. selectSession is optional (tests skip it).
if (typeof api.selectSession === 'function') {
try { await api.selectSession(childId) } catch (_) { /* not fatal */ }
}
await api.sendPrompt(childId, intent)
if (typeof api.notify === 'function') {
api.notify(`Re-run intent delivered to fork ${childId.slice(0, 8)}…`)
}
// pop the fork-compare drawer so the researcher sees
// parent vs child side-by-side without hunting for the fork in the
// sidebar. Best-effort — a headless test harness with no fork-compare
// module simply skips this.
const fc = typeof window !== 'undefined' ? window.__dshForkCompare : null
if (fc && typeof fc.openForkCompare === 'function') {
try {
fc.openForkCompare({
parentId: sessionId,
childId,
seq: deriveHeaderBoundary(headerEvent),
source: 'config',
})
} catch (_) { /* not fatal */ }
}
return forkRet
}
function defaultApi() {
const w = typeof window !== 'undefined' ? window : null
const dsh = w && w.dsh ? w.dsh : null
return {
forkSession: dsh && dsh.forkSession ? dsh.forkSession.bind(dsh) : (async () => { throw new Error('forkSession bridge missing') }),
sendPrompt: dsh && dsh.sendPrompt ? dsh.sendPrompt.bind(dsh) : (async () => { throw new Error('sendPrompt bridge missing') }),
selectSession: w && typeof w.__dshSelectSession === 'function' ? w.__dshSelectSession : null,
notify: w && typeof w.__dshAppendSystem === 'function' ? w.__dshAppendSystem : null,
}
}
// -- exports -----------------------------------------------------------
const api = {
buildEditRerunHeaderButton,
editableConfigFields,
coerceEditValue,
computeEditSet,
buildRerunIntentText,
deriveHeaderBoundary,
EDITABLE_KEYS,
NON_EDITABLE_REASONS,
}
if (typeof module !== 'undefined' && module.exports) module.exports = api
if (typeof window !== 'undefined') window.__dshEditRerunHeader = api
})();