{"version":3,"sources":["../src/agentSteering.ts"],"sourcesContent":["// Steering + follow-up queue (R3-333 / AHG-T2-3).\n//\n// WHY. R3-224 resolved the hard half — `ChatRequest.signal`, per-message abort in\n// the host that stops the upstream generator and the billing with it, and\n// `runAgent(signal)` behind the stop button. What sits on top of it was never\n// built: the human's vocabulary was still START and KILL. There was no way to say\n// \"yes, but not that file\" or \"stop and do this instead\", or to queue a follow-up\n// while a turn is in flight. Correction meant restart — which discards the\n// transcript's accumulated understanding and pays for it again.\n//\n// This is the app-layer half: a queue the loop drains at a turn boundary, plus an\n// INTERRUPT signal that aborts the in-flight model turn without ending the run.\n//\n// THE TWO VERBS ARE NOT THE SAME.\n//   * STOP  — `RunAgentOptions.signal`. Ends the run. The transcript so far is kept.\n//   * STEER — this controller. Ends the current TURN (or waits for the next\n//             boundary), injects the user's correction, and CONTINUES.\n// They are distinguishable in the transcript (`STEER_MARKER`) and in the UI, which\n// exit criterion 3 requires.\n//\n// WHY A STEER NEVER INTERRUPTS TOOL EXECUTION. A turn's `tool_use` blocks must each\n// get a matching `tool_result`, or the next request is malformed and the provider\n// rejects the whole conversation. So the interrupt signal only ever aborts the\n// in-flight MODEL call; a steer that arrives while tools are running is applied at\n// the boundary after the batch completes. That is exit criterion 5, and it is a\n// property of the design rather than a race the loop has to win.\n\n/** How a steer applies. */\nexport type SteerMode =\n  /** Apply at the next turn boundary — the in-flight turn finishes first. */\n  | 'queue'\n  /** Abort the in-flight model turn now, then apply. Never interrupts a tool call. */\n  | 'interrupt';\n\nexport interface SteerMessage {\n  id: string;\n  text: string;\n  mode: SteerMode;\n}\n\n/** Prefix marking a `user` message as a STEER rather than something typed at the\n *  start of a run — so a replayed conversation shows the interruption where it\n *  happened, instead of reconstructing a history that never ran (scope 5). Mirrors\n *  `COMPACTION_MARKER`/`NUDGE_TEXT`, which the transcript renderer already keys on. */\nexport const STEER_MARKER = '␟[steer]\\n';\n/** As above, for a steer that ABORTED an in-flight turn. */\nexport const STEER_INTERRUPT_MARKER = '␟[steer:interrupt]\\n';\n\n/** Text recorded in place of the assistant turn a steer cut short. The turn has to\n *  appear in the transcript — dropping it would put two `user` messages back to back\n *  and lose the fact that the model was mid-sentence when the user cut in. */\nexport const INTERRUPTED_TURN_TEXT = '(turn interrupted by the user)';\n\n/** Wrap a steer's text for the wire. */\nexport const steerWireText = (m: SteerMessage): string =>\n  (m.mode === 'interrupt' ? STEER_INTERRUPT_MARKER : STEER_MARKER) + m.text;\n\n/** Recognise a steer on replay; returns the mode + text, or `null`. */\nexport function parseSteer(text: string): { mode: SteerMode; text: string } | null {\n  if (text.startsWith(STEER_INTERRUPT_MARKER)) {\n    return { mode: 'interrupt', text: text.slice(STEER_INTERRUPT_MARKER.length) };\n  }\n  if (text.startsWith(STEER_MARKER)) return { mode: 'queue', text: text.slice(STEER_MARKER.length) };\n  return null;\n}\n\n/** What the loop needs from a steering source (so it can be faked in tests). */\nexport interface SteerSource {\n  /** Remove and return everything pending, in arrival order. */\n  drain(): SteerMessage[];\n  /** Is anything waiting? Checked before the loop would otherwise END, so a\n   *  follow-up queued against a finishing run continues it instead. */\n  hasPending(): boolean;\n  /** Fires when an `interrupt`-mode steer arrives. The loop composes it with the\n   *  stop signal for the in-flight model call. */\n  readonly interrupt: AbortSignal;\n  /** Called by the loop once an interrupt has been consumed, so the next turn is\n   *  not aborted by a stale signal. */\n  rearm(): void;\n}\n\nlet seq = 0;\nconst nextId = (): string => {\n  // `crypto.randomUUID` is not available in every host the app runs in; the id only\n  // has to be unique within one run, so a counter + timestamp is enough.\n  seq += 1;\n  return `steer-${Date.now().toString(36)}-${seq}`;\n};\n\n/**\n * The live steering queue. Owned by the UI (which enqueues and cancels) and read\n * by the loop (which drains at a turn boundary).\n */\nexport class SteerController implements SteerSource {\n  private queue: SteerMessage[] = [];\n  private controller = new AbortController();\n  private listeners = new Set<(pending: readonly SteerMessage[]) => void>();\n\n  /** Queue a correction. Returns its id so the UI can cancel it while it waits. */\n  enqueue(text: string, mode: SteerMode = 'queue'): SteerMessage | null {\n    const trimmed = text.trim();\n    if (!trimmed) return null;\n    const msg: SteerMessage = { id: nextId(), text: trimmed, mode };\n    this.queue.push(msg);\n    if (mode === 'interrupt') this.controller.abort();\n    this.emit();\n    return msg;\n  }\n\n  /** Drop a queued steer that has not been applied yet. */\n  cancel(id: string): boolean {\n    const before = this.queue.length;\n    this.queue = this.queue.filter((m) => m.id !== id);\n    if (this.queue.length === before) return false;\n    // Cancelling the only interrupt leaves a fired signal behind; re-arm so the\n    // next turn is not aborted by a correction the user took back.\n    if (!this.queue.some((m) => m.mode === 'interrupt')) this.rearm();\n    this.emit();\n    return true;\n  }\n\n  /** Everything still waiting, for the UI's \"queued\" affordance. */\n  pending(): readonly SteerMessage[] {\n    return this.queue;\n  }\n\n  hasPending(): boolean {\n    return this.queue.length > 0;\n  }\n\n  drain(): SteerMessage[] {\n    const out = this.queue;\n    this.queue = [];\n    if (out.length) this.emit();\n    return out;\n  }\n\n  get interrupt(): AbortSignal {\n    return this.controller.signal;\n  }\n\n  rearm(): void {\n    if (this.controller.signal.aborted) this.controller = new AbortController();\n  }\n\n  /** Subscribe to queue changes (the UI re-renders its queued chips). */\n  onChange(listener: (pending: readonly SteerMessage[]) => void): () => void {\n    this.listeners.add(listener);\n    return () => this.listeners.delete(listener);\n  }\n\n  private emit(): void {\n    for (const l of this.listeners) l(this.queue);\n  }\n}\n\n/**\n * Combine abort signals into one that fires when any of them does.\n *\n * `AbortSignal.any` exists in modern engines but not everywhere the sandbox runs,\n * and a silent `undefined` here would mean the stop button quietly stops working —\n * so this is explicit, and returns a disposer the caller uses to drop its listeners\n * (a per-turn signal that stayed subscribed to a run-long controller would leak one\n * listener per turn on a long run).\n */\nexport function anySignal(signals: Array<AbortSignal | undefined>): {\n  signal: AbortSignal;\n  dispose: () => void;\n} {\n  const live = signals.filter((s): s is AbortSignal => !!s);\n  const controller = new AbortController();\n  const already = live.find((s) => s.aborted);\n  if (already) {\n    controller.abort(already.reason);\n    return { signal: controller.signal, dispose: () => {} };\n  }\n  const onAbort = (e: Event): void => controller.abort((e.target as AbortSignal).reason);\n  for (const s of live) s.addEventListener('abort', onAbort, { once: true });\n  return {\n    signal: controller.signal,\n    dispose: () => {\n      for (const s of live) s.removeEventListener('abort', onAbort);\n    },\n  };\n}\n"],"mappings":"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","names":[]}