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
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
LocationRef,
|
LocationRef,
|
||||||
OpenCodeClient,
|
OpenCodeClient,
|
||||||
SessionMessageAssistantTool,
|
SessionMessageAssistantTool,
|
||||||
|
SessionMessageInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
|
|
@ -75,6 +76,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
const messageID = SessionMessage.ID.create()
|
const messageID = SessionMessage.ID.create()
|
||||||
const starts = new Map<string, StartedPart>()
|
const starts = new Map<string, StartedPart>()
|
||||||
const tools = new Map<string, ToolState>()
|
const tools = new Map<string, ToolState>()
|
||||||
|
const renderedText = new Map<string, string>()
|
||||||
|
const renderedReasoning = new Map<string, string>()
|
||||||
|
const renderedTools = new Set<string>()
|
||||||
let submitted = false
|
let submitted = false
|
||||||
let promoted = false
|
let promoted = false
|
||||||
let emittedError = false
|
let emittedError = false
|
||||||
|
|
@ -82,6 +86,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
let formCancelled = false
|
let formCancelled = false
|
||||||
let interrupted = false
|
let interrupted = false
|
||||||
let v1InvalidOutput = false
|
let v1InvalidOutput = false
|
||||||
|
let prePromotionError: { message: string; [key: string]: unknown } | undefined
|
||||||
|
let finalizing = false
|
||||||
let admission: AbortController | undefined
|
let admission: AbortController | undefined
|
||||||
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
|
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
|
||||||
|
|
||||||
|
|
@ -104,6 +110,17 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const writeReasoning = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
|
||||||
|
if (emit("reasoning", timestamp, { part })) return
|
||||||
|
const text = part.text.trim()
|
||||||
|
if (!text) return
|
||||||
|
const line = `Thinking: ${text}`
|
||||||
|
if (!process.stdout.isTTY) return void process.stdout.write(line + EOL)
|
||||||
|
UI.empty()
|
||||||
|
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
||||||
|
UI.empty()
|
||||||
|
}
|
||||||
|
|
||||||
const flushStep = () => {
|
const flushStep = () => {
|
||||||
if (!pendingStep) return
|
if (!pendingStep) return
|
||||||
const value = pendingStep
|
const value = pendingStep
|
||||||
|
|
@ -181,6 +198,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
if (event.type === "session.input.promoted") {
|
if (event.type === "session.input.promoted") {
|
||||||
if (event.data.inputID === messageID) {
|
if (event.data.inputID === messageID) {
|
||||||
promoted = true
|
promoted = true
|
||||||
|
prePromotionError = undefined
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +209,12 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!promoted && event.type === "session.execution.failed") {
|
||||||
|
prePromotionError = event.data.error
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (!promoted) continue
|
if (!promoted) continue
|
||||||
|
if (finalizing) continue
|
||||||
|
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
const part = {
|
const part = {
|
||||||
|
|
@ -219,12 +242,16 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
|
|
||||||
if (event.type === "session.text.started") {
|
if (event.type === "session.text.started") {
|
||||||
flushStep()
|
flushStep()
|
||||||
starts.set("text", { id: partID(event.id), timestamp: time })
|
starts.set(`text\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
|
||||||
|
id: partID(event.id),
|
||||||
|
timestamp: time,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.ended") {
|
if (event.type === "session.text.ended") {
|
||||||
const started = starts.get("text")
|
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
|
||||||
starts.delete("text")
|
const started = starts.get(`text\u0000${key}`)
|
||||||
|
starts.delete(`text\u0000${key}`)
|
||||||
const part = {
|
const part = {
|
||||||
id: started?.id ?? partID(event.id),
|
id: started?.id ?? partID(event.id),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -233,18 +260,23 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
time: { start: started?.timestamp ?? time, end: time },
|
time: { start: started?.timestamp ?? time, end: time },
|
||||||
}
|
}
|
||||||
|
renderedText.set(key, event.data.text)
|
||||||
writeText(part, time)
|
writeText(part, time)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.reasoning.started") {
|
if (event.type === "session.reasoning.started") {
|
||||||
flushStep()
|
flushStep()
|
||||||
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
starts.set(`reasoning\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
|
||||||
|
id: partID(event.id),
|
||||||
|
timestamp: time,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.ended" && input.thinking) {
|
if (event.type === "session.reasoning.ended" && input.thinking) {
|
||||||
const started = starts.get("reasoning")
|
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
|
||||||
starts.delete("reasoning")
|
const started = starts.get(`reasoning\u0000${key}`)
|
||||||
|
starts.delete(`reasoning\u0000${key}`)
|
||||||
const part = {
|
const part = {
|
||||||
id: started?.id ?? partID(event.id),
|
id: started?.id ?? partID(event.id),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -254,17 +286,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
metadata: event.data.state,
|
metadata: event.data.state,
|
||||||
time: { start: started?.timestamp ?? time, end: time },
|
time: { start: started?.timestamp ?? time, end: time },
|
||||||
}
|
}
|
||||||
if (emit("reasoning", time, { part })) continue
|
renderedReasoning.set(key, event.data.text)
|
||||||
const text = part.text.trim()
|
writeReasoning(part, time)
|
||||||
if (!text) continue
|
|
||||||
const line = `Thinking: ${text}`
|
|
||||||
if (!process.stdout.isTTY) {
|
|
||||||
process.stdout.write(line + EOL)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
UI.empty()
|
|
||||||
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
|
||||||
UI.empty()
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,6 +383,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
tools.delete(key)
|
tools.delete(key)
|
||||||
|
renderedTools.add(key)
|
||||||
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
|
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -405,6 +429,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
tools.delete(key)
|
tools.delete(key)
|
||||||
|
renderedTools.add(key)
|
||||||
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
||||||
if (!emit("tool_use", time, { part })) {
|
if (!emit("tool_use", time, { part })) {
|
||||||
if (toolOutputText(current.tool, current.content).trim())
|
if (toolOutputText(current.tool, current.content).trim())
|
||||||
|
|
@ -480,6 +505,122 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectedMessages = async () => {
|
||||||
|
const messages: SessionMessageInfo[] = []
|
||||||
|
let cursor: string | undefined
|
||||||
|
while (true) {
|
||||||
|
const page = await input.client.message.list(
|
||||||
|
cursor
|
||||||
|
? { sessionID: input.sessionID, limit: 200, cursor }
|
||||||
|
: { sessionID: input.sessionID, limit: 200, order: "desc" },
|
||||||
|
)
|
||||||
|
for (const message of page.data) {
|
||||||
|
if (message.id === messageID) return { found: true, messages: messages.toReversed() }
|
||||||
|
messages.push(message)
|
||||||
|
}
|
||||||
|
cursor = page.cursor.next ?? undefined
|
||||||
|
if (!cursor) return { found: false, messages: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconcile = async () => {
|
||||||
|
const projected = await projectedMessages()
|
||||||
|
for (const message of projected.messages) {
|
||||||
|
if (message.type !== "assistant") continue
|
||||||
|
const timestamp = message.time.completed ?? message.time.created
|
||||||
|
let textOrdinal = 0
|
||||||
|
let reasoningOrdinal = 0
|
||||||
|
for (const item of message.content) {
|
||||||
|
if (item.type === "text") {
|
||||||
|
const ordinal = textOrdinal++
|
||||||
|
const key = contentKey(message.id, ordinal)
|
||||||
|
const rendered = renderedText.get(key) ?? ""
|
||||||
|
if (rendered === item.text || !item.text.startsWith(rendered)) continue
|
||||||
|
const text = item.text.slice(rendered.length)
|
||||||
|
writeText(
|
||||||
|
{
|
||||||
|
id: projectedPartID(message.id, `text-${ordinal}`),
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: message.id,
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
time: { start: message.time.created, end: timestamp },
|
||||||
|
},
|
||||||
|
timestamp,
|
||||||
|
)
|
||||||
|
renderedText.set(key, item.text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item.type === "reasoning") {
|
||||||
|
const ordinal = reasoningOrdinal++
|
||||||
|
if (!input.thinking) continue
|
||||||
|
const key = contentKey(message.id, ordinal)
|
||||||
|
const rendered = renderedReasoning.get(key) ?? ""
|
||||||
|
if (rendered === item.text || !item.text.startsWith(rendered)) continue
|
||||||
|
const text = item.text.slice(rendered.length)
|
||||||
|
const part = {
|
||||||
|
id: projectedPartID(message.id, `reasoning-${ordinal}`),
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: message.id,
|
||||||
|
type: "reasoning",
|
||||||
|
text,
|
||||||
|
metadata: item.state,
|
||||||
|
time: { start: message.time.created, end: timestamp },
|
||||||
|
}
|
||||||
|
renderedReasoning.set(key, item.text)
|
||||||
|
writeReasoning(part, timestamp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = toolKey(message.id, item.id)
|
||||||
|
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
|
||||||
|
const part: MiniToolPart = {
|
||||||
|
id: projectedPartID(message.id, `tool-${item.id}`),
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: message.id,
|
||||||
|
type: "tool",
|
||||||
|
callID: item.id,
|
||||||
|
tool: item.name,
|
||||||
|
state:
|
||||||
|
item.state.status === "completed"
|
||||||
|
? {
|
||||||
|
status: "completed",
|
||||||
|
input: item.state.input,
|
||||||
|
output: toolOutputText(item.name, item.state.content),
|
||||||
|
title: item.name,
|
||||||
|
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
|
||||||
|
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: "error",
|
||||||
|
input: item.state.input,
|
||||||
|
error: item.state.error.message,
|
||||||
|
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
|
||||||
|
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
renderedTools.add(key)
|
||||||
|
if (emit("tool_use", timestamp, { part })) continue
|
||||||
|
if (item.state.status === "completed") {
|
||||||
|
await input.renderTool(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (toolOutputText(item.name, item.state.content).trim()) {
|
||||||
|
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
|
||||||
|
}
|
||||||
|
await input.renderToolError(item)
|
||||||
|
UI.error(item.state.error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.error && !emittedError) {
|
||||||
|
emittedError = true
|
||||||
|
process.exitCode = 1
|
||||||
|
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return projected.found
|
||||||
|
}
|
||||||
|
|
||||||
const interrupt = () => {
|
const interrupt = () => {
|
||||||
if (interrupted) process.exit(130)
|
if (interrupted) process.exit(130)
|
||||||
interrupted = true
|
interrupted = true
|
||||||
|
|
@ -559,11 +700,27 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
|
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
|
||||||
: []),
|
: []),
|
||||||
])
|
])
|
||||||
await completed
|
if (input.compatibility === "v1") {
|
||||||
|
await completed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiting = input.client.session.wait({ sessionID: input.sessionID })
|
||||||
|
await Promise.race([waiting, completed.then(() => waiting)])
|
||||||
|
finalizing = true
|
||||||
|
controller.abort()
|
||||||
|
const found = await reconcile()
|
||||||
|
if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
|
||||||
|
const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
|
||||||
|
emittedError = true
|
||||||
|
process.exitCode = 1
|
||||||
|
if (!emit("error", Date.now(), { error })) UI.error(error.message)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
process.off("SIGINT", interrupt)
|
process.off("SIGINT", interrupt)
|
||||||
controller.abort()
|
controller.abort()
|
||||||
await stream.return?.(undefined).catch(() => {})
|
if (input.compatibility === "v1") await stream.return?.(undefined).catch(() => {})
|
||||||
|
else void stream.return?.(undefined).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -595,6 +752,14 @@ function toolKey(messageID: string, callID: string) {
|
||||||
return `${messageID}\u0000${callID}`
|
return `${messageID}\u0000${callID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contentKey(messageID: string, ordinal: number) {
|
||||||
|
return `${messageID}\u0000${ordinal}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedPartID(messageID: string, part: string) {
|
||||||
|
return `prt_${messageID.replace(/^msg_/, "")}_${part}`
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackTool(event: {
|
function fallbackTool(event: {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
import {
|
||||||
|
OpenCode,
|
||||||
|
type EventSubscribeOutput,
|
||||||
|
type SessionMessageAssistantTool,
|
||||||
|
type SessionMessageInfo,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
||||||
|
|
||||||
type V2Event = EventSubscribeOutput
|
type V2Event = EventSubscribeOutput
|
||||||
|
|
@ -162,10 +167,13 @@ async function run(input: {
|
||||||
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
|
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
|
||||||
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
|
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||||
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||||
|
messages?: (inputID: string) => SessionMessageInfo[]
|
||||||
|
wait?: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
|
const wait = Promise.withResolvers<void>()
|
||||||
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
|
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
|
||||||
while (true) {
|
while (true) {
|
||||||
const value = values.shift()
|
const value = values.shift()
|
||||||
|
|
@ -175,6 +183,7 @@ async function run(input: {
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
|
||||||
yield value
|
yield value
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
@ -193,8 +202,19 @@ async function run(input: {
|
||||||
}) as never,
|
}) as never,
|
||||||
)
|
)
|
||||||
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
|
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
|
||||||
|
let promptID = "msg_prompt"
|
||||||
|
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
||||||
|
spyOn(sdk.message, "list").mockImplementation(() =>
|
||||||
|
ok({
|
||||||
|
data: input.messages?.(promptID) ?? [
|
||||||
|
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
|
||||||
|
],
|
||||||
|
cursor: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
||||||
const messageID = request.id ?? "msg_prompt"
|
const messageID = request.id ?? "msg_prompt"
|
||||||
|
promptID = messageID
|
||||||
values.push(...input.turn(messageID))
|
values.push(...input.turn(messageID))
|
||||||
wake?.()
|
wake?.()
|
||||||
wake = undefined
|
wake = undefined
|
||||||
|
|
@ -244,6 +264,63 @@ afterEach(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("runNonInteractivePrompt", () => {
|
describe("runNonInteractivePrompt", () => {
|
||||||
|
test("uses session.wait then reconciles projected output without a terminal event", async () => {
|
||||||
|
const idle = Promise.withResolvers<void>()
|
||||||
|
let done = false
|
||||||
|
const task = capture({
|
||||||
|
format: "json",
|
||||||
|
turn: (messageID) => [prompted(messageID)],
|
||||||
|
wait: () => idle.promise,
|
||||||
|
messages: (messageID) => [
|
||||||
|
{
|
||||||
|
id: "msg_assistant",
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "test", id: "test-model" },
|
||||||
|
content: [{ type: "text", text: "projected answer" }],
|
||||||
|
finish: "stop",
|
||||||
|
time: { created: 2, completed: 3 },
|
||||||
|
},
|
||||||
|
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
|
||||||
|
],
|
||||||
|
}).then((output) => {
|
||||||
|
done = true
|
||||||
|
return output
|
||||||
|
})
|
||||||
|
|
||||||
|
await Bun.sleep(0)
|
||||||
|
await Bun.sleep(0)
|
||||||
|
expect(done).toBe(false)
|
||||||
|
idle.resolve()
|
||||||
|
const output = await task
|
||||||
|
expect(
|
||||||
|
output.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line)),
|
||||||
|
).toEqual([expect.objectContaining({ type: "text", part: expect.objectContaining({ text: "projected answer" }) })])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports an observed execution failure before prompt promotion", async () => {
|
||||||
|
const output = await capture({
|
||||||
|
format: "json",
|
||||||
|
turn: () => [executionFailed("instructions unavailable")],
|
||||||
|
messages: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
output.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line)),
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "error",
|
||||||
|
error: { type: "provider.transport", message: "instructions unavailable" },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
|
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
|
||||||
const sdk = await run({
|
const sdk = await run({
|
||||||
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
|
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
|
||||||
|
|
@ -307,6 +384,9 @@ describe("runNonInteractivePrompt", () => {
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
expect(output.stderr).toBe("")
|
expect(output.stderr).toBe("")
|
||||||
|
const sdk = await run({ compatibility: "v1", turn: (messageID) => [prompted(messageID), settled()] })
|
||||||
|
expect(sdk.session.wait).not.toHaveBeenCalled()
|
||||||
|
expect(sdk.message.list).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
|
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ export const Definitions = {
|
||||||
session_interrupt: keybind("escape", "Interrupt current session"),
|
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||||
session_compact: keybind("<leader>c", "Compact the session"),
|
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_first: keybind("down,<leader>down", "Toggle subagent picker"),
|
||||||
session_child_cycle: keybind("right", "Go to next child session"),
|
session_child_cycle: keybind("right", "Go to next child session"),
|
||||||
session_child_cycle_reverse: keybind("left", "Go to previous 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,
|
action: "queued" as const,
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
display: "Manage queued prompts",
|
display: "View pending work",
|
||||||
footer: `${props.queued().length} queued`,
|
footer: `${props.queued().length} pending`,
|
||||||
keywords: props
|
keywords: props
|
||||||
.queued()
|
.queued()
|
||||||
.map((item) => item.prompt.text)
|
.map((item) => item.prompt.text)
|
||||||
|
|
@ -673,15 +673,13 @@ export function RunQueuedPromptSelectBody(props: {
|
||||||
theme: Accessor<RunFooterTheme>
|
theme: Accessor<RunFooterTheme>
|
||||||
prompts: Accessor<FooterQueuedPrompt[]>
|
prompts: Accessor<FooterQueuedPrompt[]>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onEdit: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
|
||||||
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
|
||||||
onRows?: (rows: number) => void
|
onRows?: (rows: number) => void
|
||||||
}) {
|
}) {
|
||||||
const entries = createMemo<QueuedEntry[]>(() =>
|
const entries = createMemo<QueuedEntry[]>(() =>
|
||||||
props.prompts().map((prompt) => ({
|
props.prompts().map((prompt) => ({
|
||||||
category: "",
|
category: "",
|
||||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||||
footer: "queued · ctrl+e edit · ctrl+d remove",
|
footer: prompt.delivery,
|
||||||
keywords: prompt.prompt.text,
|
keywords: prompt.prompt.text,
|
||||||
prompt,
|
prompt,
|
||||||
})),
|
})),
|
||||||
|
|
@ -690,29 +688,13 @@ export function RunQueuedPromptSelectBody(props: {
|
||||||
entries,
|
entries,
|
||||||
limit: SUBAGENT_LIST_ROWS,
|
limit: SUBAGENT_LIST_ROWS,
|
||||||
onClose: props.onClose,
|
onClose: props.onClose,
|
||||||
onSelect: (item) => props.onEdit(item.prompt),
|
onSelect: props.onClose,
|
||||||
onRows: props.onRows,
|
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 (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Queued prompts"
|
title="Pending work"
|
||||||
query={controller.query()}
|
query={controller.query()}
|
||||||
count={controller.items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
|
|
@ -730,7 +712,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||||
offset={controller.menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={controller.menu.rows}
|
rows={controller.menu.rows}
|
||||||
limit={SUBAGENT_LIST_ROWS}
|
limit={SUBAGENT_LIST_ROWS}
|
||||||
empty="No queued prompts"
|
empty="No pending work"
|
||||||
border={false}
|
border={false}
|
||||||
paddingLeft={PANEL_PAD}
|
paddingLeft={PANEL_PAD}
|
||||||
paddingRight={PANEL_PAD}
|
paddingRight={PANEL_PAD}
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,6 @@ function createEmptySubagentState(): FooterSubagentState {
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
||||||
if (next.type === "queue") {
|
|
||||||
return { queue: next.queue }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next.type === "first") {
|
if (next.type === "first") {
|
||||||
return { first: next.first }
|
return { first: next.first }
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +127,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
||||||
return {
|
return {
|
||||||
phase: "running",
|
phase: "running",
|
||||||
status: "sending prompt",
|
status: "sending prompt",
|
||||||
queue: next.queue,
|
|
||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
exit: 0,
|
exit: 0,
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +136,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
||||||
return {
|
return {
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
status: "",
|
status: "",
|
||||||
queue: next.queue,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,7 +150,6 @@ export class RunFooter implements FooterApi {
|
||||||
private closed = false
|
private closed = false
|
||||||
private destroyed = false
|
private destroyed = false
|
||||||
private prompts = new Set<(input: RunPrompt) => void>()
|
private prompts = new Set<(input: RunPrompt) => void>()
|
||||||
private queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
|
|
||||||
private closes = new Set<() => void>()
|
private closes = new Set<() => void>()
|
||||||
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
|
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
|
||||||
private queue: StreamCommit[] = []
|
private queue: StreamCommit[] = []
|
||||||
|
|
@ -226,7 +219,6 @@ export class RunFooter implements FooterApi {
|
||||||
const [state, setState] = createSignal<FooterState>({
|
const [state, setState] = createSignal<FooterState>({
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
status: "",
|
status: "",
|
||||||
queue: 0,
|
|
||||||
model: options.modelLabel,
|
model: options.modelLabel,
|
||||||
usage: "",
|
usage: "",
|
||||||
first: options.first,
|
first: options.first,
|
||||||
|
|
@ -328,7 +320,6 @@ export class RunFooter implements FooterApi {
|
||||||
onStatus: footer.setStatus,
|
onStatus: footer.setStatus,
|
||||||
onSubagentSelect: options.onSubagentSelect,
|
onSubagentSelect: options.onSubagentSelect,
|
||||||
onSubagentInterrupt: options.onSubagentInterrupt,
|
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 {
|
public onClose(fn: () => void): () => void {
|
||||||
if (this.isClosed) {
|
if (this.isClosed) {
|
||||||
fn()
|
fn()
|
||||||
|
|
@ -487,7 +471,6 @@ export class RunFooter implements FooterApi {
|
||||||
const state = {
|
const state = {
|
||||||
phase: next.phase ?? prev.phase,
|
phase: next.phase ?? prev.phase,
|
||||||
status: typeof next.status === "string" ? next.status : prev.status,
|
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,
|
model: typeof next.model === "string" ? next.model : prev.model,
|
||||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||||
|
|
@ -665,11 +648,6 @@ export class RunFooter implements FooterApi {
|
||||||
this.requestExitHandler = fn
|
this.requestExitHandler = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleQueuedRemove = async (messageID: string): Promise<boolean> => {
|
|
||||||
const fn = [...this.queuedRemoves][0]
|
|
||||||
return fn ? await fn(messageID) : false
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleInputClear = (): void => {
|
private handleInputClear = (): void => {
|
||||||
this.clearInterruptTimer()
|
this.clearInterruptTimer()
|
||||||
this.clearExitTimer()
|
this.clearExitTimer()
|
||||||
|
|
@ -1080,7 +1058,6 @@ export class RunFooter implements FooterApi {
|
||||||
for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
|
for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
|
||||||
this.themeRefreshTimeouts.length = 0
|
this.themeRefreshTimeouts.length = 0
|
||||||
this.prompts.clear()
|
this.prompts.clear()
|
||||||
this.queuedRemoves.clear()
|
|
||||||
this.closes.clear()
|
this.closes.clear()
|
||||||
this.scrollback.destroy()
|
this.scrollback.destroy()
|
||||||
for (const theme of [...this.themes]) this.destroyTheme(theme)
|
for (const theme of [...this.themes]) this.destroyTheme(theme)
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,6 @@ type RunFooterViewProps = {
|
||||||
onStatus: (text: string) => void
|
onStatus: (text: string) => void
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
onQueuedRemove: (messageID: string) => Promise<boolean>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunFooterView(props: RunFooterViewProps) {
|
export function RunFooterView(props: RunFooterViewProps) {
|
||||||
|
|
@ -179,7 +178,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
const busy = createMemo(() => props.state().phase === "running")
|
const busy = createMemo(() => props.state().phase === "running")
|
||||||
const armed = createMemo(() => props.state().interrupt > 0)
|
const armed = createMemo(() => props.state().interrupt > 0)
|
||||||
const exiting = createMemo(() => props.state().exit > 0)
|
const exiting = createMemo(() => props.state().exit > 0)
|
||||||
const queue = createMemo(() => props.state().queue)
|
|
||||||
const usage = createMemo(() => props.state().usage)
|
const usage = createMemo(() => props.state().usage)
|
||||||
const interruptLabel = createMemo(() => {
|
const interruptLabel = createMemo(() => {
|
||||||
if (!interrupt()) {
|
if (!interrupt()) {
|
||||||
|
|
@ -414,7 +412,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
|
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
|
||||||
}
|
}
|
||||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
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()) {
|
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||||
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
|
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
|
||||||
|
|
@ -495,7 +493,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "session.queued_prompts",
|
id: "session.queued_prompts",
|
||||||
title: "Manage queued prompts",
|
title: "View pending work",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
run: openQueuedMenu,
|
run: openQueuedMenu,
|
||||||
},
|
},
|
||||||
|
|
@ -656,12 +654,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
theme={theme}
|
theme={theme}
|
||||||
prompts={queuedPrompts}
|
prompts={queuedPrompts}
|
||||||
onClose={closePanel}
|
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}
|
onRows={setSubagentMenuRows}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// Serial prompt queue for direct interactive mode.
|
// Serial prompt queue for direct interactive mode.
|
||||||
//
|
//
|
||||||
// Prompts arrive from the footer (user types and hits enter) and queue up
|
// Prompts arrive from the footer (user types and hits enter) and local
|
||||||
// here. The queue drains one turn at a time; ordinary prompts waiting behind
|
// operations drain one at a time. Ordinary prompts submitted during an active
|
||||||
// an active ordinary turn are exposed for edit/removal until they begin.
|
// ordinary turn are admitted immediately to the server's durable queue.
|
||||||
//
|
//
|
||||||
// The queue also handles /exit, /quit, and /new commands, empty-prompt rejection,
|
// The queue also handles /exit, /quit, and /new commands, empty-prompt rejection,
|
||||||
// and tracks per-turn wall-clock duration for the footer status line.
|
// 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 { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { isExitCommand, isNewCommand } from "./prompt.shared"
|
import { isExitCommand, isNewCommand } from "./prompt.shared"
|
||||||
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
|
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||||
|
|
||||||
type Trace = {
|
type Trace = {
|
||||||
write(type: string, data?: unknown): void
|
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 = {
|
export type QueueInput = {
|
||||||
footer: FooterApi
|
footer: FooterApi
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
trace?: Trace
|
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>
|
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 = {
|
type State = {
|
||||||
queue: RunPrompt[]
|
queue: RunPrompt[]
|
||||||
queued: FooterQueuedPrompt[]
|
|
||||||
active?: RunPrompt
|
active?: RunPrompt
|
||||||
|
admission?: Promise<void>
|
||||||
ctrl?: AbortController
|
ctrl?: AbortController
|
||||||
closed: boolean
|
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.
|
// Runs the prompt queue until the footer closes.
|
||||||
//
|
//
|
||||||
// Subscribes to footer prompt events and drains operations through input.run().
|
// Subscribes to footer prompt events and drains operations through input.run().
|
||||||
// Ordinary prompts submitted during an ordinary active turn remain local and
|
// Ordinary prompts submitted during an ordinary active turn are admitted as
|
||||||
// are exposed by the footer for edit/removal until their turn begins.
|
// durable queued work instead of remaining editable process-local state.
|
||||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
const stop = defer<{ type: "closed" }>()
|
const stop = Promise.withResolvers<{ type: "closed" }>()
|
||||||
const done = defer()
|
const done = Promise.withResolvers<void>()
|
||||||
const state: State = {
|
const state: State = {
|
||||||
queue: [],
|
queue: [],
|
||||||
queued: [],
|
|
||||||
closed: input.footer.isClosed,
|
closed: input.footer.isClosed,
|
||||||
}
|
}
|
||||||
let draining: Promise<void> | undefined
|
let draining: Promise<void> | undefined
|
||||||
|
let admissions = Promise.resolve()
|
||||||
|
let admissionVersion = 0
|
||||||
|
const admissionController = new AbortController()
|
||||||
|
|
||||||
const emit = (next: FooterEvent, row: Record<string, unknown>) => {
|
const emit = (next: FooterEvent, row: Record<string, unknown>) => {
|
||||||
input.trace?.write("ui.patch", row)
|
input.trace?.write("ui.patch", row)
|
||||||
input.footer.event(next)
|
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 = () => {
|
const finish = () => {
|
||||||
if (!state.closed || draining) {
|
if (!state.closed || draining) {
|
||||||
return
|
return
|
||||||
|
|
@ -104,8 +74,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
|
|
||||||
state.closed = true
|
state.closed = true
|
||||||
state.queue.length = 0
|
state.queue.length = 0
|
||||||
state.queued.length = 0
|
|
||||||
state.ctrl?.abort()
|
state.ctrl?.abort()
|
||||||
|
admissionController.abort()
|
||||||
stop.resolve({ type: "closed" })
|
stop.resolve({ type: "closed" })
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
@ -123,11 +93,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const queued = state.queued.find((item) => item.prompt === prompt)
|
|
||||||
if (queued) removeLocalQueued(queued)
|
|
||||||
|
|
||||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||||
syncQueue()
|
|
||||||
if (!input.onNewSession) {
|
if (!input.onNewSession) {
|
||||||
emit(
|
emit(
|
||||||
{
|
{
|
||||||
|
|
@ -149,13 +115,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
patch: {
|
patch: {
|
||||||
phase: "running",
|
phase: "running",
|
||||||
status: "starting new session",
|
status: "starting new session",
|
||||||
queue: state.queue.length,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phase: "running",
|
phase: "running",
|
||||||
status: "starting new session",
|
status: "starting new session",
|
||||||
queue: state.queue.length,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await input.onNewSession()
|
await input.onNewSession()
|
||||||
|
|
@ -167,24 +131,23 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
? prompt
|
? prompt
|
||||||
: {
|
: {
|
||||||
...prompt,
|
...prompt,
|
||||||
messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(),
|
messageID: prompt.messageID ?? SessionMessage.ID.create(),
|
||||||
}
|
}
|
||||||
state.active = sent
|
state.active = sent
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
{
|
{ type: "turn.send" },
|
||||||
type: "turn.send",
|
|
||||||
queue: state.queue.length,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
phase: "running",
|
phase: "running",
|
||||||
status: "sending prompt",
|
status: "sending prompt",
|
||||||
queue: state.queue.length,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
|
const admission = Promise.withResolvers<void>()
|
||||||
|
const version = admissionVersion
|
||||||
state.ctrl = ctrl
|
state.ctrl = ctrl
|
||||||
|
state.admission = admission.promise
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await input.footer.idle()
|
await input.footer.idle()
|
||||||
|
|
@ -203,13 +166,13 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
input.trace?.write("ui.commit", commit)
|
input.trace?.write("ui.commit", commit)
|
||||||
input.footer.append(commit)
|
input.footer.append(commit)
|
||||||
}
|
}
|
||||||
input.onSend?.(sent)
|
input.onSend?.(sent, "steer")
|
||||||
|
|
||||||
if (state.closed) {
|
if (state.closed) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = input.run(sent, ctrl.signal).then(
|
const task = input.run(sent, ctrl.signal, admission.resolve).then(
|
||||||
() => ({ type: "done" as const }),
|
() => ({ type: "done" as const }),
|
||||||
(error) => ({ type: "error" as const, error }),
|
(error) => ({ type: "error" as const, error }),
|
||||||
)
|
)
|
||||||
|
|
@ -223,10 +186,21 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
if (next.type === "error") {
|
if (next.type === "error") {
|
||||||
throw next.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 {
|
} finally {
|
||||||
|
admission.resolve()
|
||||||
if (state.ctrl === ctrl) {
|
if (state.ctrl === ctrl) {
|
||||||
state.ctrl = undefined
|
state.ctrl = undefined
|
||||||
}
|
}
|
||||||
|
if (state.admission === admission.promise) state.admission = undefined
|
||||||
|
|
||||||
if (sent.mode !== "shell") {
|
if (sent.mode !== "shell") {
|
||||||
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
||||||
|
|
@ -249,14 +223,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
} finally {
|
} finally {
|
||||||
draining = undefined
|
draining = undefined
|
||||||
emit(
|
emit(
|
||||||
{
|
{ type: "turn.idle" },
|
||||||
type: "turn.idle",
|
|
||||||
queue: state.queue.length,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
status: "",
|
status: "",
|
||||||
queue: state.queue.length,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -279,23 +249,22 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
if (
|
if (
|
||||||
active &&
|
active &&
|
||||||
active.mode !== "shell" &&
|
active.mode !== "shell" &&
|
||||||
!active.command &&
|
|
||||||
prompt.mode !== "shell" &&
|
prompt.mode !== "shell" &&
|
||||||
!prompt.command &&
|
prompt.command?.source !== "skill" &&
|
||||||
!isNewCommand(prompt.text)
|
!isNewCommand(prompt.text)
|
||||||
) {
|
) {
|
||||||
const queued: FooterQueuedPrompt = {
|
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||||
messageID: SessionMessage.ID.create(),
|
const admission = state.admission
|
||||||
prompt,
|
admissionVersion += 1
|
||||||
}
|
input.onSend?.(sent, "queue")
|
||||||
state.queued = [...state.queued, queued]
|
admissions = admissions
|
||||||
state.queue.push(prompt)
|
.then(() => admission)
|
||||||
syncQueue()
|
.then(() => input.admit(sent, admissionController.signal))
|
||||||
|
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.queue.push(prompt)
|
state.queue.push(prompt)
|
||||||
syncQueue()
|
|
||||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||||
drain()
|
drain()
|
||||||
return
|
return
|
||||||
|
|
@ -319,14 +288,6 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
const offClose = input.footer.onClose(() => {
|
const offClose = input.footer.onClose(() => {
|
||||||
close()
|
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 {
|
try {
|
||||||
if (state.closed) {
|
if (state.closed) {
|
||||||
return
|
return
|
||||||
|
|
@ -341,8 +302,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||||
} finally {
|
} finally {
|
||||||
offPrompt()
|
offPrompt()
|
||||||
offClose()
|
offClose()
|
||||||
offRemoveQueued()
|
|
||||||
close()
|
close()
|
||||||
await draining?.catch(() => {})
|
await draining?.catch(() => {})
|
||||||
|
await admissions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -780,6 +780,22 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||||
}, RESIZE_DELAY)
|
}, 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 () => {
|
const runQueue = async () => {
|
||||||
await firstPaint
|
await firstPaint
|
||||||
if (footer.isClosed) return
|
if (footer.isClosed) return
|
||||||
|
|
@ -798,10 +814,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||||
footer,
|
footer,
|
||||||
initialInput: input.initialInput,
|
initialInput: input.initialInput,
|
||||||
trace: log,
|
trace: log,
|
||||||
onSend: (prompt) => {
|
onSend: (prompt, delivery) => {
|
||||||
state.shown = true
|
state.shown = true
|
||||||
state.history.push(prompt)
|
state.history.push(prompt)
|
||||||
if (prompt.mode !== "shell") {
|
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||||
rememberLocal({
|
rememberLocal({
|
||||||
kind: "user",
|
kind: "user",
|
||||||
text: prompt.text,
|
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
|
onNewSession: createSession
|
||||||
? async () => {
|
? async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -856,6 +890,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
footer.event({ type: "stream.view", view: { type: "prompt" } })
|
footer.event({ type: "stream.view", view: { type: "prompt" } })
|
||||||
|
footer.event({ type: "queued.prompts", prompts: [] })
|
||||||
footer.event({
|
footer.event({
|
||||||
type: "stream.patch",
|
type: "stream.patch",
|
||||||
patch: {
|
patch: {
|
||||||
|
|
@ -891,7 +926,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
run: async (prompt, signal) => {
|
run: async (prompt, signal, admitted) => {
|
||||||
if (state.demo && (await state.demo.prompt(prompt, signal))) {
|
if (state.demo && (await state.demo.prompt(prompt, signal))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -900,15 +935,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const next = await ensureStream()
|
const next = await ensureStream()
|
||||||
await next.handle.runPromptTurn({
|
await next.handle.runPromptTurn(
|
||||||
agent: state.agent,
|
{
|
||||||
model: state.model,
|
agent: state.agent,
|
||||||
variant: state.activeVariant,
|
model: state.model,
|
||||||
prompt,
|
variant: state.activeVariant,
|
||||||
files: input.files,
|
prompt,
|
||||||
includeFiles,
|
files: input.files,
|
||||||
signal,
|
includeFiles,
|
||||||
})
|
signal,
|
||||||
|
},
|
||||||
|
admitted,
|
||||||
|
)
|
||||||
if (prompt.messageID) {
|
if (prompt.messageID) {
|
||||||
state.localRows = state.localRows.filter(
|
state.localRows = state.localRows.filter(
|
||||||
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
(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.
|
// pending for the next prompt-shaped turn.
|
||||||
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
|
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted || footer.isClosed) {
|
await renderPromptError(prompt, error, signal)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
PermissionV2Request,
|
PermissionV2Request,
|
||||||
SessionMessageAssistantTool,
|
SessionMessageAssistantTool,
|
||||||
SessionMessageInfo,
|
SessionMessageInfo,
|
||||||
|
SessionPendingInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
|
|
@ -20,6 +21,7 @@ import type {
|
||||||
LocalReplayRow,
|
LocalReplayRow,
|
||||||
MiniPermissionRequest,
|
MiniPermissionRequest,
|
||||||
MiniFormRequest,
|
MiniFormRequest,
|
||||||
|
FooterQueuedPrompt,
|
||||||
RunFilePart,
|
RunFilePart,
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
|
|
@ -64,7 +66,9 @@ export type SessionResizeReplayInput = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionTransport = {
|
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>
|
interruptActiveTurn(): Promise<void>
|
||||||
selectSubagent(sessionID: string | undefined): void
|
selectSubagent(sessionID: string | undefined): void
|
||||||
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
|
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
|
||||||
|
|
@ -74,11 +78,12 @@ export type SessionTransport = {
|
||||||
|
|
||||||
type Wait = {
|
type Wait = {
|
||||||
messageID: string
|
messageID: string
|
||||||
|
failureMessageID: string
|
||||||
promoted: boolean
|
promoted: boolean
|
||||||
|
promotionObserved: boolean
|
||||||
interrupted: boolean
|
interrupted: boolean
|
||||||
failureRendered: boolean
|
failureRendered: boolean
|
||||||
resolve: () => void
|
terminalError?: Error
|
||||||
reject: (error: unknown) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One active session.shell call. The HTTP response is the completion signal;
|
// One active session.shell call. The HTTP response is the completion signal;
|
||||||
|
|
@ -134,6 +139,8 @@ type State = {
|
||||||
rootActive: boolean
|
rootActive: boolean
|
||||||
buffered?: ReplayBuffer
|
buffered?: ReplayBuffer
|
||||||
errors: Set<string>
|
errors: Set<string>
|
||||||
|
pending: Map<string, FooterQueuedPrompt>
|
||||||
|
admitted: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
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"
|
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) {
|
function wait(delay: number, signal: AbortSignal) {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
const timer = setTimeout(done, delay)
|
const timer = setTimeout(done, delay)
|
||||||
|
|
@ -369,6 +386,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
let sdk = input.sdk
|
let sdk = input.sdk
|
||||||
let generation = 0
|
let generation = 0
|
||||||
let activeAttempt: Attempt | undefined
|
let activeAttempt: Attempt | undefined
|
||||||
|
let settlementClient: OpenCodeClient | undefined
|
||||||
input.signal?.addEventListener("abort", () => controller.abort(), { once: true })
|
input.signal?.addEventListener("abort", () => controller.abort(), { once: true })
|
||||||
const state: State = {
|
const state: State = {
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
|
@ -389,6 +407,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
initial: true,
|
initial: true,
|
||||||
rootActive: false,
|
rootActive: false,
|
||||||
errors: new Set(),
|
errors: new Set(),
|
||||||
|
pending: new Map(),
|
||||||
|
admitted: new Set(),
|
||||||
}
|
}
|
||||||
let readyResolve!: () => void
|
let readyResolve!: () => void
|
||||||
let readyReject!: (error: unknown) => 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 = () => {
|
const syncBlockers = () => {
|
||||||
if (state.closed || controller.signal.aborted || input.footer.isClosed) return
|
if (state.closed || controller.signal.aborted || input.footer.isClosed) return
|
||||||
const descendant = subagents.snapshot()
|
const descendant = subagents.snapshot()
|
||||||
|
|
@ -528,15 +572,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => {
|
const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => {
|
||||||
if (message.type === "user") {
|
if (message.type === "user") {
|
||||||
const waiting = state.wait?.messageID === message.id
|
const waiting = state.wait?.messageID === message.id
|
||||||
if (waiting && state.wait) state.wait.promoted = true
|
const admitted = state.admitted.delete(message.id)
|
||||||
if (!render || state.messageIDs.has(message.id)) return
|
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)
|
state.messageIDs.add(message.id)
|
||||||
|
if (!render) return
|
||||||
if (reuseVisibleWait && waiting) return
|
if (reuseVisibleWait && waiting) return
|
||||||
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
|
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (message.type === "skill") {
|
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)) {
|
if (!render || state.skillMessages.has(message.id)) {
|
||||||
state.skillMessages.add(message.id)
|
state.skillMessages.add(message.id)
|
||||||
return
|
return
|
||||||
|
|
@ -616,8 +664,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
}
|
}
|
||||||
renderTool(message.id, item, render)
|
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)
|
state.errors.add(message.id)
|
||||||
|
if (!render) return
|
||||||
|
if (state.wait) state.wait.failureRendered = true
|
||||||
write([
|
write([
|
||||||
{
|
{
|
||||||
kind: "error",
|
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 (
|
const resolvePermissionSources = async (
|
||||||
client: OpenCodeClient,
|
client: OpenCodeClient,
|
||||||
permissions: PermissionV2Request[],
|
permissions: PermissionV2Request[],
|
||||||
|
|
@ -672,8 +738,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
) => {
|
) => {
|
||||||
const client = attempt.client
|
const client = attempt.client
|
||||||
const options = { signal: attempt.signal }
|
const options = { signal: attempt.signal }
|
||||||
const [messages, permissions, forms, globals, active] = await Promise.all([
|
const [projected, pending, permissions, forms, globals, active] = await Promise.all([
|
||||||
client.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }, options),
|
projectedMessages(client, attempt.signal),
|
||||||
|
client.session.pending.list({ sessionID: input.sessionID }, options),
|
||||||
client.permission.list({ sessionID: input.sessionID }, options),
|
client.permission.list({ sessionID: input.sessionID }, options),
|
||||||
client.form.list({ sessionID: input.sessionID }, options),
|
client.form.list({ sessionID: input.sessionID }, options),
|
||||||
input.location
|
input.location
|
||||||
|
|
@ -687,7 +754,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
client.session.active(options),
|
client.session.active(options),
|
||||||
])
|
])
|
||||||
if (!current(attempt)) return
|
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
|
state.permissions = permissions
|
||||||
pruneToolSources()
|
pruneToolSources()
|
||||||
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
|
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 (!state.rootActive) await input.footer.idle()
|
||||||
if (!current(attempt)) return
|
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) => {
|
const apply = (attempt: Attempt, event: RunV2Event) => {
|
||||||
|
|
@ -756,9 +822,37 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
}
|
}
|
||||||
input.trace?.write("recv.event", event)
|
input.trace?.write("recv.event", event)
|
||||||
subagents.main(client, event, attempt.signal)
|
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 (event.type === "session.input.promoted") {
|
||||||
if (state.wait?.messageID === event.data.inputID) state.wait.promoted = true
|
const waiting = state.wait?.messageID === event.data.inputID
|
||||||
state.messageIDs.add(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" })
|
write([], { phase: "running", status: "waiting for assistant" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -768,7 +862,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
}
|
}
|
||||||
if (event.type === "session.skill.activated") {
|
if (event.type === "session.skill.activated") {
|
||||||
const messageID = messageIDFromEvent(event.id)
|
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
|
if (state.skillMessages.has(messageID)) return
|
||||||
state.skillMessages.add(messageID)
|
state.skillMessages.add(messageID)
|
||||||
write([skillCommit(messageID, event.data.name)])
|
write([skillCommit(messageID, event.data.name)])
|
||||||
|
|
@ -1047,25 +1141,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
state.rootActive = false
|
state.rootActive = false
|
||||||
write([], { phase: "idle", status: "" })
|
write([], { phase: "idle", status: "" })
|
||||||
const current = state.wait
|
const current = state.wait
|
||||||
if (!current || (!current.promoted && !current.interrupted)) return
|
if (!current) return
|
||||||
state.wait = undefined
|
|
||||||
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
|
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
|
||||||
current.resolve()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.failed") {
|
if (event.type === "session.execution.failed") {
|
||||||
if (current.failureRendered) {
|
if (!current.failureRendered) current.terminalError = new Error(errorMessage(event.data.error))
|
||||||
current.resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current.reject(new Error(errorMessage(event.data.error)))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.interrupted") {
|
if (event.type === "session.execution.interrupted") {
|
||||||
current.reject(new Error(`Session interrupted: ${event.data.reason}`))
|
current.terminalError = new Error(`Session interrupted: ${event.data.reason}`)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
current.resolve()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1248,27 +1334,22 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared settlement scaffolding for prompt-shaped turns: registers the wait,
|
// Prompt-shaped turns complete through the process-local idle fence. Live
|
||||||
// wires interruption, sends, then blocks until the live settled event (or a
|
// lifecycle events remain presentation and best-effort outcome metadata.
|
||||||
// hydration pass over an idle session) resolves it.
|
|
||||||
const runTurnWait = async (
|
const runTurnWait = async (
|
||||||
next: SessionTurnInput,
|
next: SessionTurnInput,
|
||||||
messageID: string,
|
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 = {
|
const active: Wait = {
|
||||||
messageID,
|
messageID,
|
||||||
promoted: turn.promoted === true,
|
failureMessageID: messageID,
|
||||||
|
promoted: false,
|
||||||
|
promotionObserved: false,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
failureRendered: false,
|
failureRendered: false,
|
||||||
resolve,
|
|
||||||
reject,
|
|
||||||
}
|
}
|
||||||
state.wait = active
|
state.wait = active
|
||||||
const interrupt = () => {
|
const interrupt = () => {
|
||||||
|
|
@ -1277,14 +1358,26 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
}
|
}
|
||||||
next.signal?.addEventListener("abort", interrupt, { once: true })
|
next.signal?.addEventListener("abort", interrupt, { once: true })
|
||||||
try {
|
try {
|
||||||
await turn.send()
|
const admitted = await send()
|
||||||
await done
|
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) {
|
} catch (error) {
|
||||||
if (state.wait === active) state.wait = undefined
|
|
||||||
if (next.signal?.aborted) return
|
if (next.signal?.aborted) return
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
next.signal?.removeEventListener("abort", interrupt)
|
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 queuedResizeReplay: SessionResizeReplayInput | undefined
|
||||||
let closing: Promise<void> | 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) => {
|
const replayOnResize = (next: SessionResizeReplayInput) => {
|
||||||
queuedResizeReplay = next
|
queuedResizeReplay = next
|
||||||
if (resizeReplay) return resizeReplay
|
if (resizeReplay) return resizeReplay
|
||||||
|
|
@ -1388,7 +1521,20 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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") {
|
if (next.prompt.mode === "shell") {
|
||||||
await runShellTurn(next)
|
await runShellTurn(next)
|
||||||
return
|
return
|
||||||
|
|
@ -1402,40 +1548,21 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
const command = next.prompt.command
|
const command = next.prompt.command
|
||||||
if (command?.source === "skill") {
|
if (command?.source === "skill") {
|
||||||
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
|
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
|
||||||
await runTurnWait(next, messageID, {
|
await runTurnWait(
|
||||||
send: () =>
|
next,
|
||||||
|
messageID,
|
||||||
|
client,
|
||||||
|
() =>
|
||||||
client.session.skill(
|
client.session.skill(
|
||||||
{ sessionID: input.sessionID, id: messageID, skill: command.name },
|
{ sessionID: input.sessionID, id: messageID, skill: command.name },
|
||||||
{ signal: next.signal },
|
{ signal: next.signal },
|
||||||
),
|
),
|
||||||
})
|
admitted,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (command) {
|
if (command) {
|
||||||
const selected = await resolveSelectedModel(input, client, next)
|
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||||
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 },
|
|
||||||
),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1447,23 +1574,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
if (selected)
|
if (selected)
|
||||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||||
|
|
||||||
const attachments = await prepareAttachments(next, "prompt", input.readTextFile)
|
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||||
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 },
|
|
||||||
),
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async interruptActiveTurn() {
|
async interruptActiveTurn() {
|
||||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ export type RunPrompt = {
|
||||||
export type FooterQueuedPrompt = {
|
export type FooterQueuedPrompt = {
|
||||||
messageID: string
|
messageID: string
|
||||||
prompt: RunPrompt
|
prompt: RunPrompt
|
||||||
|
delivery: "steer" | "queue"
|
||||||
|
admittedSeq: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunAgent = {
|
export type RunAgent = {
|
||||||
|
|
@ -161,7 +163,6 @@ export type FooterPhase = "idle" | "running"
|
||||||
export type FooterState = {
|
export type FooterState = {
|
||||||
phase: FooterPhase
|
phase: FooterPhase
|
||||||
status: string
|
status: string
|
||||||
queue: number
|
|
||||||
model: string
|
model: string
|
||||||
usage: string
|
usage: string
|
||||||
first: boolean
|
first: boolean
|
||||||
|
|
@ -338,10 +339,6 @@ export type FooterEvent =
|
||||||
variants: string[]
|
variants: string[]
|
||||||
current: string | undefined
|
current: string | undefined
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "queue"
|
|
||||||
queue: number
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: "queued.prompts"
|
type: "queued.prompts"
|
||||||
prompts: FooterQueuedPrompt[]
|
prompts: FooterQueuedPrompt[]
|
||||||
|
|
@ -355,14 +352,8 @@ export type FooterEvent =
|
||||||
model: string
|
model: string
|
||||||
selection: NonNullable<RunInput["model"]>
|
selection: NonNullable<RunInput["model"]>
|
||||||
}
|
}
|
||||||
| {
|
| { type: "turn.send" }
|
||||||
type: "turn.send"
|
| { type: "turn.idle" }
|
||||||
queue: number
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "turn.idle"
|
|
||||||
queue: number
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: "turn.duration"
|
type: "turn.duration"
|
||||||
duration: string
|
duration: string
|
||||||
|
|
@ -438,7 +429,6 @@ export type LocalReplayRow = {
|
||||||
export type FooterApi = {
|
export type FooterApi = {
|
||||||
readonly isClosed: boolean
|
readonly isClosed: boolean
|
||||||
onPrompt(fn: (input: RunPrompt) => void): () => void
|
onPrompt(fn: (input: RunPrompt) => void): () => void
|
||||||
onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void
|
|
||||||
onClose(fn: () => void): () => void
|
onClose(fn: () => void): () => void
|
||||||
event(next: FooterEvent): void
|
event(next: FooterEvent): void
|
||||||
append(commit: StreamCommit): void
|
append(commit: StreamCommit): void
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
|
||||||
|
|
||||||
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
|
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
|
||||||
const prompts = new Set<(input: RunPrompt) => void>()
|
const prompts = new Set<(input: RunPrompt) => void>()
|
||||||
const queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
|
|
||||||
const closes = new Set<() => void>()
|
const closes = new Set<() => void>()
|
||||||
const events = input.events ?? []
|
const events = input.events ?? []
|
||||||
const commits = input.commits ?? []
|
const commits = input.commits ?? []
|
||||||
|
|
@ -17,10 +16,6 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||||
prompts.add(fn)
|
prompts.add(fn)
|
||||||
return () => prompts.delete(fn)
|
return () => prompts.delete(fn)
|
||||||
},
|
},
|
||||||
onQueuedRemove(fn) {
|
|
||||||
queuedRemoves.add(fn)
|
|
||||||
return () => queuedRemoves.delete(fn)
|
|
||||||
},
|
|
||||||
onClose(fn) {
|
onClose(fn) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
fn()
|
fn()
|
||||||
|
|
@ -46,7 +41,6 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||||
destroy() {
|
destroy() {
|
||||||
api.close()
|
api.close()
|
||||||
prompts.clear()
|
prompts.clear()
|
||||||
queuedRemoves.clear()
|
|
||||||
closes.clear()
|
closes.clear()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -60,8 +54,5 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||||
for (const fn of [...prompts]) fn(prompt)
|
for (const fn of [...prompts]) fn(prompt)
|
||||||
},
|
},
|
||||||
removeQueued(messageID: string) {
|
|
||||||
for (const fn of [...queuedRemoves]) void fn(messageID)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ test("down opens subagents from an empty prompt", async () => {
|
||||||
const [state] = createSignal<FooterState>({
|
const [state] = createSignal<FooterState>({
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
status: "",
|
status: "",
|
||||||
queue: 0,
|
|
||||||
model: "gpt-5",
|
model: "gpt-5",
|
||||||
usage: "",
|
usage: "",
|
||||||
first: false,
|
first: false,
|
||||||
|
|
@ -70,7 +69,6 @@ test("down opens subagents from an empty prompt", async () => {
|
||||||
onRows={() => {}}
|
onRows={() => {}}
|
||||||
onLayout={() => {}}
|
onLayout={() => {}}
|
||||||
onStatus={() => {}}
|
onStatus={() => {}}
|
||||||
onQueuedRemove={async () => true}
|
|
||||||
/>
|
/>
|
||||||
</Keymap.Provider>
|
</Keymap.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,6 @@ function footerState(input: Partial<FooterState> = {}) {
|
||||||
return createSignal<FooterState>({
|
return createSignal<FooterState>({
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
status: "",
|
status: "",
|
||||||
queue: 0,
|
|
||||||
model: "gpt-5",
|
model: "gpt-5",
|
||||||
usage: "",
|
usage: "",
|
||||||
first: false,
|
first: false,
|
||||||
|
|
@ -158,7 +157,6 @@ async function renderFooter(
|
||||||
onRows={() => {}}
|
onRows={() => {}}
|
||||||
onLayout={() => {}}
|
onLayout={() => {}}
|
||||||
onStatus={() => {}}
|
onStatus={() => {}}
|
||||||
onQueuedRemove={async () => true}
|
|
||||||
/>
|
/>
|
||||||
</Keymap.Provider>
|
</Keymap.Provider>
|
||||||
)
|
)
|
||||||
|
|
@ -680,10 +678,15 @@ test("direct subagent panel closes when moving up from the first item", async ()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("direct queued prompt panel renders pending prompt actions", async () => {
|
test("direct pending panel shows durable delivery without edit actions", async () => {
|
||||||
const [prompts] = createSignal([{ messageID: "m-1", prompt: { text: "fix the auth test", parts: [] } }])
|
const [prompts] = createSignal([
|
||||||
const edited: string[] = []
|
{
|
||||||
const deleted: string[] = []
|
messageID: "m-1",
|
||||||
|
prompt: { text: "fix the auth test", parts: [] },
|
||||||
|
delivery: "queue" as const,
|
||||||
|
admittedSeq: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
() => (
|
() => (
|
||||||
|
|
@ -692,12 +695,6 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
|
||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onEdit={(prompt) => {
|
|
||||||
edited.push(prompt.messageID)
|
|
||||||
}}
|
|
||||||
onDelete={(prompt) => {
|
|
||||||
deleted.push(prompt.messageID)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
),
|
),
|
||||||
|
|
@ -709,16 +706,14 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
|
||||||
const frame = app.captureCharFrame()
|
const frame = app.captureCharFrame()
|
||||||
const list = panelMenu(app.renderer.root)
|
const list = panelMenu(app.renderer.root)
|
||||||
|
|
||||||
expect(frame).toContain("Queued prompts")
|
expect(frame).toContain("Pending work")
|
||||||
expect(frame).toContain("fix the auth test")
|
expect(frame).toContain("fix the auth test")
|
||||||
expect(frame).toContain("queued")
|
expect(frame).toContain("queue")
|
||||||
expect(frame).not.toContain("┌")
|
expect(frame).not.toContain("┌")
|
||||||
expect(frame).not.toContain("┃")
|
expect(frame).not.toContain("┃")
|
||||||
expectPaletteList(list, 0)
|
expectPaletteList(list, 0)
|
||||||
app.mockInput.pressKey("e", { ctrl: true })
|
expect(frame).not.toContain("edit")
|
||||||
app.mockInput.pressKey("DELETE")
|
expect(frame).not.toContain("remove")
|
||||||
expect(edited).toEqual(["m-1"])
|
|
||||||
expect(deleted).toEqual(["m-1"])
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|
@ -999,11 +994,10 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("direct footer shows editable prompts and additional queued work while running", async () => {
|
test("direct footer shows authoritative pending work while running", async () => {
|
||||||
const [state] = createSignal<FooterState>({
|
const [state] = createSignal<FooterState>({
|
||||||
phase: "running",
|
phase: "running",
|
||||||
status: "",
|
status: "",
|
||||||
queue: 3,
|
|
||||||
model: "gpt-5",
|
model: "gpt-5",
|
||||||
usage: "",
|
usage: "",
|
||||||
first: false,
|
first: false,
|
||||||
|
|
@ -1036,7 +1030,14 @@ test("direct footer shows editable prompts and additional queued work while runn
|
||||||
state={state}
|
state={state}
|
||||||
view={view}
|
view={view}
|
||||||
subagent={subagents}
|
subagent={subagents}
|
||||||
queuedPrompts={() => [{ messageID: "m-queued", prompt: { text: "follow up", parts: [] } }]}
|
queuedPrompts={() => [
|
||||||
|
{
|
||||||
|
messageID: "m-queued",
|
||||||
|
prompt: { text: "follow up", parts: [] },
|
||||||
|
delivery: "queue",
|
||||||
|
admittedSeq: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
theme={() => RUN_THEME_FALLBACK}
|
theme={() => RUN_THEME_FALLBACK}
|
||||||
tuiConfig={tuiConfig}
|
tuiConfig={tuiConfig}
|
||||||
onSubmit={() => true}
|
onSubmit={() => true}
|
||||||
|
|
@ -1053,7 +1054,6 @@ test("direct footer shows editable prompts and additional queued work while runn
|
||||||
onRows={() => {}}
|
onRows={() => {}}
|
||||||
onLayout={() => {}}
|
onLayout={() => {}}
|
||||||
onStatus={() => {}}
|
onStatus={() => {}}
|
||||||
onQueuedRemove={async () => true}
|
|
||||||
/>
|
/>
|
||||||
</Keymap.Provider>
|
</Keymap.Provider>
|
||||||
)
|
)
|
||||||
|
|
@ -1088,9 +1088,9 @@ test("direct footer shows editable prompts and additional queued work while runn
|
||||||
|
|
||||||
expect(spinner).toBeDefined()
|
expect(spinner).toBeDefined()
|
||||||
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
||||||
expect(frame).toContain("3 queued")
|
expect(frame).toContain("1 pending")
|
||||||
expect(frame).toContain("ctrl+b background")
|
expect(frame).toContain("ctrl+b background")
|
||||||
expect(frame).toContain("ctrl+x q 3 queued")
|
expect(frame).toContain("ctrl+x q 1 pending")
|
||||||
expect(frame).toContain("↓ subagents")
|
expect(frame).toContain("↓ subagents")
|
||||||
expect(frame).toContain("ctrl+p cmd")
|
expect(frame).toContain("ctrl+p cmd")
|
||||||
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { runPromptQueue } from "../../src/mini/runtime.queue"
|
import { runPromptQueue as runPromptQueueBase, type QueueInput } from "../../src/mini/runtime.queue"
|
||||||
import type { RunPrompt } from "../../src/mini/types"
|
import type { RunPrompt } from "../../src/mini/types"
|
||||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||||
|
|
||||||
|
function runPromptQueue(input: Omit<QueueInput, "admit" | "settle"> & Partial<Pick<QueueInput, "admit" | "settle">>) {
|
||||||
|
return runPromptQueueBase({
|
||||||
|
admit: async () => {},
|
||||||
|
settle: async () => {},
|
||||||
|
...input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe("run runtime queue", () => {
|
describe("run runtime queue", () => {
|
||||||
test("ignores empty prompts", async () => {
|
test("ignores empty prompts", async () => {
|
||||||
const ui = createFooterApiFixture()
|
const ui = createFooterApiFixture()
|
||||||
|
|
@ -171,206 +179,90 @@ describe("run runtime queue", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes prompts to onSend", async () => {
|
test("durably admits in-flight follow-ups in submission order", async () => {
|
||||||
const ui = createFooterApiFixture()
|
const ui = createFooterApiFixture()
|
||||||
const seen: string[] = []
|
const admitted: string[] = []
|
||||||
|
const gate = Promise.withResolvers<void>()
|
||||||
await runPromptQueue({
|
|
||||||
footer: ui.api,
|
|
||||||
initialInput: " hello ",
|
|
||||||
onSend: (input) => {
|
|
||||||
seen.push(input.text)
|
|
||||||
},
|
|
||||||
run: async () => {
|
|
||||||
ui.api.close()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(seen).toEqual([" hello "])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("appends the user row before the turn starts", async () => {
|
|
||||||
const ui = createFooterApiFixture()
|
|
||||||
|
|
||||||
await runPromptQueue({
|
|
||||||
footer: ui.api,
|
|
||||||
initialInput: "/fmt bash",
|
|
||||||
run: async () => {
|
|
||||||
expect(ui.commits).toEqual([
|
|
||||||
{
|
|
||||||
kind: "user",
|
|
||||||
text: "/fmt bash",
|
|
||||||
phase: "start",
|
|
||||||
source: "system",
|
|
||||||
messageID: expect.any(String),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
ui.api.close()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("runs queued prompts in order", async () => {
|
|
||||||
const ui = createFooterApiFixture()
|
|
||||||
const seen: string[] = []
|
|
||||||
let wake: (() => void) | undefined
|
|
||||||
const gate = new Promise<void>((resolve) => {
|
|
||||||
wake = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
footer: ui.api,
|
footer: ui.api,
|
||||||
run: async (input) => {
|
run: async (input, _signal, onAdmitted) => {
|
||||||
seen.push(input.text)
|
admitted.push(`${input.text}:steer`)
|
||||||
if (seen.length === 1) {
|
onAdmitted()
|
||||||
await gate
|
await gate.promise
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.api.close()
|
|
||||||
},
|
},
|
||||||
|
admit: async (input) => {
|
||||||
|
admitted.push(`${input.text}:queue`)
|
||||||
|
},
|
||||||
|
settle: async () => ui.api.close(),
|
||||||
})
|
})
|
||||||
|
|
||||||
ui.submit("one")
|
ui.submit("one")
|
||||||
ui.submit("two")
|
ui.submit("two")
|
||||||
await Promise.resolve()
|
ui.submit("three")
|
||||||
expect(seen).toEqual(["one"])
|
while (admitted.length < 3) await Bun.sleep(0)
|
||||||
|
expect(admitted).toEqual(["one:steer", "two:queue", "three:queue"])
|
||||||
wake?.()
|
|
||||||
await task
|
|
||||||
|
|
||||||
expect(seen).toEqual(["one", "two"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("exposes ordinary in-flight prompts for removal before sending", async () => {
|
|
||||||
const ui = createFooterApiFixture()
|
|
||||||
const turns: RunPrompt[] = []
|
|
||||||
let wake: (() => void) | undefined
|
|
||||||
const gate = new Promise<void>((resolve) => {
|
|
||||||
wake = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
const task = runPromptQueue({
|
|
||||||
footer: ui.api,
|
|
||||||
run: async (input) => {
|
|
||||||
turns.push(input)
|
|
||||||
await gate
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ui.submit("one")
|
|
||||||
ui.submit("two")
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
|
|
||||||
expect(turns.map((item) => item.text)).toEqual(["one"])
|
|
||||||
expect(turns[0]?.messageID).toEqual(expect.any(String))
|
|
||||||
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
|
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
|
||||||
const first = ui.events.find((item) => item.type === "queued.prompts")
|
|
||||||
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
|
||||||
expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
|
|
||||||
expect(
|
|
||||||
first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true,
|
|
||||||
).toBe(false)
|
|
||||||
expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
|
|
||||||
expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
|
|
||||||
if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
|
|
||||||
await Promise.resolve()
|
|
||||||
|
|
||||||
wake?.()
|
gate.resolve()
|
||||||
ui.api.close()
|
|
||||||
await task
|
await task
|
||||||
expect(turns.map((item) => item.text)).toEqual(["one"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("removing one managed queued prompt preserves the others", async () => {
|
test("continues durable admission after one fails", async () => {
|
||||||
const ui = createFooterApiFixture()
|
const ui = createFooterApiFixture()
|
||||||
const turns: string[] = []
|
const admitted: string[] = []
|
||||||
let wake: (() => void) | undefined
|
const errors: string[] = []
|
||||||
const gate = new Promise<void>((resolve) => {
|
const gate = Promise.withResolvers<void>()
|
||||||
wake = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
footer: ui.api,
|
footer: ui.api,
|
||||||
run: async (input) => {
|
run: async (_input, _signal, admitted) => {
|
||||||
turns.push(input.text)
|
admitted()
|
||||||
if (input.text === "active") await gate
|
await gate.promise
|
||||||
if (input.text === "queued three") ui.api.close()
|
|
||||||
},
|
},
|
||||||
})
|
admit: async (input) => {
|
||||||
|
if (input.text === "two") throw new Error("admission failed")
|
||||||
ui.submit("active")
|
admitted.push(input.text)
|
||||||
ui.submit("queued one")
|
|
||||||
ui.submit("queued two")
|
|
||||||
ui.submit("queued three")
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
|
|
||||||
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
|
||||||
if (event?.type === "queued.prompts") {
|
|
||||||
const second = event.prompts.find((item) => item.prompt.text === "queued two")
|
|
||||||
if (second) ui.removeQueued(second.messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
wake?.()
|
|
||||||
await task
|
|
||||||
expect(turns).toEqual(["active", "queued one", "queued three"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("drains a prompt queued during an in-flight turn", async () => {
|
|
||||||
const ui = createFooterApiFixture()
|
|
||||||
const seen: string[] = []
|
|
||||||
let wake: (() => void) | undefined
|
|
||||||
const gate = new Promise<void>((resolve) => {
|
|
||||||
wake = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
const task = runPromptQueue({
|
|
||||||
footer: ui.api,
|
|
||||||
run: async (input) => {
|
|
||||||
seen.push(input.text)
|
|
||||||
if (seen.length === 1) {
|
|
||||||
await gate
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.api.close()
|
|
||||||
},
|
},
|
||||||
|
onAdmissionError: (_prompt, error) => {
|
||||||
|
errors.push(error instanceof Error ? error.message : String(error))
|
||||||
|
},
|
||||||
|
settle: async () => ui.api.close(),
|
||||||
})
|
})
|
||||||
|
|
||||||
ui.submit("one")
|
ui.submit("one")
|
||||||
await Promise.resolve()
|
|
||||||
expect(seen).toEqual(["one"])
|
|
||||||
|
|
||||||
wake?.()
|
|
||||||
await Promise.resolve()
|
|
||||||
ui.submit("two")
|
ui.submit("two")
|
||||||
|
ui.submit("three")
|
||||||
|
while (admitted.length === 0) await Bun.sleep(0)
|
||||||
|
gate.resolve()
|
||||||
await task
|
await task
|
||||||
|
|
||||||
expect(seen).toEqual(["one", "two"])
|
expect(errors).toEqual(["admission failed"])
|
||||||
|
expect(admitted).toEqual(["three"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("close aborts the active run and drops pending queued work", async () => {
|
test("close aborts an in-flight durable admission", async () => {
|
||||||
const ui = createFooterApiFixture()
|
const ui = createFooterApiFixture()
|
||||||
const seen: string[] = []
|
let admissionHit = false
|
||||||
let hit = false
|
const admissionStarted = Promise.withResolvers<void>()
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
footer: ui.api,
|
footer: ui.api,
|
||||||
run: async (input, signal) => {
|
run: async (_input, signal, admitted) => {
|
||||||
seen.push(input.text)
|
admitted()
|
||||||
|
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
||||||
|
},
|
||||||
|
admit: async (_prompt, signal) => {
|
||||||
|
admissionStarted.resolve()
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
hit = true
|
admissionHit = true
|
||||||
resolve()
|
resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
signal.addEventListener(
|
signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
hit = true
|
admissionHit = true
|
||||||
resolve()
|
resolve()
|
||||||
},
|
},
|
||||||
{ once: true },
|
{ once: true },
|
||||||
|
|
@ -382,11 +274,11 @@ describe("run runtime queue", () => {
|
||||||
ui.submit("one")
|
ui.submit("one")
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
ui.submit("two")
|
ui.submit("two")
|
||||||
|
await admissionStarted.promise
|
||||||
ui.api.close()
|
ui.api.close()
|
||||||
await task
|
await task
|
||||||
|
|
||||||
expect(hit).toBe(true)
|
expect(admissionHit).toBe(true)
|
||||||
expect(seen).toEqual(["one"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("propagates run errors", async () => {
|
test("propagates run errors", async () => {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ describe("run interactive runtime", () => {
|
||||||
streamStarted.resolve()
|
streamStarted.resolve()
|
||||||
return {
|
return {
|
||||||
runPromptTurn: async () => {},
|
runPromptTurn: async () => {},
|
||||||
|
queuePromptTurn: async () => {},
|
||||||
|
waitForIdle: async () => {},
|
||||||
interruptActiveTurn: async () => {},
|
interruptActiveTurn: async () => {},
|
||||||
selectSubagent: () => {},
|
selectSubagent: () => {},
|
||||||
settleForm: (sessionID: string, formID: string) => settled.push({ sessionID, formID }),
|
settleForm: (sessionID: string, formID: string) => settled.push({ sessionID, formID }),
|
||||||
|
|
@ -432,6 +434,8 @@ describe("run interactive runtime", () => {
|
||||||
setTimeout(() => input.footer.close(), 0)
|
setTimeout(() => input.footer.close(), 0)
|
||||||
return {
|
return {
|
||||||
runPromptTurn: async () => {},
|
runPromptTurn: async () => {},
|
||||||
|
queuePromptTurn: async () => {},
|
||||||
|
waitForIdle: async () => {},
|
||||||
interruptActiveTurn: async () => {},
|
interruptActiveTurn: async () => {},
|
||||||
selectSubagent: () => {},
|
selectSubagent: () => {},
|
||||||
replayOnResize: async () => false,
|
replayOnResize: async () => false,
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,8 @@ function sdk(input: {
|
||||||
globals?: FormInfo[]
|
globals?: FormInfo[]
|
||||||
globalLocation?: { directory: string; workspaceID?: string }
|
globalLocation?: { directory: string; workspaceID?: string }
|
||||||
permissions?: Record<string, PermissionV2Request[]>
|
permissions?: Record<string, PermissionV2Request[]>
|
||||||
|
pending?: Record<string, Awaited<ReturnType<OpenCodeClient["session"]["pending"]["list"]>>>
|
||||||
|
wait?: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
let subscription = 0
|
let subscription = 0
|
||||||
|
|
@ -159,6 +161,8 @@ function sdk(input: {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
spyOn(client.session, "active").mockImplementation(() => ok(input.active?.() ?? {}))
|
spyOn(client.session, "active").mockImplementation(() => ok(input.active?.() ?? {}))
|
||||||
|
spyOn(client.session.pending, "list").mockImplementation((request) => ok(input.pending?.[request.sessionID] ?? []))
|
||||||
|
spyOn(client.session, "wait").mockImplementation(() => input.wait?.() ?? ok(undefined))
|
||||||
spyOn(client.session, "message").mockImplementation((request) => {
|
spyOn(client.session, "message").mockImplementation((request) => {
|
||||||
const message = input.messages?.[request.sessionID]?.find((item) => item.id === request.messageID)
|
const message = input.messages?.[request.sessionID]?.find((item) => item.id === request.messageID)
|
||||||
return message ? (ok(message) as never) : Promise.reject(new Error(`message not found: ${request.messageID}`))
|
return message ? (ok(message) as never) : Promise.reject(new Error(`message not found: ${request.messageID}`))
|
||||||
|
|
@ -417,35 +421,23 @@ describe("V2 mini transport", () => {
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("finalizes an idle projection before reducing live output", async () => {
|
test("waits authoritatively and reconciles the projected terminal suffix", async () => {
|
||||||
const events = feed()
|
const events = feed()
|
||||||
events.push(connected())
|
events.push(connected())
|
||||||
|
const settled = defer()
|
||||||
|
const messages: SessionMessages = []
|
||||||
const client = sdk({
|
const client = sdk({
|
||||||
streams: [events],
|
streams: [events],
|
||||||
messages: {
|
messages: { ses_1: messages },
|
||||||
ses_1: [
|
wait: () => settled.promise,
|
||||||
{
|
|
||||||
id: "msg_old",
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "test", id: "model" },
|
|
||||||
content: [{ type: "text", text: "[Link](https://example.com)" }],
|
|
||||||
time: { created: 1 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
const idle = spyOn(ui.api, "idle")
|
|
||||||
const transport = await createSessionTransport({
|
const transport = await createSessionTransport({
|
||||||
sdk: client,
|
sdk: client,
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
thinking: true,
|
thinking: false,
|
||||||
replay: true,
|
|
||||||
footer: ui.api,
|
footer: ui.api,
|
||||||
})
|
})
|
||||||
expect(ui.commits.map((item) => item.text)).toEqual(["[Link](https://example.com)"])
|
|
||||||
expect(idle).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
let admitted = false
|
let admitted = false
|
||||||
spyOn(client.session, "prompt").mockImplementation((request) => {
|
spyOn(client.session, "prompt").mockImplementation((request) => {
|
||||||
|
|
@ -480,7 +472,7 @@ describe("V2 mini transport", () => {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_assistant",
|
assistantMessageID: "msg_assistant",
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
delta: "answer",
|
delta: "ans",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
events.push({
|
events.push({
|
||||||
|
|
@ -490,10 +482,222 @@ describe("V2 mini transport", () => {
|
||||||
durable: durable("ses_1"),
|
durable: durable("ses_1"),
|
||||||
data: { sessionID: "ses_1" },
|
data: { sessionID: "ses_1" },
|
||||||
})
|
})
|
||||||
|
let done = false
|
||||||
|
void turn.then(() => {
|
||||||
|
done = true
|
||||||
|
})
|
||||||
|
await Bun.sleep(0)
|
||||||
|
expect(done).toBe(false)
|
||||||
|
messages.push(
|
||||||
|
{ id: "msg_prompt", type: "user", text: "hello", time: { created: 2 } },
|
||||||
|
{
|
||||||
|
id: "msg_assistant",
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "test", id: "model" },
|
||||||
|
content: [{ type: "text", text: "answer" }],
|
||||||
|
time: { created: 3, completed: 4 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
settled.resolve()
|
||||||
await turn
|
await turn
|
||||||
|
|
||||||
expect(ui.commits.map((item) => item.text)).toEqual(["[Link](https://example.com)", "answer"])
|
expect(ui.commits.map((item) => item.text)).toEqual(["ans", "wer"])
|
||||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } })
|
await transport.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows durable pending delivery and appends queued input on promotion", async () => {
|
||||||
|
const events = feed()
|
||||||
|
events.push(connected())
|
||||||
|
const client = sdk({
|
||||||
|
streams: [events],
|
||||||
|
pending: {
|
||||||
|
ses_1: [
|
||||||
|
{
|
||||||
|
admittedSeq: 1,
|
||||||
|
id: "msg_queued",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
timeCreated: 1,
|
||||||
|
type: "user",
|
||||||
|
data: { text: "follow up" },
|
||||||
|
delivery: "queue",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: client,
|
||||||
|
sessionID: "ses_1",
|
||||||
|
thinking: false,
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
const pending = () =>
|
||||||
|
ui.events
|
||||||
|
.findLast((item) => item.type === "queued.prompts")
|
||||||
|
?.prompts.map((item) => [item.messageID, item.delivery, item.admittedSeq])
|
||||||
|
|
||||||
|
expect(pending()).toEqual([["msg_queued", "queue", 1]])
|
||||||
|
events.push({
|
||||||
|
id: "evt_promoted",
|
||||||
|
created: 2,
|
||||||
|
type: "session.input.promoted",
|
||||||
|
durable: durable("ses_1", 2),
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||||
|
})
|
||||||
|
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
|
||||||
|
|
||||||
|
expect(ui.commits).toContainEqual(
|
||||||
|
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||||
|
)
|
||||||
|
expect(pending()).toEqual([])
|
||||||
|
const prompt = spyOn(client.session, "prompt").mockImplementation((request) =>
|
||||||
|
ok({ ...promptAdmission(request), admittedSeq: 2 }) as never,
|
||||||
|
)
|
||||||
|
await transport.queuePromptTurn({
|
||||||
|
agent: undefined,
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
prompt: { messageID: "msg_next", text: "another", parts: [] },
|
||||||
|
files: [],
|
||||||
|
includeFiles: false,
|
||||||
|
})
|
||||||
|
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
|
||||||
|
events.push({
|
||||||
|
id: "evt_earlier_admission",
|
||||||
|
created: 3,
|
||||||
|
type: "session.input.admitted",
|
||||||
|
durable: durable("ses_1", 1),
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
inputID: "msg_earlier",
|
||||||
|
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
while (true) {
|
||||||
|
const pending = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||||
|
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
|
||||||
|
await Bun.sleep(0)
|
||||||
|
}
|
||||||
|
expect(pending()).toEqual([
|
||||||
|
["msg_earlier", "steer", 1],
|
||||||
|
["msg_next", "queue", 2],
|
||||||
|
])
|
||||||
|
await transport.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports an observed execution failure before prompt promotion", async () => {
|
||||||
|
const events = feed()
|
||||||
|
events.push(connected())
|
||||||
|
const idle = defer()
|
||||||
|
const client = sdk({ streams: [events], messages: { ses_1: [] }, wait: () => idle.promise })
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: client,
|
||||||
|
sessionID: "ses_1",
|
||||||
|
thinking: false,
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
let admitted = false
|
||||||
|
spyOn(client.session, "prompt").mockImplementation((request) => {
|
||||||
|
admitted = true
|
||||||
|
return ok(promptAdmission(request)) as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const turn = transport.runPromptTurn({
|
||||||
|
agent: undefined,
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
|
||||||
|
files: [],
|
||||||
|
includeFiles: false,
|
||||||
|
})
|
||||||
|
while (!admitted) await Bun.sleep(0)
|
||||||
|
events.push({
|
||||||
|
id: "evt_failed",
|
||||||
|
created: 2,
|
||||||
|
type: "session.execution.failed",
|
||||||
|
durable: durable("ses_1", 2),
|
||||||
|
data: { sessionID: "ses_1", error: { type: "unknown", message: "instructions unavailable" } },
|
||||||
|
})
|
||||||
|
await Bun.sleep(0)
|
||||||
|
idle.resolve()
|
||||||
|
|
||||||
|
await turn
|
||||||
|
expect(ui.commits).toContainEqual(
|
||||||
|
expect.objectContaining({ kind: "error", messageID: "msg_prompt", text: "instructions unavailable" }),
|
||||||
|
)
|
||||||
|
await transport.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("attributes an execution-only failure to the latest promoted prompt", async () => {
|
||||||
|
const events = feed()
|
||||||
|
events.push(connected())
|
||||||
|
const idle = defer()
|
||||||
|
const messages: SessionMessages = []
|
||||||
|
const client = sdk({ streams: [events], messages: { ses_1: messages }, wait: () => idle.promise })
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: client,
|
||||||
|
sessionID: "ses_1",
|
||||||
|
thinking: false,
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
let admitted = false
|
||||||
|
spyOn(client.session, "prompt").mockImplementation((request) => {
|
||||||
|
admitted = true
|
||||||
|
return ok(promptAdmission(request)) as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const turn = transport.runPromptTurn({
|
||||||
|
agent: undefined,
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
|
||||||
|
files: [],
|
||||||
|
includeFiles: false,
|
||||||
|
})
|
||||||
|
while (!admitted) await Bun.sleep(0)
|
||||||
|
events.push({
|
||||||
|
id: "evt_prompt_promoted",
|
||||||
|
created: 2,
|
||||||
|
type: "session.input.promoted",
|
||||||
|
durable: durable("ses_1", 2),
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||||
|
})
|
||||||
|
await transport.queuePromptTurn({
|
||||||
|
agent: undefined,
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
|
||||||
|
files: [],
|
||||||
|
includeFiles: false,
|
||||||
|
})
|
||||||
|
events.push({
|
||||||
|
id: "evt_queued_promoted",
|
||||||
|
created: 3,
|
||||||
|
type: "session.input.promoted",
|
||||||
|
durable: durable("ses_1", 3),
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||||
|
})
|
||||||
|
events.push({
|
||||||
|
id: "evt_failed",
|
||||||
|
created: 4,
|
||||||
|
type: "session.execution.failed",
|
||||||
|
durable: durable("ses_1", 4),
|
||||||
|
data: { sessionID: "ses_1", error: { type: "unknown", message: "model unavailable" } },
|
||||||
|
})
|
||||||
|
await Bun.sleep(0)
|
||||||
|
messages.push(
|
||||||
|
{ id: "msg_prompt", type: "user", text: "hello", time: { created: 2 } },
|
||||||
|
{ id: "msg_queued", type: "user", text: "follow up", time: { created: 3 } },
|
||||||
|
)
|
||||||
|
idle.resolve()
|
||||||
|
|
||||||
|
await turn
|
||||||
|
expect(ui.commits).toContainEqual(
|
||||||
|
expect.objectContaining({ kind: "error", messageID: "msg_queued", text: "model unavailable" }),
|
||||||
|
)
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -786,11 +990,12 @@ describe("V2 mini transport", () => {
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rebootstraps after disconnect and completes a promoted turn from idle active state", async () => {
|
test("reconnects and hydrates without completing before session.wait", async () => {
|
||||||
const first = feed()
|
const first = feed()
|
||||||
const second = feed()
|
const second = feed()
|
||||||
first.push(connected("evt_connected_1"))
|
first.push(connected("evt_connected_1"))
|
||||||
second.push(connected("evt_connected_2"))
|
second.push(connected("evt_connected_2"))
|
||||||
|
const idle = defer()
|
||||||
let running = true
|
let running = true
|
||||||
const client = sdk({
|
const client = sdk({
|
||||||
streams: [first, second],
|
streams: [first, second],
|
||||||
|
|
@ -799,6 +1004,7 @@ describe("V2 mini transport", () => {
|
||||||
if (running) active.ses_1 = { type: "running" }
|
if (running) active.ses_1 = { type: "running" }
|
||||||
return active
|
return active
|
||||||
},
|
},
|
||||||
|
wait: () => idle.promise,
|
||||||
})
|
})
|
||||||
let projected = false
|
let projected = false
|
||||||
spyOn(client.message, "list").mockImplementation(() =>
|
spyOn(client.message, "list").mockImplementation(() =>
|
||||||
|
|
@ -844,11 +1050,25 @@ describe("V2 mini transport", () => {
|
||||||
while (!admitted) await Bun.sleep(0)
|
while (!admitted) await Bun.sleep(0)
|
||||||
projected = true
|
projected = true
|
||||||
running = false
|
running = false
|
||||||
|
second.push({
|
||||||
|
id: "evt_prior_failed",
|
||||||
|
created: 1,
|
||||||
|
type: "session.execution.failed",
|
||||||
|
durable: durable("ses_1", 1),
|
||||||
|
data: { sessionID: "ses_1", error: { type: "unknown", message: "prior execution failed" } },
|
||||||
|
})
|
||||||
|
second.push({
|
||||||
|
id: "evt_prompted",
|
||||||
|
created: 2,
|
||||||
|
type: "session.input.promoted",
|
||||||
|
durable: durable("ses_1", 2),
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||||
|
})
|
||||||
first.close()
|
first.close()
|
||||||
|
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.status === "reconnecting"))
|
||||||
|
await Bun.sleep(0)
|
||||||
|
idle.resolve()
|
||||||
await turn
|
await turn
|
||||||
|
|
||||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "reconnecting" } })
|
|
||||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } })
|
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1789,52 +2009,6 @@ describe("V2 mini transport", () => {
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves an interrupted turn even when promotion never arrived", async () => {
|
|
||||||
const events = feed()
|
|
||||||
events.push(connected())
|
|
||||||
const client = sdk({
|
|
||||||
streams: [events],
|
|
||||||
active: () => ({ ses_1: { type: "running" } }),
|
|
||||||
})
|
|
||||||
const ui = footer()
|
|
||||||
const transport = await createSessionTransport({
|
|
||||||
sdk: client,
|
|
||||||
sessionID: "ses_1",
|
|
||||||
thinking: false,
|
|
||||||
footer: ui.api,
|
|
||||||
})
|
|
||||||
let admitted = false
|
|
||||||
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
|
|
||||||
// @ts-expect-error successful SDK response is valid for both modes at runtime
|
|
||||||
spyOn(client.session, "prompt").mockImplementation((request) => {
|
|
||||||
admitted = true
|
|
||||||
return ok({ data: promptAdmission(request) })
|
|
||||||
})
|
|
||||||
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
|
|
||||||
|
|
||||||
const turn = transport.runPromptTurn({
|
|
||||||
agent: undefined,
|
|
||||||
model: undefined,
|
|
||||||
variant: undefined,
|
|
||||||
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
|
|
||||||
files: [],
|
|
||||||
includeFiles: true,
|
|
||||||
})
|
|
||||||
while (!admitted) await Bun.sleep(0)
|
|
||||||
await transport.interruptActiveTurn()
|
|
||||||
events.push({
|
|
||||||
id: "evt_settled",
|
|
||||||
created: 0,
|
|
||||||
type: "session.execution.interrupted",
|
|
||||||
durable: durable("ses_1"),
|
|
||||||
data: { sessionID: "ses_1", reason: "user" },
|
|
||||||
})
|
|
||||||
await turn
|
|
||||||
|
|
||||||
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
|
||||||
await transport.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back to the default model when selecting a variant on a fresh session", async () => {
|
test("falls back to the default model when selecting a variant on a fresh session", async () => {
|
||||||
const events = feed()
|
const events = feed()
|
||||||
events.push(connected())
|
events.push(connected())
|
||||||
|
|
@ -1901,7 +2075,8 @@ describe("V2 mini transport", () => {
|
||||||
test("interrupts the current Session when an active turn is aborted", async () => {
|
test("interrupts the current Session when an active turn is aborted", async () => {
|
||||||
const events = feed()
|
const events = feed()
|
||||||
events.push(connected())
|
events.push(connected())
|
||||||
const client = sdk({ streams: [events] })
|
const idle = defer()
|
||||||
|
const client = sdk({ streams: [events], wait: () => idle.promise })
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
const transport = await createSessionTransport({
|
const transport = await createSessionTransport({
|
||||||
sdk: client,
|
sdk: client,
|
||||||
|
|
@ -1940,13 +2115,7 @@ describe("V2 mini transport", () => {
|
||||||
})
|
})
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
controller.abort()
|
controller.abort()
|
||||||
events.push({
|
idle.resolve()
|
||||||
id: "evt_settled",
|
|
||||||
created: 0,
|
|
||||||
type: "session.execution.interrupted",
|
|
||||||
durable: durable("ses_1"),
|
|
||||||
data: { sessionID: "ses_1", reason: "user" },
|
|
||||||
})
|
|
||||||
await turn
|
await turn
|
||||||
|
|
||||||
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
||||||
|
|
@ -2493,90 +2662,6 @@ describe("V2 mini transport", () => {
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not resolve a skill turn before the matching activation is observed", async () => {
|
|
||||||
const events = feed()
|
|
||||||
events.push(connected())
|
|
||||||
const client = sdk({ streams: [events] })
|
|
||||||
const ui = footer()
|
|
||||||
const transport = await createSessionTransport({
|
|
||||||
sdk: client,
|
|
||||||
sessionID: "ses_1",
|
|
||||||
thinking: false,
|
|
||||||
footer: ui.api,
|
|
||||||
})
|
|
||||||
let sent = false
|
|
||||||
spyOn(client.session, "skill").mockImplementation(() => {
|
|
||||||
sent = true
|
|
||||||
return ok(undefined) as never
|
|
||||||
})
|
|
||||||
|
|
||||||
let done = false
|
|
||||||
const turn = transport
|
|
||||||
.runPromptTurn({
|
|
||||||
agent: undefined,
|
|
||||||
model: undefined,
|
|
||||||
variant: undefined,
|
|
||||||
prompt: {
|
|
||||||
messageID: "msg_skill",
|
|
||||||
text: "/tigerstyle",
|
|
||||||
parts: [],
|
|
||||||
command: { name: "tigerstyle", arguments: "", source: "skill" },
|
|
||||||
},
|
|
||||||
files: [],
|
|
||||||
includeFiles: true,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
done = true
|
|
||||||
})
|
|
||||||
while (!sent) await Bun.sleep(0)
|
|
||||||
events.push({
|
|
||||||
id: "evt_other",
|
|
||||||
created: 0,
|
|
||||||
type: "session.skill.activated",
|
|
||||||
durable: durable("ses_1"),
|
|
||||||
data: {
|
|
||||||
sessionID: "ses_1",
|
|
||||||
id: "other",
|
|
||||||
name: "other",
|
|
||||||
text: "other instructions",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
events.push({
|
|
||||||
id: "evt_unrelated_settled",
|
|
||||||
created: 0,
|
|
||||||
type: "session.execution.succeeded",
|
|
||||||
durable: durable("ses_1"),
|
|
||||||
data: { sessionID: "ses_1" },
|
|
||||||
})
|
|
||||||
await Bun.sleep(0)
|
|
||||||
await Bun.sleep(0)
|
|
||||||
expect(done).toBe(false)
|
|
||||||
|
|
||||||
events.push({
|
|
||||||
id: "evt_skill",
|
|
||||||
created: 0,
|
|
||||||
type: "session.skill.activated",
|
|
||||||
durable: durable("ses_1"),
|
|
||||||
data: {
|
|
||||||
sessionID: "ses_1",
|
|
||||||
id: "tigerstyle",
|
|
||||||
name: "tigerstyle",
|
|
||||||
text: "skill instructions",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
events.push({
|
|
||||||
id: "evt_skill_settled",
|
|
||||||
created: 0,
|
|
||||||
type: "session.execution.succeeded",
|
|
||||||
durable: durable("ses_1"),
|
|
||||||
data: { sessionID: "ses_1" },
|
|
||||||
})
|
|
||||||
await turn
|
|
||||||
|
|
||||||
expect(done).toBe(true)
|
|
||||||
await transport.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("refreshes catalogs on connection and location-scoped invalidations", async () => {
|
test("refreshes catalogs on connection and location-scoped invalidations", async () => {
|
||||||
const events = feed()
|
const events = feed()
|
||||||
events.push(connected())
|
events.push(connected())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue