mini: settle on wait and durable pending (#37984)
This commit is contained in:
parent
fd97d789ef
commit
592ef7433a
16 changed files with 908 additions and 657 deletions
|
|
@ -96,7 +96,7 @@ export const Definitions = {
|
|||
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
|
|
|
|||
|
|
@ -465,8 +465,8 @@ export function RunCommandMenuBody(props: {
|
|||
{
|
||||
action: "queued" as const,
|
||||
category: "Agent",
|
||||
display: "Manage queued prompts",
|
||||
footer: `${props.queued().length} queued`,
|
||||
display: "View pending work",
|
||||
footer: `${props.queued().length} pending`,
|
||||
keywords: props
|
||||
.queued()
|
||||
.map((item) => item.prompt.text)
|
||||
|
|
@ -673,15 +673,13 @@ export function RunQueuedPromptSelectBody(props: {
|
|||
theme: Accessor<RunFooterTheme>
|
||||
prompts: Accessor<FooterQueuedPrompt[]>
|
||||
onClose: () => void
|
||||
onEdit: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
||||
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
const entries = createMemo<QueuedEntry[]>(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
category: "",
|
||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||
footer: "queued · ctrl+e edit · ctrl+d remove",
|
||||
footer: prompt.delivery,
|
||||
keywords: prompt.prompt.text,
|
||||
prompt,
|
||||
})),
|
||||
|
|
@ -690,29 +688,13 @@ export function RunQueuedPromptSelectBody(props: {
|
|||
entries,
|
||||
limit: SUBAGENT_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onEdit(item.prompt),
|
||||
onSelect: props.onClose,
|
||||
onRows: props.onRows,
|
||||
onKey: (event, item) => {
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
|
||||
if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
|
||||
event.preventDefault()
|
||||
props.onDelete(item.prompt)
|
||||
return true
|
||||
}
|
||||
|
||||
if (item && ctrl && event.name === "e") {
|
||||
event.preventDefault()
|
||||
props.onEdit(item.prompt)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Queued prompts"
|
||||
title="Pending work"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
|
|
@ -730,7 +712,7 @@ export function RunQueuedPromptSelectBody(props: {
|
|||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No queued prompts"
|
||||
empty="No pending work"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
|
|
|
|||
|
|
@ -115,10 +115,6 @@ function createEmptySubagentState(): FooterSubagentState {
|
|||
}
|
||||
|
||||
function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
||||
if (next.type === "queue") {
|
||||
return { queue: next.queue }
|
||||
}
|
||||
|
||||
if (next.type === "first") {
|
||||
return { first: next.first }
|
||||
}
|
||||
|
|
@ -131,7 +127,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
|||
return {
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: next.queue,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
}
|
||||
|
|
@ -141,7 +136,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
|||
return {
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: next.queue,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +150,6 @@ export class RunFooter implements FooterApi {
|
|||
private closed = false
|
||||
private destroyed = false
|
||||
private prompts = new Set<(input: RunPrompt) => void>()
|
||||
private queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
|
||||
private closes = new Set<() => void>()
|
||||
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
|
||||
private queue: StreamCommit[] = []
|
||||
|
|
@ -226,7 +219,6 @@ export class RunFooter implements FooterApi {
|
|||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: options.modelLabel,
|
||||
usage: "",
|
||||
first: options.first,
|
||||
|
|
@ -328,7 +320,6 @@ export class RunFooter implements FooterApi {
|
|||
onStatus: footer.setStatus,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
onSubagentInterrupt: options.onSubagentInterrupt,
|
||||
onQueuedRemove: footer.handleQueuedRemove,
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
|
@ -355,13 +346,6 @@ export class RunFooter implements FooterApi {
|
|||
}
|
||||
}
|
||||
|
||||
public onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void {
|
||||
this.queuedRemoves.add(fn)
|
||||
return () => {
|
||||
this.queuedRemoves.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public onClose(fn: () => void): () => void {
|
||||
if (this.isClosed) {
|
||||
fn()
|
||||
|
|
@ -487,7 +471,6 @@ export class RunFooter implements FooterApi {
|
|||
const state = {
|
||||
phase: next.phase ?? prev.phase,
|
||||
status: typeof next.status === "string" ? next.status : prev.status,
|
||||
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
|
||||
model: typeof next.model === "string" ? next.model : prev.model,
|
||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||
|
|
@ -665,11 +648,6 @@ export class RunFooter implements FooterApi {
|
|||
this.requestExitHandler = fn
|
||||
}
|
||||
|
||||
private handleQueuedRemove = async (messageID: string): Promise<boolean> => {
|
||||
const fn = [...this.queuedRemoves][0]
|
||||
return fn ? await fn(messageID) : false
|
||||
}
|
||||
|
||||
private handleInputClear = (): void => {
|
||||
this.clearInterruptTimer()
|
||||
this.clearExitTimer()
|
||||
|
|
@ -1080,7 +1058,6 @@ export class RunFooter implements FooterApi {
|
|||
for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
|
||||
this.themeRefreshTimeouts.length = 0
|
||||
this.prompts.clear()
|
||||
this.queuedRemoves.clear()
|
||||
this.closes.clear()
|
||||
this.scrollback.destroy()
|
||||
for (const theme of [...this.themes]) this.destroyTheme(theme)
|
||||
|
|
|
|||
|
|
@ -102,7 +102,6 @@ type RunFooterViewProps = {
|
|||
onStatus: (text: string) => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
onQueuedRemove: (messageID: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
|
@ -179,7 +178,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const queue = createMemo(() => props.state().queue)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptLabel = createMemo(() => {
|
||||
if (!interrupt()) {
|
||||
|
|
@ -414,7 +412,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
||||
items.push({ kind: "queued", key: queuedShortcut(), label: `${queue()} queued` })
|
||||
items.push({ kind: "queued", key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
|
||||
|
|
@ -495,7 +493,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
commands: [
|
||||
{
|
||||
id: "session.queued_prompts",
|
||||
title: "Manage queued prompts",
|
||||
title: "View pending work",
|
||||
group: "Session",
|
||||
run: openQueuedMenu,
|
||||
},
|
||||
|
|
@ -656,12 +654,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
theme={theme}
|
||||
prompts={queuedPrompts}
|
||||
onClose={closePanel}
|
||||
onDelete={(item) => void props.onQueuedRemove(item.messageID)}
|
||||
onEdit={async (item) => {
|
||||
if (!(await props.onQueuedRemove(item.messageID))) return
|
||||
closePanel()
|
||||
queueMicrotask(() => composer.replacePrompt(item.prompt))
|
||||
}}
|
||||
onRows={setSubagentMenuRows}
|
||||
/>
|
||||
</Match>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// Serial prompt queue for direct interactive mode.
|
||||
//
|
||||
// Prompts arrive from the footer (user types and hits enter) and queue up
|
||||
// here. The queue drains one turn at a time; ordinary prompts waiting behind
|
||||
// an active ordinary turn are exposed for edit/removal until they begin.
|
||||
// Prompts arrive from the footer (user types and hits enter) and local
|
||||
// operations drain one at a time. Ordinary prompts submitted during an active
|
||||
// ordinary turn are admitted immediately to the server's durable queue.
|
||||
//
|
||||
// The queue also handles /exit, /quit, and /new commands, empty-prompt rejection,
|
||||
// and tracks per-turn wall-clock duration for the footer status line.
|
||||
|
|
@ -11,84 +11,54 @@
|
|||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Locale } from "../util/locale"
|
||||
import { isExitCommand, isNewCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
|
||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type Deferred<T = void> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T | PromiseLike<T>) => void
|
||||
reject: (error?: unknown) => void
|
||||
}
|
||||
|
||||
export type QueueInput = {
|
||||
footer: FooterApi
|
||||
initialInput?: string
|
||||
trace?: Trace
|
||||
onSend?: (prompt: RunPrompt) => void
|
||||
onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
|
||||
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
||||
onNewSession?: () => void | Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
settle: () => Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
||||
}
|
||||
|
||||
type State = {
|
||||
queue: RunPrompt[]
|
||||
queued: FooterQueuedPrompt[]
|
||||
active?: RunPrompt
|
||||
admission?: Promise<void>
|
||||
ctrl?: AbortController
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
function defer<T = void>(): Deferred<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (error?: unknown) => void
|
||||
const promise = new Promise<T>((next, fail) => {
|
||||
resolve = next
|
||||
reject = fail
|
||||
})
|
||||
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
// Runs the prompt queue until the footer closes.
|
||||
//
|
||||
// Subscribes to footer prompt events and drains operations through input.run().
|
||||
// Ordinary prompts submitted during an ordinary active turn remain local and
|
||||
// are exposed by the footer for edit/removal until their turn begins.
|
||||
// Ordinary prompts submitted during an ordinary active turn are admitted as
|
||||
// durable queued work instead of remaining editable process-local state.
|
||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const stop = defer<{ type: "closed" }>()
|
||||
const done = defer()
|
||||
const stop = Promise.withResolvers<{ type: "closed" }>()
|
||||
const done = Promise.withResolvers<void>()
|
||||
const state: State = {
|
||||
queue: [],
|
||||
queued: [],
|
||||
closed: input.footer.isClosed,
|
||||
}
|
||||
let draining: Promise<void> | undefined
|
||||
let admissions = Promise.resolve()
|
||||
let admissionVersion = 0
|
||||
const admissionController = new AbortController()
|
||||
|
||||
const emit = (next: FooterEvent, row: Record<string, unknown>) => {
|
||||
input.trace?.write("ui.patch", row)
|
||||
input.footer.event(next)
|
||||
}
|
||||
|
||||
const syncQueue = () => {
|
||||
const queue = state.queue.length
|
||||
emit({ type: "queue", queue }, { queue })
|
||||
emit(
|
||||
{
|
||||
type: "queued.prompts",
|
||||
prompts: [...state.queued],
|
||||
},
|
||||
{ queued: state.queued.length },
|
||||
)
|
||||
}
|
||||
|
||||
const removeLocalQueued = (queued: FooterQueuedPrompt) => {
|
||||
if (!state.queued.includes(queued)) return
|
||||
state.queued = state.queued.filter((item) => item !== queued)
|
||||
syncQueue()
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (!state.closed || draining) {
|
||||
return
|
||||
|
|
@ -104,8 +74,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
|
||||
state.closed = true
|
||||
state.queue.length = 0
|
||||
state.queued.length = 0
|
||||
state.ctrl?.abort()
|
||||
admissionController.abort()
|
||||
stop.resolve({ type: "closed" })
|
||||
finish()
|
||||
}
|
||||
|
|
@ -123,11 +93,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
continue
|
||||
}
|
||||
|
||||
const queued = state.queued.find((item) => item.prompt === prompt)
|
||||
if (queued) removeLocalQueued(queued)
|
||||
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
syncQueue()
|
||||
if (!input.onNewSession) {
|
||||
emit(
|
||||
{
|
||||
|
|
@ -149,13 +115,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
patch: {
|
||||
phase: "running",
|
||||
status: "starting new session",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
phase: "running",
|
||||
status: "starting new session",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
await input.onNewSession()
|
||||
|
|
@ -167,24 +131,23 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
? prompt
|
||||
: {
|
||||
...prompt,
|
||||
messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(),
|
||||
messageID: prompt.messageID ?? SessionMessage.ID.create(),
|
||||
}
|
||||
state.active = sent
|
||||
|
||||
emit(
|
||||
{
|
||||
type: "turn.send",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{ type: "turn.send" },
|
||||
{
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
const start = Date.now()
|
||||
const ctrl = new AbortController()
|
||||
const admission = Promise.withResolvers<void>()
|
||||
const version = admissionVersion
|
||||
state.ctrl = ctrl
|
||||
state.admission = admission.promise
|
||||
|
||||
try {
|
||||
await input.footer.idle()
|
||||
|
|
@ -203,13 +166,13 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
input.onSend?.(sent)
|
||||
input.onSend?.(sent, "steer")
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
}
|
||||
|
||||
const task = input.run(sent, ctrl.signal).then(
|
||||
const task = input.run(sent, ctrl.signal, admission.resolve).then(
|
||||
() => ({ type: "done" as const }),
|
||||
(error) => ({ type: "error" as const, error }),
|
||||
)
|
||||
|
|
@ -223,10 +186,21 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
if (next.type === "error") {
|
||||
throw next.error
|
||||
}
|
||||
if (sent.mode !== "shell" && admissionVersion !== version) {
|
||||
do {
|
||||
const current = admissionVersion
|
||||
await admissions
|
||||
if (state.closed) break
|
||||
await input.settle()
|
||||
if (current === admissionVersion) break
|
||||
} while (!state.closed)
|
||||
}
|
||||
} finally {
|
||||
admission.resolve()
|
||||
if (state.ctrl === ctrl) {
|
||||
state.ctrl = undefined
|
||||
}
|
||||
if (state.admission === admission.promise) state.admission = undefined
|
||||
|
||||
if (sent.mode !== "shell") {
|
||||
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
||||
|
|
@ -249,14 +223,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
} finally {
|
||||
draining = undefined
|
||||
emit(
|
||||
{
|
||||
type: "turn.idle",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{ type: "turn.idle" },
|
||||
{
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -279,23 +249,22 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
if (
|
||||
active &&
|
||||
active.mode !== "shell" &&
|
||||
!active.command &&
|
||||
prompt.mode !== "shell" &&
|
||||
!prompt.command &&
|
||||
prompt.command?.source !== "skill" &&
|
||||
!isNewCommand(prompt.text)
|
||||
) {
|
||||
const queued: FooterQueuedPrompt = {
|
||||
messageID: SessionMessage.ID.create(),
|
||||
prompt,
|
||||
}
|
||||
state.queued = [...state.queued, queued]
|
||||
state.queue.push(prompt)
|
||||
syncQueue()
|
||||
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||
const admission = state.admission
|
||||
admissionVersion += 1
|
||||
input.onSend?.(sent, "queue")
|
||||
admissions = admissions
|
||||
.then(() => admission)
|
||||
.then(() => input.admit(sent, admissionController.signal))
|
||||
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||
return
|
||||
}
|
||||
|
||||
state.queue.push(prompt)
|
||||
syncQueue()
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
drain()
|
||||
return
|
||||
|
|
@ -319,14 +288,6 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
const offClose = input.footer.onClose(() => {
|
||||
close()
|
||||
})
|
||||
const offRemoveQueued = input.footer.onQueuedRemove((messageID) => {
|
||||
const queued = state.queued.find((item) => item.messageID === messageID)
|
||||
if (!queued) return false
|
||||
state.queue = state.queue.filter((prompt) => prompt !== queued.prompt)
|
||||
removeLocalQueued(queued)
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
if (state.closed) {
|
||||
return
|
||||
|
|
@ -341,8 +302,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
} finally {
|
||||
offPrompt()
|
||||
offClose()
|
||||
offRemoveQueued()
|
||||
close()
|
||||
await draining?.catch(() => {})
|
||||
await admissions
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -780,6 +780,22 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
}, RESIZE_DELAY)
|
||||
})
|
||||
|
||||
const renderPromptError = async (prompt: RunPrompt, error: unknown, signal?: AbortSignal) => {
|
||||
if (signal?.aborted || footer.isClosed) return
|
||||
const text =
|
||||
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
||||
(error instanceof Error ? error.message : String(error))
|
||||
const commit = {
|
||||
kind: "error",
|
||||
text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
} as const
|
||||
rememberLocal(commit)
|
||||
footer.append(commit)
|
||||
}
|
||||
|
||||
const runQueue = async () => {
|
||||
await firstPaint
|
||||
if (footer.isClosed) return
|
||||
|
|
@ -798,10 +814,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
footer,
|
||||
initialInput: input.initialInput,
|
||||
trace: log,
|
||||
onSend: (prompt) => {
|
||||
onSend: (prompt, delivery) => {
|
||||
state.shown = true
|
||||
state.history.push(prompt)
|
||||
if (prompt.mode !== "shell") {
|
||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
|
|
@ -811,6 +827,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
})
|
||||
}
|
||||
},
|
||||
admit: async (prompt, signal) => {
|
||||
await state.switching?.catch(() => {})
|
||||
const next = await ensureStream()
|
||||
await next.handle.queuePromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
})
|
||||
},
|
||||
onAdmissionError: renderPromptError,
|
||||
settle: async () => {
|
||||
const next = await ensureStream()
|
||||
await next.handle.waitForIdle()
|
||||
},
|
||||
onNewSession: createSession
|
||||
? async () => {
|
||||
try {
|
||||
|
|
@ -856,6 +890,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
},
|
||||
})
|
||||
footer.event({ type: "stream.view", view: { type: "prompt" } })
|
||||
footer.event({ type: "queued.prompts", prompts: [] })
|
||||
footer.event({
|
||||
type: "stream.patch",
|
||||
patch: {
|
||||
|
|
@ -891,7 +926,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
}
|
||||
}
|
||||
: undefined,
|
||||
run: async (prompt, signal) => {
|
||||
run: async (prompt, signal, admitted) => {
|
||||
if (state.demo && (await state.demo.prompt(prompt, signal))) {
|
||||
return
|
||||
}
|
||||
|
|
@ -900,15 +935,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
|
||||
try {
|
||||
const next = await ensureStream()
|
||||
await next.handle.runPromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
signal,
|
||||
})
|
||||
await next.handle.runPromptTurn(
|
||||
{
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
signal,
|
||||
},
|
||||
admitted,
|
||||
)
|
||||
if (prompt.messageID) {
|
||||
state.localRows = state.localRows.filter(
|
||||
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
||||
|
|
@ -918,22 +956,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
// pending for the next prompt-shaped turn.
|
||||
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
|
||||
} catch (error) {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
const text =
|
||||
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
||||
(error instanceof Error ? error.message : String(error))
|
||||
const commit = {
|
||||
kind: "error",
|
||||
text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
} as const
|
||||
rememberLocal(commit)
|
||||
footer.append(commit)
|
||||
await renderPromptError(prompt, error, signal)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
PermissionV2Request,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionPendingInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
|
|
@ -20,6 +21,7 @@ import type {
|
|||
LocalReplayRow,
|
||||
MiniPermissionRequest,
|
||||
MiniFormRequest,
|
||||
FooterQueuedPrompt,
|
||||
RunFilePart,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
|
|
@ -64,7 +66,9 @@ export type SessionResizeReplayInput = {
|
|||
}
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput): Promise<void>
|
||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
||||
waitForIdle(): Promise<void>
|
||||
interruptActiveTurn(): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
|
||||
|
|
@ -74,11 +78,12 @@ export type SessionTransport = {
|
|||
|
||||
type Wait = {
|
||||
messageID: string
|
||||
failureMessageID: string
|
||||
promoted: boolean
|
||||
promotionObserved: boolean
|
||||
interrupted: boolean
|
||||
failureRendered: boolean
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
terminalError?: Error
|
||||
}
|
||||
|
||||
// One active session.shell call. The HTTP response is the completion signal;
|
||||
|
|
@ -134,6 +139,8 @@ type State = {
|
|||
rootActive: boolean
|
||||
buffered?: ReplayBuffer
|
||||
errors: Set<string>
|
||||
pending: Map<string, FooterQueuedPrompt>
|
||||
admitted: Set<string>
|
||||
}
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||
|
|
@ -167,6 +174,16 @@ function errorMessage(error: { message?: string; _tag?: string }) {
|
|||
return error.message || error._tag || "Session execution failed"
|
||||
}
|
||||
|
||||
function pendingPrompt(item: SessionPendingInfo): FooterQueuedPrompt | undefined {
|
||||
if (item.type !== "user") return undefined
|
||||
return {
|
||||
messageID: item.id,
|
||||
prompt: { messageID: item.id, text: item.data.text, parts: [] },
|
||||
delivery: item.delivery,
|
||||
admittedSeq: item.admittedSeq,
|
||||
}
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
|
|
@ -369,6 +386,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
let sdk = input.sdk
|
||||
let generation = 0
|
||||
let activeAttempt: Attempt | undefined
|
||||
let settlementClient: OpenCodeClient | undefined
|
||||
input.signal?.addEventListener("abort", () => controller.abort(), { once: true })
|
||||
const state: State = {
|
||||
permissions: [],
|
||||
|
|
@ -389,6 +407,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
initial: true,
|
||||
rootActive: false,
|
||||
errors: new Set(),
|
||||
pending: new Map(),
|
||||
admitted: new Set(),
|
||||
}
|
||||
let readyResolve!: () => void
|
||||
let readyReject!: (error: unknown) => void
|
||||
|
|
@ -443,6 +463,30 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
)
|
||||
}
|
||||
|
||||
const syncPending = () => {
|
||||
const prompts = [...state.pending.values()].toSorted((left, right) => left.admittedSeq - right.admittedSeq)
|
||||
input.trace?.write("ui.patch", { pending: prompts.length })
|
||||
input.footer.event({ type: "queued.prompts", prompts })
|
||||
}
|
||||
|
||||
const mergePending = (item: SessionPendingInfo) => {
|
||||
const prompt = pendingPrompt(item)
|
||||
if (!prompt || state.messageIDs.has(prompt.messageID)) return
|
||||
state.admitted.add(prompt.messageID)
|
||||
state.pending.set(prompt.messageID, prompt)
|
||||
syncPending()
|
||||
}
|
||||
|
||||
const promoteWait = (wait: Wait, observed: boolean, messageID = wait.messageID) => {
|
||||
const transition = messageID !== wait.failureMessageID || (observed ? !wait.promotionObserved : !wait.promoted)
|
||||
wait.promoted = true
|
||||
if (observed) wait.promotionObserved = true
|
||||
if (!transition) return
|
||||
wait.failureMessageID = messageID
|
||||
wait.failureRendered = false
|
||||
wait.terminalError = undefined
|
||||
}
|
||||
|
||||
const syncBlockers = () => {
|
||||
if (state.closed || controller.signal.aborted || input.footer.isClosed) return
|
||||
const descendant = subagents.snapshot()
|
||||
|
|
@ -528,15 +572,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => {
|
||||
if (message.type === "user") {
|
||||
const waiting = state.wait?.messageID === message.id
|
||||
if (waiting && state.wait) state.wait.promoted = true
|
||||
if (!render || state.messageIDs.has(message.id)) return
|
||||
const admitted = state.admitted.delete(message.id)
|
||||
if (state.wait && (admitted || (waiting && state.wait.failureMessageID === message.id)))
|
||||
promoteWait(state.wait, false, message.id)
|
||||
if (state.pending.delete(message.id)) syncPending()
|
||||
if (state.messageIDs.has(message.id)) return
|
||||
state.messageIDs.add(message.id)
|
||||
if (!render) return
|
||||
if (reuseVisibleWait && waiting) return
|
||||
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
|
||||
return
|
||||
}
|
||||
if (message.type === "skill") {
|
||||
if (state.wait?.messageID === message.id) state.wait.promoted = true
|
||||
if (state.wait?.messageID === message.id) promoteWait(state.wait, false)
|
||||
if (!render || state.skillMessages.has(message.id)) {
|
||||
state.skillMessages.add(message.id)
|
||||
return
|
||||
|
|
@ -616,8 +664,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
renderTool(message.id, item, render)
|
||||
}
|
||||
if (render && message.error && !state.errors.has(message.id)) {
|
||||
if (message.error && !state.errors.has(message.id)) {
|
||||
state.errors.add(message.id)
|
||||
if (!render) return
|
||||
if (state.wait) state.wait.failureRendered = true
|
||||
write([
|
||||
{
|
||||
kind: "error",
|
||||
|
|
@ -630,6 +680,22 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
}
|
||||
|
||||
const projectedMessages = async (client: OpenCodeClient, signal: AbortSignal) =>
|
||||
(
|
||||
await client.message.list(
|
||||
{ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" },
|
||||
{ signal },
|
||||
)
|
||||
).data.toReversed()
|
||||
|
||||
const settleSession = async (client: OpenCodeClient) => {
|
||||
await client.session.wait({ sessionID: input.sessionID }, { signal: controller.signal })
|
||||
for (const message of await projectedMessages(client, controller.signal)) renderMessage(message, true, true)
|
||||
state.rootActive = false
|
||||
write([], { phase: "idle", status: blockerStatus(state.view) })
|
||||
await input.footer.idle()
|
||||
}
|
||||
|
||||
const resolvePermissionSources = async (
|
||||
client: OpenCodeClient,
|
||||
permissions: PermissionV2Request[],
|
||||
|
|
@ -672,8 +738,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
) => {
|
||||
const client = attempt.client
|
||||
const options = { signal: attempt.signal }
|
||||
const [messages, permissions, forms, globals, active] = await Promise.all([
|
||||
client.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }, options),
|
||||
const [projected, pending, permissions, forms, globals, active] = await Promise.all([
|
||||
projectedMessages(client, attempt.signal),
|
||||
client.session.pending.list({ sessionID: input.sessionID }, options),
|
||||
client.permission.list({ sessionID: input.sessionID }, options),
|
||||
client.form.list({ sessionID: input.sessionID }, options),
|
||||
input.location
|
||||
|
|
@ -687,7 +754,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
client.session.active(options),
|
||||
])
|
||||
if (!current(attempt)) return
|
||||
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
|
||||
state.pending = new Map(pending.flatMap((item) => {
|
||||
const prompt = pendingPrompt(item)
|
||||
return prompt ? [[prompt.messageID, prompt] as const] : []
|
||||
}))
|
||||
syncPending()
|
||||
state.permissions = permissions
|
||||
pruneToolSources()
|
||||
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
|
||||
|
|
@ -714,11 +785,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
})
|
||||
if (!state.rootActive) await input.footer.idle()
|
||||
if (!current(attempt)) return
|
||||
if (!state.rootActive && state.wait && (state.wait.promoted || state.wait.interrupted)) {
|
||||
const current = state.wait
|
||||
state.wait = undefined
|
||||
current.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
const apply = (attempt: Attempt, event: RunV2Event) => {
|
||||
|
|
@ -756,9 +822,37 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
input.trace?.write("recv.event", event)
|
||||
subagents.main(client, event, attempt.signal)
|
||||
if (event.type === "session.input.admitted") {
|
||||
if (event.data.input.type !== "user") return
|
||||
mergePending({
|
||||
admittedSeq: event.durable.seq,
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
...event.data.input,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.promoted") {
|
||||
if (state.wait?.messageID === event.data.inputID) state.wait.promoted = true
|
||||
state.messageIDs.add(event.data.inputID)
|
||||
const waiting = state.wait?.messageID === event.data.inputID
|
||||
if (state.wait) promoteWait(state.wait, true, event.data.inputID)
|
||||
state.admitted.delete(event.data.inputID)
|
||||
const pending = state.pending.get(event.data.inputID)
|
||||
state.pending.delete(event.data.inputID)
|
||||
syncPending()
|
||||
const visible = state.messageIDs.has(event.data.inputID)
|
||||
if (waiting || pending) state.messageIDs.add(event.data.inputID)
|
||||
if (!waiting && pending && !visible) {
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inputID,
|
||||
},
|
||||
])
|
||||
}
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
}
|
||||
|
|
@ -768,7 +862,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
if (event.type === "session.skill.activated") {
|
||||
const messageID = messageIDFromEvent(event.id)
|
||||
if (state.wait?.messageID === messageID) state.wait.promoted = true
|
||||
if (state.wait?.messageID === messageID) promoteWait(state.wait, true)
|
||||
if (state.skillMessages.has(messageID)) return
|
||||
state.skillMessages.add(messageID)
|
||||
write([skillCommit(messageID, event.data.name)])
|
||||
|
|
@ -1047,25 +1141,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
state.rootActive = false
|
||||
write([], { phase: "idle", status: "" })
|
||||
const current = state.wait
|
||||
if (!current || (!current.promoted && !current.interrupted)) return
|
||||
state.wait = undefined
|
||||
if (!current) return
|
||||
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
|
||||
current.resolve()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (current.failureRendered) {
|
||||
current.resolve()
|
||||
return
|
||||
}
|
||||
current.reject(new Error(errorMessage(event.data.error)))
|
||||
if (!current.failureRendered) current.terminalError = new Error(errorMessage(event.data.error))
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
current.reject(new Error(`Session interrupted: ${event.data.reason}`))
|
||||
return
|
||||
current.terminalError = new Error(`Session interrupted: ${event.data.reason}`)
|
||||
}
|
||||
current.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1248,27 +1334,22 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
}
|
||||
|
||||
// Shared settlement scaffolding for prompt-shaped turns: registers the wait,
|
||||
// wires interruption, sends, then blocks until the live settled event (or a
|
||||
// hydration pass over an idle session) resolves it.
|
||||
// Prompt-shaped turns complete through the process-local idle fence. Live
|
||||
// lifecycle events remain presentation and best-effort outcome metadata.
|
||||
const runTurnWait = async (
|
||||
next: SessionTurnInput,
|
||||
messageID: string,
|
||||
turn: { promoted?: boolean; send: () => Promise<unknown> },
|
||||
client: OpenCodeClient,
|
||||
send: () => Promise<SessionPendingInfo | void>,
|
||||
onAdmitted?: () => void,
|
||||
) => {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: unknown) => void
|
||||
const done = new Promise<void>((ok, fail) => {
|
||||
resolve = ok
|
||||
reject = fail
|
||||
})
|
||||
const active: Wait = {
|
||||
messageID,
|
||||
promoted: turn.promoted === true,
|
||||
failureMessageID: messageID,
|
||||
promoted: false,
|
||||
promotionObserved: false,
|
||||
interrupted: false,
|
||||
failureRendered: false,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
state.wait = active
|
||||
const interrupt = () => {
|
||||
|
|
@ -1277,14 +1358,26 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
next.signal?.addEventListener("abort", interrupt, { once: true })
|
||||
try {
|
||||
await turn.send()
|
||||
await done
|
||||
const admitted = await send()
|
||||
if (admitted) mergePending(admitted)
|
||||
onAdmitted?.()
|
||||
await settleSession(client)
|
||||
if (active.terminalError && !active.failureRendered)
|
||||
write([
|
||||
{
|
||||
kind: "error",
|
||||
source: "system",
|
||||
text: active.terminalError.message,
|
||||
phase: "start",
|
||||
messageID: active.failureMessageID,
|
||||
},
|
||||
])
|
||||
} catch (error) {
|
||||
if (state.wait === active) state.wait = undefined
|
||||
if (next.signal?.aborted) return
|
||||
throw error
|
||||
} finally {
|
||||
next.signal?.removeEventListener("abort", interrupt)
|
||||
if (state.wait === active) state.wait = undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1362,6 +1455,46 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
let queuedResizeReplay: SessionResizeReplayInput | undefined
|
||||
let closing: Promise<void> | undefined
|
||||
|
||||
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: "steer" | "queue") => {
|
||||
const messageID = next.prompt.messageID
|
||||
if (!messageID) throw new Error("Prompt message ID is required")
|
||||
const command = next.prompt.command
|
||||
const attachments = await prepareAttachments(next, command ? "command" : "prompt", input.readTextFile)
|
||||
const agents = promptAgents(next)
|
||||
if (!command) {
|
||||
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID, delivery })
|
||||
return client.session.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
text: [next.prompt.text, ...attachments.text].join("\n\n"),
|
||||
files: attachments.files.length ? attachments.files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery,
|
||||
},
|
||||
{ signal: next.signal },
|
||||
)
|
||||
}
|
||||
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
|
||||
return client.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
command: command.name,
|
||||
arguments: command.arguments,
|
||||
agent: next.agent,
|
||||
model: selected,
|
||||
files: attachments.files.length ? attachments.files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery,
|
||||
},
|
||||
{ signal: next.signal },
|
||||
)
|
||||
}
|
||||
|
||||
const replayOnResize = (next: SessionResizeReplayInput) => {
|
||||
queuedResizeReplay = next
|
||||
if (resizeReplay) return resizeReplay
|
||||
|
|
@ -1388,7 +1521,20 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
|
||||
return {
|
||||
async runPromptTurn(next) {
|
||||
async queuePromptTurn(next) {
|
||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||
throw new Error("This prompt cannot be queued")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const client = sdk
|
||||
mergePending(await admitPrompt(next, client, "queue"))
|
||||
settlementClient = client
|
||||
},
|
||||
async waitForIdle() {
|
||||
const client = settlementClient ?? sdk
|
||||
await settleSession(client)
|
||||
if (settlementClient === client) settlementClient = undefined
|
||||
},
|
||||
async runPromptTurn(next, admitted) {
|
||||
if (next.prompt.mode === "shell") {
|
||||
await runShellTurn(next)
|
||||
return
|
||||
|
|
@ -1402,40 +1548,21 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
const command = next.prompt.command
|
||||
if (command?.source === "skill") {
|
||||
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
await runTurnWait(
|
||||
next,
|
||||
messageID,
|
||||
client,
|
||||
() =>
|
||||
client.session.skill(
|
||||
{ sessionID: input.sessionID, id: messageID, skill: command.name },
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
admitted,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
// Agent and model ride the command payload; the server switches only
|
||||
// when the command itself does not pin them.
|
||||
const attachments = await prepareAttachments(next, "command")
|
||||
const agents = promptAgents(next)
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
client.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
command: command.name,
|
||||
arguments: command.arguments,
|
||||
agent: next.agent,
|
||||
model: selected,
|
||||
files: attachments.files.length ? attachments.files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1447,23 +1574,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
|
||||
const attachments = await prepareAttachments(next, "prompt", input.readTextFile)
|
||||
const agents = promptAgents(next)
|
||||
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
client.session.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
text: [next.prompt.text, ...attachments.text].join("\n\n"),
|
||||
files: attachments.files.length ? attachments.files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
},
|
||||
async interruptActiveTurn() {
|
||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export type RunPrompt = {
|
|||
export type FooterQueuedPrompt = {
|
||||
messageID: string
|
||||
prompt: RunPrompt
|
||||
delivery: "steer" | "queue"
|
||||
admittedSeq: number
|
||||
}
|
||||
|
||||
export type RunAgent = {
|
||||
|
|
@ -161,7 +163,6 @@ export type FooterPhase = "idle" | "running"
|
|||
export type FooterState = {
|
||||
phase: FooterPhase
|
||||
status: string
|
||||
queue: number
|
||||
model: string
|
||||
usage: string
|
||||
first: boolean
|
||||
|
|
@ -338,10 +339,6 @@ export type FooterEvent =
|
|||
variants: string[]
|
||||
current: string | undefined
|
||||
}
|
||||
| {
|
||||
type: "queue"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "queued.prompts"
|
||||
prompts: FooterQueuedPrompt[]
|
||||
|
|
@ -355,14 +352,8 @@ export type FooterEvent =
|
|||
model: string
|
||||
selection: NonNullable<RunInput["model"]>
|
||||
}
|
||||
| {
|
||||
type: "turn.send"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "turn.idle"
|
||||
queue: number
|
||||
}
|
||||
| { type: "turn.send" }
|
||||
| { type: "turn.idle" }
|
||||
| {
|
||||
type: "turn.duration"
|
||||
duration: string
|
||||
|
|
@ -438,7 +429,6 @@ export type LocalReplayRow = {
|
|||
export type FooterApi = {
|
||||
readonly isClosed: boolean
|
||||
onPrompt(fn: (input: RunPrompt) => void): () => void
|
||||
onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void
|
||||
onClose(fn: () => void): () => void
|
||||
event(next: FooterEvent): void
|
||||
append(commit: StreamCommit): void
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue