diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index 97d2fd91b65..8549b36d372 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -1,14 +1,5 @@ -import type { - EventSubscribeOutput, - OpenCodeClient, -} from "@opencode-ai/client/promise" -import type { - ReasoningPart, - StepFinishPart, - StepStartPart, - TextPart, - ToolPart, -} from "@opencode-ai/sdk/v2" +import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" +import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2" import { SessionMessage } from "@opencode-ai/schema/session-message" import { EOL } from "node:os" import { UI } from "./ui" @@ -169,8 +160,8 @@ export async function runNonInteractivePrompt(input: Input) { } } if ( - event.type === "session.execution.settled" && - event.data.outcome === "interrupted" && + event.type === "session.execution.interrupted" && + event.data.reason === "user" && (interrupted || permissionRejected || questionRejected || formCancelled) ) { return @@ -194,11 +185,12 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.text.started") { - starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) + starts.set("text", { id: partID(event.id), timestamp: time }) continue } if (event.type === "session.text.ended") { - const started = starts.get(event.data.textID) + const started = starts.get("text") + starts.delete("text") const part: TextPart = { id: started?.id ?? partID(event.id), sessionID: input.sessionID, @@ -212,18 +204,19 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.reasoning.started") { - starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) + starts.set("reasoning", { id: partID(event.id), timestamp: time }) continue } if (event.type === "session.reasoning.ended" && input.thinking) { - const started = starts.get(event.data.reasoningID) + const started = starts.get("reasoning") + starts.delete("reasoning") const part: ReasoningPart = { id: started?.id ?? partID(event.id), sessionID: input.sessionID, messageID: event.data.assistantMessageID, type: "reasoning", text: event.data.text, - metadata: event.data.providerMetadata, + metadata: event.data.state, time: { start: started?.timestamp ?? time, end: time }, } if (emit("reasoning", time, { part })) continue @@ -261,10 +254,10 @@ export async function runNonInteractivePrompt(input: Input) { id: current?.id ?? partID(event.id), timestamp: current?.timestamp ?? time, assistantMessageID: event.data.assistantMessageID, - tool: event.data.tool, + tool: current?.tool ?? "tool", input: event.data.input, raw: current?.raw, - provider: event.data.provider, + provider: { executed: event.data.executed, state: event.data.state }, }) continue } @@ -291,7 +284,7 @@ export async function runNonInteractivePrompt(input: Input) { outputPaths: event.data.outputPaths, result: event.data.result, providerCall: current.provider, - providerResult: event.data.provider, + providerResult: { executed: event.data.executed, state: event.data.resultState }, rawInput: current.raw, }, time: { start: current.timestamp, end: time }, @@ -318,7 +311,7 @@ export async function runNonInteractivePrompt(input: Input) { metadata: { result: event.data.result, providerCall: current.provider, - providerResult: event.data.provider, + providerResult: { executed: event.data.executed, state: event.data.resultState }, rawInput: current.raw, }, time: { start: current.timestamp, end: time }, @@ -353,16 +346,25 @@ export async function runNonInteractivePrompt(input: Input) { if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) continue } - if (event.type === "session.execution.settled") { - if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) { + if (event.type === "session.execution.failed") { + if (!emittedError && !questionRejected && !formCancelled) { emittedError = true process.exitCode = 1 - const error = event.data.error ?? { type: "unknown", message: "Session execution failed" } - if (!emit("error", time, { error })) UI.error(error.message) + if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) } - if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130 return } + if (event.type === "session.execution.interrupted") { + if (event.data.reason === "user" && interrupted) process.exitCode = 130 + if (event.data.reason !== "user" && !emittedError) { + emittedError = true + process.exitCode = 1 + const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` } + if (!emit("error", time, { error })) UI.error(error.message) + } + return + } + if (event.type === "session.execution.succeeded") return } } diff --git a/packages/cli/src/mini/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts index ad34f6ecff3..4aae8e12784 100644 --- a/packages/cli/src/mini/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -35,54 +35,60 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string export function legacyTool(input: { sessionID: string messageID: string - callID: string - name: string - state: SessionMessageAssistantTool["state"] - time: SessionMessageAssistantTool["time"] - provider?: SessionMessageAssistantTool["provider"] + tool: SessionMessageAssistantTool }): ToolPart { + const tool = input.tool + const providerCall = + tool.executed === undefined && tool.providerState === undefined + ? undefined + : { executed: tool.executed, state: tool.providerState } + const providerResult = + tool.executed === undefined && tool.providerResultState === undefined + ? undefined + : { executed: tool.executed, state: tool.providerResultState } const base = { - id: `prt_${input.callID}`, + id: `prt_${tool.id}`, sessionID: input.sessionID, messageID: input.messageID, type: "tool" as const, - callID: input.callID, - tool: input.name, + callID: tool.id, + tool: tool.name, } - if (input.state.status === "pending") { + if (tool.state.status === "pending") { return { ...base, - state: { status: "pending", input: {}, raw: input.state.input }, + state: { status: "pending", input: {}, raw: tool.state.input }, } } - if (input.state.status === "running") { + if (tool.state.status === "running") { return { ...base, state: { status: "running", - input: input.state.input, - title: input.name, - metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider }, - time: { start: input.time.ran ?? input.time.created }, + input: tool.state.input, + title: tool.name, + metadata: { structured: tool.state.structured, content: tool.state.content, providerCall }, + time: { start: tool.time.ran ?? tool.time.created }, }, } } - if (input.state.status === "completed") { + if (tool.state.status === "completed") { return { ...base, state: { status: "completed", - input: input.state.input, - output: outputText(input.state.content), - title: input.name, + input: tool.state.input, + output: outputText(tool.state.content), + title: tool.name, metadata: { - structured: input.state.structured, - content: input.state.content, - outputPaths: input.state.outputPaths, - result: input.state.result, - providerCall: input.provider, + structured: tool.state.structured, + content: tool.state.content, + outputPaths: tool.state.outputPaths, + result: tool.state.result, + providerCall, + providerResult, }, - time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, + time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created }, }, } } @@ -90,15 +96,16 @@ export function legacyTool(input: { ...base, state: { status: "error", - input: input.state.input, - error: input.state.error.message, + input: tool.state.input, + error: tool.state.error.message, metadata: { - structured: input.state.structured, - content: input.state.content, - result: input.state.result, - providerCall: input.provider, + structured: tool.state.structured, + content: tool.state.content, + result: tool.state.result, + providerCall, + providerResult, }, - time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, + time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created }, }, } } @@ -138,6 +145,7 @@ type ToolTrack = { name: string input: Record started: number + providerState?: Record } type ChildState = { @@ -225,6 +233,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const hydrationOverflow = new Set() const hydrations = new Map>() let selected: string | undefined + const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}` const ensureChild = (sessionID: string): ChildState => { const existing = children.get(sessionID) @@ -305,11 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const part = legacyTool({ sessionID: child.sessionID, messageID, - callID: item.id, - name: item.name, - state: item.state, - time: item.time, - provider: item.provider, + tool: item, }) if (item.state.status === "pending") return child.callIDs.add(item.id) @@ -339,31 +344,37 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } if (message.type !== "assistant") continue child.messageIDs.add(message.id) + let textOrdinal = 0 + let reasoningOrdinal = 0 for (const item of message.content) { if (item.type === "text") { - child.text.set(item.id, item.text) - child.projectedText.set(item.id, item.text) - setFrame(child, `text:${item.id}`, { + const id = `text:${textOrdinal++}` + const key = fragmentKey(message.id, id) + child.text.set(key, item.text) + child.projectedText.set(key, item.text) + setFrame(child, key, { kind: "assistant", source: "assistant", text: item.text, phase: "progress", messageID: message.id, - partID: item.id, + partID: id, }) continue } if (item.type === "reasoning") { - child.reasoning.set(item.id, item.text) - child.projectedReasoning.set(item.id, item.text) + const id = `reasoning:${reasoningOrdinal++}` + const key = fragmentKey(message.id, id) + child.reasoning.set(key, item.text) + child.projectedReasoning.set(key, item.text) if (input.thinking) - setFrame(child, `reasoning:${item.id}`, { + setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${item.text}`, phase: "progress", messageID: message.id, - partID: item.id, + partID: id, }) continue } @@ -467,74 +478,88 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac input.emit() return } + if (event.type === "session.text.started") { + return + } if (event.type === "session.text.delta") { - const projected = child.projectedText.get(event.data.textID) + const id = `text:${event.data.ordinal}` + const key = fragmentKey(event.data.assistantMessageID, id) + const projected = child.projectedText.get(key) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { - child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length)) + child.projectedText.set(key, projected.slice(covered + event.data.delta.length)) return } - const next = (child.text.get(event.data.textID) ?? "") + event.data.delta - child.text.set(event.data.textID, next) - setFrame(child, `text:${event.data.textID}`, { + const next = (child.text.get(key) ?? "") + event.data.delta + child.text.set(key, next) + setFrame(child, key, { kind: "assistant", source: "assistant", text: next, phase: "progress", messageID: event.data.assistantMessageID, - partID: event.data.textID, + partID: id, }) touch(child, event.created) notifyDetail(child) return } if (event.type === "session.text.ended") { - child.text.set(event.data.textID, event.data.text) - child.projectedText.delete(event.data.textID) - setFrame(child, `text:${event.data.textID}`, { + const id = `text:${event.data.ordinal}` + const key = fragmentKey(event.data.assistantMessageID, id) + child.text.set(key, event.data.text) + child.projectedText.delete(key) + setFrame(child, key, { kind: "assistant", source: "assistant", text: event.data.text, phase: "progress", messageID: event.data.assistantMessageID, - partID: event.data.textID, + partID: id, }) touch(child, event.created) notifyDetail(child) return } + if (event.type === "session.reasoning.started") { + return + } if (event.type === "session.reasoning.delta") { - const projected = child.projectedReasoning.get(event.data.reasoningID) + const id = `reasoning:${event.data.ordinal}` + const key = fragmentKey(event.data.assistantMessageID, id) + const projected = child.projectedReasoning.get(key) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { - child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length)) + child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length)) return } - const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta - child.reasoning.set(event.data.reasoningID, next) + const next = (child.reasoning.get(key) ?? "") + event.data.delta + child.reasoning.set(key, next) if (!input.thinking) return - setFrame(child, `reasoning:${event.data.reasoningID}`, { + setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${next}`, phase: "progress", messageID: event.data.assistantMessageID, - partID: event.data.reasoningID, + partID: id, }) notifyDetail(child) return } if (event.type === "session.reasoning.ended") { - child.reasoning.set(event.data.reasoningID, event.data.text) - child.projectedReasoning.delete(event.data.reasoningID) + const id = `reasoning:${event.data.ordinal}` + const key = fragmentKey(event.data.assistantMessageID, id) + child.reasoning.set(key, event.data.text) + child.projectedReasoning.delete(key) if (!input.thinking) return - setFrame(child, `reasoning:${event.data.reasoningID}`, { + setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${event.data.text}`, phase: "progress", messageID: event.data.assistantMessageID, - partID: event.data.reasoningID, + partID: id, }) notifyDetail(child) return @@ -548,17 +573,19 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac if (child.finishedTools.has(event.data.callID)) return const current = child.tools.get(event.data.callID) child.tools.set(event.data.callID, { - name: event.data.tool, + name: current?.name ?? "tool", input: event.data.input, started: current?.started ?? event.created, + providerState: event.data.state, }) childTool( child, structuredClone({ type: "tool", id: event.data.callID, - name: event.data.tool, - provider: event.data.provider, + name: current?.name ?? "tool", + executed: event.data.executed, + providerState: event.data.state, state: { status: "running", input: event.data.input, structured: {}, content: [] }, time: { created: current?.started ?? event.created, ran: event.created }, }) as SessionMessageAssistantTool, @@ -578,7 +605,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac type: "tool", id: event.data.callID, name: current?.name ?? "tool", - provider: event.data.provider, + executed: event.data.executed, + providerState: current?.providerState, + providerResultState: event.data.resultState, state: failed ? { status: "error", @@ -608,6 +637,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } + if (event.type === "session.step.ended") return if (event.type === "session.step.failed") { setFrame(child, `error:step:${event.data.assistantMessageID}`, { kind: "error", @@ -620,9 +650,23 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "session.execution.settled") { + if (event.type === "session.execution.started") { + child.status = "running" + touch(child, event.created) + input.emit() + return + } + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) { child.status = - event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" + event.type === "session.execution.succeeded" + ? "completed" + : event.type === "session.execution.interrupted" + ? "cancelled" + : "error" touch(child, event.created) input.emit() } @@ -644,8 +688,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac return { main(event) { + if (event.type === "session.tool.input.started") { + if (event.data.name === "subagent") pendingCalls.set(event.data.callID, {}) + return + } if (event.type === "session.tool.called") { - if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) + if (pendingCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input) return } if (event.type === "session.tool.failed") { diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index 44e413ce52e..a82fea2e58c 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -1,12 +1,8 @@ -import type { - EventSubscribeOutput, - OpenCodeClient, -} from "@opencode-ai/client/promise" +import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" import type { PermissionRequest, QuestionRequest, SessionMessage, - SessionMessageAssistant, SessionMessageAssistantTool, } from "@opencode-ai/sdk/v2" import { Event } from "@opencode-ai/schema/event" @@ -101,6 +97,7 @@ type ToolState = { input: Record started: number running: boolean + providerState?: Record } type State = { @@ -264,8 +261,7 @@ function shellTerminal( : shell.status === "exited" ? `Shell exited with code ${shell.exit ?? "unknown"}` : `Shell ${shell.status}` - if (!error) - return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })] + if (!error) return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })] return [ ...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []), shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }), @@ -310,9 +306,7 @@ async function resolveSelectedModel(input: StreamInput, next: Pick response.model) if (session) return { ...session, variant: next.variant } - const fallback = await input.sdk.model - .default(undefined, { signal: next.signal }) - .then((response) => response.data) + const fallback = await input.sdk.model.default(undefined, { signal: next.signal }).then((response) => response.data) if (!fallback) return return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } } @@ -393,11 +387,7 @@ export async function createSessionTransport(input: StreamInput): Promise= 0) { @@ -643,13 +642,14 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) write([ @@ -659,15 +659,19 @@ export async function createSessionTransport(input: StreamInput): Promise= 0) { @@ -684,13 +688,14 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) write([ @@ -700,7 +705,7 @@ export async function createSessionTransport(input: StreamInput): Promise 0 ? total.toLocaleString() : "" write([], { - phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} ยท ${money.format(event.data.cost)}` : usage, }) return @@ -802,21 +809,33 @@ export async function createSessionTransport(input: StreamInput): Promise readonly structured: { readonly [x: string]: JsonValue } - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } readonly time: { @@ -1073,7 +1070,7 @@ export type SessionContextOutput = { } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } - readonly finish?: string + readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost?: number readonly tokens?: { readonly input: number @@ -1081,7 +1078,12 @@ export type SessionContextOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly error?: { readonly type: "unknown"; readonly message: string } + readonly error?: { readonly type: string; readonly message: string } + readonly retry?: { + readonly attempt: number + readonly at: number + readonly error: { readonly type: string; readonly message: string } + } } | { readonly type: "compaction" @@ -1213,6 +1215,45 @@ export type SessionLogOutput = readonly delivery: "steer" | "queue" } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.succeeded" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly error: { readonly type: string; readonly message: string } + } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.interrupted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" } + } | { readonly id: string readonly created: number @@ -1322,7 +1363,7 @@ export type SessionLogOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly finish: string + readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost: number readonly tokens: { readonly input: number @@ -1344,7 +1385,7 @@ export type SessionLogOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } } } | { @@ -1354,7 +1395,7 @@ export type SessionLogOutput = readonly type: "session.text.started" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } + readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number } } | { readonly id: string @@ -1366,7 +1407,7 @@ export type SessionLogOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly textID: string + readonly ordinal: number readonly text: string } } @@ -1380,8 +1421,8 @@ export type SessionLogOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + readonly ordinal: number + readonly state?: { readonly [x: string]: unknown } } } | { @@ -1394,9 +1435,9 @@ export type SessionLogOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string + readonly ordinal: number readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + readonly state?: { readonly [x: string]: unknown } } } | { @@ -1438,12 +1479,9 @@ export type SessionLogOutput = readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly tool: string readonly input: { readonly [x: string]: unknown } - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly state?: { readonly [x: string]: unknown } } } | { @@ -1482,10 +1520,8 @@ export type SessionLogOutput = > readonly outputPaths?: ReadonlyArray readonly result?: unknown - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly resultState?: { readonly [x: string]: unknown } } } | { @@ -1499,32 +1535,25 @@ export type SessionLogOutput = readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: unknown - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly resultState?: { readonly [x: string]: unknown } } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.retried" + readonly type: "session.retry.scheduled" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string + readonly assistantMessageID: string readonly attempt: number - readonly error: { - readonly message: string - readonly statusCode?: number - readonly isRetryable: boolean - readonly responseHeaders?: { readonly [x: string]: string } - readonly responseBody?: string - readonly metadata?: { readonly [x: string]: string } - } + readonly at: number + readonly error: { readonly type: string; readonly message: string } } } | { @@ -1590,7 +1619,7 @@ export type SessionLogOutput = readonly type: "session.revert.committed" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly messageID: string } + readonly data: { readonly sessionID: string; readonly to: string } } ) | { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number } @@ -1703,23 +1732,20 @@ export type SessionMessageOutput = { readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< - | { readonly type: "text"; readonly id: string; readonly text: string } + | { readonly type: "text"; readonly text: string } | { readonly type: "reasoning" - readonly id: string readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly state?: { readonly [x: string]: JsonValue } readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" readonly id: string readonly name: string - readonly provider?: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - } + readonly executed?: boolean + readonly providerState?: { readonly [x: string]: JsonValue } + readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: | { readonly status: "pending"; readonly input: string } | { @@ -1758,7 +1784,7 @@ export type SessionMessageOutput = { | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > readonly structured: { readonly [x: string]: JsonValue } - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } readonly time: { @@ -1770,7 +1796,7 @@ export type SessionMessageOutput = { } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } - readonly finish?: string + readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost?: number readonly tokens?: { readonly input: number @@ -1778,7 +1804,12 @@ export type SessionMessageOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly error?: { readonly type: "unknown"; readonly message: string } + readonly error?: { readonly type: string; readonly message: string } + readonly retry?: { + readonly attempt: number + readonly at: number + readonly error: { readonly type: string; readonly message: string } + } } | { readonly type: "compaction" @@ -1905,23 +1936,20 @@ export type MessageListOutput = { readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< - | { readonly type: "text"; readonly id: string; readonly text: string } + | { readonly type: "text"; readonly text: string } | { readonly type: "reasoning" - readonly id: string readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly state?: { readonly [x: string]: JsonValue } readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" readonly id: string readonly name: string - readonly provider?: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } - } + readonly executed?: boolean + readonly providerState?: { readonly [x: string]: JsonValue } + readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: | { readonly status: "pending"; readonly input: string } | { @@ -1960,7 +1988,7 @@ export type MessageListOutput = { | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > readonly structured: { readonly [x: string]: JsonValue } - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } readonly time: { @@ -1972,7 +2000,7 @@ export type MessageListOutput = { } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } - readonly finish?: string + readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost?: number readonly tokens?: { readonly input: number @@ -1980,7 +2008,12 @@ export type MessageListOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly error?: { readonly type: "unknown"; readonly message: string } + readonly error?: { readonly type: string; readonly message: string } + readonly retry?: { + readonly attempt: number + readonly at: number + readonly error: { readonly type: string; readonly message: string } + } } | { readonly type: "compaction" @@ -4456,13 +4489,37 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.execution.settled" + readonly type: "session.execution.started" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly sessionID: string - readonly outcome: "success" | "failure" | "interrupted" - readonly error?: { readonly type: "unknown"; readonly message: string } - } + readonly data: { readonly sessionID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.succeeded" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.failed" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly error: { readonly type: string; readonly message: string } } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.execution.interrupted" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" } } | { readonly id: string @@ -4573,7 +4630,7 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly finish: string + readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" readonly cost: number readonly tokens: { readonly input: number @@ -4595,7 +4652,7 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } } } | { @@ -4605,7 +4662,7 @@ export type EventSubscribeOutput = readonly type: "session.text.started" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } + readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number } } | { readonly id: string @@ -4616,7 +4673,7 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly textID: string + readonly ordinal: number readonly delta: string } } @@ -4630,7 +4687,7 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly textID: string + readonly ordinal: number readonly text: string } } @@ -4644,8 +4701,8 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + readonly ordinal: number + readonly state?: { readonly [x: string]: unknown } } } | { @@ -4657,7 +4714,7 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string + readonly ordinal: number readonly delta: string } } @@ -4671,9 +4728,9 @@ export type EventSubscribeOutput = readonly data: { readonly sessionID: string readonly assistantMessageID: string - readonly reasoningID: string + readonly ordinal: number readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + readonly state?: { readonly [x: string]: unknown } } } | { @@ -4728,12 +4785,9 @@ export type EventSubscribeOutput = readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly tool: string readonly input: { readonly [x: string]: unknown } - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly state?: { readonly [x: string]: unknown } } } | { @@ -4772,10 +4826,8 @@ export type EventSubscribeOutput = > readonly outputPaths?: ReadonlyArray readonly result?: unknown - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly resultState?: { readonly [x: string]: unknown } } } | { @@ -4789,32 +4841,25 @@ export type EventSubscribeOutput = readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly error: { readonly type: "unknown"; readonly message: string } + readonly error: { readonly type: string; readonly message: string } readonly result?: unknown - readonly provider: { - readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } + readonly executed: boolean + readonly resultState?: { readonly [x: string]: unknown } } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.retried" + readonly type: "session.retry.scheduled" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string + readonly assistantMessageID: string readonly attempt: number - readonly error: { - readonly message: string - readonly statusCode?: number - readonly isRetryable: boolean - readonly responseHeaders?: { readonly [x: string]: string } - readonly responseBody?: string - readonly metadata?: { readonly [x: string]: string } - } + readonly at: number + readonly error: { readonly type: string; readonly message: string } } } | { @@ -4888,7 +4933,7 @@ export type EventSubscribeOutput = readonly type: "session.revert.committed" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly messageID: string } + readonly data: { readonly sessionID: string; readonly to: string } } | { readonly id: string diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 9a307f2200b..f7f798f2e1b 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -155,12 +155,10 @@ current omissions to implement, not intentional product boundaries. collection values, then extend it to bounded host streams when a stream boundary exists. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to `Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed. -- [ ] Close basic `Object` parity gaps: let `Object.values`/`Object.entries` accept arrays, make `Object.assign` validate - and mutate its target, add `Object.is`, and let `Object.fromEntries` consume every supported iterable. +- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics. - [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set composition methods, and `Array.prototype.toSpliced`. -- [ ] Complete the deterministic `Math` surface beyond the current arithmetic, rounding, root, power, and logarithm - helpers. Decide separately whether nondeterministic `Math.random` belongs in the runtime. +- [ ] Decide whether nondeterministic `Math.random` and iterable `Math.sumPrecise` belong in the runtime. - [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter defects are distinguishable without leaking private causes. diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 0869a89cb28..08d47cd37e8 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -45,6 +45,7 @@ export class CodeModeFunction { readonly parameters: ReadonlyArray, readonly body: AstNode, readonly capturedScopes: ReadonlyArray>, + readonly async: boolean, ) {} } @@ -153,7 +154,8 @@ export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRunti [supportedSyntaxMessage], ) -export const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null export const asNode = (value: unknown, context: string): AstNode => { if (!isRecord(value) || typeof value.type !== "string") { diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index f34e3949e12..2825e744b5f 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -66,7 +66,7 @@ import { numberMethods, numberStatics, } from "../stdlib/number.js" -import { invokeObjectMethod } from "../stdlib/object.js" +import { invokeObjectMethod, objectMethodsPreservingIdentity } from "../stdlib/object.js" import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js" import { escapeRegexHint, @@ -612,12 +612,13 @@ class Interpreter { // Fiber-backed promises whose settlement no program construct has observed yet. Successful // program completion drains these (like a runtime waiting on in-flight work at exit) and // surfaces a never-awaited failure as an unhandled-rejection diagnostic. - private readonly pendingSettlements = new Set() + private readonly pendingSettlements: Set constructor( invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, logs: Array = [], + shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set }, ) { const globalScope = new Map() this.scopes = [globalScope] @@ -625,7 +626,8 @@ class Interpreter { this.toolKeys = toolKeys this.logs = logs this.lastValue = undefined - this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) + this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) + this.pendingSettlements = shared?.pendingSettlements ?? new Set() globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) globalScope.set("undefined", { mutable: false, value: undefined }) @@ -705,15 +707,17 @@ class Interpreter { private drainPendingSettlements(): Effect.Effect { const self = this return Effect.gen(function* () { - for (const promise of [...self.pendingSettlements]) { + while (self.pendingSettlements.size > 0) { + const promise = self.pendingSettlements.values().next().value + if (promise === undefined) break const exit = yield* self.observePromise(promise) if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue const failure = normalizeError(Cause.squash(exit.cause)) throw new InterpreterRuntimeError( - `Unhandled rejection from an un-awaited tool call: ${failure.message}`, + `Unhandled rejection from an un-awaited promise: ${failure.message}`, undefined, failure.kind, - ["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."], + ["Await promises so failures can be caught and handled."], ) } }) @@ -727,17 +731,15 @@ class Interpreter { path: ReadonlyArray, args: Array, ): Effect.Effect { - const self = this - return Effect.map( - Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), { - startImmediately: true, - }), - (fiber) => { - const promise = new SandboxPromise(fiber) - self.pendingSettlements.add(promise) - return promise - }, - ) + return this.createPromise(this.callPermits.withPermit(Effect.suspend(() => this.invokeTool(path, args)))) + } + + private createPromise(effect: Effect.Effect): Effect.Effect { + return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => { + const promise = new SandboxPromise(fiber) + this.pendingSettlements.add(promise) + return promise + }) } // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking. @@ -858,6 +860,7 @@ class Interpreter { getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), getNode(node, "body"), this.scopes.slice(), + node.async === true, ) } @@ -1085,7 +1088,7 @@ class Interpreter { } let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined - let assignmentName: string | undefined + let assignment: AstNode | undefined if (left.type === "VariableDeclaration") { const declarations = getArray(left, "declarations") @@ -1095,8 +1098,13 @@ class Interpreter { const declarator = asNode(declarations[0], "declarations[0]") declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } - } else if (left.type === "Identifier") { - assignmentName = getString(left, "name") + } else if ( + left.type === "Identifier" || + left.type === "MemberExpression" || + left.type === "ArrayPattern" || + left.type === "ObjectPattern" + ) { + assignment = left } else { throw new InterpreterRuntimeError("Unsupported for...of binding.", left) } @@ -1105,8 +1113,8 @@ class Interpreter { if (declaration) { self.pushScope() yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) - } else if (assignmentName) { - self.setIdentifierValue(assignmentName, value, left) + } else if (assignment) { + yield* self.assignPattern(assignment, value, left) } const result = yield* self.evaluateStatement(body).pipe( @@ -1504,6 +1512,16 @@ class Interpreter { return this.evaluateUnaryExpression(node) case "AssignmentExpression": return this.evaluateAssignmentExpression(node) + case "SequenceExpression": { + const self = this + return Effect.gen(function* () { + let result: unknown + for (const expression of getArray(node, "expressions")) { + result = yield* self.evaluateExpression(asNode(expression, "expressions")) + } + return result + }) + } case "CallExpression": return this.evaluateCallExpression(node) case "ArrowFunctionExpression": @@ -2010,9 +2028,9 @@ class Interpreter { if (callable instanceof GlobalMethodReference) { if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) if (callable.namespace === "Object" && args[0] instanceof ToolReference) { - return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) + return self.invokeObjectMethodOnTools(callable.name, args[0], node) } - if (callable.namespace === "Object" && callable.name === "assign") { + if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { return invokeGlobalMethod(callable, args, node) } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) @@ -2033,8 +2051,8 @@ class Interpreter { // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate // namespace/tool names from the host tool tree - the discovery idiom a model reaches for - // first. Every other Object helper cannot produce data from a tool reference, so it fails - // with a pointer at the working idioms instead of the generic plain-objects-only message. + // first. Other Object helpers fail with a pointer at the working idioms instead of a generic + // plain-data message. private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { if (name === "keys") { return boundedData(this.enumerableKeys(ref)!, "Object.keys result") @@ -2226,14 +2244,36 @@ class Interpreter { switch (ref.name) { case "all": { // Mark every promise element observed up-front (Promise.all handles all of its - // members' failures, as in JS), then join in index order; the first failure rejects - // the whole call while unrelated in-flight members keep running. - const settles = items.map((item) => - item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item), + // members' failures, as in JS), race their settlements for fail-fast rejection, and + // preserve input order when they all fulfill. Rejected calls keep draining siblings. + const observations = items.map((item, index) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit })) + : Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }), ) return Effect.gen(function* () { + const remaining = [...observations] const values: Array = [] - for (const settle of settles) values.push(yield* settle) + values.length = items.length + while (remaining.length > 0) { + const winner = yield* Effect.raceAll(remaining) + const position = remaining.indexOf(observations[winner.index]) + if (position >= 0) remaining.splice(position, 1) + if (Exit.isSuccess(winner.exit)) { + values[winner.index] = winner.exit.value + continue + } + yield* self.createPromise( + Effect.asVoid( + Effect.forEach( + items, + (item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void), + { concurrency: "unbounded" }, + ), + ), + ) + return yield* self.unwrapPromiseExit(winner.item, winner.exit, node) + } return values }) } @@ -2307,43 +2347,42 @@ class Interpreter { } private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const self = this - return Effect.suspend(() => { - const savedScopes = self.scopes - self.scopes = [...fn.capturedScopes, new Map()] - const run = Effect.gen(function* () { - // Seed every parameter name into the scope as a TDZ slot first, so a default that - // references another parameter resolves to that (uninitialized) param rather than - // silently falling through to an outer binding of the same name - matching JS. - const paramScope = self.currentScope() - for (const parameter of fn.parameters) { - for (const name of collectPatternNames(parameter)) { - paramScope.set(name, { mutable: true, value: undefined, initialized: false }) - } - } - for (const [index, parameter] of fn.parameters.entries()) { - if (parameter.type === "RestElement") { - yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) - break - } - yield* self.declarePattern(parameter, args[index], true, parameter) - } - - if (fn.body.type === "BlockStatement") { - const result = yield* self.evaluateStatement(fn.body) - return result.kind === "return" || result.kind === "value" ? result.value : undefined - } - - return yield* self.evaluateExpression(fn.body) - }) - return run.pipe( - Effect.ensuring( - Effect.sync(() => { - self.scopes = savedScopes - }), - ), - ) + const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, { + callPermits: this.callPermits, + pendingSettlements: this.pendingSettlements, }) + invocation.scopes = [...fn.capturedScopes, new Map()] + const run = Effect.gen(function* () { + // Seed every parameter name into the scope as a TDZ slot first, so a default that + // references another parameter resolves to that (uninitialized) param rather than + // silently falling through to an outer binding of the same name - matching JS. + const paramScope = invocation.currentScope() + for (const parameter of fn.parameters) { + for (const name of collectPatternNames(parameter)) { + paramScope.set(name, { mutable: true, value: undefined, initialized: false }) + } + } + for (const [index, parameter] of fn.parameters.entries()) { + if (parameter.type === "RestElement") { + yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) + break + } + yield* invocation.declarePattern(parameter, args[index], true, parameter) + } + + if (fn.body.type === "BlockStatement") { + const result = yield* invocation.evaluateStatement(fn.body) + return result.kind === "return" || result.kind === "value" ? result.value : undefined + } + + return yield* invocation.evaluateExpression(fn.body) + }) + if (!fn.async) return run + return this.createPromise( + Effect.flatMap(run, (value) => + value instanceof SandboxPromise ? invocation.settlePromise(value) : Effect.succeed(value), + ), + ) } private invokeIntrinsic( @@ -2432,13 +2471,19 @@ class Interpreter { else value.replaceAll(pattern, collect) } + const self = this return Effect.gen(function* () { const output: Array = [] let end = 0 for (const match of matches) { + const replacement = yield* apply(match.args) + const resolved = + args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise + ? yield* self.settlePromise(replacement) + : replacement output.push( value.slice(end, match.offset), - coerceToString(boundedData(yield* apply(match.args), `String.${name} replacer result`)), + coerceToString(boundedData(resolved, `String.${name} replacer result`)), ) end = match.offset + match.match.length } diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index cc8dd0670e9..edb7e3d94bc 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -4,6 +4,13 @@ export const mathMethods = new Set([ "max", "min", "abs", + "acos", + "acosh", + "asin", + "asinh", + "atan", + "atan2", + "atanh", "floor", "ceil", "round", @@ -13,10 +20,22 @@ export const mathMethods = new Set([ "cbrt", "pow", "hypot", + "cos", + "cosh", + "sin", + "sinh", + "tan", + "tanh", "log", "log2", "log10", + "log1p", "exp", + "expm1", + "f16round", + "fround", + "clz32", + "imul", ]) export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { @@ -33,6 +52,20 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo return Math.min(...nums) case "abs": return Math.abs(a) + case "acos": + return Math.acos(a) + case "acosh": + return Math.acosh(a) + case "asin": + return Math.asin(a) + case "asinh": + return Math.asinh(a) + case "atan": + return Math.atan(a) + case "atan2": + return Math.atan2(a, b) + case "atanh": + return Math.atanh(a) case "floor": return Math.floor(a) case "ceil": @@ -51,14 +84,38 @@ export const invokeMathMethod = (name: string, args: Array, node: AstNo return Math.pow(a, b) case "hypot": return Math.hypot(...nums) + case "cos": + return Math.cos(a) + case "cosh": + return Math.cosh(a) + case "sin": + return Math.sin(a) + case "sinh": + return Math.sinh(a) + case "tan": + return Math.tan(a) + case "tanh": + return Math.tanh(a) case "log": return Math.log(a) case "log2": return Math.log2(a) case "log10": return Math.log10(a) + case "log1p": + return Math.log1p(a) case "exp": return Math.exp(a) + case "expm1": + return Math.expm1(a) + case "f16round": + return Math.f16round(a) + case "fround": + return Math.fround(a) + case "clz32": + return Math.clz32(a) + case "imul": + return Math.imul(a, b) } throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) } diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 4d454cbda25..73b548d4333 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -1,17 +1,20 @@ import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { isBlockedMember } from "../tool-runtime.js" -import { isSandboxValue, SandboxMap, SandboxURLSearchParams } from "../values.js" +import { isSandboxValue, SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js" import { boundedData, coerceToString } from "./value.js" export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"]) +export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"]) export const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) const requireObject = (): Record => { + const input = args[0] const value = boundedData(args[0], `Object.${name} input`) + if (Array.isArray(input)) return input as unknown as Record if (isSandboxValue(value)) return {} - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) + if (value === null || typeof value !== "object") { + throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node) } return value as Record } @@ -19,6 +22,11 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) out[key] = item } + const addEntry = (out: Record, key: unknown, item: unknown): void => { + boundedData(key, "Object.fromEntries key") + boundedData(item, "Object.fromEntries value") + guardedSet(out, coerceToString(key), item) + } switch (name) { case "keys": { const value = boundedData(args[0], "Object.keys input") @@ -55,7 +63,7 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast case "fromEntries": { if (args[0] instanceof SandboxMap) { const out: Record = Object.create(null) - for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item) + for (const [key, item] of args[0].map.entries()) addEntry(out, key, item) return out } if (args[0] instanceof SandboxURLSearchParams) { @@ -63,16 +71,18 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value) return out } - const pairs = boundedData(args[0], "Object.fromEntries input") + const pairs = args[0] instanceof SandboxSet ? Array.from(args[0].set.values()) : args[0] if (!Array.isArray(pairs)) { + boundedData(args[0], "Object.fromEntries input") throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) } const out: Record = Object.create(null) for (const pair of pairs) { - if (!Array.isArray(pair)) { - throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) - } - guardedSet(out, String(pair[0]), pair[1]) + const validated = boundedData(pair, "Object.fromEntries entry") + if (validated === null || typeof validated !== "object" || isSandboxValue(validated)) + throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node) + const entry = pair as Record + addEntry(out, entry[0], entry[1]) } return out } diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f4ccc61d4c4..7d67ba6c79f 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -274,6 +274,16 @@ const copyBounded = ( if (Array.isArray(value)) { const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues)) + if (preserveSandboxValues) { + // Array metadata is not serialized, but intra-sandbox copies must retain it. + for (const [key, item] of Object.entries(value)) { + if (Object.hasOwn(copied, key)) continue + if (isBlockedMember(key)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) + } + Reflect.set(copied, key, copyBounded(item, label, depth + 1, seen, true)) + } + } seen.delete(value) return copied } diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 1831a7222df..5593831127a 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -464,6 +464,54 @@ describe("H5: builtin coercion functions work as array callbacks", () => { }) }) +describe("for...of assignment destructuring", () => { + test("assigns entry pairs into predeclared variables", async () => { + expect( + await value(` + let key + let item + const out = [] + for ([key, item] of Object.entries({ a: 1, b: 2 })) out.push(key + item) + return { key, item, out } + `), + ).toEqual({ key: "b", item: 2, out: ["a1", "b2"] }) + }) + + test("assigns object patterns and defaults", async () => { + expect( + await value(` + let id + let label + const labels = [] + for ({ id, label = "unknown" } of [{ id: 1 }, { id: 2, label: "two" }]) labels.push(label) + return { id, label, labels } + `), + ).toEqual({ id: 2, label: "two", labels: ["unknown", "two"] }) + }) +}) + +describe("sequence expressions", () => { + test("evaluate left to right and return the final value", async () => { + expect(await value(`let x = 0; const result = (x += 1, x *= 3, x + 2); return { x, result }`)).toEqual({ + x: 3, + result: 5, + }) + }) + + test("support comma-separated for-loop updates", async () => { + expect( + await value(` + const pairs = [] + for (let left = 0, right = 3; left < right; left++, right--) pairs.push([left, right]) + return pairs + `), + ).toEqual([ + [0, 3], + [1, 2], + ]) + }) +}) + describe("destructuring assignment", () => { test("assigns object and array patterns to existing bindings", async () => { expect( diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 545d463abfa..313b9c22cc0 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -48,6 +48,14 @@ const failingTool = Tool.make({ run: () => Effect.fail(toolError("Lookup refused")), }) +const completedTool = (trace: Trace) => + Tool.make({ + description: "Return the number of completed sleepy calls", + input: Schema.Struct({}), + output: Schema.Number, + run: () => Effect.succeed(trace.completed), + }) + const run = ( code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}, @@ -55,7 +63,7 @@ const run = ( const trace = options.trace ?? makeTrace() return Effect.runPromise( CodeMode.execute({ - tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, + tools: { host: { sleepy: sleepyTool(trace), fail: failingTool, completed: completedTool(trace) } }, code, ...(options.limits ? { limits: options.limits } : {}), }), @@ -75,6 +83,42 @@ const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.E } describe("first-class promise values", () => { + test("async functions return promises with isolated concurrent invocations", async () => { + expect( + await value(` + const load = async (id) => { + const result = await tools.host.sleepy({ id, ms: 20 }) + return [id, result] + } + const first = load(1) + const second = load(2) + return [first instanceof Promise, second instanceof Promise, await Promise.all([first, second])] + `), + ).toEqual([ + true, + true, + [ + [1, 1], + [2, 2], + ], + ]) + }) + + test("async function errors reject instead of throwing at the call site", async () => { + expect( + await value(` + const fail = async () => { throw new Error("boom") } + const promise = fail() + try { + await promise + return "no" + } catch (error) { + return error.message + } + `), + ).toBe("boom") + }) + test("an un-awaited tool call starts eagerly, in call order, before any await", async () => { const trace = makeTrace() const result = await value( @@ -163,9 +207,33 @@ describe("first-class promise values", () => { return "done" `) expect(diagnostic.kind).toBe("ToolFailure") - expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise") + expect(diagnostic.message).toContain("Lookup refused") + expect(diagnostic.suggestions?.join(" ")).toContain("Await promises") + }) + + test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => { + const diagnostic = await error(` + const fail = async () => { throw new Error("boom") } + fail() + return "done" + `) + expect(diagnostic.kind).toBe("ExecutionFailure") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise") + expect(diagnostic.message).toContain("boom") + }) + + test("drains promises started by an async function after an await", async () => { + const diagnostic = await error(` + const run = async () => { + await tools.host.sleepy({ id: 1 }) + tools.host.fail({}) + } + run() + return "done" + `) + expect(diagnostic.kind).toBe("ToolFailure") expect(diagnostic.message).toContain("Lookup refused") - expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)") }) }) @@ -232,6 +300,19 @@ describe("Promise.all over arbitrary arrays", () => { expect(trace.maxActive).toBeGreaterThan(1) }) + test("runs async map callbacks concurrently", async () => { + const trace = makeTrace() + const result = await value( + ` + const ids = [1, 2, 3, 4] + return await Promise.all(ids.map(async (id) => await tools.host.sleepy({ id, ms: 40 }))) + `, + { trace }, + ) + expect(result).toEqual([1, 2, 3, 4]) + expect(trace.maxActive).toBeGreaterThan(1) + }) + test("caps live tool-call concurrency at the fixed internal constant (8)", async () => { const trace = makeTrace() const result = await value( @@ -265,6 +346,28 @@ describe("Promise.all over arbitrary arrays", () => { ).toBe("Lookup refused") }) + test("rejects before an earlier slow promise fulfills", async () => { + const trace = makeTrace() + expect( + await value( + ` + try { + await Promise.all([ + tools.host.sleepy({ id: 1, ms: 100 }), + tools.host.fail({}), + ]) + return -1 + } catch { + return await tools.host.completed({}) + } + `, + { trace }, + ), + ).toBe(0) + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + test("a non-collection argument is a clear error", async () => { const diagnostic = await error(`return await Promise.all(42)`) expect(diagnostic.message).toContain("Promise.all expects an array") diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 489de451a79..59262f3197d 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -586,6 +586,85 @@ describe("Set", () => { }) describe("stdlib integration", () => { + test("Object values and entries accept arrays", async () => { + expect(await value(`return [Object.values(["a", "b"]), Object.entries(["a", "b"])]`)).toEqual([ + ["a", "b"], + [ + ["0", "a"], + ["1", "b"], + ], + ]) + expect(await value(`const match = /a/.exec("ba"); return [Object.values(match), Object.entries(match)]`)).toEqual([ + ["a", 1], + [ + ["0", "a"], + ["index", 1], + ], + ]) + expect(await value(`return Object.keys(Object.values({ match: /a/.exec("ba") })[0])`)).toEqual(["0", "index"]) + }) + + test("Object.fromEntries accepts every supported entry collection", async () => { + expect( + await value(` + return [ + Object.fromEntries([["a", 1]]), + Object.fromEntries(new Map([["b", 2]])), + Object.fromEntries(new Set([["c", 3]])), + Object.fromEntries(new URLSearchParams("d=4")), + Object.fromEntries([{ 0: "e", 1: 5 }]), + Object.fromEntries(new Set([[{}, 6], [new Date(0), 7], [null, 8], [undefined, 9]])), + ] + `), + ).toEqual([ + { a: 1 }, + { b: 2 }, + { c: 3 }, + { d: "4" }, + { e: 5 }, + { "[object Object]": 6, "1970-01-01T00:00:00.000Z": 7, null: 8, undefined: 9 }, + ]) + expect(await value(`try { Object.fromEntries(new Set([Math.max])); return false } catch { return true }`)).toBe( + true, + ) + expect( + await value( + `try { Object.fromEntries(new Map([["fn", Math.max]])); return false } catch { return true }`, + ), + ).toBe(true) + }) + + test("deterministic Math methods match the host runtime", async () => { + const result = await value(` + return [ + Math.acos(0.5), Math.acosh(2), Math.asin(0.5), Math.asinh(2), Math.atan(1), Math.atan2(1, 2), Math.atanh(0.5), + Math.cos(0.5), Math.cosh(0.5), Math.sin(0.5), Math.sinh(0.5), Math.tan(0.5), Math.tanh(0.5), + Math.log1p(0.5), Math.expm1(0.5), Math.f16round(1.337), Math.fround(1.337), Math.clz32(1), Math.imul(2, 3), + ] + `) + expect(result).toEqual([ + Math.acos(0.5), + Math.acosh(2), + Math.asin(0.5), + Math.asinh(2), + Math.atan(1), + Math.atan2(1, 2), + Math.atanh(0.5), + Math.cos(0.5), + Math.cosh(0.5), + Math.sin(0.5), + Math.sinh(0.5), + Math.tan(0.5), + Math.tanh(0.5), + Math.log1p(0.5), + Math.expm1(0.5), + Math.f16round(1.337), + Math.fround(1.337), + Math.clz32(1), + Math.imul(2, 3), + ]) + }) + test("Object.assign mutates and returns its target", async () => { expect( await value(` diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 9d3e4e673f9..adb443ae0ef 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -44,6 +44,7 @@ export const migrations = ( import("./migration/20260703090000_reset_v2_event_rename_sweep"), import("./migration/20260703181610_event_created_column"), import("./migration/20260703190000_reset_v2_shell_event_payloads"), + import("./migration/20260703200000_reset_v2_session_events"), import("./migration/20260705180000_rename_instructions"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts b/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts new file mode 100644 index 00000000000..75108b3f118 --- /dev/null +++ b/packages/core/src/database/migration/20260703200000_reset_v2_session_events.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260703200000_reset_v2_session_events", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 15d9047c4f7..99743abd2f3 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -67,7 +67,13 @@ export class CorrectedError extends Schema.TaggedErrorClass()("P export class BlockedError extends Schema.TaggedErrorClass()("PermissionV2.BlockedError", { rules: Permission.Ruleset, -}) {} + permission: Schema.String, + resources: Schema.Array(Schema.String), +}) { + override get message() { + return `Permission denied: ${this.permission}` + } +} export class NotFoundError extends Schema.TaggedErrorClass()("PermissionV2.NotFoundError", { requestID: ID, @@ -201,6 +207,8 @@ const layer = Layer.effect( if (result.effect === "deny") { return yield* new BlockedError({ rules: relevant(input, result.rules), + permission: input.action, + resources: input.resources, }) } if (result.effect === "allow") return diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 0d3cd10bc94..57cdbb2c2b5 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -238,7 +238,13 @@ const make = (dependencies: Dependencies) => { .pipe( Stream.runForEach((event) => { if (LLMEvent.is.providerError(event)) failed = true - if (LLMEvent.is.textDelta(event)) chunks.push(event.text) + if (LLMEvent.is.textDelta(event)) { + chunks.push(event.text) + return dependencies.events.publish(SessionEvent.Compaction.Delta, { + sessionID: input.sessionID, + text: event.text, + }) + } return Effect.void }), Effect.as(true), diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 68f4fd32a43..7842390f3b9 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -1,6 +1,7 @@ import { Schema } from "effect" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" +import { SessionError } from "@opencode-ai/schema/session-error" export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { sessionID: SessionSchema.ID, @@ -10,3 +11,20 @@ export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.StepFailedError", { + error: SessionError.Error, +}) { + override get message() { + return this.error.message + } +} + +export class UserInterruptedError extends Schema.TaggedErrorClass()( + "Session.UserInterruptedError", + {}, +) { + override get message() { + return "Session interrupted by user" + } +} diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 4e68e6c3d96..c925ee655c0 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -1,4 +1,4 @@ -import { Cause, DateTime, Effect, Exit, Layer } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import { EventV2 } from "../../event" import { LocationServiceMap } from "../../location-service-map" import { makeGlobalNode } from "../../effect/app-node" @@ -8,6 +8,16 @@ import { SessionRunner } from "../runner" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionExecution } from "../execution" +import { toSessionError } from "../to-session-error" +import { UserInterruptedError } from "../error" + +export function terminal(exit: Exit.Exit, reason?: "user" | "shutdown" | "superseded") { + if (Exit.isSuccess(exit)) return { type: "succeeded" as const } + if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" } + const failure = Cause.squash(exit.cause) + if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const } + return { type: "failed" as const, error: toSessionError(failure) } +} /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ const layer = Layer.effect( @@ -16,7 +26,23 @@ const layer = Layer.effect( const store = yield* SessionStore.Service const locations = yield* LocationServiceMap.Service const events = yield* EventV2.Service - const coordinator = yield* SessionRunCoordinator.make({ + const reportLifecycle = (sessionID: SessionSchema.ID, effect: Effect.Effect) => + effect.pipe( + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("Failed to publish Session execution lifecycle", cause).pipe( + Effect.annotateLogs({ sessionID }), + ), + ), + Effect.asVoid, + ) + const coordinator = yield* SessionRunCoordinator.make< + SessionSchema.ID, + SessionRunner.RunError, + "user" | "shutdown" | "superseded" + >({ + started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })), drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) @@ -29,28 +55,31 @@ const layer = Layer.effect( ), ) }), - // One ExecutionSettled per execution (busy period), covering every coalesced drain. - settled: (sessionID, exit) => - Effect.gen(function* () { - const failure = - Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined - yield* events.publish(SessionEvent.ExecutionSettled, { - sessionID, - outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure", - error: - failure !== undefined - ? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) } - : undefined, - }) - }).pipe( - Effect.catchCause(() => Effect.void), - Effect.asVoid, + // One terminal observation per busy period, covering every coalesced drain. + settled: (sessionID, exit, reason) => + reportLifecycle( + sessionID, + Effect.gen(function* () { + const outcome = terminal(exit, reason) + if (outcome.type === "succeeded") { + yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID }) + return + } + if (outcome.type === "interrupted") { + yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason }) + return + } + yield* events.publish(SessionEvent.Execution.Failed, { + sessionID, + error: outcome.error, + }) + }), ), }) return SessionExecution.Service.of({ active: coordinator.active, - interrupt: coordinator.interrupt, + interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"), resume: coordinator.run, wake: coordinator.wake, awaitIdle: coordinator.awaitIdle, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 4bf4e438258..e6f455518c7 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -1,5 +1,5 @@ import { castDraft, produce, type WritableDraft } from "immer" -import { Effect } from "effect" +import { DateTime, Effect } from "effect" import { SessionEvent } from "./event" import { SessionMessage } from "./message" @@ -99,11 +99,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID), ) - const latestText = (assistant: DraftAssistant | undefined, textID: string) => - assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID) + const latestText = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftText => item.type === "text") - const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => - assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) + const latestReasoning = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed) const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) => Effect.gen(function* () { @@ -111,6 +111,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe)) }) + const clearCurrentRetry = Effect.gen(function* () { + const assistant = yield* adapter.getCurrentAssistant() + if (assistant?.retry) { + yield* adapter.updateAssistant( + produce(assistant, (draft) => { + draft.retry = undefined + }), + ) + } + }) + return Effect.gen(function* () { yield* SessionEvent.All.match(event, { "session.agent.selected": (event) => { @@ -144,7 +155,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.forked": () => Effect.void, "session.prompt.promoted": () => Effect.void, "session.prompt.admitted": () => Effect.void, - "session.execution.settled": () => Effect.void, + "session.execution.started": () => Effect.void, + "session.execution.succeeded": () => clearCurrentRetry, + "session.execution.failed": () => clearCurrentRetry, + "session.execution.interrupted": () => clearCurrentRetry, "session.instructions.updated": (event) => adapter.appendMessage( SessionMessage.System.make({ @@ -206,10 +220,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.step.started": (event) => { return Effect.gen(function* () { + const existing = yield* adapter.getAssistant(event.data.assistantMessageID) + if (existing) { + yield* adapter.updateAssistant( + produce(existing, (draft) => { + draft.agent = event.data.agent + draft.model = castDraft(event.data.model) + draft.retry = undefined + draft.error = undefined + draft.finish = undefined + draft.time.completed = undefined + if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot } + }), + ) + return + } const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { + draft.retry = undefined draft.time.completed = event.created }), ) @@ -245,25 +275,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.time.completed = event.created draft.finish = "error" - draft.error = event.data.error + draft.error = castDraft(event.data.error) + draft.retry = undefined }) }, "session.text.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - draft.content.push( - castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), - ) + draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" }))) }) }, "session.text.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - const match = latestText(draft, event.data.textID) + const match = latestText(draft) if (match) match.text += event.data.delta }) }, "session.text.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - const match = latestText(draft, event.data.textID) + const match = latestText(draft) if (match) match.text = event.data.text }) }, @@ -293,7 +322,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match) { - match.provider = event.data.provider + match.executed = event.data.executed + match.providerState = event.data.state match.time.ran = event.created match.state = castDraft( SessionMessage.ToolStateRunning.make({ @@ -319,11 +349,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { - match.provider = { - executed: event.data.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.data.provider.metadata, - } + match.executed = event.data.executed || match.executed === true + match.providerResultState = event.data.resultState match.time.completed = event.created match.state = castDraft( SessionMessage.ToolStateCompleted.make({ @@ -342,11 +369,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && (match.state.status === "pending" || match.state.status === "running")) { - match.provider = { - executed: event.data.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.data.provider.metadata, - } + match.executed = event.data.executed || match.executed === true + match.providerResultState = event.data.resultState match.time.completed = event.created match.state = castDraft( SessionMessage.ToolStateError.make({ @@ -367,9 +391,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { castDraft( SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: event.data.reasoningID, text: "", - providerMetadata: event.data.providerMetadata, + state: event.data.state, time: { created: event.created }, }), ), @@ -378,21 +401,29 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.reasoning.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) + const match = latestReasoning(draft) if (match) match.text += event.data.delta }) }, "session.reasoning.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) + const match = latestReasoning(draft) if (match) { match.text = event.data.text match.time = { created: match.time?.created ?? event.created, completed: event.created } - if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata + if (event.data.state !== undefined) match.state = event.data.state + } + }) + }, + "session.retry.scheduled": (event) => { + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + draft.retry = { + attempt: event.data.attempt, + at: DateTime.makeUnsafe(event.data.at), + error: castDraft(event.data.error), } }) }, - "session.retried": () => Effect.void, "session.compaction.started": () => Effect.void, "session.compaction.delta": () => Effect.void, "session.compaction.ended": (event) => { diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index bbe9ce53b14..cda0cd7d7b6 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -634,6 +634,9 @@ const layer = Layer.effectDiscard( }) }), ) + yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event)) + yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event)) + yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event)) yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Skill.Activated, (event) => @@ -660,7 +663,7 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) - // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) + yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.RevertEvent.Staged, (event) => db @@ -687,14 +690,11 @@ const layer = Layer.effectDiscard( .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where( - and( - eq(SessionMessageTable.session_id, event.data.sessionID), - eq(SessionMessageTable.id, event.data.messageID), - ), + and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)), ) .get() .pipe(Effect.orDie) - if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`)) + if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`)) yield* db .delete(SessionMessageTable) .where( diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 8c0b8bbf7b6..80ec1874726 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -113,6 +113,6 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID: session.id, - messageID: session.revert.messageID, + to: session.revert.messageID, }) }) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index 8524b1c1f8e..15502801641 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -3,7 +3,7 @@ export * as SessionRunCoordinator from "./run-coordinator" import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" /** Serializes execution for each key while allowing different keys to run concurrently. */ -export interface Coordinator { +export interface Coordinator { /** Snapshots keys with an execution owned by this coordinator. */ readonly active: Effect.Effect> /** Starts an execution while idle, or joins the active execution and returns its exit. */ @@ -11,7 +11,7 @@ export interface Coordinator { /** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */ readonly wake: (key: Key) => Effect.Effect /** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */ - readonly interrupt: (key: Key) => Effect.Effect + readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect /** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ readonly awaitIdle: (key: Key) => Effect.Effect } @@ -23,11 +23,13 @@ export interface Coordinator { * closes the gap between a drain's last eligibility check and the idle transition, since * those cannot be one atomic step. `done` resolves joiners with this execution's exit. */ -type Execution = { +type Execution = { readonly done: Deferred.Deferred owner?: Fiber.Fiber pendingWake: boolean stopping: boolean + settling: boolean + interruptionReason?: Reason } /** @@ -41,19 +43,21 @@ type Execution = { * waiters get this exit * ``` */ -export const make = (options: { +export const make = (options: { readonly drain: (key: Key, force: boolean) => Effect.Effect + /** Runs once when a process-local busy period begins, before its first drain. */ + readonly started?: (key: Key) => Effect.Effect /** * Runs in the execution fiber for every exit, including interruption, after the final * drain and before the execution settles (waiters resolve after it completes). */ - readonly settled?: (key: Key, exit: Exit.Exit) => Effect.Effect -}): Effect.Effect, never, Scope.Scope> => + readonly settled?: (key: Key, exit: Exit.Exit, reason?: Reason) => Effect.Effect +}): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const executions = new Map>() + const executions = new Map>() const fork = yield* FiberSet.makeRuntime() - const loop = (key: Key, execution: Execution, force: boolean): Effect.Effect => + const loop = (key: Key, execution: Execution, force: boolean): Effect.Effect => Effect.suspend(() => options.drain(key, force)).pipe( Effect.flatMap(() => Effect.suspend(() => { @@ -66,15 +70,25 @@ export const make = (options: { ) const start = (key: Key, force: boolean) => { - const execution: Execution = { done: Deferred.makeUnsafe(), pendingWake: false, stopping: false } + const execution: Execution = { + done: Deferred.makeUnsafe(), + pendingWake: false, + stopping: false, + settling: false, + } executions.set(key, execution) // The leading yield lets `owner` be assigned before the drain can settle, and keeps // failing self-waking executions from growing the stack across successor starts. // Drains start one tick after wake; callers observe progress through events or run. execution.owner = fork( Effect.yieldNow.pipe( + Effect.andThen(Effect.uninterruptible(options.started?.(key) ?? Effect.void)), Effect.andThen(loop(key, execution, force)), - Effect.onExit((exit) => options.settled?.(key, exit) ?? Effect.void), + Effect.onExit((exit) => + Effect.sync(() => { + execution.settling = true + }).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)), + ), Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))), Effect.exit, Effect.asVoid, @@ -85,7 +99,7 @@ export const make = (options: { // A doorbell that survives the execution loop (rung after the loop decided to end, or // during failure or interruption cleanup) starts a fresh execution for the remaining work. - const settle = (key: Key, execution: Execution, exit: Exit.Exit) => { + const settle = (key: Key, execution: Execution, exit: Exit.Exit) => { if (execution.pendingWake) start(key, false) else executions.delete(key) Deferred.doneUnsafe(execution.done, exit) @@ -112,12 +126,13 @@ export const make = (options: { start(key, false) }) - const interrupt = (key: Key): Effect.Effect => + const interrupt = (key: Key, reason?: Reason): Effect.Effect => Effect.suspend(() => { const execution = executions.get(key) - if (execution?.owner === undefined) return Effect.void + if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void execution.stopping = true execution.pendingWake = false + execution.interruptionReason = reason return Fiber.interrupt(execution.owner) }) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 3decb42f052..d40efb79456 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -3,13 +3,19 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" import { Context, Effect } from "effect" import { SessionSchema } from "../schema" -import type { MessageDecodeError } from "../error" +import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error" import { SessionRunnerModel } from "./model" import type { Instructions } from "../../instructions/index" import type { ToolOutputStore } from "../../tool-output-store" export type RunError = - LLMError | SessionRunnerModel.Error | MessageDecodeError | Instructions.InitializationBlocked | ToolOutputStore.Error + | LLMError + | SessionRunnerModel.Error + | MessageDecodeError + | StepFailedError + | UserInterruptedError + | Instructions.InitializationBlocked + | ToolOutputStore.Error /** Runs one local continuation from already-recorded Session history. */ export interface Interface { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d696dd7ff0b..46c5eb7297a 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -10,7 +10,8 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" -import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect" +import { SessionError } from "@opencode-ai/schema/session-error" +import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" @@ -32,6 +33,7 @@ import { SessionCompaction } from "../compaction" import { SessionEvent } from "../event" import { SessionHistory } from "../history" import { SessionInput } from "../input" +import { SessionMessage } from "../message" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionTitle } from "../title" @@ -44,6 +46,9 @@ import { SessionRunnerSystemPrompt } from "./system-prompt" import { Snapshot } from "../../snapshot" import { makeLocationNode } from "../../effect/app-node" import { llmClient } from "../../effect/app-node-platform" +import { StepFailedError, UserInterruptedError } from "../error" +import { toSessionError } from "../to-session-error" +import { SessionRunnerRetry } from "./retry" /** * Runs one durable coding-agent Session until it settles. @@ -54,10 +59,10 @@ import { llmClient } from "../../effect/app-node-platform" * - Session ownership and controls * - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce. * - [ ] Replace local ownership with durable multi-node ownership when clustered. - * - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. + * - [x] Publish durable historical execution lifecycle and bounded retry observations. * - [ ] Honor interruption and reject stale work after runtime attachment replacement. * - [x] Honor optional agent step limits. - * - [ ] Bound provider retries and repeated identical tool calls. + * - [ ] Bound repeated identical tool calls (provider retries are bounded). * * - Runtime context assembly * - Track V1 runtime-context parity canonically in `specs/v2/session.md`. @@ -66,7 +71,7 @@ import { llmClient } from "../../effect/app-node-platform" * - [x] Translate every projected V2 Session message variant into canonical * `@opencode-ai/llm` messages. * - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions. - * - [x] Stream exactly one `llm.stream(request)` physical attempt. + * - [x] Stream exactly one `llm.stream(request)` call per attempt. * - [x] Persist assistant text and usage events incrementally as they arrive. * - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive. * - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive. @@ -87,7 +92,7 @@ import { llmClient } from "../../effect/app-node-platform" * - [ ] Coalesce streamed deltas and add covering projected-history indexes. * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * - * Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here. + * Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here. * Durable continuation recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one @@ -137,19 +142,13 @@ const layer = Layer.effect( sessionID, assistantMessageID: message.id, callID: tool.id, - error: { type: "unknown", message: "Tool execution interrupted" }, - provider: { - executed: tool.provider?.executed === true, - ...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }), - }, + error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` }, + executed: tool.executed === true, }) } } }) - const awaitToolFibers = (fibers: FiberSet.FiberSet) => - Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers)) - // Declining an interactive prompt halts the drain instead of becoming model-facing tool output. const isUserDeclined = (cause: Cause.Cause) => cause.reasons.some( @@ -176,6 +175,7 @@ const layer = Layer.effect( promotion: SessionInput.Delivery | undefined, step: number, recoverOverflow?: typeof compaction.compactAfterOverflow, + assistantMessageID?: SessionMessage.ID, ) { const session = yield* getSession(sessionID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) @@ -189,7 +189,8 @@ const layer = Layer.effect( loadInstructions(agent, session.id), session.id, ) - const toolFibers = yield* FiberSet.make() + const toolFibers = yield* FiberSet.make() + const ownedToolFibers: Array> = [] let needsContinuation = false let currentStep = step if (promotion) { @@ -236,21 +237,23 @@ const layer = Layer.effect( // The selected catalog identity, not model.id: route-level ids are provider API // model ids (for example gpt-5.5-fast resolves to api id gpt-5.5). model: resolved.ref, + provider: model.provider, snapshot: startSnapshot, + assistantMessageID, }) const publication = Semaphore.makeUnsafe(1) // Durable publishes are serialized so tool fibers and step settlement never interleave // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) - const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => - serialized(publisher.publish(event, outputPaths)) + const publish = (event: LLMEvent, outputPaths: ReadonlyArray = [], error?: SessionError.Error) => + serialized(publisher.publish(event, outputPaths, error)) let overflowFailure: ProviderErrorEvent | undefined const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return if (LLMEvent.is.providerError(event)) { - if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) { + if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) { overflowFailure = event return } @@ -258,33 +261,49 @@ const layer = Layer.effect( yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return if (!toolMaterialization) { - yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps")) + yield* serialized( + publisher.failUnsettledTools({ + type: "tool.execution", + message: "Tools are disabled after the maximum agent steps", + }), + ) return } needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) - yield* Effect.uninterruptibleMask((restore) => - restore( - toolMaterialization.settle({ - sessionID: session.id, - agent: agent.id, - assistantMessageID, - call: event, - }), - ).pipe( - Effect.flatMap((settlement) => - publish( - LLMEvent.toolResult({ - id: event.id, - name: event.name, - result: settlement.result, - output: settlement.output, - }), - settlement.outputPaths ?? [], + ownedToolFibers.push( + yield* Effect.uninterruptibleMask((restore) => + restore( + toolMaterialization.settle({ + sessionID: session.id, + agent: agent.id, + assistantMessageID, + call: event, + }), + ).pipe( + Effect.flatMap((settlement) => + publish( + LLMEvent.toolResult({ + id: event.id, + name: event.name, + result: settlement.result, + output: settlement.output, + }), + settlement.outputPaths ?? [], + settlement.error, + ).pipe( + Effect.andThen( + settlement.error?.type === "permission.rejected" + ? serialized(publisher.failAssistant(settlement.error)).pipe( + Effect.andThen(Effect.fail(new UserInterruptedError())), + ) + : Effect.void, + ), + ), ), ), - ), - ).pipe(FiberSet.run(toolFibers)) + ).pipe(FiberSet.run(toolFibers)), + ) }), ), Effect.ensuring(serialized(publisher.flush())), @@ -327,64 +346,118 @@ const layer = Layer.effect( // restart the step instead of surfacing the provider error. if ( recoverOverflow && - !publisher.hasAssistantStarted() && + !publisher.hasRetryEvidence() && isContextOverflowFailure(overflowFailure ?? streamFailure) && (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request }))) ) return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const // An unrecovered held-back overflow becomes the step's durable provider error. A - // thrown LLM failure fails hosted tool calls and the assistant unless a provider - // error was already recorded from the stream. + // thrown LLM failure records the assistant failure unless a provider error was + // already recorded from the stream. Terminal publication waits for owned tools. if (overflowFailure) yield* publish(overflowFailure) const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined if (llmFailure && !publisher.hasProviderError()) { - yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true)) - yield* serialized(publisher.failAssistant(llmFailure.reason.message)) + const error = toSessionError(llmFailure) + if ( + SessionRunnerRetry.isRetryable(llmFailure) && + !publisher.hasRetryEvidence() && + (agent.info?.steps === undefined || currentStep < agent.info.steps) + ) { + return yield* new SessionRunnerRetry.RetryableFailure({ + cause: llmFailure, + assistantMessageID: yield* publisher.startAssistant(), + error, + step: currentStep, + }) + } + yield* serialized(publisher.failAssistant(error)) } // Provider error events only arrive from the stream, so the flag is final here. const providerFailed = publisher.hasProviderError() - // Settle tool fibers: an interrupted stream abandons unstarted tool work first. + // Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain + // the individual fibers and await all exits before publishing the terminal step event. if (streamInterrupted) yield* FiberSet.clear(toolFibers) - const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) - const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause) - const userDeclined = settled._tag === "Failure" && isUserDeclined(settled.cause) + const settled = yield* restore( + Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }), + ).pipe(Effect.exit) + const settledCauses = + settled._tag === "Failure" + ? [settled.cause] + : settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : [])) + const toolsInterrupted = settledCauses.some(Cause.hasInterrupts) + const userDeclined = settledCauses.some(isUserDeclined) + const permissionRejected = settledCauses.some( + (cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError, + ) - if (userDeclined || streamInterrupted || toolsInterrupted) { + if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) { yield* FiberSet.clear(toolFibers) - yield* serialized(publisher.failUnsettledTools("Tool execution interrupted")) - yield* serialized(publisher.failAssistant("Step interrupted")) - if (userDeclined) return yield* Effect.interrupt + yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" })) + yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" })) } // A settled tool fiber failure is one of two things. A defect from a tool // implementation becomes a failed tool call the model can read, and the step still // settles so the model may recover. A typed infrastructure failure (tool output // could not be persisted) also fails the assistant and then fails the drain. - const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined + const settledFailure = settledCauses.find( + (cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause) && !permissionRejected, + ) const infraError = settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure)) if (settledFailure !== undefined) { const failure = infraError ?? Cause.squash(settledFailure) - const message = failure instanceof Error ? failure.message : String(failure) - yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) - if (infraError !== undefined) - yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`)) + const error = toSessionError(failure) + yield* serialized(publisher.failUnsettledTools(error)) + if (infraError !== undefined) yield* serialized(publisher.failAssistant(error)) } + // Fail unresolved calls before the terminal step event. Local calls have joined, so + // these sweeps only close calls that could not produce a truthful settlement. + if (providerFailed) + yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" })) + if (llmFailure && !providerFailed) + yield* serialized( + publisher.failUnsettledTools( + { + type: "tool.result-missing", + message: "Provider did not return a tool result", + }, + true, + ), + ) + const hostedResultMissing = + stream._tag === "Success" && !providerFailed + ? yield* serialized( + publisher.failUnsettledTools( + { type: "tool.result-missing", message: "Provider did not return a tool result" }, + true, + ), + ) + : false + if (hostedResultMissing && !publisher.stepSettlement()) + yield* serialized( + publisher.failAssistant({ + type: "tool.result-missing", + message: "Provider did not return a tool result", + }), + ) + + const stepFailure = publisher.stepFailure() const stepSettlement = publisher.stepSettlement() const stepEndedCleanly = - !streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed + !streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement) - // A provider error orphans recorded local calls; a clean stream can still leave - // hosted calls without results. - if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted")) - if (stream._tag === "Success" && !providerFailed) - yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true)) + if (stepFailure) yield* serialized(publisher.publishStepFailure()) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) - if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined)) - return yield* Effect.failCause(settled.cause) + if (userDeclined) return yield* Effect.interrupt + if (permissionRejected) return yield* new UserInterruptedError() + if ((toolsInterrupted || infraError !== undefined) && settledFailure) + return yield* Effect.failCause(settledFailure) + if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause) + if (stepFailure) return yield* new StepFailedError({ error: stepFailure }) return { _tag: "Completed", needsContinuation: !providerFailed && needsContinuation, @@ -405,8 +478,31 @@ const layer = Layer.effect( let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow let currentPromotion = promotion let currentStep = step + let assistantMessageID: SessionMessage.ID | undefined while (true) { - const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow) + const attempt = yield* Effect.suspend(() => + attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID), + ).pipe( + Effect.tapError((error) => + error instanceof SessionRunnerRetry.RetryableFailure + ? Effect.sync(() => { + currentStep = error.step + 1 + assistantMessageID = error.assistantMessageID + currentPromotion = undefined + }) + : Effect.void, + ), + Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => { + if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error) + return events + .publish(SessionEvent.Step.Failed, { + sessionID, + assistantMessageID: error.assistantMessageID, + error: error.error, + }) + .pipe(Effect.andThen(Effect.fail(error.cause))) + }), + ) if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step } if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined yield* Effect.yieldNow @@ -415,8 +511,7 @@ const layer = Layer.effect( } }) - // ExecutionSettled is published per execution (busy period) by SessionExecution, not per - // drain here. + // Execution lifecycle is published per busy period by SessionExecution, not per drain here. const drain = Effect.fn("SessionRunner.drain")(function* (input: { readonly sessionID: SessionSchema.ID readonly force: boolean diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 9a334a0a6d4..5f4eeebc84a 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -1,16 +1,19 @@ import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm" -import { DateTime, Effect } from "effect" +import { Effect } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { SessionEvent } from "../event" import { SessionMessage } from "../message" import { SessionSchema } from "../schema" +import { SessionError } from "@opencode-ai/schema/session-error" type Input = { readonly sessionID: SessionSchema.ID readonly agent: string readonly model: ModelV2.Ref + readonly provider: string readonly snapshot?: string + readonly assistantMessageID?: SessionMessage.ID } const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0) @@ -41,10 +44,10 @@ const message = (value: unknown) => { type SettledOutput = | { readonly structured: Record; readonly content: ToolOutput["content"] } - | { readonly error: { readonly type: "unknown"; readonly message: string } } + | { readonly error: SessionError.Error } const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => { - if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } } + if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } } const settled = value ?? ToolOutput.fromResultValue(result) if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`) return { structured: record(settled.structured), content: settled.content } @@ -61,20 +64,25 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) called: boolean settled: boolean providerExecuted: boolean - providerMetadata?: ProviderMetadata } >() - const timestamp = DateTime.now - let assistantMessageID: SessionMessage.ID | undefined - let assistantActive = false - let assistantFailed = false + let assistantMessageID = input.assistantMessageID + let stepStarted = false + let stepFailed = false let providerFailed = false - let stepSettlement: { readonly finish: string; readonly tokens: ReturnType } | undefined + let retryEvidence = false + let stepFailure: SessionError.Error | undefined + let stepSettlement: + | { + readonly finish: Extract["reason"] + readonly tokens: ReturnType + } + | undefined const startAssistant = Effect.fnUntraced(function* () { - if (assistantMessageID !== undefined) return assistantMessageID - assistantMessageID = SessionMessage.ID.create() - assistantActive = true + if (stepStarted && assistantMessageID !== undefined) return assistantMessageID + assistantMessageID ??= SessionMessage.ID.create() + stepStarted = true yield* events.publish(SessionEvent.Step.Started, { ...input, assistantMessageID, @@ -86,29 +94,34 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID === undefined ? Effect.die(new Error("Tool event before assistant step start")) : Effect.succeed(assistantMessageID) + const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider] const fragments = ( name: string, - ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect, + ended: (id: string, value: string, ordinal: number, state?: Record) => Effect.Effect, + single = false, ) => { - const chunks = new Map() + const chunks = new Map() + let nextOrdinal = 0 const start = (id: string) => Effect.suspend(() => { if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) - chunks.set(id, []) - return Effect.void + if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`)) + const ordinal = nextOrdinal++ + chunks.set(id, { ordinal, values: [] }) + return Effect.succeed(ordinal) }) const append = (id: string, value: string) => Effect.suspend(() => { const current = chunks.get(id) if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) - current.push(value) - return Effect.void + current.values.push(value) + return Effect.succeed(current.ordinal) }) - const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) { + const end = Effect.fnUntraced(function* (id: string, state?: Record) { const current = chunks.get(id) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) - yield* ended(id, current.join(""), providerMetadata) + yield* ended(id, current.values.join(""), current.ordinal, state) chunks.delete(id) }) const flush = Effect.fnUntraced(function* () { @@ -117,26 +130,32 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return { start, append, end, flush } } - const text = fragments("text", (textID, value) => - Effect.gen(function* () { - yield* events.publish(SessionEvent.Text.Ended, { - sessionID: input.sessionID, - assistantMessageID: yield* currentAssistantMessageID(), - textID, - text: value, - }) - }), + const text = fragments( + "text", + (_textID, value, ordinal) => + Effect.gen(function* () { + yield* events.publish(SessionEvent.Text.Ended, { + sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), + ordinal, + text: value, + }) + }), + true, ) - const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) => - Effect.gen(function* () { - yield* events.publish(SessionEvent.Reasoning.Ended, { - sessionID: input.sessionID, - assistantMessageID: yield* currentAssistantMessageID(), - reasoningID, - text: value, - providerMetadata, - }) - }), + const reasoning = fragments( + "reasoning", + (_reasoningID, value, ordinal, state) => + Effect.gen(function* () { + yield* events.publish(SessionEvent.Reasoning.Ended, { + sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), + ordinal, + text: value, + state, + }) + }), + true, ) const toolInput = fragments("tool input", (callID, value) => Effect.gen(function* () { @@ -191,37 +210,41 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* flushFragments() }) - const failAssistant = Effect.fnUntraced(function* (message: string) { - if (assistantFailed) return + const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) { yield* flush() + yield* startAssistant() + if (replace || stepFailure === undefined) stepFailure = error + }) + + const publishStepFailure = Effect.fnUntraced(function* () { + if (stepFailed || stepFailure === undefined) return const assistantMessageID = yield* startAssistant() - assistantActive = false - assistantFailed = true + stepFailed = true yield* events.publish(SessionEvent.Step.Failed, { sessionID: input.sessionID, assistantMessageID, - error: { type: "unknown", message }, + error: stepFailure, }) }) const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( - message: string, + error: SessionError.Error, hostedOnly = false, ) { + let failed = false for (const [callID, tool] of tools) { if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue tool.settled = true + failed = true yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID, - error: { type: "unknown", message }, - provider: { - executed: tool.providerExecuted, - ...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }), - }, + error, + executed: tool.providerExecuted, }) } + return failed }) const assistantMessageIDForTool = (callID: string) => { @@ -232,24 +255,27 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( event: LLMEvent, outputPaths: ReadonlyArray = [], + error?: SessionError.Error, ) { switch (event.type) { case "step-start": + yield* startAssistant() return case "text-start": - yield* text.start(event.id) + retryEvidence = true + const startedTextOrdinal = yield* text.start(event.id) yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), - textID: event.id, + ordinal: startedTextOrdinal, }) return case "text-delta": - yield* text.append(event.id, event.text) + const deltaTextOrdinal = yield* text.append(event.id, event.text) yield* events.publish(SessionEvent.Text.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - textID: event.id, + ordinal: deltaTextOrdinal, delta: event.text, }) return @@ -257,27 +283,29 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* text.end(event.id) return case "reasoning-start": - yield* reasoning.start(event.id) + retryEvidence = true + const startedReasoningOrdinal = yield* reasoning.start(event.id) yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), - reasoningID: event.id, - providerMetadata: event.providerMetadata, + ordinal: startedReasoningOrdinal, + state: providerState(event.providerMetadata), }) return case "reasoning-delta": - yield* reasoning.append(event.id, event.text) + const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text) yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - reasoningID: event.id, + ordinal: deltaReasoningOrdinal, delta: event.text, }) return case "reasoning-end": - yield* reasoning.end(event.id, event.providerMetadata) + yield* reasoning.end(event.id, providerState(event.providerMetadata)) return case "tool-input-start": + retryEvidence = true yield* startToolInput(event) return case "tool-input-delta": { @@ -299,6 +327,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* endToolInput(event) return case "tool-call": { + retryEvidence = true if (!tools.has(event.id)) yield* startToolInput(event) const tool = tools.get(event.id)! if (!tool.inputEnded) yield* endToolInput(event) @@ -307,21 +336,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`)) tool.called = true tool.providerExecuted = event.providerExecuted === true - tool.providerMetadata = event.providerMetadata + const state = providerState(event.providerMetadata) yield* events.publish(SessionEvent.Tool.Called, { sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID: event.id, - tool: event.name, input: record(event.input), - provider: { - executed: tool.providerExecuted, - ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), - }, + executed: tool.providerExecuted, + state, }) return } case "tool-result": { + retryEvidence = true const tool = tools.get(event.id) if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`)) if (tool.name !== event.name) @@ -331,11 +358,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`)) } tool.settled = true - const result = settledOutput(event.output, event.result) - const provider = { - executed: event.providerExecuted === true || tool.providerExecuted, - ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), - } + const result = error ? { error } : settledOutput(event.output, event.result) + const executed = event.providerExecuted === true || tool.providerExecuted + const resultState = providerState(event.providerMetadata) if ("error" in result) { yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, @@ -343,7 +368,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) callID: event.id, error: result.error, result: event.result, - provider, + executed, + resultState, }) return } @@ -353,12 +379,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) callID: event.id, ...result, outputPaths, - ...(provider.executed ? { result: event.result } : {}), - provider, + ...(executed ? { result: event.result } : {}), + executed, + resultState, }) return } case "tool-error": { + retryEvidence = true const tool = tools.get(event.id) if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`)) if (tool.name !== event.name) @@ -369,25 +397,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID: event.id, - error: { type: "unknown", message: event.message }, - provider: { - executed: tool.providerExecuted, - ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), - }, + error: + event.message === `Unknown tool: ${event.name}` + ? { type: "tool.unknown", message: event.message } + : { type: "tool.execution", message: event.message }, + executed: tool.providerExecuted, + resultState: providerState(event.providerMetadata), }) return } case "step-finish": yield* flush() - assistantActive = false if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) + if (event.reason === "content-filter") { + providerFailed = true + yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true) + return + } stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } return case "finish": return case "provider-error": providerFailed = true - yield* failAssistant(event.message) + yield* failAssistant({ type: "provider.unknown", message: event.message }, true) return } }) @@ -396,10 +429,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) publish, flush, failAssistant, + publishStepFailure, failUnsettledTools, - hasActiveAssistant: () => assistantActive, - hasAssistantStarted: () => assistantMessageID !== undefined, hasProviderError: () => providerFailed, + hasRetryEvidence: () => retryEvidence, + stepFailure: () => stepFailure, stepSettlement: () => stepSettlement, startAssistant, assistantMessageID: assistantMessageIDForTool, diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts new file mode 100644 index 00000000000..3ada83b4e41 --- /dev/null +++ b/packages/core/src/session/runner/retry.ts @@ -0,0 +1,67 @@ +export * as SessionRunnerRetry from "./retry" + +import { LLMError } from "@opencode-ai/llm" +import { SessionError } from "@opencode-ai/schema/session-error" +import { Data, Duration, Effect, Schedule } from "effect" +import { EventV2 } from "../../event" +import { SessionEvent } from "../event" +import { SessionMessage } from "../message" +import { SessionSchema } from "../schema" +import type { SessionRunner } from "./index" + +export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{ + readonly cause: LLMError + readonly assistantMessageID: SessionMessage.ID + readonly error: SessionError.Error + readonly step: number +}> {} + +export function isRetryable(error: LLMError) { + switch (error.reason._tag) { + case "RateLimit": + case "ProviderInternal": + case "Transport": + return true + case "Authentication": + case "QuotaExceeded": + case "ContentPolicy": + case "InvalidProviderOutput": + case "InvalidRequest": + case "NoRoute": + case "UnknownProvider": + return false + default: { + const exhaustive: never = error.reason + return exhaustive + } + } +} + +const retryAfter = (failure: RetryableFailure) => { + if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal") + return failure.cause.reason.retryAfterMs + return undefined +} + +export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) => + Schedule.exponential("2 seconds").pipe( + Schedule.take(4), + Schedule.setInputType(), + Schedule.passthrough, + Schedule.while(({ input }) => input instanceof RetryableFailure), + Schedule.modifyDelay((failure, delay) => { + const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined + return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))) + }), + Schedule.tap((metadata) => + metadata.input instanceof RetryableFailure + ? events.publish(SessionEvent.RetryScheduled, { + sessionID, + assistantMessageID: metadata.input.assistantMessageID, + attempt: metadata.attempt + 1, + at: metadata.now + Duration.toMillis(metadata.duration), + error: metadata.input.error, + }) + : Effect.void, + ), + ) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 0663ac8e6de..39df522866f 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -43,6 +43,11 @@ const textAttachment = (file: FileAttachment) => const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const providerMetadata = ( + provider: string, + state: Record | undefined, +): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state }) + const toolInput = (tool: SessionMessage.AssistantTool) => tool.state.status === "pending" ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input) @@ -53,7 +58,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider id: tool.id, name: tool.name, input: toolInput(tool), - providerExecuted: tool.provider?.executed, + providerExecuted: tool.executed, providerMetadata, }) @@ -62,14 +67,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid // TODO: Materialize remote and managed URIs before provider-history lowering. // ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes. const result = - tool.provider?.executed === true && tool.state.result !== undefined + tool.executed === true && tool.state.result !== undefined ? tool.state.result : ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content }) return ToolResultPart.make({ id: tool.id, name: tool.name, result, - providerExecuted: tool.provider?.executed, + providerExecuted: tool.executed, providerMetadata, }) } @@ -78,11 +83,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid id: tool.id, name: tool.name, result: - tool.provider?.executed === true && tool.state.result !== undefined + tool.executed === true && tool.state.result !== undefined ? tool.state.result : { error: tool.state.error, content: tool.state.content, structured: tool.state.structured }, resultType: "error", - providerExecuted: tool.provider?.executed, + providerExecuted: tool.executed, providerMetadata, }) } @@ -100,17 +105,22 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { { type: "reasoning", text: item.text, - providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, + providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined, }, ] : item.text.length > 0 ? [{ type: "text", text: item.text }] : [] - const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) - if (item.provider?.executed !== true) return [call] + const call = toolCall( + item, + reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined, + ) + if (item.executed !== true) return [call] const result = toolResult( item, - reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, + reuseProviderMetadata + ? providerMetadata(model.providerID, item.providerResultState ?? item.providerState) + : undefined, ) return result ? [call, result] : [call] }) @@ -120,9 +130,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) }) const results = message.content - .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) + .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true) .map((item) => - toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), + toolResult( + item, + reuseProviderMetadata + ? providerMetadata(model.providerID, item.providerResultState ?? item.providerState) + : undefined, + ), ) .filter((message) => message !== undefined) .map(Message.tool) @@ -141,9 +156,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess case "user": const files = message.files ?? [] return [ - ...files - .filter((file) => file.mime === "text/plain") - .map(textAttachment), + ...files.filter((file) => file.mime === "text/plain").map(textAttachment), Message.make({ id: message.id, role: "user", diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts new file mode 100644 index 00000000000..df84a46668d --- /dev/null +++ b/packages/core/src/session/to-session-error.ts @@ -0,0 +1,55 @@ +import { LLMError, ToolFailure } from "@opencode-ai/llm" +import { SessionError } from "@opencode-ai/schema/session-error" +import { PermissionV2 } from "../permission" +import { QuestionV2 } from "../question" +import { Integration } from "../integration" +import { ToolOutputStore } from "../tool-output-store" +import { StepFailedError, UserInterruptedError } from "./error" +import { SessionRunnerModel } from "./runner/model" + +export function toSessionError(cause: unknown): SessionError.Error { + if (cause instanceof LLMError) { + switch (cause.reason._tag) { + case "RateLimit": + return { type: "provider.rate-limit", message: cause.reason.message } + case "Authentication": + return { type: "provider.auth", message: cause.reason.message } + case "QuotaExceeded": + return { type: "provider.quota", message: cause.reason.message } + case "ContentPolicy": + return { type: "provider.content-filter", message: cause.reason.message } + case "Transport": + return { type: "provider.transport", message: cause.reason.message } + case "ProviderInternal": + return { type: "provider.internal", message: cause.reason.message } + case "InvalidProviderOutput": + return { type: "provider.invalid-output", message: cause.reason.message } + case "InvalidRequest": + return { type: "provider.invalid-request", message: cause.reason.message } + case "NoRoute": + return { type: "provider.no-route", message: cause.reason.message } + case "UnknownProvider": + return { type: "provider.unknown", message: cause.reason.message } + default: { + const exhaustive: never = cause.reason + return exhaustive + } + } + } + if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message } + if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message } + if (cause instanceof ToolFailure) + return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error) + if (cause instanceof StepFailedError) return cause.error + if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message } + if ( + cause instanceof SessionRunnerModel.ModelNotSelectedError || + cause instanceof SessionRunnerModel.ModelUnavailableError || + cause instanceof SessionRunnerModel.VariantUnavailableError || + cause instanceof SessionRunnerModel.UnsupportedPackageError + ) + return { type: "provider.no-route", message: cause.message } + if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message } + if (cause instanceof ToolOutputStore.StorageError) return { type: "unknown", message: cause.message } + return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) } +} diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 2853a9130b6..aee442169e4 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -75,12 +75,12 @@ export const Plugin = { toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => { const applied: Array = [] - const fail = (path: string) => { + const fail = (path: string, error?: unknown) => { const prefix = applied.length === 0 ? `Unable to apply patch at ${path}` : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` - return new ToolFailure({ message: prefix }) + return new ToolFailure({ message: prefix, error }) } return Effect.gen(function* () { const source = { @@ -152,7 +152,7 @@ export const Plugin = { before, after: update.content, }) - }).pipe(Effect.mapError(() => fail(hunk.path))) + }).pipe(Effect.mapError((error) => fail(hunk.path, error))) } const patchFiles = prepared.map(patchFile) @@ -182,11 +182,11 @@ export const Plugin = { content: change.content, }) applied.push({ type: change.type, resource: result.resource, target: result.target }) - }).pipe(Effect.mapError(() => fail(change.path))), + }).pipe(Effect.mapError((error) => fail(change.path, error))), { discard: true }, ) return { applied, files: patchFiles } - }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) + }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error)))) }, }), "edit", diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 88feac6c346..1128243b70c 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -113,8 +113,9 @@ export const Plugin = { error instanceof FileMutation.StaleContentError ? new ToolFailure({ message: "File changed after permission approval. Read it again before editing.", + error, }) - : new ToolFailure({ message: `Unable to edit ${input.path}` }), + : new ToolFailure({ message: `Unable to edit ${input.path}`, error }), ), ) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 80f0ca2da4f..4c0938c3d2b 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -102,7 +102,7 @@ export const Plugin = { Effect.mapError((error) => error instanceof ToolFailure ? error - : new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), + : new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }), ), ), }), diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 9efce1712a1..64f2a3a9d64 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -133,7 +133,7 @@ export const Plugin = { Effect.mapError((error) => error instanceof ToolFailure ? error - : new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), + : new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }), ), ), }), diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index e19706f4ae7..a2ac9828c6c 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -76,7 +76,7 @@ export const Plugin = { source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) .pipe( - Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), + Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })), Effect.andThen( forms .ask({ diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 9ee5b4c42f0..f6a7c474b55 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -134,7 +134,7 @@ export const Plugin = { error instanceof Image.SizeError ? error.message : `Unable to read ${input.path}` - return new ToolFailure({ message }) + return new ToolFailure({ message, error }) }), ) }, diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index de11c10e6fd..0275cd8d875 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -14,6 +14,8 @@ import { definition, permission, registrationEntries, RegistrationError, settle, import { Tools } from "./tools" import { ToolHooks } from "./hooks" import { makeLocationNode } from "../effect/app-node" +import { SessionError } from "@opencode-ai/schema/session-error" +import { toSessionError } from "../session/to-session-error" export type ExecuteInput = { readonly sessionID: SessionSchema.ID @@ -45,6 +47,7 @@ export interface Settlement { readonly result: ToolResultValue readonly output?: ToolOutput readonly outputPaths?: ReadonlyArray + readonly error?: SessionError.Error } export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} @@ -86,7 +89,10 @@ const registryLayer = Layer.effect( ).pipe( Effect.map((output) => ({ output })), Effect.catchTag("LLM.ToolFailure", (failure) => - Effect.succeed({ result: { type: "error" as const, value: failure.message } }), + Effect.succeed({ + result: { type: "error" as const, value: failure.message }, + error: toSessionError(failure), + }), ), ) let settlement: Settlement @@ -124,20 +130,19 @@ const registryLayer = Layer.effect( result: afterEvent.result, ...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), ...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), + ...(settlement.error !== undefined ? { error: settlement.error } : {}), } }) const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) { const registration = local.get(input.call.name)?.at(-1)?.registration - if (!registration) + if (!registration || registration.identity !== advertised) { + const message = `Stale tool call: ${input.call.name}` return { - result: { - type: "error" as const, - value: `Stale tool call: ${input.call.name}`, - }, + result: { type: "error" as const, value: message }, + error: { type: "tool.stale" as const, message }, } - if (registration.identity !== advertised) - return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } + } return yield* settleTool(input, registration.tool) }) @@ -215,7 +220,10 @@ const registryLayer = Layer.effect( if (input.call.name === "execute" && execute) return settleTool(input, execute) const registration = direct.get(input.call.name) if (registration) return settleWith(input, registration.identity) - return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } }) + return Effect.succeed({ + result: { type: "error", value: `Unknown tool: ${input.call.name}` }, + error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` }, + }) }, } }), diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index c4a2820c168..7c2a87b043c 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -270,7 +270,9 @@ export const Plugin = { ...(warnings.length ? { warnings } : {}), } }).pipe( - Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })), + Effect.mapError( + (error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }), + ), ), }), ), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index c5932fce8b1..0f781c5e6c8 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -107,7 +107,7 @@ export const Plugin = { .get(context.sessionID) .pipe( Effect.mapError( - () => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }), + (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), ), ) const agent = yield* agents.resolve(input.agent) @@ -128,7 +128,7 @@ export const Plugin = { }) .pipe( Effect.mapError( - () => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }), + (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), ), ) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index f763be04296..189ee846b9c 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -48,7 +48,7 @@ export const Plugin = { }) yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) return { todos: input.todos } - }).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), + }).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))), }), ), ) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 0e046daff9c..d2efc61c73e 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -172,7 +172,7 @@ export const Plugin = { format: input.format, output, } - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), + }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))), }), ), ) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index bca404e185c..4064dc0f850 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -246,7 +246,9 @@ export const Plugin = { text: text ?? NO_RESULTS, } }).pipe( - Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })), + Effect.mapError( + (error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }), + ), ) }, }), diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 8e8f1b563b6..c50a3a1e6d4 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -85,7 +85,9 @@ export const Plugin = { source, }) return yield* files.writeTextPreservingBom({ target, content: input.content }) - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))), + }).pipe( + Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), + ), }), "edit", ), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index eda14d14b10..c6384c55de1 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -15,6 +15,7 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" +import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -39,6 +40,29 @@ const run = (effect: Effect.Effect) => const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { + test("resets incompatible V2 Session event history", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`) + yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`) + yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`) + + yield* DatabaseMigration.applyOnly(db, [resetSessionEventsMigration]) + + expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined() + expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined() + expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined() + expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined() + }), + ) + }) + test("serializes concurrent embedded initialization for one database path", async () => { await using tmp = await tmpdir() const filename = path.join(tmp.path, "embedded.sqlite") diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 29ac8a96fb1..e43ac3062b2 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -71,9 +71,9 @@ const it = testEffect( describe("MCP errors", () => { test("expose useful messages", () => { expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo") - expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe( - "failed", - ) + expect( + new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message, + ).toBe("failed") expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo") expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline") }) @@ -231,7 +231,7 @@ it.effect("does not call MCP when permission is blocked", () => Effect.gen(function* () { calls = 0 assertion = yield* Deferred.make() - decision = Effect.fail(new PermissionV2.BlockedError({ rules: [] })) + decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] })) const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 7af86834b04..cf24bcf87ba 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -23,7 +23,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" -import { DateTime, Effect, Layer, Stream } from "effect" +import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -141,11 +141,30 @@ it.effect("does not count file attachments as text context", () => }), ) +test("compaction prompt requires the checkpoint headings in order", () => { + const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] }) + expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([ + "## Objective", + "## Important Details", + "## Work State", + "## Next Move", + ]) + expect(prompt).toContain("one or two brief sentences") + expect(prompt).toContain("constraints/preferences, decisions and why") + expect(prompt).toContain("Completed:") + expect(prompt).toContain("Active:") + expect(prompt).toContain("Blocked:") + expect(prompt).toContain("immediate concrete action") + expect(prompt).toContain("next action if known") + expect(prompt).toContain("Keep every section, even when empty.") +}) + it.effect("manual compaction summarizes short context instead of no-op", () => Effect.gen(function* () { requests = [] const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service + const events = yield* EventV2.Service const store = yield* SessionStore.Service const sessionID = SessionV2.ID.make("ses_manual_compaction") const userMessage = { @@ -181,7 +200,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () => ), ) + const delta = yield* events + .subscribe(SessionEvent.Compaction.Delta) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true) + expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"]) expect(requests).toHaveLength(1) expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts new file mode 100644 index 00000000000..4ae6196272e --- /dev/null +++ b/packages/core/test/session-error.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import { + AuthenticationReason, + ContentPolicyReason, + InvalidProviderOutputReason, + InvalidRequestReason, + LLMError, + NoRouteReason, + ModelID, + ProviderID, + ProviderInternalReason, + QuotaExceededReason, + RateLimitReason, + TransportReason, + UnknownProviderReason, + ToolFailure, +} from "@opencode-ai/llm" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { toSessionError } from "@opencode-ai/core/session/to-session-error" +import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry" + +const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason }) + +describe("toSessionError", () => { + test("maps every LLM reason to the open wire type", () => { + expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({ + type: "provider.rate-limit", + message: "rate", + }) + expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe( + "provider.auth", + ) + expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota") + expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter") + expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport") + expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe( + "provider.internal", + ) + expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe( + "provider.invalid-output", + ) + expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request") + expect( + toSessionError( + llm( + new NoRouteReason({ + route: "route", + provider: ProviderID.make("provider"), + model: ModelID.make("model"), + }), + ), + ).type, + ).toBe("provider.no-route") + expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown") + }) + + test("preserves the permission rejection type without exposing internal fields", () => { + const blocked = new PermissionV2.BlockedError({ rules: [], permission: "external_directory", resources: [] }) + expect(toSessionError(blocked)).toEqual({ + type: "permission.rejected", + message: "Permission denied: external_directory", + }) + expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({ + type: "permission.rejected", + message: "Permission denied: external_directory", + }) + }) + + test("retries only rate limits, provider-internal failures, and transport failures", () => { + const eligible = [ + llm(new RateLimitReason({ message: "rate" })), + llm(new ProviderInternalReason({ message: "internal", status: 500 })), + llm(new TransportReason({ message: "transport" })), + ] + const ineligible = [ + llm(new AuthenticationReason({ message: "auth", kind: "invalid" })), + llm(new QuotaExceededReason({ message: "quota" })), + llm(new ContentPolicyReason({ message: "blocked" })), + llm(new InvalidProviderOutputReason({ message: "output" })), + llm(new InvalidRequestReason({ message: "request" })), + llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })), + llm(new UnknownProviderReason({ message: "unknown" })), + ] + + expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true]) + expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false]) + }) +}) diff --git a/packages/core/test/session-execution-local.test.ts b/packages/core/test/session-execution-local.test.ts new file mode 100644 index 00000000000..be1c4aebea4 --- /dev/null +++ b/packages/core/test/session-execution-local.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { LLMError, TransportReason } from "@opencode-ai/llm" +import { terminal } from "@opencode-ai/core/session/execution/local" +import { UserInterruptedError } from "@opencode-ai/core/session/error" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { Effect, Exit } from "effect" + +describe("SessionExecutionLocal lifecycle", () => { + test("classifies success and typed failure terminals", () => { + expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" }) + expect( + terminal( + Exit.fail( + new LLMError({ + module: "test", + method: "stream", + reason: new TransportReason({ message: "Disconnected" }), + }), + ), + ), + ).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } }) + const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") }) + expect(terminal(Exit.fail(storage))).toEqual({ + type: "failed", + error: { type: "unknown", message: storage.message }, + }) + }) + + test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => { + const interrupted = Effect.runSyncExit(Effect.interrupt) + expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" }) + expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" }) + expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" }) + expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" }) + }) +}) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 77dd272b120..ce301703626 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -101,7 +101,7 @@ describe("SessionProjector", () => { }) yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID, - messageID: boundary, + to: boundary, }) expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), @@ -437,6 +437,73 @@ describe("SessionProjector", () => { }), ) + it.effect("projects retry state and clears it at the next step or execution terminal", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + const first = SessionMessage.ID.make("msg_retry_first") + const second = SessionMessage.ID.make("msg_retry_second") + yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model }) + yield* events.publish(SessionEvent.RetryScheduled, { + sessionID, + assistantMessageID: first, + attempt: 2, + at: 2_000, + error: { type: "provider.transport", message: "Disconnected" }, + }) + + const decode = (row: typeof SessionMessageTable.$inferSelect) => + Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }) + const firstRow = yield* db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, first)) + .get() + .pipe(Effect.orDie) + const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection"))) + expect(decode(projected)).toMatchObject({ + retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } }, + }) + + yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model }) + yield* events.publish(SessionEvent.RetryScheduled, { + sessionID, + assistantMessageID: second, + attempt: 3, + at: 6_000, + error: { type: "provider.internal", message: "Unavailable" }, + }) + yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" }) + + const rows = yield* db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, sessionID)) + .orderBy(asc(SessionMessageTable.seq)) + .all() + .pipe(Effect.orDie) + expect(decode(rows[0])).not.toHaveProperty("retry") + expect(decode(rows[1])).not.toHaveProperty("retry") + }), + ) + it.effect("updates only the newest incomplete assistant projection", () => Effect.gen(function* () { const { db } = yield* Database.Service @@ -530,7 +597,7 @@ describe("SessionProjector", () => { yield* service.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), - textID: "text-stale", + ordinal: 0, }) const rows = yield* db @@ -549,7 +616,7 @@ describe("SessionProjector", () => { type: "assistant", agent: "build", model, - content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })], + content: [SessionMessage.AssistantText.make({ type: "text", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }), SessionMessage.Assistant.make({ diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 8250d876d21..2890fb1fcc4 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -275,9 +275,11 @@ describe("SessionV2.prompt", () => { source: { type: "uri", uri: sourceUri.href }, name: "main.ts", }) - expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8").replace(/\r$/, "")).toBe( - 'import { describe, expect } from "bun:test"', - ) + expect( + Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64") + .toString("utf8") + .replace(/\r$/, ""), + ).toBe('import { describe, expect } from "bun:test"') }), ) @@ -565,7 +567,12 @@ describe("SessionV2.prompt", () => { const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Promote once" }), resume: false }) + yield* session.prompt({ + id: messageID, + sessionID, + prompt: PromptInput.Prompt.make({ text: "Promote once" }), + resume: false, + }) yield* Effect.all( [SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)], @@ -677,7 +684,12 @@ describe("SessionV2.prompt", () => { .pipe(Effect.orDie) const failure = yield* session - .prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), resume: false }) + .prompt({ + id: messageID, + sessionID, + prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), + resume: false, + }) .pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) diff --git a/packages/core/test/session-run-coordinator.test.ts b/packages/core/test/session-run-coordinator.test.ts index ecc4e613ac2..c566c3f33c1 100644 --- a/packages/core/test/session-run-coordinator.test.ts +++ b/packages/core/test/session-run-coordinator.test.ts @@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => { Effect.gen(function* () { const failure = new Error("failed") const defect = new Error("defect") + const settled: Exit.Exit[] = [] const coordinator = yield* SessionRunCoordinator.make({ drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)), + settled: (_key, exit) => Effect.sync(() => void settled.push(exit)), }) const failed = yield* coordinator.run("failure").pipe(Effect.exit) @@ -115,6 +117,25 @@ describe("SessionRunCoordinator", () => { const died = yield* coordinator.run("defect").pipe(Effect.exit) expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue() expect(Array.from(yield* coordinator.active)).toEqual([]) + expect(settled).toHaveLength(2) + }), + ), + ) + + it.effect("preserves settlement hook defects while releasing ownership", () => + Effect.scoped( + Effect.gen(function* () { + const defect = new Error("terminal publication failed") + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => Effect.void, + settled: () => Effect.die(defect), + }) + + const exit = yield* coordinator.run("session").pipe(Effect.exit) + + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(defect) + expect(yield* coordinator.active).toEqual(new Set()) }), ), ) @@ -209,8 +230,41 @@ describe("SessionRunCoordinator", () => { it.effect("does nothing when interrupted while idle", () => Effect.scoped( Effect.gen(function* () { - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) - yield* coordinator.interrupt("session") + const reasons: Array = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => Effect.void, + settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)), + }) + yield* coordinator.interrupt("session", "user") + yield* coordinator.run("session") + expect(reasons).toEqual([undefined]) + }), + ), + ) + + it.effect("does not attach a late interrupt reason after terminal settlement starts", () => + Effect.scoped( + Effect.gen(function* () { + const settling = yield* Deferred.make() + const release = yield* Deferred.make() + const reasons: Array = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => Effect.void, + settled: (_key, _exit, reason) => + Deferred.succeed(settling, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.sync(() => void reasons.push(reason))), + ), + }) + + const run = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Deferred.await(settling) + yield* coordinator.interrupt("session", "user") + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(run) + yield* coordinator.run("session") + + expect(reasons).toEqual([undefined, undefined]) }), ), ) @@ -221,25 +275,28 @@ describe("SessionRunCoordinator", () => { const started = yield* Deferred.make() const interrupted = yield* Deferred.make() let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ + const reasons: Array = [] + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( Effect.andThen(Deferred.succeed(started, undefined)), Effect.andThen(Effect.never), Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), ), + settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)), }) const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Deferred.await(started) yield* coordinator.wake("session") - yield* coordinator.interrupt("session") + yield* coordinator.interrupt("session", "user") yield* Deferred.await(interrupted) const exit = yield* Fiber.await(resumed) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() expect(Array.from(yield* coordinator.active)).toEqual([]) expect(runs).toBe(1) + expect(reasons).toEqual(["user"]) }), ), ) @@ -252,6 +309,7 @@ describe("SessionRunCoordinator", () => { const cleanupGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() let runs = 0 + let starts = 0 const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( @@ -266,6 +324,7 @@ describe("SessionRunCoordinator", () => { : Deferred.succeed(secondStarted, undefined), ), ), + started: () => Effect.sync(() => starts++).pipe(Effect.asVoid), }) yield* coordinator.wake("session") @@ -278,6 +337,7 @@ describe("SessionRunCoordinator", () => { yield* Deferred.await(secondStarted) expect(runs).toBe(2) + expect(starts).toBe(2) }), ), ) @@ -399,6 +459,7 @@ describe("SessionRunCoordinator", () => { const gate = yield* Deferred.make() const idle = yield* Deferred.make() let drains = 0 + let starts = 0 const settled: Exit.Exit[] = [] const coordinator = yield* SessionRunCoordinator.make({ drain: () => @@ -410,6 +471,7 @@ describe("SessionRunCoordinator", () => { ), Effect.asVoid, ), + started: () => Effect.sync(() => starts++).pipe(Effect.asVoid), settled: (_key, exit) => Effect.sync(() => void settled.push(exit)).pipe( Effect.andThen(Deferred.succeed(idle, undefined)), @@ -424,6 +486,7 @@ describe("SessionRunCoordinator", () => { yield* Deferred.await(idle) expect(drains).toBe(2) + expect(starts).toBe(1) expect(settled).toHaveLength(1) expect(Exit.isSuccess(settled[0]!)).toBe(true) }), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index f415614cf58..14771c5b9ef 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -27,17 +27,14 @@ describe("toLLMMessages", () => { const messages = toLLMMessages( [ assistant("empty", []), - assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]), - assistant("empty-reasoning", [ - SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }), - ]), - assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]), + assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]), + assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]), + assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]), assistant("reasoning", [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning", text: "", - providerMetadata: { anthropic: { signature: "sig_1" } }, + state: { signature: "sig_1" }, }), ]), ], @@ -258,12 +255,11 @@ Recent work agent: "build", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ - SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }), + SessionMessage.AssistantText.make({ type: "text", text: "Checking" }), SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-1", text: "Think", - providerMetadata: { anthropic: { signature: "sig_1" } }, + state: { signature: "sig_1" }, }), SessionMessage.AssistantTool.make({ type: "tool", @@ -308,11 +304,9 @@ Recent work type: "tool", id: "hosted", name: "web_search", - provider: { - executed: true, - metadata: { fake: { continuation: "hosted-call" } }, - resultMetadata: { fake: { continuation: "hosted-result" } }, - }, + executed: true, + providerState: { continuation: "hosted-call" }, + providerResultState: { continuation: "hosted-result" }, state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, @@ -325,7 +319,8 @@ Recent work type: "tool", id: "hosted-failed", name: "write", - provider: { executed: true, metadata: { fake: { continuation: "failed" } } }, + executed: true, + providerState: { continuation: "failed" }, state: SessionMessage.ToolStateError.make({ status: "error", input: { path: "README.md" }, @@ -345,7 +340,7 @@ Recent work expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"]) expect(messages[0]?.content).toEqual([ { type: "text", text: "Checking" }, - { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } }, + { type: "reasoning", text: "Think", providerMetadata: { provider: { signature: "sig_1" } } }, { type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } }, { type: "tool-call", id: "running", name: "read", input: { path: "README.md" } }, { @@ -360,14 +355,14 @@ Recent work name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { fake: { continuation: "hosted-call" } }, + providerMetadata: { provider: { continuation: "hosted-call" } }, }, { type: "tool-result", id: "hosted", name: "web_search", providerExecuted: true, - providerMetadata: { fake: { continuation: "hosted-result" } }, + providerMetadata: { provider: { continuation: "hosted-result" } }, result: { type: "text", value: "Found it" }, }, { @@ -376,14 +371,14 @@ Recent work name: "write", input: { path: "README.md" }, providerExecuted: true, - providerMetadata: { fake: { continuation: "failed" } }, + providerMetadata: { provider: { continuation: "failed" } }, }, { type: "tool-result", id: "hosted-failed", name: "write", providerExecuted: true, - providerMetadata: { fake: { continuation: "failed" } }, + providerMetadata: { provider: { continuation: "failed" } }, result: { type: "error", value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} }, @@ -417,9 +412,8 @@ Recent work content: [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-openai", text: "Think", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, }), ], time: { created, completed: created }, @@ -432,7 +426,7 @@ Recent work { type: "reasoning", text: "Think", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }, ]) }) @@ -448,19 +442,16 @@ Recent work content: [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-failed", text: "Partial thought", - providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } }, + state: { itemId: "rs_failed", reasoningEncryptedContent: null }, }), SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-failed", name: "web_search", - provider: { - executed: true, - metadata: { openai: { itemId: "call_failed" } }, - resultMetadata: { openai: { itemId: "result_failed" } }, - }, + executed: true, + providerState: { itemId: "call_failed" }, + providerResultState: { itemId: "result_failed" }, state: SessionMessage.ToolStateError.make({ status: "error", input: { query: "Effect" }, @@ -520,19 +511,16 @@ Recent work content: [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-old-model", text: "Visible thought", - providerMetadata: { anthropic: { signature: "sig_old" } }, + state: { signature: "sig_old" }, }), SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-old-model", name: "web_search", - provider: { - executed: true, - metadata: { openai: { itemId: "hosted-old-model" } }, - resultMetadata: { openai: { itemId: "hosted-old-model" } }, - }, + executed: true, + providerState: { itemId: "hosted-old-model" }, + providerResultState: { itemId: "hosted-old-model" }, state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, @@ -546,11 +534,9 @@ Recent work type: "tool", id: "local-old-model", name: "read", - provider: { - executed: false, - metadata: { fake: { call: "old" } }, - resultMetadata: { fake: { result: "old" } }, - }, + executed: false, + providerState: { call: "old" }, + providerResultState: { result: "old" }, state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, @@ -620,9 +606,8 @@ Recent work content: [ SessionMessage.AssistantReasoning.make({ type: "reasoning", - id: "reasoning-alias", text: "Visible thought", - providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } }, + state: { reasoningEncryptedContent: "encrypted" }, }), ], time: { created, completed: created }, @@ -635,7 +620,7 @@ Recent work { type: "reasoning", text: "Visible thought", - providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } }, + providerMetadata: { provider: { reasoningEncryptedContent: "encrypted" } }, }, ]) }) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index de4700ca072..4fdbb49e8b3 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -45,6 +45,7 @@ const capture = () => { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), }, + provider: "openai", }), } } @@ -88,7 +89,7 @@ test("local tool success serializes media base64 once and reconstructs from stru }) }) -test("provider-executed success retains its compatibility result", async () => { +test("provider-executed success retains its raw provider result", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))) await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true }))) @@ -96,6 +97,19 @@ test("provider-executed success retains its compatibility result", async () => { expect(success?.data).toHaveProperty("result") }) +test("provider state uses the route provider instead of the catalog provider", async () => { + const { published, publisher } = capture() + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }), + ), + ) + + expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({ + state: { itemId: "reasoning" }, + }) +}) + test("binary failure emits no success event", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(call)) @@ -112,7 +126,7 @@ test("binary failure emits no success event", async () => { expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true) }) -test("old success event data containing result still decodes", () => { +test("success event data can carry a provider-executed result", () => { const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ sessionID, assistantMessageID: SessionMessage.ID.create(), @@ -120,7 +134,7 @@ test("old success event data containing result still decodes", () => { structured: { type: "media", mime: "image/png" }, content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }], result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] }, - provider: { executed: false }, + executed: true, }) expect(decoded.result).toMatchObject({ type: "content" }) }) @@ -133,3 +147,40 @@ test("step finish records settlement without publishing step ended", async () => expect(published.some((event) => event.type === "step.ended.2")).toBe(false) expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) }) + +test("content-filter finish retains failure evidence until step closeout", async () => { + const { published, publisher } = capture() + await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) + await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" }))) + + expect(published.map((event) => event.type)).toEqual(["session.step.started.1"]) + await Effect.runPromise(publisher.publishStepFailure()) + expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"]) + expect(published.at(-1)?.data).toMatchObject({ + error: { type: "provider.content-filter", message: "Provider blocked the response" }, + }) + expect(publisher.stepSettlement()).toBeUndefined() +}) + +test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => { + const { published, publisher } = capture() + await Effect.runPromise( + Effect.forEach( + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text" }), + LLMEvent.textDelta({ id: "text", text: "Partial" }), + LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), + ], + (event) => publisher.publish(event), + { discard: true }, + ), + ) + await Effect.runPromise(publisher.publishStepFailure()) + + expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false) + expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" }) + expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({ + error: { type: "provider.content-filter" }, + }) +}) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 83d7e04abf1..969d4fabbe7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -4,8 +4,10 @@ import { LLMError, LLMEvent, Model, + ToolFailure, TransportReason, InvalidRequestReason, + RateLimitReason, type LLMClientShape, type LLMRequest, } from "@opencode-ai/llm" @@ -16,7 +18,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" -import { Job } from "@opencode-ai/core/job" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -26,8 +27,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionCompaction } from "@opencode-ai/core/session/compaction" -import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { PromptInput } from "@opencode-ai/schema/prompt-input" @@ -63,6 +62,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Location } from "@opencode-ai/core/location" import { ProviderV2 } from "@opencode-ai/core/provider" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" +import { TestClock } from "effect/testing" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -154,7 +154,10 @@ const echo = Layer.effectDiscard( description: "Fail unexpectedly", input: Schema.Struct({}), output: Schema.Struct({}), - execute: () => Effect.die("unexpected tool defect"), + execute: () => + (toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe( + Effect.andThen(Effect.die("unexpected tool defect")), + ), }), // BigInt output with no model content forces ToolOutputStore.bound onto its // JSON.stringify encode path, which fails with a typed StorageError. @@ -369,6 +372,20 @@ const providerUnavailable = () => reason: new TransportReason({ message: "Provider unavailable" }), }) +const invalidRequest = () => + new LLMError({ + module: "test", + method: "stream", + reason: new InvalidRequestReason({ message: "Invalid request" }), + }) + +const rateLimited = (retryAfterMs?: number) => + new LLMError({ + module: "test", + method: "stream", + reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }), + }) + const setupOverflowRecovery = Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -406,6 +423,34 @@ const recordedEventTypes = (id: SessionV2.ID) => ) }) +const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: SessionMessage.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + const settlementTypes = new Set([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.tool.failed.1", + "session.step.ended.1", + "session.step.failed.1", + ]) + return (yield* db + .select({ type: EventTable.type, data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, id)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie)).filter( + (event) => settlementTypes.has(event.type) && event.data.assistantMessageID === assistantMessageID, + ) + }) + +const requireAssistant = (messages: readonly SessionMessage.Message[]) => { + const assistant = messages.find((message) => message.type === "assistant") + if (!assistant) throw new Error("Assistant message missing") + return assistant +} + const replaySessionProjection = (id: SessionV2.ID) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -456,7 +501,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string LLMEvent.textStart({ id }), ...chunks.map((text) => LLMEvent.textDelta({ id, text })), ] - const expectedContent = { type: "text", id, text } + const expectedContent = { type: "text", text } return { delta: SessionEvent.Text.Delta, partialEvents, @@ -476,7 +521,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string LLMEvent.reasoningStart({ id }), ...chunks.map((text) => LLMEvent.reasoningDelta({ id, text })), ] - const expectedContent = { type: "reasoning", id, text } + const expectedContent = { type: "reasoning", text } return { delta: SessionEvent.Reasoning.Delta, partialEvents, @@ -511,6 +556,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string const verifyEphemeralDeltas = (kind: FragmentKind) => Effect.gen(function* () { yield* setup + requests.length = 0 const session = yield* SessionV2.Service const prompt = `Stream ${kind}` const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) @@ -543,6 +589,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const verifyPartialFlushOnFailure = (kind: FragmentKind) => Effect.gen(function* () { yield* setup + requests.length = 0 const session = yield* SessionV2.Service const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) @@ -556,10 +603,11 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => { type: "assistant", finish: "error", - error: { type: "unknown", message: "Provider unavailable" }, + error: { type: "provider.transport", message: "Provider unavailable" }, content: [fixture.expectedContent], }, ]) + expect(requests).toHaveLength(1) }) const verifyPartialFlushOnInterruption = (kind: FragmentKind) => @@ -584,7 +632,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "assistant", finish: "error", - error: { type: "unknown", message: "Step interrupted" }, + error: { type: "aborted", message: "Step interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } @@ -613,7 +661,11 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use application context" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Use application context" }), + resume: false, + }) responses = [ [ LLMEvent.stepStart({ index: 0 }), @@ -661,7 +713,10 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [] - const message = yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run automatically" }) }) + const message = yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Run automatically" }), + }) yield* session.wait(sessionID) expect(requests).toHaveLength(1) @@ -703,7 +758,12 @@ describe("SessionRunnerLLM", () => { const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true - yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ + id: messageID, + sessionID, + prompt: PromptInput.Prompt.make({ text: "First" }), + resume: false, + }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -1240,7 +1300,6 @@ describe("SessionRunnerLLM", () => { requests.length = 0 response = [] yield* session.resume(sessionID) - const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", @@ -1370,7 +1429,7 @@ describe("SessionRunnerLLM", () => { overflow(), ] yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(3) expect(yield* session.context(sessionID)).toMatchObject([ @@ -1416,7 +1475,7 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "summary unavailable" })], ] yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(2) const context = yield* session.context(sessionID) @@ -1466,7 +1525,6 @@ describe("SessionRunnerLLM", () => { systemBaseline = "Changed context" yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) - const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", @@ -1553,7 +1611,7 @@ describe("SessionRunnerLLM", () => { finish: "tool-calls", tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } }, content: [ - { type: "reasoning", id: "reasoning-1", text: "Think" }, + { type: "reasoning", text: "Think" }, { type: "tool", id: "call-error", @@ -1561,14 +1619,16 @@ describe("SessionRunnerLLM", () => { state: { status: "error", input: { path: "README.md" }, - error: { type: "unknown", message: "Denied" }, + error: { type: "tool.execution", message: "Denied" }, }, }, { type: "tool", id: "call-provider", name: "web_search", - provider: { executed: true, metadata: { fake: { source: "provider" } } }, + executed: true, + providerState: { source: "provider" }, + providerResultState: { source: "provider" }, state: { status: "completed", input: { query: "hello" }, @@ -1619,7 +1679,8 @@ describe("SessionRunnerLLM", () => { expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }]) expect(executions).toEqual(["hello"]) - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Echo this" }, { type: "assistant", @@ -1638,7 +1699,14 @@ describe("SessionRunnerLLM", () => { }, ], }, - { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] }, + ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.step.ended.1", ]) }), ) @@ -1697,15 +1765,24 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.reasoningStart({ id: "reasoning-anthropic" }), LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }), - LLMEvent.reasoningEnd({ id: "reasoning-anthropic", providerMetadata: { anthropic: { signature: "sig_1" } } }), + LLMEvent.reasoningEnd({ + id: "reasoning-anthropic", + providerMetadata: { fake: { signature: "sig_1" }, anthropic: { ignored: true } }, + }), LLMEvent.reasoningStart({ id: "reasoning-openai", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, + providerMetadata: { + fake: { itemId: "rs_1", reasoningEncryptedContent: null }, + openai: { ignored: true }, + }, }), LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), LLMEvent.reasoningEnd({ id: "reasoning-openai", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + providerMetadata: { + fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, + openai: { ignored: true }, + }, }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), @@ -1718,11 +1795,11 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [ - { type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } }, + { type: "reasoning", text: "Signed thought", state: { signature: "sig_1" } }, { type: "reasoning", text: "Encrypted thought", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, }, ], }, @@ -1733,11 +1810,11 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests[1]?.messages[1]?.content).toEqual([ - { type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } }, + { type: "reasoning", text: "Signed thought", providerMetadata: { fake: { signature: "sig_1" } } }, { type: "reasoning", text: "Encrypted thought", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + providerMetadata: { fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }, ]) }), @@ -1757,14 +1834,14 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { openai: { itemId: "hosted-search" } }, + providerMetadata: { fake: { itemId: "hosted-search" }, openai: { ignored: true } }, }), LLMEvent.toolResult({ id: "hosted-search", name: "web_search", result: { type: "json", value: [{ title: "Effect" }] }, providerExecuted: true, - providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + providerMetadata: { fake: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), @@ -1784,7 +1861,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { openai: { itemId: "hosted-search" } }, + providerMetadata: { fake: { itemId: "hosted-search" } }, }, { type: "tool-result", @@ -1792,7 +1869,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", result: { type: "json", value: [{ title: "Effect" }] }, providerExecuted: true, - providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + providerMetadata: { fake: { blockType: "web_search_tool_result" } }, }, ]) }), @@ -1981,7 +2058,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Run once" }, - { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-once", text: "Once" }] }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Once" }] }, ]) }), ) @@ -2081,7 +2158,11 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), + resume: false, + }) requests.length = 0 responses = [ @@ -2124,7 +2205,11 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), + resume: false, + }) requests.length = 0 responses = [ @@ -2280,7 +2365,10 @@ describe("SessionRunnerLLM", () => { yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Steer before next queued input" }) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }) }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }), + }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2353,7 +2441,7 @@ describe("SessionRunnerLLM", () => { requests.length = 0 responses = undefined response = [] - streamFailure = providerUnavailable() + streamFailure = invalidRequest() streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2378,7 +2466,11 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool" }), + resume: false, + }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2403,9 +2495,8 @@ describe("SessionRunnerLLM", () => { sessionID, assistantMessageID, callID: "call-interrupted", - tool: "echo", input: { text: "stale" }, - provider: { executed: false }, + executed: false, }) requests.length = 0 response = [] @@ -2421,7 +2512,10 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-interrupted", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, + state: { + status: "error", + error: { type: "tool.stale", message: "Tool execution interrupted: echo" }, + }, }, ], }, @@ -2463,9 +2557,9 @@ describe("SessionRunnerLLM", () => { sessionID, assistantMessageID, callID: "call-hosted-interrupted", - tool: "web_search", input: { query: "stale" }, - provider: { executed: true, metadata: { openai: { itemId: "call-hosted-interrupted" } } }, + executed: true, + state: { itemId: "call-hosted-interrupted" }, }) requests.length = 0 response = [] @@ -2478,7 +2572,7 @@ describe("SessionRunnerLLM", () => { type: "tool-call", id: "call-hosted-interrupted", providerExecuted: true, - providerMetadata: { openai: { itemId: "call-hosted-interrupted" } }, + providerMetadata: { fake: { itemId: "call-hosted-interrupted" } }, }, { type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } }, ]) @@ -2550,7 +2644,11 @@ describe("SessionRunnerLLM", () => { const defect = new Error("fail after prompt promotion") let fail = true yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover promoted input" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Recover promoted input" }), + resume: false, + }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false @@ -2598,7 +2696,11 @@ describe("SessionRunnerLLM", () => { yield* insertSession(otherSessionID) const session = yield* SessionV2.Service yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run first" }), resume: false }) - yield* session.prompt({ sessionID: otherSessionID, prompt: PromptInput.Prompt.make({ text: "Run second" }), resume: false }) + yield* session.prompt({ + sessionID: otherSessionID, + prompt: PromptInput.Prompt.make({ text: "Run second" }), + resume: false, + }) requests.length = 0 responses = undefined @@ -2659,12 +2761,16 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry after failure" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Retry after failure" }), + resume: false, + }) requests.length = 0 responses = undefined response = [] - streamFailure = providerUnavailable() + streamFailure = invalidRequest() streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2727,7 +2833,7 @@ describe("SessionRunnerLLM", () => { }, ], }, - { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-after-error", text: "Recovered" }] }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, ]) }), ) @@ -2760,7 +2866,8 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Call defect" }, { type: "assistant", @@ -2770,13 +2877,20 @@ describe("SessionRunnerLLM", () => { id: "call-defect", state: { status: "error", - error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" }, + error: { type: "unknown", message: "unexpected tool defect" }, }, }, ], }, { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.ended.1", + ]) }), ) @@ -2791,7 +2905,7 @@ describe("SessionRunnerLLM", () => { input: Schema.Struct({}), output: Schema.Struct({}), execute: () => - Effect.fail(new PermissionV2.BlockedError({ rules: [] })).pipe( + Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe( Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })), ), }), @@ -2955,13 +3069,84 @@ describe("SessionRunnerLLM", () => { status: "error", error: { type: "unknown", - message: expect.stringContaining("Tool execution failed: Failed to encode tool output"), + message: expect.stringContaining("Failed to encode tool output"), + }, + }, + }, + ], + finish: "error", + error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") }, + }, + ]) + }), + ) + + it.effect("preserves permission rejection and stops before continuation", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const registry = yield* ToolRegistry.Service + yield* registry.register({ + permissionfail: Tool.make({ + description: "Reject a permission", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + new ToolFailure({ + message: "Permission denied: edit", + error: new PermissionV2.BlockedError({ + rules: [], + permission: "edit", + resources: ["src/index.ts"], + }), + }), + }), + }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Reject permission" }), + resume: false, + }) + requests.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-permission", name: "permissionfail", input: {} }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })], + ] + + const exit = yield* session.resume(sessionID).pipe(Effect.exit) + + expect(exit._tag).toBe("Failure") + expect(requests).toHaveLength(1) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user" }, + { + type: "assistant", + finish: "error", + error: { + type: "permission.rejected", + message: "Permission denied: edit", + }, + content: [ + { + type: "tool", + id: "call-permission", + state: { + status: "error", + error: { + type: "permission.rejected", + message: "Permission denied: edit", }, }, }, ], }, ]) + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1") }), ) @@ -3005,7 +3190,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-question", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, + state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } }, }, ], }, @@ -3017,7 +3202,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Settle before failing" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Settle before failing" }), + resume: false, + }) const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3035,7 +3224,8 @@ describe("SessionRunnerLLM", () => { expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) toolExecutionGate = undefined - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Settle before failing" }, { type: "assistant", @@ -3044,6 +3234,13 @@ describe("SessionRunnerLLM", () => { ], }, ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.step.failed.1", + ]) }), ) @@ -3051,7 +3248,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Interrupt blocked tool" }), + resume: false, + }) executions.length = 0 toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3069,7 +3270,8 @@ describe("SessionRunnerLLM", () => { expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) yield* session.interrupt(sessionID) - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Interrupt blocked tool" }, { type: "assistant", @@ -3077,11 +3279,18 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-before-interrupt", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, + state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } }, }, ], }, ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) yield* replaySessionProjection(sessionID) @@ -3101,7 +3310,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt provider" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Interrupt provider" }), + resume: false, + }) requests.length = 0 response = [] streamGate = yield* Deferred.make() @@ -3118,7 +3331,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Interrupt provider" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } }, + { type: "assistant", finish: "error", error: { type: "aborted", message: "Step interrupted" } }, ]) expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1") yield* session.interrupt(sessionID) @@ -3129,7 +3342,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Interrupt tool settlement" }), + resume: false, + }) executions.length = 0 toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() @@ -3153,12 +3370,12 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "error", - error: { type: "unknown", message: "Step interrupted" }, + error: { type: "aborted", message: "Step interrupted" }, content: [ { type: "tool", id: "call-await-interrupt", - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, + state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } }, }, ], }, @@ -3179,7 +3396,11 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Finish at the limit" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Finish at the limit" }), + resume: false, + }) requests.length = 0 executions.length = 0 @@ -3281,12 +3502,12 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail durably" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } }, + { type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } }, ]) }), ) @@ -3300,21 +3521,92 @@ describe("SessionRunnerLLM", () => { requests.length = 0 response = [LLMEvent.providerError({ message: "Provider unavailable" })] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail before step" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } }, + { type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } }, ]) }), ) + it.effect("projects content-filter finishes as visible terminal failures", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Blocked response" }), resume: false }) + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "partial" }), + LLMEvent.textDelta({ id: "partial", text: "Partial" }), + LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), + LLMEvent.finish({ reason: "content-filter" }), + ] + + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response") + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user" }, + { + type: "assistant", + finish: "error", + error: { type: "provider.content-filter" }, + content: [{ type: "text", text: "Partial" }], + }, + ]) + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1") + }), + ) + + it.effect("settles a local tool before one content-filter step failure", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Tool before blocked response" }), + resume: false, + }) + toolExecutionGate = yield* Deferred.make() + toolExecutionsStarted = yield* Deferred.make() + toolExecutionsReady = 1 + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }), + LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), + LLMEvent.finish({ reason: "content-filter" }), + ] + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(toolExecutionsStarted) + yield* Deferred.succeed(toolExecutionGate, undefined) + expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider blocked the response") + toolExecutionGate = undefined + toolExecutionsStarted = undefined + + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.step.failed.1", + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) + }), + ) + it.effect("does not recover context overflow after durable assistant output", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail after output" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Fail after output" }), + resume: false, + }) requests.length = 0 response = [ @@ -3324,7 +3616,7 @@ describe("SessionRunnerLLM", () => { LLMEvent.textEnd({ id: "text-partial" }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ @@ -3343,19 +3635,155 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail raw stream durably" }), resume: false }) - const failure = providerUnavailable() + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Fail raw stream durably" }), + resume: false, + }) + const failure = invalidRequest() responseStream = Stream.fail(failure) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) yield* replaySessionProjection(sessionID) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail raw stream durably" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } }, + { type: "assistant", finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, ]) }), ) + it.effect("retries eligible pre-output failures after exponential backoff", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry transport" }), resume: false }) + requests.length = 0 + responseStream = Stream.fail(providerUnavailable()) + response = fragmentFixture("text", "retry-success", ["Recovered"]).completeEvents + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + yield* TestClock.adjust("1999 millis") + expect(requests).toHaveLength(1) + yield* TestClock.adjust("1 millis") + yield* Fiber.join(run) + + expect(requests).toHaveLength(2) + const eventTypes = yield* recordedEventTypes(sessionID) + expect(eventTypes).toContain("session.retry.scheduled.1") + expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user" }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, + ]) + yield* replaySessionProjection(sessionID) + expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) + }), + ) + + it.effect("uses a larger provider retry-after delay", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry rate limit" }), resume: false }) + requests.length = 0 + responseStream = Stream.fail(rateLimited(5_000)) + response = fragmentFixture("text", "retry-after-success", ["Recovered"]).completeEvents + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + yield* TestClock.adjust("4999 millis") + expect(requests).toHaveLength(1) + yield* TestClock.adjust("1 millis") + yield* Fiber.join(run) + expect(requests).toHaveLength(2) + }), + ) + + it.effect("stops after five total retry attempts", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Exhaust retries" }), resume: false }) + requests.length = 0 + streamFailure = providerUnavailable() + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) { + yield* TestClock.adjust(delay) + while (requests.length < index + 2) yield* Effect.yieldNow + } + expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(streamFailure) + expect(requests).toHaveLength(5) + + const database = (yield* Database.Service).db + const retries = yield* database + .select({ data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.type, "session.retry.scheduled.1")) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) + expect(retries.map((event) => event.data)).toMatchObject([ + { attempt: 2, at: 2_000 }, + { attempt: 3, at: 6_000 }, + { attempt: 4, at: 14_000 }, + { attempt: 5, at: 30_000 }, + ]) + expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5) + expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) + }), + ) + + it.effect("counts retry attempts against the agent step allowance", () => + Effect.gen(function* () { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.transform((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Bound retries by steps" }), + resume: false, + }) + requests.length = 0 + const failure = providerUnavailable() + responseStream = Stream.fail(failure) + streamFailure = failure + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + while (requests.length < 1) yield* Effect.yieldNow + yield* TestClock.adjust("2 seconds") + expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) + + expect(requests).toHaveLength(2) + const eventTypes = yield* recordedEventTypes(sessionID) + expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2) + expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1) + expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) + }), + ) + + it.effect("does not retry non-eligible provider failures", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not retry" }), resume: false }) + requests.length = 0 + const failure = invalidRequest() + streamFailure = failure + + expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(requests).toHaveLength(1) + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1") + }), + ) + it.effect("does not continue automatically after a provider error follows a local tool call", () => Effect.gen(function* () { yield* setup @@ -3368,16 +3796,32 @@ describe("SessionRunnerLLM", () => { requests.length = 0 const executionCount = executions.length + toolExecutionGate = yield* Deferred.make() + toolExecutionsStarted = yield* Deferred.make() + toolExecutionsReady = 1 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }), LLMEvent.providerError({ message: "Provider unavailable" }), ] - yield* session.resume(sessionID) + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(toolExecutionsStarted) + yield* Deferred.succeed(toolExecutionGate, undefined) + expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider unavailable") + toolExecutionGate = undefined + toolExecutionsStarted = undefined expect(requests).toHaveLength(1) expect(executions.slice(executionCount)).toEqual(["settled"]) + const context = yield* session.context(sessionID) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.success.1", + "session.step.failed.1", + ]) }), ) @@ -3385,7 +3829,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail hosted tool durably" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Fail hosted tool durably" }), + resume: false, + }) requests.length = 0 response = [ @@ -3399,16 +3847,54 @@ describe("SessionRunnerLLM", () => { LLMEvent.providerError({ message: "Provider unavailable" }), ] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") expect(requests).toHaveLength(1) - expect(yield* session.context(sessionID)).toMatchObject([ + const context = yield* session.context(sessionID) + expect(context).toMatchObject([ { type: "user", text: "Fail hosted tool durably" }, { type: "assistant", content: [{ type: "tool", id: "call-hosted-provider-error", state: { status: "error" } }], }, ]) + const assistant = requireAssistant(context) + expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) + }), + ) + + it.effect("preserves a tool defect before provider failure settlement", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Defect while provider fails" }), + resume: false, + }) + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }), + LLMEvent.providerError({ message: "Provider unavailable" }), + ] + + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") + + const context = yield* session.context(sessionID) + const assistant = requireAssistant(context) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) + expect(events[2]?.data.error).toMatchObject({ type: "unknown", message: "unexpected tool defect" }) }), ) @@ -3416,7 +3902,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Fail hosted tool at EOF" }), + resume: false, + }) response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ @@ -3427,16 +3917,117 @@ describe("SessionRunnerLLM", () => { }), ] - yield* session.resume(sessionID) + expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider did not return a tool result") + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) yield* replaySessionProjection(sessionID) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail hosted tool at EOF" }, - { type: "assistant", content: [{ type: "tool", id: "call-hosted-eof", state: { status: "error" } }] }, + { + type: "assistant", + finish: "error", + error: { type: "tool.result-missing" }, + content: [{ type: "tool", id: "call-hosted-eof", state: { status: "error" } }], + }, ]) }), ) + it.effect("fails an unresolved hosted tool before one clean step end", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Settle hosted tool before ending" }), + resume: false, + }) + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ + id: "call-hosted-clean-end", + name: "web_search", + input: { query: "effect" }, + providerExecuted: true, + }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ] + + yield* session.resume(sessionID) + + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.ended.1", + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) + }), + ) + + it.effect("settles unresolved local and hosted tools before one raw provider failure", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Fail unresolved tools" }), + resume: false, + }) + const failure = invalidRequest() + const providerFailed = yield* Deferred.make() + toolExecutionGate = yield* Deferred.make() + responseStream = Stream.concat( + Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }), + LLMEvent.toolCall({ + id: "call-hosted-raw-failure-pair", + name: "web_search", + input: { query: "effect" }, + providerExecuted: true, + }), + ]), + Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(Stream.flatMap(() => Stream.fail(failure))), + ) + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(providerFailed) + yield* Deferred.succeed(toolExecutionGate, undefined) + expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) + toolExecutionGate = undefined + + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => ({ type: event.type, callID: event.data.callID }))).toEqual([ + { type: "session.step.started.1", callID: undefined }, + { type: "session.tool.called.1", callID: "call-local-raw-failure" }, + { type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" }, + { type: "session.tool.failed.1", callID: "call-local-raw-failure" }, + { type: "session.tool.failed.1", callID: "call-hosted-raw-failure-pair" }, + { type: "session.step.failed.1", callID: undefined }, + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) + }), + ) + it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () => Effect.gen(function* () { yield* setup @@ -3446,6 +4037,7 @@ describe("SessionRunnerLLM", () => { prompt: PromptInput.Prompt.make({ text: "Fail hosted tool on raw failure" }), resume: false, }) + requests.length = 0 const failure = providerUnavailable() responseStream = Stream.concat( Stream.fromIterable([ @@ -3461,20 +4053,32 @@ describe("SessionRunnerLLM", () => { ) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(requests).toHaveLength(1) + const assistant = requireAssistant(yield* session.context(sessionID)) + const events = yield* recordedStepSettlementEvents(sessionID, assistant.id) + expect(events.map((event) => event.type)).toEqual([ + "session.step.started.1", + "session.tool.called.1", + "session.tool.failed.1", + "session.step.failed.1", + ]) + expect( + events.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), + ).toHaveLength(1) yield* replaySessionProjection(sessionID) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail hosted tool on raw failure" }, { type: "assistant", finish: "error", - error: { type: "unknown", message: "Provider unavailable" }, + error: { type: "provider.transport", message: "Provider unavailable" }, content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }], }, ]) }), ) - it.effect("keeps interleaved assistant text blocks separate", () => + it.effect("rejects a second text start before the open fragment ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -3487,9 +4091,31 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-2" }), + ] + + const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) + expect(defect).toBeInstanceOf(Error) + if (!(defect instanceof Error)) return + expect(defect.message).toBe("text start before end: text-2") + }), + ) + + it.effect("projects sequential text fragments as separate content parts", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Two blocks" }), resume: false }) + + responses = undefined + streamGate = undefined + streamStarted = undefined + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-1" }), LLMEvent.textDelta({ id: "text-1", text: "First" }), - LLMEvent.textDelta({ id: "text-2", text: "Second" }), LLMEvent.textEnd({ id: "text-1" }), + LLMEvent.textStart({ id: "text-2" }), + LLMEvent.textDelta({ id: "text-2", text: "Second" }), LLMEvent.textEnd({ id: "text-2" }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), @@ -3502,8 +4128,8 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [ - { type: "text", id: "text-1", text: "First" }, - { type: "text", id: "text-2", text: "Second" }, + { type: "text", text: "First" }, + { type: "text", text: "Second" }, ], }, ]) @@ -3542,7 +4168,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call provider tool" }), resume: false }) + yield* session.prompt({ + sessionID, + prompt: PromptInput.Prompt.make({ text: "Call provider tool" }), + resume: false, + }) responses = undefined streamGate = undefined @@ -3553,6 +4183,8 @@ describe("SessionRunnerLLM", () => { LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }), LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }), LLMEvent.toolCall({ id: "call-parsed", name: "web_search", input: { query: "hello" }, providerExecuted: true }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), ] yield* session.resume(sessionID) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index e730b3556b5..8344771d2a6 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -76,9 +76,8 @@ describe("Tool.Progress", () => { sessionID, assistantMessageID, callID, - tool: "bash", input: { command: "pwd" }, - provider: { executed: false }, + executed: false, }) }) @@ -104,7 +103,7 @@ describe("Tool.Progress", () => { callID: "call-success", structured: { phase: "done" }, content: content("complete"), - provider: { executed: false }, + executed: false, }) expect((yield* readAssistant).content[0]).toMatchObject({ state: { status: "completed", structured: { phase: "done" }, content: content("complete") }, @@ -123,7 +122,7 @@ describe("Tool.Progress", () => { assistantMessageID, callID: "call-failed", error: { type: "unknown", message: "boom" }, - provider: { executed: false }, + executed: false, }) expect((yield* readAssistant).content[1]).toMatchObject({ state: { diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 9e48790dd0d..ae68d6c7ff3 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -103,6 +103,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreIntegration.Ref, Integration.Ref], [coreLocation.Ref, Location.Ref], [coreLLM.ProviderMetadata, LLM.ProviderMetadata], + [coreLLM.FinishReason, LLM.FinishReason], [coreLLM.ToolTextContent, LLM.ToolTextContent], [coreLLM.ToolFileContent, LLM.ToolFileContent], [coreLLM.ToolContent, LLM.ToolContent], @@ -137,7 +138,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionInput.Delivery, SessionInput.Delivery], [coreSessionInput.Admitted, SessionInput.Admitted], [coreSessionMessage.ID, SessionMessage.ID], - [coreSessionMessage.UnknownError, SessionMessage.UnknownError], + [coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry], [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected], [coreSessionMessage.ModelSelected, SessionMessage.ModelSelected], [coreSessionMessage.User, SessionMessage.User], @@ -183,7 +184,7 @@ test("Core reuses the canonical shared schemas", async () => { test("shared record schemas construct and decode plain objects", () => { const made = Prompt.make({ text: "hello" }) const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) - const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" }) + const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", text: "hi" }) expect(Object.getPrototypeOf(made)).toBe(Object.prototype) expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype) diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index a97c3f662a6..6aa7c89aa28 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -47,7 +47,15 @@ const permission = Layer.succeed( }).pipe( Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void), Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, + input.action === denyAction + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 7481c087cc1..ded236e6541 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -40,7 +40,15 @@ const permission = Layer.succeed( assert: (input) => Effect.sync(() => assertions.push(input)).pipe( Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, + input.action === denyAction + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 4ac6f558a9e..7afb2158c87 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -23,7 +23,17 @@ const permission = Layer.succeed( PermissionV2.Service.of({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), + Effect.andThen( + deny + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), @@ -82,7 +92,13 @@ describe("QuestionTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } }, }), - ).toEqual({ result: { type: "error", value: "Permission denied: question" } }) + ).toEqual({ + result: { type: "error", value: "Permission denied: question" }, + error: { + type: "permission.rejected", + message: "Permission denied: question", + }, + }) expect(capturedInput()).toBeUndefined() deny = false }), diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 227be289e92..c5e7eaac487 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -81,7 +81,19 @@ const permission = Layer.succeed( assert: (input) => Effect.sync(() => { assertions.push(input) - }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))), + }).pipe( + Effect.andThen( + allow + ? Effect.void + : Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ), + ), + ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), get: () => Effect.die("unused"), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index ab3926f3b97..678e4a7c934 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -47,7 +47,15 @@ const permission = Layer.succeed( Effect.sync(() => assertions.push(input)).pipe( Effect.andThen(Effect.suspend(() => afterPermission(input))), Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, + input.action === denyAction + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), @@ -75,7 +83,6 @@ const executionNode = makeGlobalNode({ const session = yield* store.get(id) if (!session) return const assistantMessageID = SessionMessage.ID.create() - const textID = "text_shell_test" yield* events.publish(SessionEvent.Step.Started, { sessionID: id, assistantMessageID, @@ -85,12 +92,12 @@ const executionNode = makeGlobalNode({ yield* events.publish(SessionEvent.Text.Started, { sessionID: id, assistantMessageID, - textID, + ordinal: 0, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID: id, assistantMessageID, - textID, + ordinal: 0, text: "ok", }) yield* events.publish(SessionEvent.Step.Ended, { diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index a9987154d87..6a49fa22fad 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -55,7 +55,17 @@ describe("SkillTool", () => { PermissionV2.Service.of({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), + Effect.andThen( + deny + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 4dd46da6919..9f7bf8c7b97 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -49,7 +49,6 @@ const executionNode = makeGlobalNode({ } completed.add(sessionID) const assistantMessageID = SessionMessage.ID.create() - const textID = "text_subagent_test" yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, @@ -59,12 +58,12 @@ const executionNode = makeGlobalNode({ yield* events.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, - textID, + ordinal: 0, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, - textID, + ordinal: 0, text: childText, }) yield* events.publish(SessionEvent.Step.Ended, { diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 1e967c5fcf6..055df2fbc73 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -33,7 +33,17 @@ const permission = Layer.succeed( PermissionV2.Service.of({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), + Effect.andThen( + deny + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 6bd4f2b78e1..1fc82f14282 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -38,7 +38,15 @@ const permission = Layer.succeed( assert: (input) => Effect.sync(() => assertions.push(input)).pipe( Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, + input.action === denyAction + ? Effect.fail( + new PermissionV2.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index de18bf42a09..e0546f47ed0 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -619,6 +619,11 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste ] } +const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const events: LLMEvent[] = [] + return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events] +} + const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] const events: LLMEvent[] = [] @@ -810,6 +815,8 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* const item = event.item if (!item) return [state, NO_EVENTS] satisfies StepResult + if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id }) + if (item.type === "function_call") { if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult const tools = state.tools[item.id] @@ -920,6 +927,7 @@ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => const step = (state: ParserState, event: OpenAIResponsesEvent) => { if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) + if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event)) if ( event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta" || diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 279a3097e14..4775caf2ef9 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { ProviderMetadata } from "@opencode-ai/schema/llm" +import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm" export { ProviderMetadata } @@ -36,7 +36,7 @@ export type TextVerbosity = Schema.Schema.Type export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"]) export type MessageRole = Schema.Schema.Type -export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export const FinishReason = LLM.FinishReason export type FinishReason = Schema.Schema.Type export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index fbc5f2a8642..3d048d4eeea 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -764,6 +764,35 @@ describe("OpenAI Responses route", () => { }), ) + // OpenAI's documented stream orders output text within one message item; no + // provider-valid same-kind overlap is evidenced, so done boundaries close it. + it.effect("closes sequential output messages before starting the next", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "First" }, + { type: "response.output_text.done", item_id: "msg_1" }, + { type: "response.output_text.delta", item_id: "msg_2", delta: "Second" }, + { type: "response.output_item.done", item: { type: "message", id: "msg_2" } }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ + { type: "text-start", id: "msg_1" }, + { type: "text-delta", id: "msg_1", text: "First" }, + { type: "text-end", id: "msg_1" }, + { type: "text-start", id: "msg_2" }, + { type: "text-delta", id: "msg_2", text: "Second" }, + { type: "text-end", id: "msg_2" }, + ]) + }), + ) + it.effect("parses reasoning summary stream fixtures", () => Effect.gen(function* () { const body = sseEvents( diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts index e77d28b62bd..377d6c582b4 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -28,11 +28,20 @@ function prompted(inputID: string): V2Event { } function settled(outcome: "success" | "interrupted" = "success"): V2Event { + if (outcome === "interrupted") + return { + id: "evt_interrupted", + created: 0, + type: "session.execution.interrupted", + durable: { aggregateID: "ses_1", seq: 1, version: 1 }, + data: { sessionID: "ses_1", reason: "user" }, + } return { - id: "evt_settled", + id: "evt_succeeded", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome }, + type: "session.execution.succeeded", + durable: { aggregateID: "ses_1", seq: 1, version: 1 }, + data: { sessionID: "ses_1" }, } } @@ -58,8 +67,7 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.form, "list").mockImplementation( - (request) => - ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, + (request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, ) spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never) spyOn(sdk.session, "prompt").mockImplementation((request) => { diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index 5400c6c05e9..be248d3a3dc 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "node:url" -import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise" +import { + OpenCode, + type EventSubscribeOutput, + type MessageListOutput, + type OpenCodeClient, +} from "@opencode-ai/client/promise" import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport" import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types" import { tmpdir } from "../../fixture/fixture" @@ -200,15 +205,16 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", - textID: "txt_1", + ordinal: 0, delta: "answer", }, }) events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) await turn @@ -253,8 +259,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok({ @@ -347,8 +354,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok({ @@ -444,8 +452,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok({ @@ -693,7 +702,7 @@ describe("V2 mini transport", () => { type: "assistant", agent: "build", model: { providerID: "test", id: "model" }, - content: [{ type: "text", id: "txt_1", text: "the answer" }], + content: [{ type: "text", text: "the answer" }], time: { created: 2, completed: 3 }, }, ], @@ -705,6 +714,17 @@ describe("V2 mini transport", () => { reset = resolve }) const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting }) + events.push({ + id: "evt_text_started", + created: 0, + type: "session.text.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + ordinal: 0, + }, + }) events.push({ id: "evt_text", created: 0, @@ -712,7 +732,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", - textID: "txt_1", + ordinal: 0, delta: "answer", }, }) @@ -725,7 +745,7 @@ describe("V2 mini transport", () => { await transport.close() }) - test("scopes repeated text and reasoning ids by assistant message", async () => { + test("scopes text and reasoning ordinals by assistant message", async () => { const events = feed() events.push(connected()) const client = sdk({ streams: [events] }) @@ -738,8 +758,8 @@ describe("V2 mini transport", () => { agent: "build", model: { providerID: "test", id: "model" }, content: [ - { type: "reasoning", id: "reasoning-0", text: "second thought" }, - { type: "text", id: "text-0", text: "second answer" }, + { type: "reasoning", text: "second thought" }, + { type: "text", text: "second answer" }, ], time: { created: 4, completed: 5 }, }, @@ -749,8 +769,8 @@ describe("V2 mini transport", () => { agent: "build", model: { providerID: "test", id: "model" }, content: [ - { type: "reasoning", id: "reasoning-0", text: "first thought" }, - { type: "text", id: "text-0", text: "first answer" }, + { type: "reasoning", text: "first thought" }, + { type: "text", text: "first answer" }, ], time: { created: 2, completed: 3 }, }, @@ -798,7 +818,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", - reasoningID: "reasoning_1", + ordinal: 0, text: "considering", }, }) @@ -808,6 +828,52 @@ describe("V2 mini transport", () => { await transport.close() }) + test("renders a live tool start when the call begins", 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, + limits: () => ({}), + footer: ui.api, + }) + + events.push({ + id: "evt_tool_input", + created: 1, + type: "session.tool.input.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + callID: "call_read", + name: "read", + }, + }) + events.push({ + id: "evt_tool_called", + created: 2, + type: "session.tool.called", + durable: durable("ses_1", 1), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + callID: "call_read", + input: { path: "README.md" }, + executed: false, + }, + }) + await Bun.sleep(0) + + expect(ui.commits).toContainEqual( + expect.objectContaining({ kind: "tool", phase: "start", partID: "prt_call_read", tool: "read" }), + ) + await transport.close() + }) + test("resolves an interrupted turn even when promotion never arrived", async () => { const events = feed() events.push(connected()) @@ -849,8 +915,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.interrupted", + durable: durable("ses_1"), + data: { sessionID: "ses_1", reason: "user" }, }) await turn @@ -913,8 +980,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) await turn @@ -975,8 +1043,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.interrupted", + durable: durable("ses_1"), + data: { sessionID: "ses_1", reason: "user" }, }) await turn @@ -1413,8 +1482,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok({ @@ -1488,8 +1558,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) }) return ok(undefined) as never @@ -1569,8 +1640,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_unrelated_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) await Bun.sleep(0) await Bun.sleep(0) @@ -1590,8 +1662,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_skill_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_1", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_1"), + data: { sessionID: "ses_1" }, }) await turn @@ -1701,18 +1774,19 @@ describe("V2 mini transport", () => { ], }, }) - spyOn(client.session, "get").mockImplementation(() => - ok({ - id: "ses_child", - parentID: "ses_1", - projectID: "proj_1", - agent: "explore", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "Find files", - location: { directory: "/tmp" }, - }) as never, + spyOn(client.session, "get").mockImplementation( + () => + ok({ + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp" }, + }) as never, ) const ui = footer() const transport = await createSessionTransport({ @@ -1750,7 +1824,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_child", assistantMessageID: "msg_child_a", - textID: "txt_child", + ordinal: 0, delta: "child answer", }, }) @@ -1760,8 +1834,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_child", outcome: "success" }, + type: "session.execution.succeeded", + durable: durable("ses_child"), + data: { sessionID: "ses_child" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0) await transport.close() @@ -1801,7 +1876,9 @@ describe("V2 mini transport", () => { }) await Bun.sleep(0) expect( - states().at(-1)?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"), + states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"), ).toBe(false) events.push({ @@ -1955,19 +2032,36 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_child", assistantMessageID: "msg_overflow_assistant", - textID: `txt_overflow_${index}`, + ordinal: index, delta: `live ${index}`, }, }) - while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")) await Bun.sleep(0) + while ( + !states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.text === "live 64") + ) + await Bun.sleep(0) releaseStale() while (childRequests < 2) await Bun.sleep(0) - expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true) + expect( + states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.text === "live 64"), + ).toBe(true) releaseRetry() - while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "baseline history")) + while ( + !states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.text === "baseline history") + ) await Bun.sleep(0) - expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true) + expect( + states() + .at(-1) + ?.details.ses_child?.commits.some((item) => item.text === "live 64"), + ).toBe(true) expect(childRequests).toBe(2) await transport.close() }) @@ -2032,7 +2126,7 @@ describe("V2 mini transport", () => { durable: durable("ses_child", seq), data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name }, }) - const called = (callID: string, tool: string, input: Record, seq: number) => + const called = (callID: string, input: Record, seq: number) => events.push({ id: `evt_called_${callID}`, created: seq, @@ -2042,14 +2136,13 @@ describe("V2 mini transport", () => { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, - tool, input, - provider: { executed: true }, + executed: true, }, }) inputStarted("call_terminal", "grep", 0) - called("call_terminal", "grep", { pattern: "needle" }, 1) + called("call_terminal", { pattern: "needle" }, 1) await Bun.sleep(0) transport.selectSubagent("ses_child") while (!childHydrating) await Bun.sleep(0) @@ -2064,11 +2157,11 @@ describe("V2 mini transport", () => { callID: "call_terminal", structured: {}, content: [{ type: "text", text: "found" }], - provider: { executed: true }, + executed: true, }, }) inputStarted("call_overlap", "bash", 3) - called("call_overlap", "bash", { command: "stale" }, 4) + called("call_overlap", { command: "stale" }, 4) await Bun.sleep(0) const beforeHydration = states().length releaseHydration() @@ -2137,8 +2230,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_child", outcome: "interrupted" }, + type: "session.execution.interrupted", + durable: durable("ses_child"), + data: { sessionID: "ses_child", reason: "user" }, }) await Bun.sleep(0) resolveGet?.() @@ -2192,6 +2286,18 @@ describe("V2 mini transport", () => { }, }) // Parent's background subagent tool.success adopts the child mid-discovery. + events.push({ + id: "evt_parent_input", + created: 0, + type: "session.tool.input.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_parent_a", + callID: "call_sub", + name: "subagent", + }, + }) events.push({ id: "evt_parent_call", created: 0, @@ -2201,9 +2307,8 @@ describe("V2 mini transport", () => { sessionID: "ses_1", assistantMessageID: "msg_parent_a", callID: "call_sub", - tool: "subagent", input: { agent: "explore", description: "Find things", prompt: "go", background: true }, - provider: { executed: true }, + executed: true, }, }) events.push({ @@ -2217,15 +2322,16 @@ describe("V2 mini transport", () => { callID: "call_sub", structured: { sessionID: "ses_child", status: "running", output: "" }, content: [], - provider: { executed: true }, + executed: true, }, }) // The settled event arrives after adoption, so it applies directly. events.push({ id: "evt_child_settled", created: 0, - type: "session.execution.settled", - data: { sessionID: "ses_child", outcome: "interrupted" }, + type: "session.execution.interrupted", + durable: durable("ses_child"), + data: { sessionID: "ses_child", reason: "shutdown" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 1fc3dda0df4..8f8286d5628 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -101,7 +101,7 @@ test.skip("text ended populates assistant text content", () => { data: { sessionID, assistantMessageID, - textID: "text-1", + ordinal: 0, }, } satisfies SessionEvent.Event), ) @@ -115,7 +115,7 @@ test.skip("text ended populates assistant text content", () => { data: { sessionID, assistantMessageID, - textID: "text-1", + ordinal: 0, text: "hello assistant", }, } satisfies SessionEvent.Event), @@ -123,7 +123,7 @@ test.skip("text ended populates assistant text content", () => { expect(state.messages[0]?.type).toBe("assistant") if (state.messages[0]?.type !== "assistant") return - expect(state.messages[0].content).toEqual([{ type: "text", id: "text-1", text: "hello assistant" }]) + expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }]) }) test.skip("tool completion stores completed timestamp", () => { @@ -176,9 +176,9 @@ test.skip("tool completion stores completed timestamp", () => { sessionID, assistantMessageID, callID, - tool: "bash", input: { command: "pwd" }, - provider: { executed: true, metadata: { fake: { source: "provider" } } }, + executed: true, + state: { source: "provider" }, }, } satisfies SessionEvent.Event), ) @@ -195,7 +195,8 @@ test.skip("tool completion stores completed timestamp", () => { callID, structured: {}, content: [{ type: "text", text: "/tmp" }], - provider: { executed: true, metadata: { fake: { status: "done" } } }, + executed: true, + resultState: { status: "done" }, }, } satisfies SessionEvent.Event), ) @@ -205,7 +206,11 @@ test.skip("tool completion stores completed timestamp", () => { expect(state.messages[0].content[0]?.type).toBe("tool") if (state.messages[0].content[0]?.type !== "tool") return expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4)) - expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } }) + expect(state.messages[0].content[0]).toMatchObject({ + executed: true, + providerState: { source: "provider" }, + providerResultState: { status: "done" }, + }) }) test("compaction events reduce to compaction message only when completed", () => { diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 831f97b0664..3f2efee68d1 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -20,6 +20,7 @@ export { Revert } from "./revert.js" export { Session } from "./session.js" export { Vcs } from "./vcs.js" export { SessionInput } from "./session-input.js" +export { SessionError } from "./session-error.js" export { SessionMessage } from "./session-message.js" export { Shell } from "./shell.js" export { Skill } from "./skill.js" diff --git a/packages/schema/src/llm.ts b/packages/schema/src/llm.ts index 5e19ecc6d3b..2bc1d93bd8f 100644 --- a/packages/schema/src/llm.ts +++ b/packages/schema/src/llm.ts @@ -8,6 +8,9 @@ export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schem }) export type ProviderMetadata = Schema.Schema.Type +export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export type FinishReason = typeof FinishReason.Type + export interface ToolTextContent extends Schema.Schema.Type {} export const ToolTextContent = Schema.Struct({ type: Schema.Literal("text"), diff --git a/packages/schema/src/session-error.ts b/packages/schema/src/session-error.ts new file mode 100644 index 00000000000..b11efdbdeeb --- /dev/null +++ b/packages/schema/src/session-error.ts @@ -0,0 +1,9 @@ +export * as SessionError from "./session-error.js" + +import { Schema } from "effect" + +export interface Error extends Schema.Schema.Type {} +export const Error = Schema.Struct({ + type: Schema.String, + message: Schema.String, +}).annotate({ identifier: "Session.StructuredError" }) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index c79b0da7462..09319c60a8d 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -3,16 +3,18 @@ export * as SessionEvent from "./session-event.js" import { Schema } from "effect" import { optional } from "./schema.js" import { Event } from "./event.js" -import { ProviderMetadata, ToolContent } from "./llm.js" +import { ToolContent } from "./llm.js" +import { FinishReason } from "./llm.js" import { Delivery } from "./session-delivery.js" import { Model } from "./model.js" -import { NonNegativeInt, RelativePath } from "./schema.js" +import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" import { FileAttachment, Prompt } from "./prompt.js" import { SessionID } from "./session-id.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" import { Revert } from "./revert.js" import { Shell as ShellSchema } from "./shell.js" +import { SessionError } from "./session-error.js" export { FileAttachment } @@ -41,16 +43,6 @@ const options = { version: 1, }, } as const -const stepSettlementOptions = { - durable: { - aggregate: "sessionID", - version: 1, - }, -} as const - -export const UnknownError = SessionMessage.UnknownError -export type UnknownError = SessionMessage.UnknownError - export const AgentSelected = Event.durable({ type: "session.agent.selected", ...options, @@ -120,15 +112,27 @@ export const PromptAdmitted = Event.durable({ }) export type PromptAdmitted = typeof PromptAdmitted.Type -export const ExecutionSettled = Event.ephemeral({ - type: "session.execution.settled", - schema: { - ...Base, - outcome: Schema.Literals(["success", "failure", "interrupted"]), - error: UnknownError.pipe(optional), - }, -}) -export type ExecutionSettled = typeof ExecutionSettled.Type +export namespace Execution { + export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base }) + export type Started = typeof Started.Type + + export const Succeeded = Event.durable({ type: "session.execution.succeeded", ...options, schema: Base }) + export type Succeeded = typeof Succeeded.Type + + export const Failed = Event.durable({ + type: "session.execution.failed", + ...options, + schema: { ...Base, error: SessionError.Error }, + }) + export type Failed = typeof Failed.Type + + export const Interrupted = Event.durable({ + type: "session.execution.interrupted", + ...options, + schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded"]) }, + }) + export type Interrupted = typeof Interrupted.Type +} export const InstructionsUpdated = Event.durable({ type: "session.instructions.updated", @@ -204,11 +208,11 @@ export namespace Step { export const Ended = Event.durable({ type: "session.step.ended", - ...stepSettlementOptions, + ...options, schema: { ...Base, assistantMessageID: SessionMessage.ID, - finish: Schema.String, + finish: FinishReason, cost: Schema.Finite, tokens: Schema.Struct({ input: Schema.Finite, @@ -227,11 +231,11 @@ export namespace Step { export const Failed = Event.durable({ type: "session.step.failed", - ...stepSettlementOptions, + ...options, schema: { ...Base, assistantMessageID: SessionMessage.ID, - error: UnknownError, + error: SessionError.Error, }, }) export type Failed = typeof Failed.Type @@ -244,7 +248,7 @@ export namespace Text { schema: { ...Base, assistantMessageID: SessionMessage.ID, - textID: Schema.String, + ordinal: NonNegativeInt, }, }) export type Started = typeof Started.Type @@ -255,7 +259,7 @@ export namespace Text { schema: { ...Base, assistantMessageID: SessionMessage.ID, - textID: Schema.String, + ordinal: NonNegativeInt, delta: Schema.String, }, }) @@ -267,7 +271,7 @@ export namespace Text { schema: { ...Base, assistantMessageID: SessionMessage.ID, - textID: Schema.String, + ordinal: NonNegativeInt, text: Schema.String, }, }) @@ -281,8 +285,8 @@ export namespace Reasoning { schema: { ...Base, assistantMessageID: SessionMessage.ID, - reasoningID: Schema.String, - providerMetadata: ProviderMetadata.pipe(optional), + ordinal: NonNegativeInt, + state: SessionMessage.ProviderState.pipe(optional), }, }) export type Started = typeof Started.Type @@ -293,7 +297,7 @@ export namespace Reasoning { schema: { ...Base, assistantMessageID: SessionMessage.ID, - reasoningID: Schema.String, + ordinal: NonNegativeInt, delta: Schema.String, }, }) @@ -305,9 +309,9 @@ export namespace Reasoning { schema: { ...Base, assistantMessageID: SessionMessage.ID, - reasoningID: Schema.String, + ordinal: NonNegativeInt, text: Schema.String, - providerMetadata: ProviderMetadata.pipe(optional), + state: SessionMessage.ProviderState.pipe(optional), }, }) export type Ended = typeof Ended.Type @@ -357,12 +361,9 @@ export namespace Tool { ...options, schema: { ...ToolBase, - tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(optional), - }), + executed: Schema.Boolean, + state: SessionMessage.ProviderState.pipe(optional), }, }) export type Called = typeof Called.Type @@ -391,10 +392,8 @@ export namespace Tool { content: Schema.Array(ToolContent), outputPaths: Schema.Array(Schema.String).pipe(optional), result: Schema.Unknown.pipe(optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(optional), - }), + executed: Schema.Boolean, + resultState: SessionMessage.ProviderState.pipe(optional), }, }) export type Success = typeof Success.Type @@ -404,39 +403,27 @@ export namespace Tool { ...options, schema: { ...ToolBase, - error: UnknownError, + error: SessionError.Error, result: Schema.Unknown.pipe(optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(optional), - }), + executed: Schema.Boolean, + resultState: SessionMessage.ProviderState.pipe(optional), }, }) export type Failed = typeof Failed.Type } -export const RetryError = Schema.Struct({ - message: Schema.String, - statusCode: Schema.Finite.pipe(optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional), - responseBody: Schema.String.pipe(optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), -}).annotate({ - identifier: "session.retry.error", -}) -export interface RetryError extends Schema.Schema.Type {} - -export const Retried = Event.durable({ - type: "session.retried", +export const RetryScheduled = Event.durable({ + type: "session.retry.scheduled", ...options, schema: { ...Base, - attempt: Schema.Finite, - error: RetryError, + assistantMessageID: SessionMessage.ID, + attempt: PositiveInt, + at: NonNegativeInt, + error: SessionError.Error, }, }) -export type Retried = typeof Retried.Type +export type RetryScheduled = typeof RetryScheduled.Type export namespace Compaction { export const Started = Event.durable({ @@ -481,7 +468,7 @@ export namespace RevertEvent { export const Committed = Event.durable({ type: "session.revert.committed", ...options, - schema: { ...Base, messageID: SessionMessage.ID }, + schema: { ...Base, to: SessionMessage.ID }, }) } @@ -493,7 +480,10 @@ export const Definitions = Event.inventory( Forked, PromptPromoted, PromptAdmitted, - ExecutionSettled, + Execution.Started, + Execution.Succeeded, + Execution.Failed, + Execution.Interrupted, InstructionsUpdated, Synthetic, Skill.Activated, @@ -515,7 +505,7 @@ export const Definitions = Event.inventory( Tool.Progress, Tool.Success, Tool.Failed, - Retried, + RetryScheduled, Compaction.Started, Compaction.Delta, Compaction.Ended, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index c9f39193a5a..aafef1dba2a 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -2,14 +2,16 @@ export * as SessionMessage from "./session-message.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { ProviderMetadata, ToolContent } from "./llm.js" +import { ToolContent } from "./llm.js" import { Model } from "./model.js" import { FileAttachment, Prompt } from "./prompt.js" -import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js" +import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js" import { SessionID } from "./session-id.js" import { ascending } from "./identifier.js" import { Event } from "./event.js" import { Shell as ShellSchema } from "./shell.js" +import { FinishReason } from "./llm.js" +import { SessionError } from "./session-error.js" export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( Schema.brand("Session.Message.ID"), @@ -20,18 +22,17 @@ export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( ) export type ID = typeof ID.Type -export interface UnknownError extends Schema.Schema.Type {} -export const UnknownError = Schema.Struct({ - type: Schema.Literal("unknown"), - message: Schema.String, -}).annotate({ identifier: "Session.Error.Unknown" }) - const Base = { id: ID, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis }), } +export const ProviderState = Schema.Record(Schema.String, Schema.Unknown).annotate({ + identifier: "Session.Message.ProviderState", +}) +export type ProviderState = typeof ProviderState.Type + export interface AgentSelected extends Schema.Schema.Type {} export const AgentSelected = Schema.Struct({ ...Base, @@ -123,7 +124,7 @@ export const ToolStateError = Schema.Struct({ input: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), structured: Schema.Record(Schema.String, Schema.Unknown), - error: UnknownError, + error: SessionError.Error, result: Schema.Unknown.pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Error" }) @@ -137,11 +138,9 @@ export const AssistantTool = Schema.Struct({ type: Schema.Literal("tool"), id: Schema.String, name: Schema.String, - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(optional), - resultMetadata: ProviderMetadata.pipe(optional), - }).pipe(optional), + executed: Schema.Boolean.pipe(optional), + providerState: ProviderState.pipe(optional), + providerResultState: ProviderState.pipe(optional), state: ToolState, time: Schema.Struct({ created: DateTimeUtcFromMillis, @@ -154,16 +153,14 @@ export const AssistantTool = Schema.Struct({ export interface AssistantText extends Schema.Schema.Type {} export const AssistantText = Schema.Struct({ type: Schema.Literal("text"), - id: Schema.String, text: Schema.String, }).annotate({ identifier: "Session.Message.Assistant.Text" }) export interface AssistantReasoning extends Schema.Schema.Type {} export const AssistantReasoning = Schema.Struct({ type: Schema.Literal("reasoning"), - id: Schema.String, text: Schema.String, - providerMetadata: ProviderMetadata.pipe(optional), + state: ProviderState.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(optional), @@ -175,6 +172,13 @@ export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, ) export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool +export interface AssistantRetry extends Schema.Schema.Type {} +export const AssistantRetry = Schema.Struct({ + attempt: PositiveInt, + at: DateTimeUtcFromMillis, + error: SessionError.Error, +}).annotate({ identifier: "Session.Message.Assistant.Retry" }) + export interface Assistant extends Schema.Schema.Type {} export const Assistant = Schema.Struct({ ...Base, @@ -187,7 +191,7 @@ export const Assistant = Schema.Struct({ end: Schema.String.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }).pipe(optional), - finish: Schema.String.pipe(optional), + finish: FinishReason.pipe(optional), cost: Schema.Finite.pipe(optional), tokens: Schema.Struct({ input: Schema.Finite, @@ -195,7 +199,8 @@ export const Assistant = Schema.Struct({ reasoning: Schema.Finite, cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }), }).pipe(optional), - error: UnknownError.pipe(optional), + error: SessionError.Error.pipe(optional), + retry: AssistantRetry.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(optional), diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 58ba6ba856f..236e15b91fe 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { DateTime, Schema } from "effect" import { Agent } from "../src/agent.js" import { FileSystem } from "../src/filesystem.js" import { Model } from "../src/model.js" @@ -8,6 +8,7 @@ import { Provider } from "../src/provider.js" import { Pty } from "../src/pty.js" import { Question } from "../src/question.js" import { Session } from "../src/session.js" +import { SessionMessage } from "../src/session-message.js" import { SessionTodo } from "../src/session-todo.js" import { optional } from "../src/schema.js" @@ -81,4 +82,25 @@ describe("contract hygiene", () => { expect(source).not.toContain("Schema.Any") expect(source).not.toContain("Schema.mutable") }) + + test("assistant content keeps only domain identities", () => { + expect(SessionMessage.AssistantText.make({ type: "text", text: "hello" })).toEqual({ + type: "text", + text: "hello", + }) + expect( + SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", state: { id: "opaque" } }), + ).toEqual({ type: "reasoning", text: "thinking", state: { id: "opaque" } }) + expect( + SessionMessage.AssistantTool.make({ + type: "tool", + id: "call_1", + name: "search", + executed: true, + providerState: { itemId: "item_1" }, + state: { status: "pending", input: "" }, + time: { created: DateTime.makeUnsafe(0) }, + }), + ).not.toHaveProperty("provider") + }) }) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 51400b2e39f..0d07d2d00b0 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -17,6 +17,8 @@ import { IdeEvent } from "../src/ide-event.js" import { McpEvent } from "../src/mcp-event.js" import { Plugin } from "../src/plugin.js" import { SessionEvent } from "../src/session-event.js" +import { SessionID } from "../src/session-id.js" +import { SessionMessage } from "../src/session-message.js" import { SessionTodo } from "../src/session-todo.js" import { SessionV1 } from "../src/session-v1.js" import { WorkspaceEvent } from "../src/workspace-event.js" @@ -104,7 +106,10 @@ describe("public event manifest", () => { "session.forked.1", "session.prompt.promoted.1", "session.prompt.admitted.1", - + "session.execution.started.1", + "session.execution.succeeded.1", + "session.execution.failed.1", + "session.execution.interrupted.1", "session.instructions.updated.1", "session.synthetic.1", "session.skill.activated.1", @@ -123,7 +128,7 @@ describe("public event manifest", () => { "session.tool.failed.1", "session.reasoning.started.1", "session.reasoning.ended.1", - "session.retried.1", + "session.retry.scheduled.1", "session.compaction.started.1", "session.compaction.ended.1", "session.revert.staged.1", @@ -136,4 +141,33 @@ describe("public event manifest", () => { ) expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true) }) + + test("keeps simplified session fragment and tool payloads on durable version 1", () => { + const sessionID = SessionID.make("ses_test") + const assistantMessageID = SessionMessage.ID.make("msg_test") + const text = SessionEvent.Text.Started.data.make({ sessionID, assistantMessageID, ordinal: 0 }) + const reasoning = SessionEvent.Reasoning.Ended.data.make({ + sessionID, + assistantMessageID, + ordinal: 0, + text: "thought", + state: { signature: "sig" }, + }) + const tool = SessionEvent.Tool.Called.data.make({ + sessionID, + assistantMessageID, + callID: "call_test", + input: {}, + executed: true, + state: { itemId: "item_test" }, + }) + + expect(text).not.toHaveProperty("textID") + expect(reasoning).not.toHaveProperty("reasoningID") + expect(reasoning).not.toHaveProperty("providerMetadata") + expect(tool).not.toHaveProperty("tool") + expect(tool).not.toHaveProperty("provider") + expect(SessionEvent.Text.Started.durable?.version).toBe(1) + expect(SessionEvent.Tool.Called.durable?.version).toBe(1) + }) }) diff --git a/packages/schema/test/session-error.test.ts b/packages/schema/test/session-error.test.ts new file mode 100644 index 00000000000..de53e1fd5f1 --- /dev/null +++ b/packages/schema/test/session-error.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { LLM, SessionError } from "../src/index.js" + +describe("SessionError", () => { + test("exports one identified open envelope", () => { + expect(SessionError.Error.ast.annotations?.identifier).toBe("Session.StructuredError") + expect(Object.keys(SessionError).filter((key) => key !== "SessionError")).toEqual(["Error"]) + }) + + test("round trips current and future error types through JSON", () => { + const values: SessionError.Error[] = [ + { type: "provider.rate-limit", message: "Slow down" }, + { type: "provider.auth", message: "Authentication failed" }, + { type: "provider.future-condition", message: "A future provider failure" }, + { type: "unknown", message: "Unexpected" }, + ] + const codec = Schema.fromJsonString(SessionError.Error) + + for (const value of values) { + const encoded = Schema.encodeSync(codec)(value) + expect(Schema.decodeUnknownSync(codec)(encoded)).toEqual(value) + } + }) + + test("accepts future fields while exposing only the stable envelope", () => { + expect( + Schema.decodeUnknownSync(SessionError.Error)({ + type: "provider.timeout", + message: "Timeout", + retryAfterMs: 2_500, + }), + ).toEqual({ type: "provider.timeout", message: "Timeout" }) + }) + + test("rejects missing envelope fields", () => { + expect(() => Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth" })).toThrow() + expect(() => Schema.decodeUnknownSync(SessionError.Error)({ message: "Missing type" })).toThrow() + }) +}) + +test("FinishReason is the closed browser-safe provider set", () => { + const reasons = ["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const + expect(reasons.map((reason) => Schema.decodeUnknownSync(LLM.FinishReason)(reason))).toEqual([...reasons]) + expect(() => Schema.decodeUnknownSync(LLM.FinishReason)("other")).toThrow() +}) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index fdc1d76bc62..4101e0751ff 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -30,6 +30,8 @@ type OpenApiDocument = { const document = (await Bun.file("./openapi.json").json()) as OpenApiDocument const v2Document = (await Bun.file("./openapi-v2.json").json()) as OpenApiDocument +normalizeComponentNames(v2Document) +deduplicateEquivalentComponent(v2Document, "Shell", "Shell1") renameCollidingComponents(document, v2Document) document.paths = { ...document.paths, ...v2Document.paths } document.components = { @@ -60,7 +62,7 @@ if (schemas) { visit({ ...document, components: { ...document.components, schemas: undefined } }) for (const name of Object.keys(schemas)) { if ( - /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test( + /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test( name, ) && !reachable.has(name) @@ -100,17 +102,28 @@ await createClient({ const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypes = await Bun.file(generatedTypesPath).text() if ( - /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test( + /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test( generatedTypes, ) ) { throw new Error("Session history generated duplicate Session event variants") } -const logTypesPatched = generatedTypes.replace( +const sessionErrorTypesPatched = deduplicateEquivalentGeneratedTypes( + generatedTypes, + "SessionStructuredError", + /^SessionStructuredError\d+$/, +) +const obsoleteSessionNext = [...sessionErrorTypesPatched.matchAll(/export type (SessionNext\w*) =/g)].map( + (match) => match[1], +) +if (obsoleteSessionNext.length > 0) { + throw new Error(`Obsolete SessionNext generated type noise reintroduced: ${obsoleteSessionNext.join(", ")}`) +} +const logTypesPatched = sessionErrorTypesPatched.replace( /(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/, "$1number", ) -if (logTypesPatched === generatedTypes) { +if (logTypesPatched === sessionErrorTypesPatched) { throw new Error("Session log numeric query patch did not apply") } const sessionListTypesPatched = logTypesPatched.replace( @@ -128,12 +141,21 @@ if (sessionMessagesTypesPatched === sessionListTypesPatched) { throw new Error("Session messages numeric query patch did not apply") } const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace( - /(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStreamV2;?\s*\};?/, + /(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStream(?:V2)?;?\s*\};?/, "$1V2Event", ) if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) { throw new Error("Event subscribe response patch did not apply") } +if (/SessionStructuredError\d/.test(eventSubscribeTypesPatched)) { + throw new Error("Session structured error generated a name-mangled duplicate") +} +if (/\bSessionNext\w*\b/.test(eventSubscribeTypesPatched)) { + throw new Error("Obsolete SessionNext generated type noise reintroduced") +} +if (/export type Shell\d+V2 =/.test(eventSubscribeTypesPatched)) { + throw new Error("Shell generated a name-mangled duplicate") +} await Bun.write(generatedTypesPath, eventSubscribeTypesPatched) const querySerializerPath = "./src/v2/gen/client/utils.gen.ts" @@ -206,6 +228,10 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum const renames = new Map() for (const name of Object.keys(sourceSchemas)) { if (!Object.hasOwn(targetSchemas, name)) continue + if (JSON.stringify(normalizeSchema(sourceSchemas[name])) === JSON.stringify(normalizeSchema(targetSchemas[name]))) { + delete sourceSchemas[name] + continue + } let renamed = `${name}V2` let index = 2 while (Object.hasOwn(targetSchemas, renamed) || Object.hasOwn(sourceSchemas, renamed)) { @@ -225,6 +251,136 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum source.paths = rewriteRefs(source.paths, renames) as Record | undefined } +function normalizeComponentNames(document: OpenApiDocument) { + const schemas = document.components?.schemas + if (!schemas) return + + const canonical = new Map(Object.entries(schemas)) + const renames = new Map() + for (const name of Object.keys(schemas)) { + const next = componentTypeName(name) + if (next === name) continue + const existing = canonical.get(next) + if (existing !== undefined) { + if (JSON.stringify(normalizeSchema(schemas[name])) !== JSON.stringify(normalizeSchema(existing))) continue + renames.set(name, next) + continue + } + renames.set(name, next) + canonical.set(next, schemas[name]) + } + if (renames.size === 0) return + + const renamed = new Set() + document.components = { + ...document.components, + schemas: Object.fromEntries( + [ + ...Object.entries(schemas).filter(([name]) => !renames.has(name)), + ...Object.entries(schemas).flatMap(([name, schema]) => { + const next = renames.get(name) + if (!next || Object.hasOwn(schemas, next) || renamed.has(next)) return [] + renamed.add(next) + return [[next, schema] as const] + }), + ].map(([name, schema]) => [name, rewriteRefs(schema, renames)]), + ), + } + document.paths = rewriteRefs(document.paths, renames) as Record | undefined +} + +function componentTypeName(name: string) { + if (!name.includes(".")) return name + return name + .split(".") + .filter((part) => !/^\d+$/.test(part)) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join("") +} + +function deduplicateEquivalentComponent(document: OpenApiDocument, canonical: string, duplicate: string) { + const schemas = document.components?.schemas + if (!schemas?.[canonical] || !schemas[duplicate]) return + if (JSON.stringify(normalizeSchema(schemas[canonical])) !== JSON.stringify(normalizeSchema(schemas[duplicate]))) { + throw new Error(`${duplicate} no longer has the same wire shape as ${canonical}`) + } + + const renames = new Map([[duplicate, canonical]]) + const rewritten = rewriteRefs(schemas, renames) as Record + delete rewritten[duplicate] + document.components = { ...document.components, schemas: rewritten } + document.paths = rewriteRefs(document.paths, renames) as Record | undefined +} + +function deduplicateEquivalentGeneratedTypes(source: string, canonical: string, duplicates: RegExp) { + const canonicalType = generatedType(source, canonical) + if (!canonicalType) throw new Error(`Generated canonical type missing: ${canonical}`) + const names = [...source.matchAll(/export type (\w+) =/g)] + .map((match) => match[1]) + .filter((name): name is string => name !== undefined && duplicates.test(name)) + + return names.reduce((patched, name) => { + const duplicate = generatedType(patched, name) + const currentCanonical = generatedType(patched, canonical) + if (!duplicate || !currentCanonical) throw new Error(`Generated type declaration missing while comparing ${name}`) + if (normalizeGeneratedType(currentCanonical.shape) !== normalizeGeneratedType(duplicate.shape)) { + throw new Error(`${name} no longer has the same generated type shape as ${canonical}`) + } + return (patched.slice(0, duplicate.start) + patched.slice(duplicate.end)).replaceAll(name, canonical) + }, source) +} + +function generatedType(source: string, name: string) { + const start = source.indexOf(`export type ${name} =`) + if (start === -1) return undefined + const next = source.indexOf("\n\nexport type ", start + 1) + const shapeEnd = next === -1 ? source.length : next + return { + start, + end: next === -1 ? source.length : next + 2, + shape: source.slice(source.indexOf("=", start) + 1, shapeEnd), + } +} + +function normalizeGeneratedType(shape: string) { + return shape.replaceAll(/\s/g, "") +} + +function normalizeSchema(value: unknown, key?: string): unknown { + if (Array.isArray(value)) { + const flattened = + key === "anyOf" + ? value.flatMap((item) => + typeof item === "object" && item !== null && Object.keys(item).length === 1 && "anyOf" in item + ? Array.isArray(item.anyOf) + ? item.anyOf + : [item] + : [item], + ) + : value + const expanded = + key === "anyOf" + ? flattened.flatMap((item) => { + if (typeof item !== "object" || item === null || !("type" in item) || !("enum" in item)) return [item] + if (Object.keys(item).some((property) => property !== "type" && property !== "enum")) return [item] + if (!Array.isArray(item.enum)) return [item] + return item.enum.map((member) => ({ type: item.type, enum: [member] })) + }) + : flattened + const normalized = expanded.map((item) => normalizeSchema(item)) + if (key !== "anyOf" && key !== "required" && key !== "enum") return normalized + return [...new Map(normalized.map((item) => [JSON.stringify(item), item])).values()].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)), + ) + } + if (typeof value !== "object" || value === null) return value + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([property, child]) => [property, normalizeSchema(child, property)]), + ) +} + function rewriteRefs(value: unknown, renames: Map): unknown { if (Array.isArray(value)) return value.map((item) => rewriteRefs(item, renames)) if (typeof value !== "object" || value === null) return value diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67fd49d378c..5ff2606c867 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -76,8 +76,8 @@ import type { FindTextResponses, FormatterStatusErrors, FormatterStatusResponses, - FormCreatePayload2, - FormReply2, + FormCreatePayloadV2, + FormReply, GlobalConfigGetErrors, GlobalConfigGetResponses, GlobalConfigUpdateErrors, @@ -92,8 +92,8 @@ import type { GlobalUpgradeResponses, InstanceDisposeErrors, InstanceDisposeResponses, - InstructionEntryKey2, - LocationRef2, + InstructionEntryKeyV2, + LocationRefV2, LspStatusErrors, LspStatusResponses, McpAddErrors, @@ -114,7 +114,7 @@ import type { McpRemoteConfig, McpStatusErrors, McpStatusResponses, - ModelRef2, + ModelRef, MoveSessionDestination, OutputFormat, Part as Part2, @@ -131,8 +131,8 @@ import type { PermissionRespondErrors, PermissionRespondResponses, PermissionRuleset, - PermissionV2Reply2, - PermissionV2Source2, + PermissionV2Reply, + PermissionV2SourceV2, ProjectCommands, ProjectCurrentErrors, ProjectCurrentResponses, @@ -145,9 +145,9 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, - PromptAgentAttachment2, - PromptInputFileAttachment2, - PromptInputV2, + PromptAgentAttachment, + PromptInput, + PromptInputFileAttachment, ProviderAuthErrors, ProviderAuthResponses, ProviderListErrors, @@ -179,7 +179,7 @@ import type { QuestionRejectResponses, QuestionReplyErrors, QuestionReplyResponses, - QuestionV2Reply2, + QuestionV2Reply, SessionAbortErrors, SessionAbortResponses, SessionChildrenErrors, @@ -460,7 +460,7 @@ import type { VcsDiffResponses, VcsGetErrors, VcsGetResponses, - VcsMode2, + VcsMode, VcsStatusErrors, VcsStatusResponses, WorktreeCreateErrors, @@ -5292,7 +5292,7 @@ export class Entry extends HeyApiClient { public remove( parameters: { sessionID: string - key: InstructionEntryKey2 + key: InstructionEntryKeyV2 }, options?: Options, ) { @@ -5326,7 +5326,7 @@ export class Entry extends HeyApiClient { public put( parameters: { sessionID: string - key: InstructionEntryKey2 + key: InstructionEntryKeyV2 value?: unknown }, options?: Options, @@ -5395,7 +5395,7 @@ export class Form extends HeyApiClient { public create( parameters: { sessionID: string - formCreatePayload: FormCreatePayload2 + formCreatePayloadV2: FormCreatePayloadV2 }, options?: Options, ) { @@ -5405,7 +5405,7 @@ export class Form extends HeyApiClient { { args: [ { in: "path", key: "sessionID" }, - { key: "formCreatePayload", map: "body" }, + { key: "formCreatePayloadV2", map: "body" }, ], }, ], @@ -5493,7 +5493,7 @@ export class Form extends HeyApiClient { parameters: { sessionID: string formID: string - formReply: FormReply2 + formReply: FormReply }, options?: Options, ) { @@ -5593,7 +5593,7 @@ export class Permission2 extends HeyApiClient { metadata?: { [key: string]: unknown } - source?: PermissionV2Source2 + source?: PermissionV2SourceV2 agent?: string | null }, options?: Options, @@ -5674,7 +5674,7 @@ export class Permission2 extends HeyApiClient { parameters: { sessionID: string requestID: string - reply?: PermissionV2Reply2 + reply?: PermissionV2Reply message?: string | null }, options?: Options, @@ -5742,7 +5742,7 @@ export class Question2 extends HeyApiClient { parameters: { sessionID: string requestID: string - questionV2Reply: QuestionV2Reply2 + questionV2Reply: QuestionV2Reply }, options?: Options, ) { @@ -5863,8 +5863,8 @@ export class Session3 extends HeyApiClient { parameters?: { id?: string | null agent?: string | null - model?: ModelRef2 | null - location?: LocationRef2 | null + model?: ModelRef | null + location?: LocationRefV2 | null }, options?: Options, ) { @@ -6006,7 +6006,7 @@ export class Session3 extends HeyApiClient { public switchModel( parameters: { sessionID: string - model?: ModelRef2 + model?: ModelRef }, options?: Options, ) { @@ -6081,7 +6081,7 @@ export class Session3 extends HeyApiClient { parameters: { sessionID: string id?: string | null - prompt?: PromptInputV2 + prompt?: PromptInput delivery?: "steer" | "queue" | null resume?: boolean | null }, @@ -6125,9 +6125,9 @@ export class Session3 extends HeyApiClient { command?: string arguments?: string | null agent?: string | null - model?: ModelRef2 | null - files?: Array - agents?: Array + model?: ModelRef | null + files?: Array + agents?: Array delivery?: "steer" | "queue" | null resume?: boolean | null }, @@ -6559,7 +6559,7 @@ export class Generate extends HeyApiClient { workspace?: string | null } | null prompt?: string - model?: ModelRef2 | null + model?: ModelRef | null }, options?: Options, ) { @@ -8013,7 +8013,7 @@ export class Vcs2 extends HeyApiClient { directory?: string | null workspace?: string | null } | null - mode: VcsMode2 + mode: VcsMode context?: string | null }, options?: Options, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 70b85f2e0bb..f1e2076e7ef 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -24,7 +24,10 @@ export type Event = | EventSessionForked | EventSessionPromptPromoted | EventSessionPromptAdmitted - | EventSessionExecutionSettled + | EventSessionExecutionStarted + | EventSessionExecutionSucceeded + | EventSessionExecutionFailed + | EventSessionExecutionInterrupted | EventSessionInstructionsUpdated | EventSessionSynthetic | EventSessionSkillActivated @@ -46,7 +49,7 @@ export type Event = | EventSessionToolProgress | EventSessionToolSuccess | EventSessionToolFailed - | EventSessionRetried + | EventSessionRetryScheduled | EventSessionCompactionStarted | EventSessionCompactionDelta | EventSessionCompactionEnded @@ -920,11 +923,32 @@ export type GlobalEvent = { } | { id: string - type: "session.execution.settled" + type: "session.execution.started" properties: { sessionID: string - outcome: "success" | "failure" | "interrupted" - error?: SessionErrorUnknown + } + } + | { + id: string + type: "session.execution.succeeded" + properties: { + sessionID: string + } + } + | { + id: string + type: "session.execution.failed" + properties: { + sessionID: string + error: SessionStructuredError + } + } + | { + id: string + type: "session.execution.interrupted" + properties: { + sessionID: string + reason: "user" | "shutdown" | "superseded" } } | { @@ -995,7 +1019,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - finish: string + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" cost: number tokens: { input: number @@ -1016,7 +1040,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - error: SessionErrorUnknown + error: SessionStructuredError } } | { @@ -1025,7 +1049,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number } } | { @@ -1034,7 +1058,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number delta: string } } @@ -1044,7 +1068,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number text: string } } @@ -1054,8 +1078,8 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata + ordinal: number + state?: SessionMessageProviderState } } | { @@ -1064,7 +1088,7 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number delta: string } } @@ -1074,9 +1098,9 @@ export type GlobalEvent = { properties: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState } } | { @@ -1116,14 +1140,11 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string callID: string - tool: string input: { [key: string]: unknown } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + state?: SessionMessageProviderState } } | { @@ -1152,10 +1173,8 @@ export type GlobalEvent = { content: Array outputPaths?: Array result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } | { @@ -1165,21 +1184,21 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string callID: string - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } | { id: string - type: "session.retried" + type: "session.retry.scheduled" properties: { sessionID: string + assistantMessageID: string attempt: number - error: SessionRetryError + at: number + error: SessionStructuredError } } | { @@ -1228,7 +1247,7 @@ export type GlobalEvent = { type: "session.revert.committed" properties: { sessionID: string - messageID: string + to: string } } | { @@ -1731,6 +1750,10 @@ export type GlobalEvent = { | SyncEventSessionForked | SyncEventSessionPromptPromoted | SyncEventSessionPromptAdmitted + | SyncEventSessionExecutionStarted + | SyncEventSessionExecutionSucceeded + | SyncEventSessionExecutionFailed + | SyncEventSessionExecutionInterrupted | SyncEventSessionInstructionsUpdated | SyncEventSessionSynthetic | SyncEventSessionSkillActivated @@ -1749,7 +1772,7 @@ export type GlobalEvent = { | SyncEventSessionToolProgress | SyncEventSessionToolSuccess | SyncEventSessionToolFailed - | SyncEventSessionRetried + | SyncEventSessionRetryScheduled | SyncEventSessionCompactionStarted | SyncEventSessionCompactionEnded | SyncEventSessionRevertStaged @@ -2894,6 +2917,10 @@ export type SessionDurableEvent = | SessionForked | SessionPromptPromoted | SessionPromptAdmitted + | SessionExecutionStarted + | SessionExecutionSucceeded + | SessionExecutionFailed + | SessionExecutionInterrupted | SessionInstructionsUpdated | SessionSynthetic | SessionSkillActivated @@ -2912,7 +2939,7 @@ export type SessionDurableEvent = | SessionToolProgress | SessionToolSuccess | SessionToolFailed - | SessionRetried + | SessionRetryScheduled | SessionCompactionStarted | SessionCompactionEnded | SessionRevertStaged @@ -3034,7 +3061,10 @@ export type V2Event = | SessionForked | SessionPromptPromoted | SessionPromptAdmitted - | SessionExecutionSettled + | SessionExecutionStarted + | SessionExecutionSucceeded + | SessionExecutionFailed + | SessionExecutionInterrupted | SessionInstructionsUpdated | SessionSynthetic | SessionSkillActivated @@ -3056,7 +3086,7 @@ export type V2Event = | SessionToolProgress | SessionToolSuccess | SessionToolFailed - | SessionRetried + | SessionRetryScheduled | SessionCompactionStarted | SessionCompactionDelta | SessionCompactionEnded @@ -3286,15 +3316,13 @@ export type PromptAgentAttachment = { mention?: PromptMention } -export type SessionErrorUnknown = { - type: "unknown" +export type SessionStructuredError = { + type: string message: string } -export type LlmProviderMetadata = { - [key: string]: { - [key: string]: unknown - } +export type SessionMessageProviderState = { + [key: string]: unknown } export type ToolTextContent = { @@ -3311,19 +3339,6 @@ export type ToolFileContent = { export type LlmToolContent = ToolTextContent | ToolFileContent -export type SessionRetryError = { - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: { - [key: string]: string - } - responseBody?: string - metadata?: { - [key: string]: string - } -} - export type FileDiff = { path: string status: "added" | "modified" | "deleted" @@ -3743,6 +3758,64 @@ export type SyncEventSessionPromptAdmitted = { } } +export type SyncEventSessionExecutionStarted = { + type: "sync" + id: string + syncEvent: { + type: "session.execution.started.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + } + } +} + +export type SyncEventSessionExecutionSucceeded = { + type: "sync" + id: string + syncEvent: { + type: "session.execution.succeeded.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + } + } +} + +export type SyncEventSessionExecutionFailed = { + type: "sync" + id: string + syncEvent: { + type: "session.execution.failed.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + error: SessionStructuredError + } + } +} + +export type SyncEventSessionExecutionInterrupted = { + type: "sync" + id: string + syncEvent: { + type: "session.execution.interrupted.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + reason: "user" | "shutdown" | "superseded" + } + } +} + export type SyncEventSessionInstructionsUpdated = { type: "sync" id: string @@ -3858,7 +3931,7 @@ export type SyncEventSessionStepEnded = { data: { sessionID: string assistantMessageID: string - finish: string + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" cost: number tokens: { input: number @@ -3886,7 +3959,7 @@ export type SyncEventSessionStepFailed = { data: { sessionID: string assistantMessageID: string - error: SessionErrorUnknown + error: SessionStructuredError } } } @@ -3902,7 +3975,7 @@ export type SyncEventSessionTextStarted = { data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number } } } @@ -3918,7 +3991,7 @@ export type SyncEventSessionTextEnded = { data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number text: string } } @@ -3935,8 +4008,8 @@ export type SyncEventSessionReasoningStarted = { data: { sessionID: string assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata + ordinal: number + state?: SessionMessageProviderState } } } @@ -3952,9 +4025,9 @@ export type SyncEventSessionReasoningEnded = { data: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState } } } @@ -4005,14 +4078,11 @@ export type SyncEventSessionToolCalled = { sessionID: string assistantMessageID: string callID: string - tool: string input: { [key: string]: unknown } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + state?: SessionMessageProviderState } } } @@ -4055,10 +4125,8 @@ export type SyncEventSessionToolSuccess = { content: Array outputPaths?: Array result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } } @@ -4075,28 +4143,28 @@ export type SyncEventSessionToolFailed = { sessionID: string assistantMessageID: string callID: string - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } } -export type SyncEventSessionRetried = { +export type SyncEventSessionRetryScheduled = { type: "sync" id: string syncEvent: { - type: "session.retried.1" + type: "session.retry.scheduled.1" id: string seq: number aggregateID: string data: { sessionID: string + assistantMessageID: string attempt: number - error: SessionRetryError + at: number + error: SessionStructuredError } } } @@ -4172,7 +4240,7 @@ export type SyncEventSessionRevertCommitted = { aggregateID: string data: { sessionID: string - messageID: string + to: string } } } @@ -4402,15 +4470,13 @@ export type SessionMessageShell = { export type SessionMessageAssistantText = { type: "text" - id: string text: string } export type SessionMessageAssistantReasoning = { type: "reasoning" - id: string text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState time?: { created: number completed?: number @@ -4456,7 +4522,7 @@ export type SessionMessageToolStateError = { structured: { [key: string]: unknown } - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown } @@ -4464,11 +4530,9 @@ export type SessionMessageAssistantTool = { type: "tool" id: string name: string - provider?: { - executed: boolean - metadata?: LlmProviderMetadata - resultMetadata?: LlmProviderMetadata - } + executed?: boolean + providerState?: SessionMessageProviderState + providerResultState?: SessionMessageProviderState state: | SessionMessageToolStatePending | SessionMessageToolStateRunning @@ -4482,6 +4546,12 @@ export type SessionMessageAssistantTool = { } } +export type SessionMessageAssistantRetry = { + attempt: number + at: number + error: SessionStructuredError +} + export type SessionMessageAssistant = { id: string metadata?: { @@ -4500,7 +4570,7 @@ export type SessionMessageAssistant = { end?: string files?: Array } - finish?: string + finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" cost?: number tokens?: { input: number @@ -4511,7 +4581,8 @@ export type SessionMessageAssistant = { write: number } } - error?: SessionErrorUnknown + error?: SessionStructuredError + retry?: SessionMessageAssistantRetry } export type SessionMessageCompaction = { @@ -4683,6 +4754,80 @@ export type SessionPromptAdmitted = { } } +export type SessionExecutionStarted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type SessionExecutionSucceeded = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.succeeded" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type SessionExecutionFailed = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.failed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + error: SessionStructuredError + } +} + +export type SessionExecutionInterrupted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.interrupted" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + reason: "user" | "shutdown" | "superseded" + } +} + export type SessionInstructionsUpdated = { id: string created: number @@ -4827,7 +4972,7 @@ export type SessionStepEnded = { data: { sessionID: string assistantMessageID: string - finish: string + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" cost: number tokens: { input: number @@ -4859,7 +5004,7 @@ export type SessionStepFailed = { data: { sessionID: string assistantMessageID: string - error: SessionErrorUnknown + error: SessionStructuredError } } @@ -4879,7 +5024,7 @@ export type SessionTextStarted = { data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number } } @@ -4899,7 +5044,7 @@ export type SessionTextEnded = { data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number text: string } } @@ -4920,8 +5065,8 @@ export type SessionReasoningStarted = { data: { sessionID: string assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata + ordinal: number + state?: SessionMessageProviderState } } @@ -4941,9 +5086,9 @@ export type SessionReasoningEnded = { data: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState } } @@ -5006,14 +5151,11 @@ export type SessionToolCalled = { sessionID: string assistantMessageID: string callID: string - tool: string input: { [key: string]: unknown } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + state?: SessionMessageProviderState } } @@ -5064,10 +5206,8 @@ export type SessionToolSuccess = { content: Array outputPaths?: Array result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } @@ -5088,22 +5228,20 @@ export type SessionToolFailed = { sessionID: string assistantMessageID: string callID: string - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } -export type SessionRetried = { +export type SessionRetryScheduled = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.retried" + type: "session.retry.scheduled" durable: { aggregateID: string seq: number @@ -5112,8 +5250,10 @@ export type SessionRetried = { location?: LocationRef data: { sessionID: string + assistantMessageID: string attempt: number - error: SessionRetryError + at: number + error: SessionStructuredError } } @@ -5209,7 +5349,7 @@ export type SessionRevertCommitted = { location?: LocationRef data: { sessionID: string - messageID: string + to: string } } @@ -5714,21 +5854,6 @@ export type MessagePartRemoved = { } } -export type SessionExecutionSettled = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.execution.settled" - location?: LocationRef - data: { - sessionID: string - outcome: "success" | "failure" | "interrupted" - error?: SessionErrorUnknown - } -} - export type SessionTextDelta = { id: string created: number @@ -5740,7 +5865,7 @@ export type SessionTextDelta = { data: { sessionID: string assistantMessageID: string - textID: string + ordinal: number delta: string } } @@ -5756,7 +5881,7 @@ export type SessionReasoningDelta = { data: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number delta: string } } @@ -6815,13 +6940,37 @@ export type EventSessionPromptAdmitted = { } } -export type EventSessionExecutionSettled = { +export type EventSessionExecutionStarted = { id: string - type: "session.execution.settled" + type: "session.execution.started" properties: { sessionID: string - outcome: "success" | "failure" | "interrupted" - error?: SessionErrorUnknown + } +} + +export type EventSessionExecutionSucceeded = { + id: string + type: "session.execution.succeeded" + properties: { + sessionID: string + } +} + +export type EventSessionExecutionFailed = { + id: string + type: "session.execution.failed" + properties: { + sessionID: string + error: SessionStructuredError + } +} + +export type EventSessionExecutionInterrupted = { + id: string + type: "session.execution.interrupted" + properties: { + sessionID: string + reason: "user" | "shutdown" | "superseded" } } @@ -6899,7 +7048,7 @@ export type EventSessionStepEnded = { properties: { sessionID: string assistantMessageID: string - finish: string + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" cost: number tokens: { input: number @@ -6921,7 +7070,7 @@ export type EventSessionStepFailed = { properties: { sessionID: string assistantMessageID: string - error: SessionErrorUnknown + error: SessionStructuredError } } @@ -6931,7 +7080,7 @@ export type EventSessionTextStarted = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number } } @@ -6941,7 +7090,7 @@ export type EventSessionTextDelta = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number delta: string } } @@ -6952,7 +7101,7 @@ export type EventSessionTextEnded = { properties: { sessionID: string assistantMessageID: string - textID: string + ordinal: number text: string } } @@ -6963,8 +7112,8 @@ export type EventSessionReasoningStarted = { properties: { sessionID: string assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata + ordinal: number + state?: SessionMessageProviderState } } @@ -6974,7 +7123,7 @@ export type EventSessionReasoningDelta = { properties: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number delta: string } } @@ -6985,9 +7134,9 @@ export type EventSessionReasoningEnded = { properties: { sessionID: string assistantMessageID: string - reasoningID: string + ordinal: number text: string - providerMetadata?: LlmProviderMetadata + state?: SessionMessageProviderState } } @@ -7031,14 +7180,11 @@ export type EventSessionToolCalled = { sessionID: string assistantMessageID: string callID: string - tool: string input: { [key: string]: unknown } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + state?: SessionMessageProviderState } } @@ -7069,10 +7215,8 @@ export type EventSessionToolSuccess = { content: Array outputPaths?: Array result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } @@ -7083,22 +7227,22 @@ export type EventSessionToolFailed = { sessionID: string assistantMessageID: string callID: string - error: SessionErrorUnknown + error: SessionStructuredError result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } + executed: boolean + resultState?: SessionMessageProviderState } } -export type EventSessionRetried = { +export type EventSessionRetryScheduled = { id: string - type: "session.retried" + type: "session.retry.scheduled" properties: { sessionID: string + assistantMessageID: string attempt: number - error: SessionRetryError + at: number + error: SessionStructuredError } } @@ -7153,7 +7297,7 @@ export type EventSessionRevertCommitted = { type: "session.revert.committed" properties: { sessionID: string - messageID: string + to: string } } @@ -7707,11 +7851,6 @@ export type BadRequestError = { } } -export type UnauthorizedErrorV2 = { - _tag: "UnauthorizedError" - message: string -} - export type InvalidRequestErrorV2 = { _tag: "InvalidRequestError" message: string @@ -7719,125 +7858,14 @@ export type InvalidRequestErrorV2 = { field?: string | null } -export type LocationInfo2 = { - directory: string - workspaceID?: string - project: { - id: string - directory: string - } -} - -export type ModelRef2 = { - id: string - providerID: string - variant?: string -} - -export type ProviderSettings2 = { - [key: string]: unknown -} - -export type ProviderRequest2 = { - settings: ProviderSettings2 - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } -} - -export type AgentColor2 = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - -export type PermissionV2Effect2 = "allow" | "deny" | "ask" - -export type PermissionV2Rule2 = { - action: string - resource: string - effect: PermissionV2Effect2 -} - -export type PermissionV2Ruleset2 = Array - -export type AgentV2Info2 = { - id: string - model?: ModelRef2 - request: ProviderRequest2 - system?: string - description?: string - mode: "subagent" | "primary" | "all" - hidden: boolean - color?: AgentColor2 - steps?: number - permissions: PermissionV2Ruleset2 -} - -export type PluginInfo2 = { - id: string -} - -export type LocationRef2 = { - directory: string - workspaceID?: string -} - -export type FileDiff2 = { - path: string - status: "added" | "modified" | "deleted" - additions: number - deletions: number - patch: string -} - -export type RevertState2 = { - messageID: string - partID?: string - snapshot?: string - diff?: string - files?: Array -} - -export type SessionV2Info2 = { - id: string - parentID?: string - projectID: string - agent?: string - model?: ModelRef2 - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - time: { - created: number - updated: number - archived?: number - } - title: string - location: LocationRef2 - subpath?: string - revert?: RevertState2 -} - export type SessionsResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null } } -export type InvalidCursorErrorV2 = { - _tag: "InvalidCursorError" - message: string -} - export type InvalidRequestError1 = { _tag: "InvalidRequestError" message: string @@ -7845,113 +7873,12 @@ export type InvalidRequestError1 = { field?: string | null } -export type SessionActiveV2 = { - type: "running" -} - -export type SessionNotFoundErrorV2 = { - _tag: "SessionNotFoundError" - sessionID: string - message: string -} - -export type MessageNotFoundErrorV2 = { - _tag: "MessageNotFoundError" - sessionID: string - messageID: string - message: string -} - -export type PromptMention2 = { - start: number - end: number - text: string -} - -export type PromptInputFileAttachment2 = { - uri: string - name?: string - description?: string - mention?: PromptMention2 -} - -export type PromptAgentAttachment2 = { - name: string - mention?: PromptMention2 -} - -export type PromptInputV2 = { - text: string - files?: Array - agents?: Array -} - -export type PromptBase642 = string - -export type PromptFileSource2 = - | { - type: "inline" - } - | { - type: "uri" - uri: string - } - -export type PromptFileAttachment2 = { - data: PromptBase642 - mime: string - source: PromptFileSource2 - name?: string - description?: string - mention?: PromptMention2 -} - -export type PromptV2 = { - text: string - files?: Array - agents?: Array -} - -export type SessionInputAdmitted2 = { - admittedSeq: number - id: string - sessionID: string - prompt: PromptV2 - delivery: "steer" | "queue" - timeCreated: number - promotedSeq?: number -} - export type ConflictErrorV2 = { _tag: "ConflictError" message: string resource?: string | null } -export type CommandNotFoundErrorV2 = { - _tag: "CommandNotFoundError" - command: string - message: string -} - -export type CommandEvaluationErrorV2 = { - _tag: "CommandEvaluationError" - command: string - message: string -} - -export type SkillNotFoundErrorV2 = { - _tag: "SkillNotFoundError" - skill: string - message: string -} - -export type SessionBusyErrorV2 = { - _tag: "SessionBusyError" - sessionID: string - message: string -} - export type ServiceUnavailableErrorV2 = { _tag: "ServiceUnavailableError" message: string @@ -7964,84 +7891,6 @@ export type UnknownErrorV2 = { ref?: string | null } -export type SessionMessageAgentSelected2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "agent-switched" - agent: string -} - -export type SessionMessageModelSelected2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "model-switched" - model: ModelRef2 - previous?: ModelRef2 -} - -export type SessionMessageUser2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - text: string - files?: Array - agents?: Array - type: "user" -} - -export type SessionMessageSynthetic2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - sessionID: string - text: string - description?: string - type: "synthetic" -} - -export type SessionMessageSystem2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "system" - text: string -} - -export type SessionMessageSkill2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - type: "skill" - name: string - text: string -} - export type ShellV2 = { id: string status: "running" | "exited" | "timeout" | "killed" @@ -8060,1569 +7909,14 @@ export type ShellV2 = { } } -export type SessionMessageShell2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - completed?: number - } - type: "shell" - shell: ShellV2 - output?: { - output: string - cursor: number - size: number - truncated: boolean - } -} - -export type SessionMessageAssistantText2 = { - type: "text" - id: string - text: string -} - -export type LlmProviderMetadata2 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionMessageAssistantReasoning2 = { - type: "reasoning" - id: string - text: string - providerMetadata?: LlmProviderMetadata2 - time?: { - created: number - completed?: number - } -} - -export type SessionMessageToolStatePending2 = { - status: "pending" - input: string -} - -export type ToolTextContent2 = { - type: "text" - text: string -} - -export type ToolFileContent2 = { - type: "file" - uri: string - mime: string - name?: string -} - -export type LlmToolContent2 = ToolTextContent2 | ToolFileContent2 - -export type SessionMessageToolStateRunning2 = { - status: "running" - input: { - [key: string]: unknown - } - structured: { - [key: string]: unknown - } - content: Array -} - -export type SessionMessageToolStateCompleted2 = { - status: "completed" - input: { - [key: string]: unknown - } - attachments?: Array - content: Array - outputPaths?: Array - structured: { - [key: string]: unknown - } - result?: unknown -} - -export type SessionErrorUnknown2 = { - type: "unknown" - message: string -} - -export type SessionMessageToolStateError2 = { - status: "error" - input: { - [key: string]: unknown - } - content: Array - structured: { - [key: string]: unknown - } - error: SessionErrorUnknown2 - result?: unknown -} - -export type SessionMessageAssistantTool2 = { - type: "tool" - id: string - name: string - provider?: { - executed: boolean - metadata?: LlmProviderMetadata2 - resultMetadata?: LlmProviderMetadata2 - } - state: - | SessionMessageToolStatePending2 - | SessionMessageToolStateRunning2 - | SessionMessageToolStateCompleted2 - | SessionMessageToolStateError2 - time: { - created: number - ran?: number - completed?: number - pruned?: number - } -} - -export type SessionMessageAssistant2 = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - completed?: number - } - type: "assistant" - agent: string - model: ModelRef2 - content: Array - snapshot?: { - start?: string - end?: string - files?: Array - } - finish?: string - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - error?: SessionErrorUnknown2 -} - -export type SessionMessageCompaction2 = { - type: "compaction" - reason: "auto" | "manual" - summary: string - recent: string - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } -} - -export type SessionMessage2 = - | SessionMessageAgentSelected2 - | SessionMessageModelSelected2 - | SessionMessageUser2 - | SessionMessageSynthetic2 - | SessionMessageSystem2 - | SessionMessageSkill2 - | SessionMessageShell2 - | SessionMessageAssistant2 - | SessionMessageCompaction2 - -/** - * Instruction entry key (lowercase alphanumerics plus . _ -) - */ -export type InstructionEntryKey2 = string - -export type InstructionEntryInfo2 = { - key: InstructionEntryKey2 - value: unknown -} - -export type SessionAgentSelected2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.agent.selected" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - agent: string - } -} - -export type SessionModelSelected2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.model.selected" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - model: ModelRef2 - } -} - -export type SessionMoved2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.moved" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - location: LocationRef2 - subpath?: string - } -} - -export type SessionRenamed2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.renamed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - title: string - } -} - -export type SessionForked2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.forked" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - parentID: string - from?: string - } -} - -export type SessionPromptPromoted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.prompt.promoted" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - inputID: string - } -} - -export type SessionPromptAdmitted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.prompt.admitted" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - inputID: string - prompt: PromptV2 - delivery: "steer" | "queue" - } -} - -export type SessionInstructionsUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.instructions.updated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - text: string - } -} - -export type SessionSynthetic2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.synthetic" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - text: string - description?: string - metadata?: { - [key: string]: unknown - } - } -} - -export type SessionSkillActivated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.skill.activated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - name: string - text: string - } -} - -export type Shell1V2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - -export type SessionShellStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.shell.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - shell: Shell1V2 - } -} - -export type SessionShellEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.shell.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - shell: Shell1V2 - output: { - output: string - cursor: number - size: number - truncated: boolean - } - } -} - -export type SessionStepStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.step.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - agent: string - model: ModelRef2 - snapshot?: string - } -} - -export type SessionStepEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.step.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - snapshot?: string - files?: Array - } -} - -export type SessionStepFailed2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.step.failed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - error: SessionErrorUnknown2 - } -} - -export type SessionTextStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.text.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - textID: string - } -} - -export type SessionTextEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.text.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - textID: string - text: string - } -} - -export type LlmProviderMetadata3 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionReasoningStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.reasoning.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata3 - } -} - -export type LlmProviderMetadata4 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionReasoningEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.reasoning.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - reasoningID: string - text: string - providerMetadata?: LlmProviderMetadata4 - } -} - -export type SessionToolInputStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.input.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - name: string - } -} - -export type SessionToolInputEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.input.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - text: string - } -} - -export type LlmProviderMetadata5 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionToolCalled2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.called" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - tool: string - input: { - [key: string]: unknown - } - provider: { - executed: boolean - metadata?: LlmProviderMetadata5 - } - } -} - -export type SessionToolProgress2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.progress" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - } -} - -export type LlmProviderMetadata6 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionToolSuccess2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.success" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - outputPaths?: Array - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata6 - } - } -} - -export type LlmProviderMetadata7 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionToolFailed2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.failed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - error: SessionErrorUnknown2 - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata7 - } - } -} - -export type SessionRetryError2 = { - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: { - [key: string]: string - } - responseBody?: string - metadata?: { - [key: string]: string - } -} - -export type SessionRetried2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.retried" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - attempt: number - error: SessionRetryError2 - } -} - -export type SessionCompactionStarted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.compaction.started" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - reason: "auto" | "manual" - } -} - -export type SessionCompactionEnded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.compaction.ended" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - reason: "auto" | "manual" - text: string - recent: string - } -} - -export type SessionRevertStaged2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.revert.staged" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - revert: RevertState2 - } -} - -export type SessionRevertCleared2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.revert.cleared" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - } -} - -export type SessionRevertCommitted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.revert.committed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - messageID: string - } -} - -export type SessionDurableEventV2 = - | SessionAgentSelected2 - | SessionModelSelected2 - | SessionMoved2 - | SessionRenamed2 - | SessionForked2 - | SessionPromptPromoted2 - | SessionPromptAdmitted2 - | SessionInstructionsUpdated2 - | SessionSynthetic2 - | SessionSkillActivated2 - | SessionShellStarted2 - | SessionShellEnded2 - | SessionStepStarted2 - | SessionStepEnded2 - | SessionStepFailed2 - | SessionTextStarted2 - | SessionTextEnded2 - | SessionReasoningStarted2 - | SessionReasoningEnded2 - | SessionToolInputStarted2 - | SessionToolInputEnded2 - | SessionToolCalled2 - | SessionToolProgress2 - | SessionToolSuccess2 - | SessionToolFailed2 - | SessionRetried2 - | SessionCompactionStarted2 - | SessionCompactionEnded2 - | SessionRevertStaged2 - | SessionRevertCleared2 - | SessionRevertCommitted2 - -/** - * Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq. - */ -export type EventLogSynced2 = { - type: "log.synced" - aggregateID: string - seq?: number -} - -export type SessionLogItemV2 = SessionDurableEventV2 | EventLogSynced2 - -export type SessionLogItemStreamV2 = string - export type SessionMessagesResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null } } -export type ModelCapabilities2 = { - tools: boolean - input: Array - output: Array -} - -export type ModelVariant2 = { - id: string - settings?: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - body?: { - [key: string]: unknown - } -} - -export type ModelCost2 = { - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } -} - -export type ModelV2Info2 = { - id: string - modelID: string - providerID: string - family?: string - name: string - package?: string - settings?: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - body?: { - [key: string]: unknown - } - capabilities: ModelCapabilities2 - variants: Array - time: { - released: number - } - cost: Array - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number - } -} - -export type GenerateTextResponseV2 = { - data: { - text: string - } -} - -export type ProviderV2Info2 = { - id: string - integrationID?: string - name: string - disabled?: boolean - package: string - settings?: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - body?: { - [key: string]: unknown - } -} - -export type ProviderNotFoundErrorV2 = { - _tag: "ProviderNotFoundError" - providerID: string - message: string -} - -export type IntegrationWhen2 = { - key: string - op: "eq" | "neq" - value: string -} - -export type IntegrationTextPrompt2 = { - type: "text" - key: string - message: string - placeholder?: string - when?: IntegrationWhen2 -} - -export type IntegrationSelectPrompt2 = { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: IntegrationWhen2 -} - -export type IntegrationOAuthMethod2 = { - id: string - type: "oauth" - label: string - prompts?: Array -} - -export type IntegrationKeyMethod2 = { - type: "key" - label?: string -} - -export type IntegrationEnvMethod2 = { - type: "env" - names: Array -} - -export type IntegrationMethod2 = IntegrationOAuthMethod2 | IntegrationKeyMethod2 | IntegrationEnvMethod2 - -export type ConnectionCredentialInfo2 = { - type: "credential" - id: string - label: string -} - -export type ConnectionEnvInfo2 = { - type: "env" - name: string -} - -export type ConnectionInfo2 = ConnectionCredentialInfo2 | ConnectionEnvInfo2 - -export type IntegrationInfo2 = { - id: string - name: string - methods: Array - connections: Array -} - -export type IntegrationAttempt2 = { - attemptID: string - url: string - instructions: string - mode: "auto" | "code" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } -} - -export type IntegrationAttemptStatus2 = - | { - status: "pending" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "complete" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "failed" - message: string - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "expired" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - -export type McpStatusConnected3 = { - status: "connected" -} - -export type McpStatusPending2 = { - status: "pending" -} - -export type McpStatusDisabled3 = { - status: "disabled" -} - -export type McpStatusFailed3 = { - status: "failed" - error: string -} - -export type McpStatusNeedsAuth3 = { - status: "needs_auth" -} - -export type McpStatusNeedsClientRegistration3 = { - status: "needs_client_registration" - error: string -} - -export type McpServer2 = { - name: string - status: - | McpStatusConnected3 - | McpStatusPending2 - | McpStatusDisabled3 - | McpStatusFailed3 - | McpStatusNeedsAuth3 - | McpStatusNeedsClientRegistration3 - integrationID?: string -} - -export type ProjectVcs2 = "git" | "hg" - -export type ProjectIcon2 = { - url?: string - override?: string - color?: string -} - -export type ProjectCommands2 = { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string -} - -export type ProjectTime2 = { - created: number - updated: number - initialized?: number -} - -export type ProjectV2 = { - id: string - worktree: string - vcs?: ProjectVcs2 - name?: string - icon?: ProjectIcon2 - commands?: ProjectCommands2 - time: ProjectTime2 - sandboxes: Array -} - -export type ProjectCurrent2 = { - id: string - directory: string -} - -export type ProjectDirectory2 = { - directory: string - strategy?: string -} - -export type ProjectDirectories2 = Array - -export type FormMetadata2 = { - [key: string]: unknown -} - -export type FormWhen2 = { - key: string - op: "eq" | "neq" - value: string | number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" | boolean -} - -export type FormOption2 = { - value: string - label: string - description?: string -} - -export type FormStringField2 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array - custom?: boolean -} - -export type FormNumberField3 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "number" - minimum?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - default?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" -} - -export type FormIntegerField3 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "integer" - minimum?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - default?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" -} - -export type FormBooleanField2 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "boolean" - default?: boolean -} - -export type FormMultiselectField2 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "multiselect" - options: Array - minItems?: number - maxItems?: number - custom?: boolean - default?: Array -} - -export type FormFormInfo2 = { - id: string - sessionID: string - title?: string - metadata?: FormMetadata2 - mode: "form" - fields: Array -} - -export type FormUrlInfo2 = { - id: string - sessionID: string - title?: string - metadata?: FormMetadata2 - mode: "url" - url: string -} - -export type FormCreatePayload2 = { - id?: string | null - title?: string - metadata?: FormMetadata2 - mode: "form" | "url" - fields?: Array< - FormStringField2 | FormNumberField3 | FormIntegerField3 | FormBooleanField2 | FormMultiselectField2 - > | null - url?: string | null -} - -export type FormNotFoundErrorV2 = { - _tag: "FormNotFoundError" - id: string - message: string -} - -export type FormValue2 = - | string - | number - | "NaN" - | "Infinity" - | "-Infinity" - | "Infinity" - | "-Infinity" - | "NaN" - | boolean - | Array - -export type FormAnswer2 = { - [key: string]: FormValue2 -} - -export type FormState2 = - | { - status: "pending" - } - | { - status: "answered" - answer: FormAnswer2 - } - | { - status: "cancelled" - } - -export type FormReply2 = { - answer: FormAnswer2 -} - -export type FormAlreadySettledErrorV2 = { - _tag: "FormAlreadySettledError" - id: string - message: string -} - -export type FormInvalidAnswerErrorV2 = { - _tag: "FormInvalidAnswerError" - id: string - message: string -} - -export type PermissionV2Source2 = { - type: "tool" - messageID: string - callID: string -} - -export type PermissionV2Request2 = { - id: string - sessionID: string - action: string - resources: Array - save?: Array - metadata?: { - [key: string]: unknown - } - source?: PermissionV2Source2 -} - -export type PermissionSavedInfo2 = { - id: string - projectID: string - action: string - resource: string -} - -export type PermissionNotFoundErrorV2 = { - _tag: "PermissionNotFoundError" - requestID: string - message: string -} - -export type PermissionV2Reply2 = "once" | "always" | "reject" - -export type FileSystemEntry2 = { - path: string - type: "file" | "directory" -} - -export type CommandV2Info2 = { - name: string - template: string - description?: string - agent?: string - model?: ModelRef2 - subtask?: boolean -} - -export type SkillV2Info2 = { - name: string - description?: string - slash?: boolean - autoinvoke?: boolean - location: string - content: string -} - -export type ModelsDevRefreshed2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "models-dev.refreshed" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type IntegrationUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "integration.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type IntegrationConnectionUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "integration.connection.updated" - location?: LocationRef2 - data: { - integrationID: string - } -} - -export type CatalogUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "catalog.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type AgentUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "agent.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type SnapshotFileDiffV2 = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - -export type PermissionActionV2 = "allow" | "deny" | "ask" - -export type PermissionRuleV2 = { - permission: string - pattern: string - action: PermissionActionV2 -} - -export type PermissionRulesetV2 = Array - export type SessionV2 = { id: string slug: string @@ -9635,7 +7929,7 @@ export type SessionV2 = { additions: number deletions: number files: number - diffs?: Array + diffs?: Array } cost?: number tokens?: { @@ -9667,7 +7961,7 @@ export type SessionV2 = { compacting?: number archived?: number } - permission?: PermissionRulesetV2 + permission?: PermissionRuleset revert?: { messageID: string partID?: string @@ -9676,74 +7970,13 @@ export type SessionV2 = { } } -export type SessionCreated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.created" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - info: SessionV2 - } -} - -export type SessionUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.updated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - info: SessionV2 - } -} - -export type SessionDeleted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.deleted" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - info: SessionV2 - } -} - -export type JsonSchemaV2 = { - [key: string]: unknown -} - export type OutputFormatV2 = | { type: "text" } | { type: "json_schema" - schema: JsonSchemaV2 + schema: JsonSchema retryCount?: number | null | null } @@ -9758,7 +7991,7 @@ export type UserMessageV2 = { summary?: { title?: string | null body?: string | null - diffs: Array + diffs: Array } | null agent: string model: { @@ -9772,14 +8005,6 @@ export type UserMessageV2 = { } | null } -export type ProviderAuthErrorV2 = { - name: "ProviderAuthError" - data: { - providerID: string - message: string - } -} - export type UnknownError1V2 = { name: "UnknownError" data: { @@ -9797,13 +8022,6 @@ export type MessageOutputLengthErrorV2 = { | Array } -export type MessageAbortedErrorV2 = { - name: "MessageAbortedError" - data: { - message: string - } -} - export type StructuredOutputErrorV2 = { name: "StructuredOutputError" data: { @@ -9820,13 +8038,6 @@ export type ContextOverflowErrorV2 = { } } -export type ContentFilterErrorV2 = { - name: "ContentFilterError" - data: { - message: string - } -} - export type ApiErrorV2 = { name: "APIError" data: { @@ -9852,13 +8063,13 @@ export type AssistantMessageV2 = { completed?: number | null } error?: - | ProviderAuthErrorV2 + | ProviderAuthError | UnknownError1V2 | MessageOutputLengthErrorV2 - | MessageAbortedErrorV2 + | MessageAbortedError | StructuredOutputErrorV2 | ContextOverflowErrorV2 - | ContentFilterErrorV2 + | ContentFilterError | ApiErrorV2 | null parentID: string @@ -9887,46 +8098,6 @@ export type AssistantMessageV2 = { finish?: string | null } -export type MessageV2 = UserMessageV2 | AssistantMessageV2 - -export type MessageUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "message.updated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - info: MessageV2 - } -} - -export type MessageRemoved2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "message.removed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - messageID: string - } -} - export type TextPartV2 = { id: string sessionID: string @@ -9974,18 +8145,6 @@ export type ReasoningPartV2 = { } } -export type FilePartSourceTextV2 = { - value: string - start: number - end: number -} - -export type FileSourceV2 = { - text: FilePartSourceTextV2 - type: "file" - path: string -} - export type RangeV2 = { start: { line: number @@ -9998,7 +8157,7 @@ export type RangeV2 = { } export type SymbolSourceV2 = { - text: FilePartSourceTextV2 + text: FilePartSourceText type: "symbol" path: string range: RangeV2 @@ -10006,15 +8165,6 @@ export type SymbolSourceV2 = { kind: number } -export type ResourceSourceV2 = { - text: FilePartSourceTextV2 - type: "resource" - clientName: string - uri: string -} - -export type FilePartSourceV2 = FileSourceV2 | SymbolSourceV2 | ResourceSourceV2 - export type FilePartV2 = { id: string sessionID: string @@ -10023,15 +8173,7 @@ export type FilePartV2 = { mime: string filename?: string | null url: string - source?: FilePartSourceV2 | null -} - -export type ToolStatePendingV2 = { - status: "pending" - input: { - [key: string]: unknown - } - raw: string + source?: FilePartSource | null } export type ToolStateRunningV2 = { @@ -10081,8 +8223,6 @@ export type ToolStateErrorV2 = { } } -export type ToolStateV2 = ToolStatePendingV2 | ToolStateRunningV2 | ToolStateCompletedV2 | ToolStateErrorV2 - export type ToolPartV2 = { id: string sessionID: string @@ -10090,7 +8230,7 @@ export type ToolPartV2 = { type: "tool" callID: string tool: string - state: ToolStateV2 + state: ToolState metadata?: { [key: string]: unknown } | null @@ -10176,288 +8316,6 @@ export type CompactionPartV2 = { tail_start_id?: string | null } -export type PartV2 = - | TextPartV2 - | SubtaskPartV2 - | ReasoningPartV2 - | FilePartV2 - | ToolPartV2 - | StepStartPartV2 - | StepFinishPartV2 - | SnapshotPartV2 - | PatchPartV2 - | AgentPartV2 - | RetryPartV2 - | CompactionPartV2 - -export type MessagePartUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "message.part.updated" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - part: PartV2 - time: number - } -} - -export type MessagePartRemoved2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "message.part.removed" - durable: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SessionExecutionSettled2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.execution.settled" - location?: LocationRef2 - data: { - sessionID: string - outcome: "success" | "failure" | "interrupted" - error?: SessionErrorUnknown2 - } -} - -export type SessionTextDelta2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.text.delta" - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - textID: string - delta: string - } -} - -export type SessionReasoningDelta2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.reasoning.delta" - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - reasoningID: string - delta: string - } -} - -export type SessionToolInputDelta2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.tool.input.delta" - location?: LocationRef2 - data: { - sessionID: string - assistantMessageID: string - callID: string - delta: string - } -} - -export type SessionCompactionDelta2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "session.compaction.delta" - location?: LocationRef2 - data: { - sessionID: string - text: string - } -} - -export type FilesystemChanged2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "filesystem.changed" - location?: LocationRef2 - data: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type ReferenceUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "reference.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type PermissionV2Asked2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "permission.v2.asked" - location?: LocationRef2 - data: { - id: string - sessionID: string - action: string - resources: Array - save?: Array - metadata?: { - [key: string]: unknown - } - source?: PermissionV2Source2 - } -} - -export type PermissionV2Replied2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "permission.v2.replied" - location?: LocationRef2 - data: { - sessionID: string - requestID: string - reply: PermissionV2Reply2 - } -} - -export type PluginAdded2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "plugin.added" - location?: LocationRef2 - data: { - id: string - } -} - -export type PluginUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "plugin.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type ProjectDirectoriesUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "project.directories.updated" - location?: LocationRef2 - data: { - projectID: string - } -} - -export type CommandUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "command.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type ConfigUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "config.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - -export type SkillUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "skill.updated" - location?: LocationRef2 - data: - | { - [key: string]: unknown - } - | Array -} - export type PtyV2 = { id: string title: string @@ -10469,353 +8327,6 @@ export type PtyV2 = { exitCode?: number } -export type PtyCreated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "pty.created" - location?: LocationRef2 - data: { - info: PtyV2 - } -} - -export type PtyUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "pty.updated" - location?: LocationRef2 - data: { - info: PtyV2 - } -} - -export type PtyExited2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "pty.exited" - location?: LocationRef2 - data: { - id: string - exitCode: number - } -} - -export type PtyDeleted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "pty.deleted" - location?: LocationRef2 - data: { - id: string - } -} - -export type ShellCreated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "shell.created" - location?: LocationRef2 - data: { - info: Shell1V2 - } -} - -export type ShellExited2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "shell.exited" - location?: LocationRef2 - data: { - id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" - status: "running" | "exited" | "timeout" | "killed" - } -} - -export type ShellDeleted2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "shell.deleted" - location?: LocationRef2 - data: { - id: string - } -} - -export type QuestionV2Option2 = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionV2Info2 = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - multiple?: boolean - custom?: boolean -} - -export type QuestionV2Tool2 = { - messageID: string - callID: string -} - -export type QuestionV2Asked2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "question.v2.asked" - location?: LocationRef2 - data: { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: QuestionV2Tool2 - } -} - -export type QuestionV2Answer2 = Array - -export type QuestionV2Replied2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "question.v2.replied" - location?: LocationRef2 - data: { - sessionID: string - requestID: string - answers: Array - } -} - -export type QuestionV2Rejected2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "question.v2.rejected" - location?: LocationRef2 - data: { - sessionID: string - requestID: string - } -} - -export type FormMetadata1 = { - [key: string]: unknown -} - -export type FormWhen12 = { - key: string - op: "eq" | "neq" - value: string | number | "NaN" | "Infinity" | "-Infinity" | boolean -} - -export type FormStringField1 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array - custom?: boolean -} - -export type FormNumberField12 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "number" - minimum?: number | "NaN" | "Infinity" | "-Infinity" - maximum?: number | "NaN" | "Infinity" | "-Infinity" - default?: number | "NaN" | "Infinity" | "-Infinity" -} - -export type FormIntegerField12 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "integer" - minimum?: number | "NaN" | "Infinity" | "-Infinity" - maximum?: number | "NaN" | "Infinity" | "-Infinity" - default?: number | "NaN" | "Infinity" | "-Infinity" -} - -export type FormBooleanField1 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "boolean" - default?: boolean -} - -export type FormMultiselectField1 = { - key: string - title?: string - description?: string - required?: boolean - when?: Array - type: "multiselect" - options: Array - minItems?: number - maxItems?: number - custom?: boolean - default?: Array -} - -export type FormFormInfo1 = { - id: string - sessionID: string - title?: string - metadata?: FormMetadata1 - mode: "form" - fields: Array -} - -export type FormUrlInfo1 = { - id: string - sessionID: string - title?: string - metadata?: FormMetadata1 - mode: "url" - url: string -} - -export type FormCreated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "form.created" - location?: LocationRef2 - data: { - form: FormFormInfo1 | FormUrlInfo1 - } -} - -export type FormValue12 = string | number | "NaN" | "Infinity" | "-Infinity" | boolean | Array - -export type FormAnswer1 = { - [key: string]: FormValue12 -} - -export type FormReplied2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "form.replied" - location?: LocationRef2 - data: { - id: string - sessionID: string - answer: FormAnswer1 - } -} - -export type FormCancelled2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "form.cancelled" - location?: LocationRef2 - data: { - id: string - sessionID: string - } -} - -export type TodoV2 = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - -export type TodoUpdated2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "todo.updated" - location?: LocationRef2 - data: { - sessionID: string - todos: Array - } -} - export type SessionStatusV2 = | { type: "idle" @@ -10845,47 +8356,1969 @@ export type SessionStatusV22 = { [key: string]: unknown } type: "session.status" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string status: SessionStatusV2 } } -export type SessionIdle2 = { +export type QuestionInfoV2 = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean | null + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean | null +} + +export type QuestionToolV2 = { + messageID: string + callID: string +} + +export type V2EventV2 = + | ModelsDevRefreshedV2 + | IntegrationUpdatedV2 + | IntegrationConnectionUpdatedV2 + | CatalogUpdatedV2 + | AgentUpdatedV2 + | SessionCreatedV2 + | SessionUpdatedV2 + | SessionDeletedV2 + | MessageUpdatedV2 + | MessageRemovedV2 + | MessagePartUpdatedV2 + | MessagePartRemovedV2 + | SessionAgentSelectedV2 + | SessionModelSelectedV2 + | SessionMovedV2 + | SessionRenamedV2 + | SessionForkedV2 + | SessionPromptPromotedV2 + | SessionPromptAdmittedV2 + | SessionExecutionStartedV2 + | SessionExecutionSucceededV2 + | SessionExecutionFailedV2 + | SessionExecutionInterruptedV2 + | SessionInstructionsUpdatedV2 + | SessionSyntheticV2 + | SessionSkillActivatedV2 + | SessionShellStartedV2 + | SessionShellEndedV2 + | SessionStepStartedV2 + | SessionStepEndedV2 + | SessionStepFailedV2 + | SessionTextStartedV2 + | SessionTextDeltaV2 + | SessionTextEndedV2 + | SessionReasoningStartedV2 + | SessionReasoningDeltaV2 + | SessionReasoningEndedV2 + | SessionToolInputStartedV2 + | SessionToolInputDeltaV2 + | SessionToolInputEndedV2 + | SessionToolCalledV2 + | SessionToolProgressV2 + | SessionToolSuccessV2 + | SessionToolFailedV2 + | SessionRetryScheduledV2 + | SessionCompactionStartedV2 + | SessionCompactionDeltaV2 + | SessionCompactionEndedV2 + | SessionRevertStagedV2 + | SessionRevertClearedV2 + | SessionRevertCommittedV2 + | FilesystemChangedV2 + | ReferenceUpdatedV2 + | PermissionV2AskedV2 + | PermissionV2RepliedV2 + | PluginAddedV2 + | PluginUpdatedV2 + | ProjectDirectoriesUpdatedV2 + | CommandUpdatedV2 + | ConfigUpdatedV2 + | SkillUpdatedV2 + | PtyCreatedV2 + | PtyUpdatedV2 + | PtyExitedV2 + | PtyDeletedV2 + | ShellCreatedV2 + | ShellExitedV2 + | ShellDeletedV2 + | QuestionV2AskedV2 + | QuestionV2RepliedV2 + | QuestionV2RejectedV2 + | FormCreatedV2 + | FormRepliedV2 + | FormCancelledV2 + | TodoUpdatedV2 + | SessionStatusV22 + | SessionIdleV2 + | TuiPromptAppendV2 + | TuiCommandExecuteV2 + | TuiToastShowV2 + | TuiSessionSelectV2 + | InstallationUpdatedV2 + | InstallationUpdateAvailableV2 + | VcsBranchUpdatedV2 + | McpStatusChangedV2 + | PermissionAskedV2 + | PermissionRepliedV2 + | QuestionAskedV2 + | QuestionRepliedV2 + | QuestionRejectedV2 + | SessionErrorV2 + | V2EventServerConnected + +export type ProjectCopyErrorV2 = { + name: "ProjectCopyError" + data: { + message: string + forceRequired?: boolean | null + } +} + +export type LocationInfoV2 = { + directory: string + workspaceID?: string + project: { + id: string + directory: string + } +} + +export type AgentColorV2 = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + +export type AgentV2InfoV2 = { + id: string + model?: ModelRef + request: ProviderRequest + system?: string + description?: string + mode: "subagent" | "primary" | "all" + hidden: boolean + color?: AgentColorV2 + steps?: number + permissions: PermissionV2Ruleset +} + +export type LocationRefV2 = { + directory: string + workspaceID?: string +} + +export type FileDiffV2 = { + path: string + status: "added" | "modified" | "deleted" + additions: number + deletions: number + patch: string +} + +export type RevertStateV2 = { + messageID: string + partID?: string + snapshot?: string + diff?: string + files?: Array +} + +export type SessionV2InfoV2 = { + id: string + parentID?: string + projectID: string + agent?: string + model?: ModelRef + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + time: { + created: number + updated: number + archived?: number + } + title: string + location: LocationRefV2 + subpath?: string + revert?: RevertStateV2 +} + +export type PromptBase64V2 = string + +export type SessionInputAdmittedV2 = { + admittedSeq: number + id: string + sessionID: string + prompt: Prompt + delivery: "steer" | "queue" + timeCreated: number + promotedSeq?: number +} + +export type SessionMessageAgentSelectedV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "agent-switched" + agent: string +} + +export type SessionMessageModelSelectedV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: ModelRef + previous?: ModelRef +} + +export type SessionMessageUserV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionMessageSyntheticV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + sessionID: string + text: string + description?: string + type: "synthetic" +} + +export type SessionMessageSystemV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "system" + text: string +} + +export type SessionMessageSkillV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "skill" + name: string + text: string +} + +export type SessionMessageShellV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + shell: ShellV2 + output?: { + output: string + cursor: number + size: number + truncated: boolean + } +} + +export type SessionMessageAssistantRetryV2 = { + attempt: number + at: number + error: SessionStructuredError +} + +export type SessionMessageAssistantV2 = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: ModelRef + content: Array + snapshot?: { + start?: string + end?: string + files?: Array + } + finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: SessionStructuredError + retry?: SessionMessageAssistantRetryV2 +} + +export type SessionMessageCompactionV2 = { + type: "compaction" + reason: "auto" | "manual" + summary: string + recent: string + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } +} + +/** + * Instruction entry key (lowercase alphanumerics plus . _ -) + */ +export type InstructionEntryKeyV2 = string + +export type SessionAgentSelectedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.agent.selected" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + agent: string + } +} + +export type SessionModelSelectedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.model.selected" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + model: ModelRef + } +} + +export type SessionMovedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.moved" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + location: LocationRefV2 + subpath?: string + } +} + +export type SessionRenamedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.renamed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + title: string + } +} + +export type SessionForkedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.forked" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + parentID: string + from?: string + } +} + +export type SessionPromptPromotedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.prompt.promoted" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + inputID: string + } +} + +export type SessionPromptAdmittedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.prompt.admitted" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + inputID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionExecutionStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + } +} + +export type SessionExecutionSucceededV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.succeeded" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + } +} + +export type SessionExecutionFailedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.failed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + error: SessionStructuredError + } +} + +export type SessionExecutionInterruptedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.execution.interrupted" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + reason: "user" | "shutdown" | "superseded" + } +} + +export type SessionInstructionsUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.instructions.updated" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + text: string + } +} + +export type SessionSyntheticV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.synthetic" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + text: string + description?: string + metadata?: { + [key: string]: unknown + } + } +} + +export type SessionSkillActivatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.skill.activated" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + name: string + text: string + } +} + +export type SessionShellStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.shell.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + shell: ShellV2 + } +} + +export type SessionShellEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.shell.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + shell: ShellV2 + output: { + output: string + cursor: number + size: number + truncated: boolean + } + } +} + +export type SessionStepStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.step.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type SessionStepEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.step.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } +} + +export type SessionStepFailedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.step.failed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + error: SessionStructuredError + } +} + +export type SessionTextStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.text.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + } +} + +export type SessionTextEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.text.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + text: string + } +} + +export type SessionMessageProviderState3 = { + [key: string]: unknown +} + +export type SessionReasoningStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.reasoning.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + state?: SessionMessageProviderState3 + } +} + +export type SessionMessageProviderState4 = { + [key: string]: unknown +} + +export type SessionReasoningEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.reasoning.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + text: string + state?: SessionMessageProviderState4 + } +} + +export type SessionToolInputStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type SessionToolInputEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type SessionMessageProviderState5 = { + [key: string]: unknown +} + +export type SessionToolCalledV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.called" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + input: { + [key: string]: unknown + } + executed: boolean + state?: SessionMessageProviderState5 + } +} + +export type SessionToolProgressV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.progress" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SessionMessageProviderState6 = { + [key: string]: unknown +} + +export type SessionToolSuccessV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.success" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + executed: boolean + resultState?: SessionMessageProviderState6 + } +} + +export type SessionMessageProviderState7 = { + [key: string]: unknown +} + +export type SessionToolFailedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.failed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + error: SessionStructuredError + result?: unknown + executed: boolean + resultState?: SessionMessageProviderState7 + } +} + +export type SessionRetryScheduledV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.retry.scheduled" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + attempt: number + at: number + error: SessionStructuredError + } +} + +export type SessionCompactionStartedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + reason: "auto" | "manual" + } +} + +export type SessionCompactionEndedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + reason: "auto" | "manual" + text: string + recent: string + } +} + +export type SessionRevertStagedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.revert.staged" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + revert: RevertStateV2 + } +} + +export type SessionRevertClearedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.revert.cleared" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + } +} + +export type SessionRevertCommittedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.revert.committed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + to: string + } +} + +/** + * Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq. + */ +export type EventLogSyncedV2 = { + type: "log.synced" + aggregateID: string + seq?: number +} + +export type ProjectTimeV2 = { + created: number + updated: number + initialized?: number +} + +export type FormWhenV2 = { + key: string + op: "eq" | "neq" + value: string | number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" | boolean +} + +export type FormStringFieldV2 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array + custom?: boolean +} + +export type FormMultiselectFieldV2 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "multiselect" + options: Array + minItems?: number + maxItems?: number + custom?: boolean + default?: Array +} + +export type FormFormInfoV2 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata + mode: "form" + fields: Array +} + +export type FormUrlInfoV2 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata + mode: "url" + url: string +} + +export type FormCreatePayloadV2 = { + id?: string | null + title?: string + metadata?: FormMetadata + mode: "form" | "url" + fields?: Array< + FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2 + > | null + url?: string | null +} + +export type FormValueV2 = + | string + | number + | "NaN" + | "Infinity" + | "-Infinity" + | "Infinity" + | "-Infinity" + | "NaN" + | boolean + | Array + +export type PermissionV2SourceV2 = { + type: "tool" + messageID: string + callID: string +} + +export type PermissionV2RequestV2 = { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2SourceV2 +} + +export type ModelsDevRefreshedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "models-dev.refreshed" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type IntegrationUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "integration.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type IntegrationConnectionUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "integration.connection.updated" + location?: LocationRefV2 + data: { + integrationID: string + } +} + +export type CatalogUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "catalog.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type AgentUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "agent.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type SessionCreatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.created" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + info: SessionV2 + } +} + +export type SessionUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.updated" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + info: SessionV2 + } +} + +export type SessionDeletedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.deleted" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + info: SessionV2 + } +} + +export type MessageUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "message.updated" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + info: Message + } +} + +export type MessageRemovedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "message.removed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + messageID: string + } +} + +export type MessagePartUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "message.part.updated" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + part: Part + time: number + } +} + +export type MessagePartRemovedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "message.part.removed" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRefV2 + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SessionTextDeltaV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.text.delta" + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + delta: string + } +} + +export type SessionReasoningDeltaV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.reasoning.delta" + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + ordinal: number + delta: string + } +} + +export type SessionToolInputDeltaV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.delta" + location?: LocationRefV2 + data: { + sessionID: string + assistantMessageID: string + callID: string + delta: string + } +} + +export type SessionCompactionDeltaV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.compaction.delta" + location?: LocationRefV2 + data: { + sessionID: string + text: string + } +} + +export type FilesystemChangedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "filesystem.changed" + location?: LocationRefV2 + data: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type ReferenceUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "reference.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type PermissionV2AskedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "permission.v2.asked" + location?: LocationRefV2 + data: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2SourceV2 + } +} + +export type PermissionV2RepliedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "permission.v2.replied" + location?: LocationRefV2 + data: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + +export type PluginAddedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "plugin.added" + location?: LocationRefV2 + data: { + id: string + } +} + +export type PluginUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "plugin.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type ProjectDirectoriesUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "project.directories.updated" + location?: LocationRefV2 + data: { + projectID: string + } +} + +export type CommandUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "command.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type ConfigUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "config.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type SkillUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "skill.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type PtyCreatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "pty.created" + location?: LocationRefV2 + data: { + info: PtyV2 + } +} + +export type PtyUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "pty.updated" + location?: LocationRefV2 + data: { + info: PtyV2 + } +} + +export type PtyExitedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "pty.exited" + location?: LocationRefV2 + data: { + id: string + exitCode: number + } +} + +export type PtyDeletedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "pty.deleted" + location?: LocationRefV2 + data: { + id: string + } +} + +export type ShellCreatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "shell.created" + location?: LocationRefV2 + data: { + info: ShellV2 + } +} + +export type ShellExitedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "shell.exited" + location?: LocationRefV2 + data: { + id: string + exit?: number | "NaN" | "Infinity" | "-Infinity" + status: "running" | "exited" | "timeout" | "killed" + } +} + +export type ShellDeletedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "shell.deleted" + location?: LocationRefV2 + data: { + id: string + } +} + +export type QuestionV2AskedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "question.v2.asked" + location?: LocationRefV2 + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } +} + +export type QuestionV2RepliedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "question.v2.replied" + location?: LocationRefV2 + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionV2RejectedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "question.v2.rejected" + location?: LocationRefV2 + data: { + sessionID: string + requestID: string + } +} + +export type FormMetadata1 = { + [key: string]: unknown +} + +export type FormStringField1 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array + custom?: boolean +} + +export type FormBooleanField1 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "boolean" + default?: boolean +} + +export type FormMultiselectField1 = { + key: string + title?: string + description?: string + required?: boolean + when?: Array + type: "multiselect" + options: Array + minItems?: number + maxItems?: number + custom?: boolean + default?: Array +} + +export type FormFormInfo1 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata1 + mode: "form" + fields: Array +} + +export type FormUrlInfo1 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata1 + mode: "url" + url: string +} + +export type FormCreatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "form.created" + location?: LocationRefV2 + data: { + form: FormFormInfo1 | FormUrlInfo1 + } +} + +export type FormAnswer1 = { + [key: string]: FormValue1 +} + +export type FormRepliedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "form.replied" + location?: LocationRefV2 + data: { + id: string + sessionID: string + answer: FormAnswer1 + } +} + +export type FormCancelledV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "form.cancelled" + location?: LocationRefV2 + data: { + id: string + sessionID: string + } +} + +export type TodoUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "todo.updated" + location?: LocationRefV2 + data: { + sessionID: string + todos: Array + } +} + +export type SessionIdleV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "session.idle" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string } } -export type TuiPromptAppend2 = { +export type TuiPromptAppendV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "tui.prompt.append" - location?: LocationRef2 + location?: LocationRefV2 data: { text: string } } -export type TuiCommandExecute2 = { +export type TuiCommandExecuteV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "tui.command.execute" - location?: LocationRef2 + location?: LocationRefV2 data: { command: | "session.list" @@ -10909,14 +10342,14 @@ export type TuiCommandExecute2 = { } } -export type TuiToastShow2 = { +export type TuiToastShowV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "tui.toast.show" - location?: LocationRef2 + location?: LocationRefV2 data: { title?: string message: string @@ -10925,14 +10358,14 @@ export type TuiToastShow2 = { } } -export type TuiSessionSelect2 = { +export type TuiSessionSelectV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "tui.session.select" - location?: LocationRef2 + location?: LocationRefV2 data: { /** * Session ID to navigate to @@ -10941,66 +10374,66 @@ export type TuiSessionSelect2 = { } } -export type InstallationUpdated2 = { +export type InstallationUpdatedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "installation.updated" - location?: LocationRef2 + location?: LocationRefV2 data: { version: string } } -export type InstallationUpdateAvailable2 = { +export type InstallationUpdateAvailableV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "installation.update-available" - location?: LocationRef2 + location?: LocationRefV2 data: { version: string } } -export type VcsBranchUpdated2 = { +export type VcsBranchUpdatedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "vcs.branch.updated" - location?: LocationRef2 + location?: LocationRefV2 data: { branch?: string } } -export type McpStatusChanged2 = { +export type McpStatusChangedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "mcp.status.changed" - location?: LocationRef2 + location?: LocationRefV2 data: { server: string } } -export type PermissionAsked2 = { +export type PermissionAskedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "permission.asked" - location?: LocationRef2 + location?: LocationRefV2 data: { id: string sessionID: string @@ -11017,14 +10450,14 @@ export type PermissionAsked2 = { } } -export type PermissionReplied2 = { +export type PermissionRepliedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "permission.replied" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string requestID: string @@ -11032,53 +10465,14 @@ export type PermissionReplied2 = { } } -export type QuestionOptionV2 = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfoV2 = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean | null - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean | null -} - -export type QuestionToolV2 = { - messageID: string - callID: string -} - -export type QuestionAsked2 = { +export type QuestionAskedV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "question.asked" - location?: LocationRef2 + location?: LocationRefV2 data: { id: string sessionID: string @@ -11090,8 +10484,6 @@ export type QuestionAsked2 = { } } -export type QuestionAnswerV2 = Array - export type QuestionRepliedV2 = { id: string created: number @@ -11099,11 +10491,11 @@ export type QuestionRepliedV2 = { [key: string]: unknown } type: "question.replied" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string requestID: string - answers: Array + answers: Array } } @@ -11114,31 +10506,31 @@ export type QuestionRejectedV2 = { [key: string]: unknown } type: "question.rejected" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID: string requestID: string } } -export type SessionError2 = { +export type SessionErrorV2 = { id: string created: number metadata?: { [key: string]: unknown } type: "session.error" - location?: LocationRef2 + location?: LocationRefV2 data: { sessionID?: string | null error?: - | ProviderAuthErrorV2 + | ProviderAuthError | UnknownError1V2 | MessageOutputLengthErrorV2 - | MessageAbortedErrorV2 + | MessageAbortedError | StructuredOutputErrorV2 | ContextOverflowErrorV2 - | ContentFilterErrorV2 + | ContentFilterError | ApiErrorV2 | null } @@ -11149,7 +10541,7 @@ export type V2EventServerConnected = { metadata?: { [key: string]: unknown } | null - location?: LocationRef2 | null + location?: LocationRefV2 | null type: "server.connected" data: | { @@ -11158,179 +10550,19 @@ export type V2EventServerConnected = { | Array } -export type V2EventV2 = - | ModelsDevRefreshed2 - | IntegrationUpdated2 - | IntegrationConnectionUpdated2 - | CatalogUpdated2 - | AgentUpdated2 - | SessionCreated2 - | SessionUpdated2 - | SessionDeleted2 - | MessageUpdated2 - | MessageRemoved2 - | MessagePartUpdated2 - | MessagePartRemoved2 - | SessionAgentSelected2 - | SessionModelSelected2 - | SessionMoved2 - | SessionRenamed2 - | SessionForked2 - | SessionPromptPromoted2 - | SessionPromptAdmitted2 - | SessionExecutionSettled2 - | SessionInstructionsUpdated2 - | SessionSynthetic2 - | SessionSkillActivated2 - | SessionShellStarted2 - | SessionShellEnded2 - | SessionStepStarted2 - | SessionStepEnded2 - | SessionStepFailed2 - | SessionTextStarted2 - | SessionTextDelta2 - | SessionTextEnded2 - | SessionReasoningStarted2 - | SessionReasoningDelta2 - | SessionReasoningEnded2 - | SessionToolInputStarted2 - | SessionToolInputDelta2 - | SessionToolInputEnded2 - | SessionToolCalled2 - | SessionToolProgress2 - | SessionToolSuccess2 - | SessionToolFailed2 - | SessionRetried2 - | SessionCompactionStarted2 - | SessionCompactionDelta2 - | SessionCompactionEnded2 - | SessionRevertStaged2 - | SessionRevertCleared2 - | SessionRevertCommitted2 - | FilesystemChanged2 - | ReferenceUpdated2 - | PermissionV2Asked2 - | PermissionV2Replied2 - | PluginAdded2 - | PluginUpdated2 - | ProjectDirectoriesUpdated2 - | CommandUpdated2 - | ConfigUpdated2 - | SkillUpdated2 - | PtyCreated2 - | PtyUpdated2 - | PtyExited2 - | PtyDeleted2 - | ShellCreated2 - | ShellExited2 - | ShellDeleted2 - | QuestionV2Asked2 - | QuestionV2Replied2 - | QuestionV2Rejected2 - | FormCreated2 - | FormReplied2 - | FormCancelled2 - | TodoUpdated2 - | SessionStatusV22 - | SessionIdle2 - | TuiPromptAppend2 - | TuiCommandExecute2 - | TuiToastShow2 - | TuiSessionSelect2 - | InstallationUpdated2 - | InstallationUpdateAvailable2 - | VcsBranchUpdated2 - | McpStatusChanged2 - | PermissionAsked2 - | PermissionReplied2 - | QuestionAsked2 - | QuestionRepliedV2 - | QuestionRejectedV2 - | SessionError2 - | V2EventServerConnected - -export type V2EventStreamV2 = string - -export type PtyNotFoundErrorV2 = { - _tag: "PtyNotFoundError" - ptyID: string - message: string -} - -export type PtyTicketConnectToken2 = { +export type PtyTicketConnectTokenV2 = { ticket: string expires_in: number } -export type ForbiddenErrorV2 = { - _tag: "ForbiddenError" - message: string -} - -export type ShellNotFoundErrorV2 = { - _tag: "ShellNotFoundError" - id: string - message: string -} - -export type QuestionV2Request2 = { +export type QuestionV2RequestV2 = { id: string sessionID: string /** * Questions to ask */ - questions: Array - tool?: QuestionV2Tool2 -} - -export type QuestionV2Reply2 = { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array -} - -export type QuestionNotFoundErrorV2 = { - _tag: "QuestionNotFoundError" - requestID: string - message: string -} - -export type ReferenceLocalSource2 = { - type: "local" - path: string - description?: string - hidden?: boolean -} - -export type ReferenceGitSource2 = { - type: "git" - repository: string - branch?: string - description?: string - hidden?: boolean -} - -export type ReferenceSource2 = ReferenceLocalSource2 | ReferenceGitSource2 - -export type ReferenceInfo2 = { - name: string - path: string - description?: string - hidden?: boolean - source: ReferenceSource2 -} - -export type ProjectCopyCopy2 = { - directory: string -} - -export type ProjectCopyErrorV2 = { - name: "ProjectCopyError" - data: { - message: string - forceRequired?: boolean | null - } + questions: Array + tool?: QuestionV2Tool } export type VcsFileStatusV2 = { @@ -11340,8 +10572,6 @@ export type VcsFileStatusV2 = { status: "added" | "deleted" | "modified" } -export type VcsMode2 = "working" | "branch" - export type AuthRemoveData = { body?: never path: { @@ -15490,7 +14720,7 @@ export type V2HealthGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors] @@ -15528,7 +14758,7 @@ export type V2LocationGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2LocationGetError = V2LocationGetErrors[keyof V2LocationGetErrors] @@ -15537,7 +14767,7 @@ export type V2LocationGetResponses = { /** * Location.Info */ - 200: LocationInfo2 + 200: LocationInfoV2 } export type V2LocationGetResponse = V2LocationGetResponses[keyof V2LocationGetResponses] @@ -15562,7 +14792,7 @@ export type V2AgentListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2AgentListError = V2AgentListErrors[keyof V2AgentListErrors] @@ -15572,8 +14802,8 @@ export type V2AgentListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -15599,7 +14829,7 @@ export type V2PluginListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PluginListError = V2PluginListErrors[keyof V2PluginListErrors] @@ -15609,8 +14839,8 @@ export type V2PluginListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -15643,11 +14873,11 @@ export type V2SessionListErrors = { /** * InvalidCursorError | InvalidRequestError */ - 400: InvalidCursorErrorV2 | InvalidRequestError1 | InvalidRequestErrorV2 + 400: InvalidCursorError | InvalidRequestError1 | InvalidRequestErrorV2 /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] @@ -15665,8 +14895,8 @@ export type V2SessionCreateData = { body: { id?: string | null agent?: string | null - model?: ModelRef2 | null - location?: LocationRef2 | null + model?: ModelRef | null + location?: LocationRefV2 | null } path?: never query?: never @@ -15681,7 +14911,7 @@ export type V2SessionCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2SessionCreateError = V2SessionCreateErrors[keyof V2SessionCreateErrors] @@ -15691,7 +14921,7 @@ export type V2SessionCreateResponses = { * Success */ 200: { - data: SessionV2Info2 + data: SessionV2InfoV2 } } @@ -15712,7 +14942,7 @@ export type V2SessionActiveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2SessionActiveError = V2SessionActiveErrors[keyof V2SessionActiveErrors] @@ -15723,7 +14953,7 @@ export type V2SessionActiveResponses = { */ 200: { data: { - [key: string]: unknown | SessionActiveV2 + [key: string]: unknown | SessionActive } } } @@ -15747,11 +14977,11 @@ export type V2SessionGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionGetError = V2SessionGetErrors[keyof V2SessionGetErrors] @@ -15761,7 +14991,7 @@ export type V2SessionGetResponses = { * Success */ 200: { - data: SessionV2Info2 + data: SessionV2InfoV2 } } @@ -15786,11 +15016,11 @@ export type V2SessionForkErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | MessageNotFoundError */ - 404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: MessageNotFoundError | SessionNotFoundError } export type V2SessionForkError = V2SessionForkErrors[keyof V2SessionForkErrors] @@ -15800,7 +15030,7 @@ export type V2SessionForkResponses = { * Success */ 200: { - data: SessionV2Info2 + data: SessionV2InfoV2 } } @@ -15825,11 +15055,11 @@ export type V2SessionSwitchAgentErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors] @@ -15845,7 +15075,7 @@ export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V export type V2SessionSwitchModelData = { body: { - model: ModelRef2 + model: ModelRef } path: { sessionID: string @@ -15862,11 +15092,11 @@ export type V2SessionSwitchModelErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors] @@ -15899,11 +15129,11 @@ export type V2SessionRenameErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionRenameError = V2SessionRenameErrors[keyof V2SessionRenameErrors] @@ -15920,7 +15150,7 @@ export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRe export type V2SessionPromptData = { body: { id?: string | null - prompt: PromptInputV2 + prompt: PromptInput delivery?: "steer" | "queue" | null resume?: boolean | null } @@ -15939,11 +15169,11 @@ export type V2SessionPromptErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * ConflictError */ @@ -15957,7 +15187,7 @@ export type V2SessionPromptResponses = { * Success */ 200: { - data: SessionInputAdmitted2 + data: SessionInputAdmittedV2 } } @@ -15969,9 +15199,9 @@ export type V2SessionCommandData = { command: string arguments?: string | null agent?: string | null - model?: ModelRef2 | null - files?: Array - agents?: Array + model?: ModelRef | null + files?: Array + agents?: Array delivery?: "steer" | "queue" | null resume?: boolean | null } @@ -15990,11 +15220,11 @@ export type V2SessionCommandErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | CommandNotFoundError */ - 404: CommandNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: CommandNotFoundError | SessionNotFoundError /** * ConflictError */ @@ -16002,7 +15232,7 @@ export type V2SessionCommandErrors = { /** * CommandEvaluationError */ - 500: CommandEvaluationErrorV2 + 500: CommandEvaluationError } export type V2SessionCommandError = V2SessionCommandErrors[keyof V2SessionCommandErrors] @@ -16012,7 +15242,7 @@ export type V2SessionCommandResponses = { * Success */ 200: { - data: SessionInputAdmitted2 + data: SessionInputAdmittedV2 } } @@ -16039,11 +15269,11 @@ export type V2SessionSkillErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | SkillNotFoundError */ - 404: SkillNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: SkillNotFoundError | SessionNotFoundError } export type V2SessionSkillError = V2SessionSkillErrors[keyof V2SessionSkillErrors] @@ -16080,11 +15310,11 @@ export type V2SessionSyntheticErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionSyntheticError = V2SessionSyntheticErrors[keyof V2SessionSyntheticErrors] @@ -16118,11 +15348,11 @@ export type V2SessionShellErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionShellError = V2SessionShellErrors[keyof V2SessionShellErrors] @@ -16153,15 +15383,15 @@ export type V2SessionCompactErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * SessionBusyError */ - 409: SessionBusyErrorV2 + 409: SessionBusyError /** * UnknownError */ @@ -16200,11 +15430,11 @@ export type V2SessionWaitErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * ServiceUnavailableError */ @@ -16242,15 +15472,15 @@ export type V2SessionRevertStageErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * MessageNotFoundError | SessionNotFoundError */ - 404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: MessageNotFoundError | SessionNotFoundError /** * SessionBusyError */ - 409: SessionBusyErrorV2 + 409: SessionBusyError /** * UnknownError */ @@ -16264,7 +15494,7 @@ export type V2SessionRevertStageResponses = { * Success */ 200: { - data: RevertState2 + data: RevertStateV2 } } @@ -16287,15 +15517,15 @@ export type V2SessionRevertClearErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * SessionBusyError */ - 409: SessionBusyErrorV2 + 409: SessionBusyError /** * UnknownError */ @@ -16330,15 +15560,15 @@ export type V2SessionRevertCommitErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * SessionBusyError */ - 409: SessionBusyErrorV2 + 409: SessionBusyError } export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors] @@ -16369,11 +15599,11 @@ export type V2SessionContextErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * UnknownError */ @@ -16387,7 +15617,7 @@ export type V2SessionContextResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -16410,11 +15640,11 @@ export type V2SessionInstructionsEntryListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionInstructionsEntryListError = @@ -16425,7 +15655,7 @@ export type V2SessionInstructionsEntryListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -16436,7 +15666,7 @@ export type V2SessionInstructionsEntryRemoveData = { body?: never path: { sessionID: string - key: InstructionEntryKey2 + key: InstructionEntryKeyV2 } query?: never url: "/api/session/{sessionID}/instructions/entries/{key}" @@ -16450,11 +15680,11 @@ export type V2SessionInstructionsEntryRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionInstructionsEntryRemoveError = @@ -16476,7 +15706,7 @@ export type V2SessionInstructionsEntryPutData = { } path: { sessionID: string - key: InstructionEntryKey2 + key: InstructionEntryKeyV2 } query?: never url: "/api/session/{sessionID}/instructions/entries/{key}" @@ -16490,11 +15720,11 @@ export type V2SessionInstructionsEntryPutErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionInstructionsEntryPutError = @@ -16530,11 +15760,11 @@ export type V2SessionLogErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionLogError = V2SessionLogErrors[keyof V2SessionLogErrors] @@ -16546,7 +15776,7 @@ export type V2SessionLogResponses = { 200: { id: string | null event: string - data: SessionLogItemStreamV2 + data: SessionLogItemStream } } @@ -16569,11 +15799,11 @@ export type V2SessionInterruptErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionInterruptError = V2SessionInterruptErrors[keyof V2SessionInterruptErrors] @@ -16604,11 +15834,11 @@ export type V2SessionBackgroundErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionBackgroundError = V2SessionBackgroundErrors[keyof V2SessionBackgroundErrors] @@ -16640,11 +15870,11 @@ export type V2SessionMessageErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | MessageNotFoundError */ - 404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: MessageNotFoundError | SessionNotFoundError } export type V2SessionMessageError = V2SessionMessageErrors[keyof V2SessionMessageErrors] @@ -16654,7 +15884,7 @@ export type V2SessionMessageResponses = { * Success */ 200: { - data: SessionMessage2 + data: SessionMessage } } @@ -16683,15 +15913,15 @@ export type V2SessionMessagesErrors = { /** * InvalidCursorError | InvalidRequestError */ - 400: InvalidCursorErrorV2 | InvalidRequestErrorV2 + 400: InvalidCursorError | InvalidRequestErrorV2 /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * UnknownError */ @@ -16729,7 +15959,7 @@ export type V2ModelListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ServiceUnavailableError */ @@ -16743,8 +15973,8 @@ export type V2ModelListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -16770,7 +16000,7 @@ export type V2ModelDefaultErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ServiceUnavailableError */ @@ -16784,8 +16014,8 @@ export type V2ModelDefaultResponses = { * Success */ 200: { - location: LocationInfo2 - data: ModelV2Info2 | null + location: LocationInfoV2 + data: ModelV2Info | null } } @@ -16794,7 +16024,7 @@ export type V2ModelDefaultResponse = V2ModelDefaultResponses[keyof V2ModelDefaul export type V2GenerateTextData = { body: { prompt: string - model?: ModelRef2 | null + model?: ModelRef | null } path?: never query?: { @@ -16814,7 +16044,7 @@ export type V2GenerateTextErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ServiceUnavailableError */ @@ -16827,7 +16057,7 @@ export type V2GenerateTextResponses = { /** * GenerateTextResponse */ - 200: GenerateTextResponseV2 + 200: GenerateTextResponse } export type V2GenerateTextResponse = V2GenerateTextResponses[keyof V2GenerateTextResponses] @@ -16852,7 +16082,7 @@ export type V2ProviderListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ServiceUnavailableError */ @@ -16866,8 +16096,8 @@ export type V2ProviderListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -16895,11 +16125,11 @@ export type V2ProviderGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ProviderNotFoundError */ - 404: ProviderNotFoundErrorV2 + 404: ProviderNotFoundError /** * ServiceUnavailableError */ @@ -16913,8 +16143,8 @@ export type V2ProviderGetResponses = { * Success */ 200: { - location: LocationInfo2 - data: ProviderV2Info2 + location: LocationInfoV2 + data: ProviderV2Info } } @@ -16940,7 +16170,7 @@ export type V2IntegrationListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationListError = V2IntegrationListErrors[keyof V2IntegrationListErrors] @@ -16950,8 +16180,8 @@ export type V2IntegrationListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -16979,7 +16209,7 @@ export type V2IntegrationGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationGetError = V2IntegrationGetErrors[keyof V2IntegrationGetErrors] @@ -16989,8 +16219,8 @@ export type V2IntegrationGetResponses = { * Success */ 200: { - location: LocationInfo2 - data: IntegrationInfo2 | null + location: LocationInfoV2 + data: IntegrationInfo | null } } @@ -17021,7 +16251,7 @@ export type V2IntegrationConnectKeyErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationConnectKeyError = V2IntegrationConnectKeyErrors[keyof V2IntegrationConnectKeyErrors] @@ -17063,7 +16293,7 @@ export type V2IntegrationConnectOauthErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationConnectOauthError = V2IntegrationConnectOauthErrors[keyof V2IntegrationConnectOauthErrors] @@ -17073,8 +16303,8 @@ export type V2IntegrationConnectOauthResponses = { * Success */ 200: { - location: LocationInfo2 - data: IntegrationAttempt2 + location: LocationInfoV2 + data: IntegrationAttempt } } @@ -17103,7 +16333,7 @@ export type V2IntegrationAttemptCancelErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationAttemptCancelError = V2IntegrationAttemptCancelErrors[keyof V2IntegrationAttemptCancelErrors] @@ -17140,7 +16370,7 @@ export type V2IntegrationAttemptStatusErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationAttemptStatusError = V2IntegrationAttemptStatusErrors[keyof V2IntegrationAttemptStatusErrors] @@ -17150,8 +16380,8 @@ export type V2IntegrationAttemptStatusResponses = { * Success */ 200: { - location: LocationInfo2 - data: IntegrationAttemptStatus2 + location: LocationInfoV2 + data: IntegrationAttemptStatus } } @@ -17182,7 +16412,7 @@ export type V2IntegrationAttemptCompleteErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2IntegrationAttemptCompleteError = @@ -17218,7 +16448,7 @@ export type V2McpListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2McpListError = V2McpListErrors[keyof V2McpListErrors] @@ -17228,8 +16458,8 @@ export type V2McpListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -17257,7 +16487,7 @@ export type V2CredentialRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2CredentialRemoveError = V2CredentialRemoveErrors[keyof V2CredentialRemoveErrors] @@ -17295,7 +16525,7 @@ export type V2CredentialUpdateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2CredentialUpdateError = V2CredentialUpdateErrors[keyof V2CredentialUpdateErrors] @@ -17324,7 +16554,7 @@ export type V2ProjectListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectListError = V2ProjectListErrors[keyof V2ProjectListErrors] @@ -17333,7 +16563,7 @@ export type V2ProjectListResponses = { /** * Success */ - 200: Array + 200: Array } export type V2ProjectListResponse = V2ProjectListResponses[keyof V2ProjectListResponses] @@ -17358,7 +16588,7 @@ export type V2ProjectCurrentErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectCurrentError = V2ProjectCurrentErrors[keyof V2ProjectCurrentErrors] @@ -17367,7 +16597,7 @@ export type V2ProjectCurrentResponses = { /** * Project.Current */ - 200: ProjectCurrent2 + 200: ProjectCurrent } export type V2ProjectCurrentResponse = V2ProjectCurrentResponses[keyof V2ProjectCurrentResponses] @@ -17394,7 +16624,7 @@ export type V2ProjectDirectoriesErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectDirectoriesError = V2ProjectDirectoriesErrors[keyof V2ProjectDirectoriesErrors] @@ -17403,7 +16633,7 @@ export type V2ProjectDirectoriesResponses = { /** * Project.Directories */ - 200: ProjectDirectories2 + 200: ProjectDirectories } export type V2ProjectDirectoriesResponse = V2ProjectDirectoriesResponses[keyof V2ProjectDirectoriesResponses] @@ -17428,7 +16658,7 @@ export type V2FormRequestListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2FormRequestListError = V2FormRequestListErrors[keyof V2FormRequestListErrors] @@ -17438,8 +16668,8 @@ export type V2FormRequestListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -17462,11 +16692,11 @@ export type V2SessionFormListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionFormListError = V2SessionFormListErrors[keyof V2SessionFormListErrors] @@ -17476,14 +16706,14 @@ export type V2SessionFormListResponses = { * Success */ 200: { - data: Array + data: Array } } export type V2SessionFormListResponse = V2SessionFormListResponses[keyof V2SessionFormListResponses] export type V2SessionFormCreateData = { - body: FormCreatePayload2 + body: FormCreatePayloadV2 path: { sessionID: string } @@ -17499,11 +16729,11 @@ export type V2SessionFormCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError /** * ConflictError */ @@ -17517,7 +16747,7 @@ export type V2SessionFormCreateResponses = { * Success */ 200: { - data: FormFormInfo2 | FormUrlInfo2 + data: FormFormInfoV2 | FormUrlInfoV2 } } @@ -17541,11 +16771,11 @@ export type V2SessionFormGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | FormNotFoundError */ - 404: FormNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: FormNotFoundError | SessionNotFoundError } export type V2SessionFormGetError = V2SessionFormGetErrors[keyof V2SessionFormGetErrors] @@ -17555,7 +16785,7 @@ export type V2SessionFormGetResponses = { * Success */ 200: { - data: FormFormInfo2 | FormUrlInfo2 + data: FormFormInfoV2 | FormUrlInfoV2 } } @@ -17579,11 +16809,11 @@ export type V2SessionFormStateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | FormNotFoundError */ - 404: FormNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: FormNotFoundError | SessionNotFoundError } export type V2SessionFormStateError = V2SessionFormStateErrors[keyof V2SessionFormStateErrors] @@ -17593,14 +16823,14 @@ export type V2SessionFormStateResponses = { * Success */ 200: { - data: FormState2 + data: FormState } } export type V2SessionFormStateResponse = V2SessionFormStateResponses[keyof V2SessionFormStateResponses] export type V2SessionFormReplyData = { - body: FormReply2 + body: FormReply path: { sessionID: string formID: string @@ -17613,19 +16843,19 @@ export type V2SessionFormReplyErrors = { /** * FormInvalidAnswerError | InvalidRequestError */ - 400: FormInvalidAnswerErrorV2 | InvalidRequestErrorV2 + 400: FormInvalidAnswerError | InvalidRequestErrorV2 /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | FormNotFoundError */ - 404: FormNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: FormNotFoundError | SessionNotFoundError /** * FormAlreadySettledError */ - 409: FormAlreadySettledErrorV2 + 409: FormAlreadySettledError } export type V2SessionFormReplyError = V2SessionFormReplyErrors[keyof V2SessionFormReplyErrors] @@ -17657,15 +16887,15 @@ export type V2SessionFormCancelErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | FormNotFoundError */ - 404: FormNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: FormNotFoundError | SessionNotFoundError /** * FormAlreadySettledError */ - 409: FormAlreadySettledErrorV2 + 409: FormAlreadySettledError } export type V2SessionFormCancelError = V2SessionFormCancelErrors[keyof V2SessionFormCancelErrors] @@ -17699,7 +16929,7 @@ export type V2PermissionRequestListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors] @@ -17709,8 +16939,8 @@ export type V2PermissionRequestListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -17733,7 +16963,7 @@ export type V2PermissionSavedListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors] @@ -17743,7 +16973,7 @@ export type V2PermissionSavedListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -17766,7 +16996,7 @@ export type V2PermissionSavedRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors] @@ -17797,11 +17027,11 @@ export type V2SessionPermissionListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors] @@ -17811,7 +17041,7 @@ export type V2SessionPermissionListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -17826,7 +17056,7 @@ export type V2SessionPermissionCreateData = { metadata?: { [key: string]: unknown } - source?: PermissionV2Source2 + source?: PermissionV2SourceV2 agent?: string | null } path: { @@ -17844,11 +17074,11 @@ export type V2SessionPermissionCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionPermissionCreateError = V2SessionPermissionCreateErrors[keyof V2SessionPermissionCreateErrors] @@ -17860,7 +17090,7 @@ export type V2SessionPermissionCreateResponses = { 200: { data: { id: string - effect: PermissionV2Effect2 + effect: PermissionV2Effect } } } @@ -17886,11 +17116,11 @@ export type V2SessionPermissionGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | PermissionNotFoundError */ - 404: PermissionNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: PermissionNotFoundError | SessionNotFoundError } export type V2SessionPermissionGetError = V2SessionPermissionGetErrors[keyof V2SessionPermissionGetErrors] @@ -17900,7 +17130,7 @@ export type V2SessionPermissionGetResponses = { * Success */ 200: { - data: PermissionV2Request2 + data: PermissionV2RequestV2 } } @@ -17908,7 +17138,7 @@ export type V2SessionPermissionGetResponse = V2SessionPermissionGetResponses[key export type V2SessionPermissionReplyData = { body: { - reply: PermissionV2Reply2 + reply: PermissionV2Reply message?: string | null } path: { @@ -17927,11 +17157,11 @@ export type V2SessionPermissionReplyErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | PermissionNotFoundError */ - 404: PermissionNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: PermissionNotFoundError | SessionNotFoundError } export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors] @@ -17966,7 +17196,7 @@ export type V2FsReadErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2FsReadError = V2FsReadErrors[keyof V2FsReadErrors] @@ -18001,7 +17231,7 @@ export type V2FsListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2FsListError = V2FsListErrors[keyof V2FsListErrors] @@ -18011,8 +17241,8 @@ export type V2FsListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18041,7 +17271,7 @@ export type V2FsFindErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2FsFindError = V2FsFindErrors[keyof V2FsFindErrors] @@ -18051,8 +17281,8 @@ export type V2FsFindResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18078,7 +17308,7 @@ export type V2CommandListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2CommandListError = V2CommandListErrors[keyof V2CommandListErrors] @@ -18088,8 +17318,8 @@ export type V2CommandListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18115,7 +17345,7 @@ export type V2SkillListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2SkillListError = V2SkillListErrors[keyof V2SkillListErrors] @@ -18125,8 +17355,8 @@ export type V2SkillListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18147,7 +17377,7 @@ export type V2EventSubscribeErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2EventSubscribeError = V2EventSubscribeErrors[keyof V2EventSubscribeErrors] @@ -18181,7 +17411,7 @@ export type V2PtyListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PtyListError = V2PtyListErrors[keyof V2PtyListErrors] @@ -18191,7 +17421,7 @@ export type V2PtyListResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: Array } } @@ -18226,7 +17456,7 @@ export type V2PtyCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2PtyCreateError = V2PtyCreateErrors[keyof V2PtyCreateErrors] @@ -18236,7 +17466,7 @@ export type V2PtyCreateResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: PtyV2 } } @@ -18265,11 +17495,11 @@ export type V2PtyRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyRemoveError = V2PtyRemoveErrors[keyof V2PtyRemoveErrors] @@ -18305,11 +17535,11 @@ export type V2PtyGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyGetError = V2PtyGetErrors[keyof V2PtyGetErrors] @@ -18319,7 +17549,7 @@ export type V2PtyGetResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: PtyV2 } } @@ -18354,11 +17584,11 @@ export type V2PtyUpdateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyUpdateError = V2PtyUpdateErrors[keyof V2PtyUpdateErrors] @@ -18368,7 +17598,7 @@ export type V2PtyUpdateResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: PtyV2 } } @@ -18397,15 +17627,15 @@ export type V2PtyConnectTokenErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ForbiddenError */ - 403: ForbiddenErrorV2 + 403: ForbiddenError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyConnectTokenError = V2PtyConnectTokenErrors[keyof V2PtyConnectTokenErrors] @@ -18415,8 +17645,8 @@ export type V2PtyConnectTokenResponses = { * Success */ 200: { - location: LocationInfo2 - data: PtyTicketConnectToken2 + location: LocationInfoV2 + data: PtyTicketConnectTokenV2 } } @@ -18444,15 +17674,15 @@ export type V2PtyConnectErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ForbiddenError */ - 403: ForbiddenErrorV2 + 403: ForbiddenError /** * PtyNotFoundError */ - 404: PtyNotFoundErrorV2 + 404: PtyNotFoundError } export type V2PtyConnectError = V2PtyConnectErrors[keyof V2PtyConnectErrors] @@ -18486,7 +17716,7 @@ export type V2ShellListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ShellListError = V2ShellListErrors[keyof V2ShellListErrors] @@ -18496,7 +17726,7 @@ export type V2ShellListResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: Array } } @@ -18530,7 +17760,7 @@ export type V2ShellCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ShellCreateError = V2ShellCreateErrors[keyof V2ShellCreateErrors] @@ -18540,7 +17770,7 @@ export type V2ShellCreateResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: ShellV2 } } @@ -18569,11 +17799,11 @@ export type V2ShellRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ShellNotFoundError */ - 404: ShellNotFoundErrorV2 + 404: ShellNotFoundError } export type V2ShellRemoveError = V2ShellRemoveErrors[keyof V2ShellRemoveErrors] @@ -18609,11 +17839,11 @@ export type V2ShellGetErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ShellNotFoundError */ - 404: ShellNotFoundErrorV2 + 404: ShellNotFoundError } export type V2ShellGetError = V2ShellGetErrors[keyof V2ShellGetErrors] @@ -18623,7 +17853,7 @@ export type V2ShellGetResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: ShellV2 } } @@ -18654,11 +17884,11 @@ export type V2ShellOutputErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * ShellNotFoundError */ - 404: ShellNotFoundErrorV2 + 404: ShellNotFoundError } export type V2ShellOutputError = V2ShellOutputErrors[keyof V2ShellOutputErrors] @@ -18668,7 +17898,7 @@ export type V2ShellOutputResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: { output: string cursor: number @@ -18700,7 +17930,7 @@ export type V2QuestionRequestListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2QuestionRequestListError = V2QuestionRequestListErrors[keyof V2QuestionRequestListErrors] @@ -18710,8 +17940,8 @@ export type V2QuestionRequestListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18734,11 +17964,11 @@ export type V2SessionQuestionListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError */ - 404: SessionNotFoundErrorV2 + 404: SessionNotFoundError } export type V2SessionQuestionListError = V2SessionQuestionListErrors[keyof V2SessionQuestionListErrors] @@ -18748,14 +17978,14 @@ export type V2SessionQuestionListResponses = { * Success */ 200: { - data: Array + data: Array } } export type V2SessionQuestionListResponse = V2SessionQuestionListResponses[keyof V2SessionQuestionListResponses] export type V2SessionQuestionReplyData = { - body: QuestionV2Reply2 + body: QuestionV2Reply path: { sessionID: string requestID: string @@ -18772,11 +18002,11 @@ export type V2SessionQuestionReplyErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | QuestionNotFoundError */ - 404: QuestionNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: QuestionNotFoundError | SessionNotFoundError } export type V2SessionQuestionReplyError = V2SessionQuestionReplyErrors[keyof V2SessionQuestionReplyErrors] @@ -18808,11 +18038,11 @@ export type V2SessionQuestionRejectErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError /** * SessionNotFoundError | QuestionNotFoundError */ - 404: QuestionNotFoundErrorV2 | SessionNotFoundErrorV2 + 404: QuestionNotFoundError | SessionNotFoundError } export type V2SessionQuestionRejectError = V2SessionQuestionRejectErrors[keyof V2SessionQuestionRejectErrors] @@ -18846,7 +18076,7 @@ export type V2ReferenceListErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ReferenceListError = V2ReferenceListErrors[keyof V2ReferenceListErrors] @@ -18856,8 +18086,8 @@ export type V2ReferenceListResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -18888,7 +18118,7 @@ export type V2ProjectCopyRemoveErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectCopyRemoveError = V2ProjectCopyRemoveErrors[keyof V2ProjectCopyRemoveErrors] @@ -18928,7 +18158,7 @@ export type V2ProjectCopyCreateErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectCopyCreateError = V2ProjectCopyCreateErrors[keyof V2ProjectCopyCreateErrors] @@ -18937,7 +18167,7 @@ export type V2ProjectCopyCreateResponses = { /** * ProjectCopy.Copy */ - 200: ProjectCopyCopy2 + 200: ProjectCopyCopy } export type V2ProjectCopyCreateResponse = V2ProjectCopyCreateResponses[keyof V2ProjectCopyCreateResponses] @@ -18964,7 +18194,7 @@ export type V2ProjectCopyRefreshErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2ProjectCopyRefreshError = V2ProjectCopyRefreshErrors[keyof V2ProjectCopyRefreshErrors] @@ -18998,7 +18228,7 @@ export type V2VcsStatusErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2VcsStatusError = V2VcsStatusErrors[keyof V2VcsStatusErrors] @@ -19008,7 +18238,7 @@ export type V2VcsStatusResponses = { * Success */ 200: { - location: LocationInfo2 + location: LocationInfoV2 data: Array } } @@ -19023,7 +18253,7 @@ export type V2VcsDiffData = { directory?: string | null workspace?: string | null } | null - mode: VcsMode2 + mode: VcsMode context?: string | null } url: "/api/vcs/diff" @@ -19037,7 +18267,7 @@ export type V2VcsDiffErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2VcsDiffError = V2VcsDiffErrors[keyof V2VcsDiffErrors] @@ -19047,8 +18277,8 @@ export type V2VcsDiffResponses = { * Success */ 200: { - location: LocationInfo2 - data: Array + location: LocationInfoV2 + data: Array } } @@ -19069,7 +18299,7 @@ export type V2DebugLocationErrors = { /** * UnauthorizedError */ - 401: UnauthorizedErrorV2 + 401: UnauthorizedError } export type V2DebugLocationError = V2DebugLocationErrors[keyof V2DebugLocationErrors] @@ -19078,7 +18308,7 @@ export type V2DebugLocationResponses = { /** * Success */ - 200: Array + 200: Array } export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 67d16c1c481..fa36ad18396 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -500,7 +500,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } if (route.data.type === "session") { - const session = sync.session.get(route.data.sessionID) + const session = data.session.get(route.data.sessionID) if (!session || isDefaultTitle(session.title)) { renderer.setTerminalTitle("OpenCode") return diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 8e3e8973f31..1ab7687e7ee 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -54,6 +54,7 @@ type Data = { // session ID in that family, including the key itself once its info arrives. family: Record status: Record + compaction: Partial> message: Record input: Record permission: Record @@ -90,6 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ info: {}, family: {}, status: {}, + compaction: {}, message: {}, input: {}, permission: {}, @@ -149,14 +151,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ item.type === "tool" && (callID === undefined || item.id === callID), ) }, - latestText(assistant: SessionMessageAssistant | undefined, textID: string) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID, - ) + latestText(assistant: SessionMessageAssistant | undefined) { + return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") }, - latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { + latestReasoning(assistant: SessionMessageAssistant | undefined) { return assistant?.content.findLast( - (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, + (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed, ) }, } @@ -273,7 +273,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", event.data.sessionID, "title", event.data.title) break case "session.prompt.promoted": { - setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) if (position === undefined) return @@ -335,7 +334,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.shell.started": - setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), @@ -346,7 +344,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.shell.ended": - setSessionStatus(event.data.sessionID, "idle") message.update(event.data.sessionID, (draft) => { const match = message.shell(draft, event.data.shell.id) if (!match) return @@ -356,11 +353,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.step.started": - setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { - if (index.has(event.data.assistantMessageID)) return + const position = index.get(event.data.assistantMessageID) + const existing = position === undefined ? undefined : draft[position] + if (existing?.type === "assistant") { + existing.agent = event.data.agent + existing.model = event.data.model + existing.retry = undefined + existing.error = undefined + existing.finish = undefined + existing.time.completed = undefined + if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot } + return + } const currentAssistant = message.activeAssistant(draft) - if (currentAssistant) currentAssistant.time.completed = event.created + if (currentAssistant) { + currentAssistant.retry = undefined + currentAssistant.time.completed = event.created + } message.append(draft, index, { id: event.data.assistantMessageID, type: "assistant", @@ -373,7 +383,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.step.ended": - setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return @@ -392,32 +401,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.time.completed = event.created currentAssistant.finish = "error" currentAssistant.error = event.data.error + currentAssistant.retry = undefined }) break case "session.text.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "text", - id: event.data.textID, text: "", }) }) break case "session.text.delta": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.textID, - ) + const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) if (match) match.text += event.data.delta }) break case "session.text.ended": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.textID, - ) + const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) if (match) match.text = event.data.text }) break @@ -458,7 +461,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) if (!match) return match.time.ran = event.created - match.provider = event.data.provider + match.executed = event.data.executed + match.providerState = event.data.state match.state = { status: "running", input: event.data.input, structured: {}, content: [] } }) break @@ -487,11 +491,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ content: [...event.data.content], result: event.data.result, } - match.provider = { - executed: event.data.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.data.provider.metadata, - } + match.executed = event.data.executed || match.executed === true + match.providerResultState = event.data.resultState match.time.completed = event.created }) break @@ -510,11 +511,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ content: match.state.status === "running" ? match.state.content : [], result: event.data.result, } - match.provider = { - executed: event.data.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.data.provider.metadata, - } + match.executed = event.data.executed || match.executed === true + match.providerResultState = event.data.resultState match.time.completed = event.created }) break @@ -522,41 +520,55 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "reasoning", - id: event.data.reasoningID, text: "", - providerMetadata: event.data.providerMetadata, + state: event.data.state, time: { created: event.created }, }) }) break case "session.reasoning.delta": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.reasoningID, - ) + const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) if (match) match.text += event.data.delta }) break case "session.reasoning.ended": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.reasoningID, - ) + const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) if (match) { match.text = event.data.text match.time = { created: match.time?.created ?? event.created, completed: event.created } - if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata + if (event.data.state !== undefined) match.state = event.data.state } }) break - case "session.retried": - case "session.compaction.started": + case "session.retry.scheduled": + message.update(event.data.sessionID, (draft, index) => { + const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) + if (!currentAssistant) return + currentAssistant.retry = { + attempt: event.data.attempt, + at: event.data.at, + error: event.data.error, + } + }) + break + case "session.execution.started": setSessionStatus(event.data.sessionID, "running") break - case "session.execution.settled": + case "session.compaction.started": + setStore("session", "compaction", event.data.sessionID, "") + break + case "session.execution.succeeded": + case "session.execution.failed": + case "session.execution.interrupted": setSessionStatus(event.data.sessionID, "idle") + if (store.session.compaction[event.data.sessionID] !== undefined) + setStore("session", "compaction", event.data.sessionID, undefined) + message.update(event.data.sessionID, (draft) => { + const currentAssistant = message.activeAssistant(draft) + if (currentAssistant) currentAssistant.retry = undefined + }) break case "session.revert.staged": if (store.session.info[event.data.sessionID]) @@ -573,17 +585,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ "session", "input", event.data.sessionID, - (store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.messageID), + (store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to), ) message.update(event.data.sessionID, (draft, index) => { - const position = draft.findIndex((item) => item.id >= event.data.messageID) + const position = draft.findIndex((item) => item.id >= event.data.to) if (position === -1) return for (const item of draft.splice(position)) index.delete(item.id) }) break case "session.compaction.delta": + setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text) break case "session.compaction.ended": + setStore("session", "compaction", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), @@ -710,6 +724,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.input[sessionID]?.includes(inputID) ?? false }, }, + compaction(sessionID: string) { + return store.session.compaction[sessionID] + }, async refresh(sessionID: string) { setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index a5033b62e0b..b8f2727bae2 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -27,8 +27,8 @@ function sessionErrorMessage(error: SessionError) { } const tui: TuiPlugin = async (api) => { - const active = new Set() const errored = new Set() + const terminal = new Set() const forms = new Set() const questions = new Set() const permissions = new Set() @@ -73,14 +73,13 @@ const tui: TuiPlugin = async (api) => { }) const started = (sessionID: string) => { - active.add(sessionID) errored.delete(sessionID) + terminal.delete(sessionID) } const ended = (sessionID: string) => { - if (!active.has(sessionID)) return - active.delete(sessionID) - + if (terminal.has(sessionID)) return + terminal.add(sessionID) if (errored.has(sessionID)) { errored.delete(sessionID) return @@ -90,28 +89,25 @@ const tui: TuiPlugin = async (api) => { notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } - api.event.on("session.prompt.promoted", (event) => started(event.data.sessionID)) - api.event.on("session.shell.started", (event) => started(event.data.sessionID)) - api.event.on("session.step.started", (event) => started(event.data.sessionID)) - api.event.on("session.retried", (event) => started(event.data.sessionID)) - api.event.on("session.compaction.started", (event) => started(event.data.sessionID)) - api.event.on("session.shell.ended", (event) => ended(event.data.sessionID)) - api.event.on("session.step.ended", (event) => { - if (event.data.finish === "tool-calls") return - ended(event.data.sessionID) - }) - api.event.on("session.step.failed", (event) => { + api.event.on("session.execution.started", (event) => started(event.data.sessionID)) + api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID)) + api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID)) + api.event.on("session.execution.failed", (event) => { const sessionID = event.data.sessionID - if (!active.has(sessionID)) return + if (errored.has(sessionID)) { + ended(sessionID) + return + } errored.add(sessionID) - notify(api, sessionID, "Session error", "error") + notify(api, sessionID, event.data.error.message, "error") ended(sessionID) }) api.event.on("session.error", (event) => { const sessionID = event.data.sessionID if (!sessionID) return - if (!active.has(sessionID)) return + if (api.state.session.status(sessionID)?.type !== "busy") return + if (errored.has(sessionID)) return errored.add(sessionID) notify(api, sessionID, sessionErrorMessage(event.data.error), "error") }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 30cdbc2a42c..0972a3545ef 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -70,7 +70,7 @@ import { usePluginRuntime } from "../../plugin/runtime" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" -import { createSessionRows, type PartRef, type SessionRow } from "./rows" +import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows" import { switchLabel } from "../../util/model" addDefaultParsers(parsers.parsers) @@ -914,6 +914,9 @@ export function Session() { /> )} + + {(text) => } + { const item = message() if (item?.type !== "assistant") return - return item.content.find((part) => part.id === props.partRef.partID) + return resolvePart(item, props.partRef.partID) }) return ( @@ -1136,7 +1139,7 @@ function SessionGroupView(props: { refs.flatMap((ref) => { const message = props.message(ref.messageID) if (message?.type !== "assistant") return [] - const part = message.content.find((part) => part.id === ref.partID) + const part = resolvePart(message, ref.partID) if (part?.type !== "tool") return [] return [part] }) @@ -1216,6 +1219,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { {errorMessage(props.message.error)} + @@ -1269,9 +1273,15 @@ function SessionSkillMessage(props: { message: Extract + return ( + + + {props.text} + + + ) } function statusLabel(status: "added" | "modified" | "deleted") { @@ -1547,6 +1557,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole {errorMessage(props.message.error)} + @@ -1566,6 +1577,21 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole ) } +function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { + const { theme } = useTheme() + return ( + + {(retry) => ( + + + Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}] + + + )} + + ) +} + function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) { const { theme } = useTheme() const pathFormatter = usePathFormatter() @@ -2137,7 +2163,7 @@ function Shell(props: ToolProps) { - Backgrounded + Background diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index ff8e2d51559..0bbcedd6fba 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -74,19 +74,17 @@ export function createSessionRows(sessionID: Accessor) { createEffect( on( () => - data.session.message - .list(sessionID()) - .flatMap((message) => - message.type === "user" - ? [ - { - id: message.id, - created: message.time.created, - input: data.session.input.has(sessionID(), message.id), - }, - ] - : [], - ), + data.session.message.list(sessionID()).flatMap((message) => + message.type === "user" + ? [ + { + id: message.id, + created: message.time.created, + input: data.session.input.has(sessionID(), message.id), + }, + ] + : [], + ), () => setRows(reconcile(reduce())), ), ) @@ -138,6 +136,14 @@ export function createSessionRows(sessionID: Accessor) { }), ) + const removeFooter = (messageID: string) => + setRows( + produce((draft) => { + const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID) + if (index !== -1) draft.splice(index, 1) + }), + ) + const isQueued = (messageID: string) => { return data.session.input.has(sessionID(), messageID) } @@ -166,24 +172,30 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.compaction.ended", message), data.on("session.text.delta", (event) => { if (event.data.sessionID === sessionID()) - appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) + appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) }), data.on("session.text.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) + appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) }), data.on("session.reasoning.delta", (event) => { if (event.data.sessionID === sessionID()) - appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) + appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }) }), data.on("session.reasoning.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) + appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }) }), data.on("session.tool.input.started", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name) }), + data.on("session.retry.scheduled", (event) => { + if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) + }), + data.on("session.step.started", (event) => { + if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID) + }), data.on("session.step.ended", (event) => { if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return appendFooter(event.data.assistantMessageID) @@ -207,11 +219,13 @@ export function reduceSessionRows(messages: SessionMessage[], inputs = new Set { + const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return - append(rows, { messageID: message.id, partID: part.id }, part) + append(rows, { messageID: message.id, partID }, part) }) - if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) { + if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) { completePrevious(rows) rows.push({ type: "assistant-footer", messageID: message.id }) } @@ -221,6 +235,15 @@ export function reduceSessionRows(messages: SessionMessage[], inputs = new Set part.type === "tool" && part.id === partID) + if (tool) return tool + const match = /^(text|reasoning):(\d+)$/.exec(partID) + if (!match) return + const ordinal = Number(match[2]) + return message.content.filter((part) => part.type === match[1])[ordinal] +} + function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) { if (part.type === "tool") { if (exploration(part.name)) { diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 30601bb5284..c4302e2790e 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -58,10 +58,24 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { } }) -test("app.exit prints the session epilogue after scoped cleanup", async () => { +test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + let initialTitle!: () => void + const initialTitleSet = new Promise((resolve) => { + initialTitle = resolve + }) + let renamedTitle!: () => void + const renamedTitleSet = new Promise((resolve) => { + renamedTitle = resolve + }) + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + if (title === "OC | Demo session") initialTitle() + if (title === "OC | Renamed session") renamedTitle() + setTitle(title) + } const events = createEventStream() const calls = createFetch((url) => { if (url.pathname === "/api/session") @@ -100,7 +114,7 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { client: createClient(calls.fetch), api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), - args: { continue: true }, + args: { sessionID: "dummy" }, pluginHost: { async start(input) { api = input.api @@ -112,12 +126,19 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { ) await ready - await setup.renderOnce() - await setup.renderOnce() + await initialTitleSet + events.emit({ + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: { aggregateID: "dummy", seq: 1, version: 1 }, + data: { sessionID: "dummy", title: "Renamed session" }, + }) + await renamedTitleSet api?.keymap.dispatchCommand("app.exit") await task - expect(stdout).toContain("Demo session") + expect(stdout).toContain("Renamed session") expect(stdout).toContain("opencode -s dummy") } finally { process.stdout.write = originalWrite diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 0014292d23e..c08b557eee3 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -49,6 +49,7 @@ async function setup() { state: { session: { get: (sessionID: string) => sessions[sessionID], + status: () => ({ type: "busy" }), }, }, }), @@ -96,46 +97,34 @@ function durable(sessionID: string) { return { aggregateID: sessionID, seq: 0, version: 1 } } -function stepStarted(id: string, sessionID = "session"): V2Event { +function executionStarted(id: string, sessionID = "session"): V2Event { return { id, created: 0, - type: "session.step.started", + type: "session.execution.started", durable: durable(sessionID), - data: { - sessionID, - assistantMessageID: `msg_${id}`, - agent: "build", - model: { id: "model", providerID: "provider" }, - }, + data: { sessionID }, } } -function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event { +function executionSucceeded(id: string, sessionID = "session"): V2Event { return { id, created: 0, - type: "session.step.ended", + type: "session.execution.succeeded", durable: durable(sessionID), - data: { - sessionID, - assistantMessageID: `msg_${id}`, - finish, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }, + data: { sessionID }, } } -function stepFailed(id: string, sessionID = "session"): V2Event { +function executionFailed(id: string, sessionID = "session"): V2Event { return { id, created: 0, - type: "session.step.failed", + type: "session.execution.failed", durable: durable(sessionID), data: { sessionID, - assistantMessageID: `msg_${id}`, error: { type: "unknown", message: "boom" }, }, } @@ -224,12 +213,12 @@ describe("internal notifications TUI plugin", () => { ]) }) - test("notifies when an active session becomes idle and suppresses no-op idle", async () => { + test("notifies for terminal lifecycle events even when attached after execution started", async () => { const harness = await setup() - harness.emit(stepEnded("event-1")) - harness.emit(stepStarted("event-2")) - harness.emit(stepEnded("event-3")) + harness.emit(executionSucceeded("event-1")) + harness.emit(executionStarted("event-2")) + harness.emit(executionSucceeded("event-3")) expect(harness.notifications).toEqual([ { @@ -238,6 +227,12 @@ describe("internal notifications TUI plugin", () => { notification: { when: "blurred" }, sound: { name: "done", when: "always" }, }, + { + title: "Demo session", + message: "Session done", + notification: { when: "blurred" }, + sound: { name: "done", when: "always" }, + }, ]) }) @@ -250,8 +245,8 @@ describe("internal notifications TUI plugin", () => { type: "form.created", data: { form: form("form-1", "subagent") }, }) - harness.emit(stepStarted("event-2", "subagent")) - harness.emit(stepEnded("event-3", "subagent")) + harness.emit(executionStarted("event-2", "subagent")) + harness.emit(executionSucceeded("event-3", "subagent")) expect(harness.notifications).toEqual([ { @@ -272,14 +267,14 @@ describe("internal notifications TUI plugin", () => { test("notifies session errors once and suppresses the following idle done notification", async () => { const harness = await setup() - harness.emit(stepStarted("event-1")) - harness.emit(stepFailed("event-2")) - harness.emit(stepEnded("event-3")) + harness.emit(executionStarted("event-1")) + harness.emit(executionFailed("event-2")) + harness.emit(executionSucceeded("event-3")) expect(harness.notifications).toEqual([ { title: "Demo session", - message: "Session error", + message: "boom", notification: { when: "blurred" }, sound: { name: "error", when: "always" }, }, @@ -289,20 +284,21 @@ describe("internal notifications TUI plugin", () => { test("special-cases aborts and model response timeouts", async () => { const harness = await setup() - harness.emit(stepStarted("event-1", "abort")) + harness.emit(executionStarted("event-1", "abort")) harness.emit({ id: "event-2", created: 0, type: "session.error", data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, }) - harness.emit(stepStarted("event-3", "timeout")) + harness.emit(executionStarted("event-3", "timeout")) harness.emit({ id: "event-4", created: 0, type: "session.error", data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } }, }) + harness.emit(executionFailed("event-5", "timeout")) expect(harness.notifications).toEqual([ { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 6bd413b39de..8a8b6956ae4 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -8,7 +8,7 @@ import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" import { DataProvider, useData } from "../../../src/context/data" -import { createSessionRows } from "../../../src/routes/session/rows" +import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -177,16 +177,11 @@ test("reconnects the event stream and bootstraps fresh data", async () => { await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000) emitEvent(events, { - id: "evt_step_started_after_reconnect", + id: "evt_execution_started_after_reconnect", created: 1, - type: "session.step.started", + type: "session.execution.started", durable: durable("session-new"), - data: { - sessionID: "session-new", - assistantMessageID: "message-new", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, + data: { sessionID: "session-new" }, }) await wait(() => data.session.status("session-new") === "running") resolveActive(json({ data: {} })) @@ -326,7 +321,7 @@ test("removes committed revert messages from local state", async () => { created: 3, type: "session.revert.committed", durable: durable(sessionID, 3), - data: { sessionID, messageID: "msg_002" }, + data: { sessionID, to: "msg_002" }, }) await wait(() => data.session.message.ids(sessionID).length === 1) @@ -406,9 +401,11 @@ test("tracks session status from active sessions and execution events", async () if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } }) }, events) let data!: ReturnType + let rows!: SessionRow[] function Probe() { data = useData() + rows = createSessionRows(() => "session-retry") return } @@ -428,6 +425,15 @@ test("tracks session status from active sessions and execution events", async () await wait(() => data.session.status("session-active") === "running") expect(data.session.status("session-idle")).toBe("idle") + emitEvent(events, { + id: "evt_execution_started", + created: 0, + type: "session.execution.started", + durable: durable("session-live"), + data: { sessionID: "session-live" }, + }) + await wait(() => data.session.status("session-live") === "running") + emitEvent(events, { id: "evt_step_started", created: 0, @@ -440,8 +446,6 @@ test("tracks session status from active sessions and execution events", async () model: { id: "model", providerID: "provider" }, }, }) - await wait(() => data.session.status("session-live") === "running") - emitEvent(events, { id: "evt_step_ended", created: 0, @@ -462,16 +466,23 @@ test("tracks session status from active sessions and execution events", async () expect(data.session.status("session-live")).toBe("running") emitEvent(events, { - id: "evt_execution_settled", + id: "evt_execution_succeeded", created: 0, - type: "session.execution.settled", - data: { - sessionID: "session-live", - outcome: "success", - }, + type: "session.execution.succeeded", + durable: durable("session-live", 1, 3), + data: { sessionID: "session-live" }, }) await wait(() => data.session.status("session-live") === "idle") + emitEvent(events, { + id: "evt_failed_execution_started", + created: 0, + type: "session.execution.started", + durable: durable("session-failed"), + data: { sessionID: "session-failed" }, + }) + await wait(() => data.session.status("session-failed") === "running") + emitEvent(events, { id: "evt_failed_step_started", created: 0, @@ -484,8 +495,6 @@ test("tracks session status from active sessions and execution events", async () model: { id: "model", providerID: "provider" }, }, }) - await wait(() => data.session.status("session-failed") === "running") - emitEvent(events, { id: "evt_step_failed", created: 0, @@ -494,26 +503,146 @@ test("tracks session status from active sessions and execution events", async () data: { sessionID: "session-failed", assistantMessageID: "message-failed", - error: { type: "unknown", message: "Provider unavailable" }, + error: { type: "provider.content-filter", message: "Provider blocked the response" }, }, }) await wait(() => { const assistant = data.session.message.get("session-failed", "message-failed") - return assistant?.type === "assistant" && assistant.finish === "error" + return ( + assistant?.type === "assistant" && + assistant.finish === "error" && + assistant.error?.type === "provider.content-filter" + ) }) expect(data.session.status("session-failed")).toBe("running") emitEvent(events, { - id: "evt_failed_execution_settled", + id: "evt_failed_execution_failed", created: 0, - type: "session.execution.settled", + type: "session.execution.failed", + durable: durable("session-failed", 1, 3), data: { sessionID: "session-failed", - outcome: "failure", - error: { type: "unknown", message: "Provider unavailable" }, + error: { type: "provider.content-filter", message: "Provider blocked the response" }, }, }) await wait(() => data.session.status("session-failed") === "idle") + + emitEvent(events, { + id: "evt_retry_execution_started", + created: 0, + type: "session.execution.started", + durable: durable("session-retry"), + data: { sessionID: "session-retry" }, + }) + emitEvent(events, { + id: "evt_retry_step_started", + created: 0, + type: "session.step.started", + durable: durable("session-retry", 1, 2), + data: { + sessionID: "session-retry", + assistantMessageID: "message-retry", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + emitEvent(events, { + id: "evt_retry_scheduled", + created: 0, + type: "session.retry.scheduled", + durable: durable("session-retry", 1, 3), + data: { + sessionID: "session-retry", + assistantMessageID: "message-retry", + attempt: 2, + at: 2_000, + error: { type: "provider.transport", message: "Disconnected" }, + }, + }) + await wait(() => { + const assistant = data.session.message.get("session-retry", "message-retry") + return assistant?.type === "assistant" && assistant.retry?.attempt === 2 + }) + await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry")) + emitEvent(events, { + id: "evt_retry_next_step", + created: 2_000, + type: "session.step.started", + durable: durable("session-retry", 1, 4), + data: { + sessionID: "session-retry", + assistantMessageID: "message-retry", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + await wait(() => { + const assistant = data.session.message.get("session-retry", "message-retry") + return assistant?.type === "assistant" && assistant.retry === undefined + }) + await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry")) + expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1) + emitEvent(events, { + id: "evt_retry_scheduled_again", + created: 2_000, + type: "session.retry.scheduled", + durable: durable("session-retry", 1, 5), + data: { + sessionID: "session-retry", + assistantMessageID: "message-retry", + attempt: 3, + at: 6_000, + error: { type: "provider.transport", message: "Disconnected again" }, + }, + }) + await wait(() => { + const assistant = data.session.message.get("session-retry", "message-retry") + return assistant?.type === "assistant" && assistant.retry?.attempt === 3 + }) + emitEvent(events, { + id: "evt_retry_interrupted", + created: 2_000, + type: "session.execution.interrupted", + durable: durable("session-retry", 1, 6), + data: { sessionID: "session-retry", reason: "shutdown" }, + }) + await wait(() => data.session.status("session-retry") === "idle") + expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry") + + emitEvent(events, { + id: "evt_compaction_started", + created: 0, + type: "session.compaction.started", + durable: durable("session-live", 2), + data: { sessionID: "session-live", reason: "auto" }, + }) + emitEvent(events, { + id: "evt_compaction_delta_1", + created: 0, + type: "session.compaction.delta", + data: { sessionID: "session-live", text: "Live " }, + }) + emitEvent(events, { + id: "evt_compaction_delta_2", + created: 0, + type: "session.compaction.delta", + data: { sessionID: "session-live", text: "summary" }, + }) + await wait(() => data.session.compaction("session-live") === "Live summary") + + emitEvent(events, { + id: "evt_compaction_ended", + created: 0, + type: "session.compaction.ended", + durable: durable("session-live", 3), + data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" }, + }) + await wait(() => data.session.compaction("session-live") === undefined) + expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({ + type: "compaction", + summary: "Live summary", + }) } finally { app.renderer.destroy() } @@ -1020,9 +1149,9 @@ test("settles pending tools when a live failure arrives", async () => { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", callID: "call-1", - tool: "bash", input: {}, - provider: { executed: false, metadata: { fake: { call: true } } }, + executed: false, + state: { call: true }, }, }) emitEvent(events, { @@ -1035,7 +1164,8 @@ test("settles pending tools when a live failure arrives", async () => { assistantMessageID: "msg_explicit_assistant_9", callID: "call-1", error: { type: "unknown", message: "aborted" }, - provider: { executed: false, metadata: { fake: { result: true } } }, + executed: false, + resultState: { result: true }, }, }) @@ -1061,11 +1191,9 @@ test("settles pending tools when a live failure arrives", async () => { expect(tool.state.input).toEqual({}) expect(tool.state.structured).toEqual({}) expect(tool.state.content).toEqual([]) - expect(tool.provider).toEqual({ - executed: false, - metadata: { fake: { call: true } }, - resultMetadata: { fake: { result: true } }, - }) + expect(tool.executed).toBe(false) + expect(tool.providerState).toEqual({ call: true }) + expect(tool.providerResultState).toEqual({ result: true }) expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([ "agent-switched", "model-switched", diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index fec6f5eb871..e55e42231b7 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -6,19 +6,19 @@ test("groups exploration parts across assistant messages until a delimiter", () const messages: SessionMessage[] = [ { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, assistant("assistant-1", [ - { type: "text", id: "text-1", text: "Looking" }, + { type: "text", text: "Looking" }, { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, { type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } }, ]), assistant("assistant-2", [ { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } }, - { type: "text", id: "text-2", text: "Done" }, + { type: "text", text: "Done" }, ]), ] expect(reduceSessionRows(messages)).toEqual([ { type: "message", messageID: "user-1" }, - { type: "part", ref: { messageID: "assistant-1", partID: "text-1" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, { type: "group", kind: "exploration", @@ -30,7 +30,7 @@ test("groups exploration parts across assistant messages until a delimiter", () { messageID: "assistant-2", partID: "grep-1" }, ], }, - { type: "part", ref: { messageID: "assistant-2", partID: "text-2" } }, + { type: "part", ref: { messageID: "assistant-2", partID: "text:0" } }, ]) }) @@ -62,20 +62,38 @@ test("keeps non-exploration tools as individual part rows", () => { ]) }) +test("assigns stable kind ordinals within an assistant message", () => { + const messages: SessionMessage[] = [ + assistant("assistant-1", [ + { type: "text", text: "First" }, + { type: "reasoning", text: "Think" }, + { type: "text", text: "Second" }, + { type: "reasoning", text: "Check" }, + ]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "text:1" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } }, + ]) +}) + test("groups across empty assistant reasoning parts", () => { const messages: SessionMessage[] = [ assistant("assistant-1", [ - { type: "reasoning", id: "reasoning-1", text: "Looking" }, + { type: "reasoning", text: "Looking" }, { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, ]), assistant("assistant-2", [ - { type: "reasoning", id: "reasoning-2", text: "" }, + { type: "reasoning", text: "" }, { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, ]), ] expect(reduceSessionRows(messages)).toEqual([ - { type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } }, + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } }, { type: "group", kind: "exploration", @@ -95,9 +113,7 @@ test("completes exploration groups when another row follows", () => { ]) finished.finish = "stop" const messages: SessionMessage[] = [ - assistant("assistant-1", [ - { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, - ]), + assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "user", id: "user-1", text: "Continue", time: { created: 2 } }, finished, ] @@ -182,6 +198,17 @@ test("renders synthetic messages with descriptions", () => { ]) }) +test("renders a footer for a pre-output retry assistant after replay", () => { + const message = assistant("assistant-retry", []) + message.retry = { + attempt: 2, + at: 2_000, + error: { type: "provider.transport", message: "Disconnected" }, + } + + expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) +}) + function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { return { type: "assistant", diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 62c85ef3e83..fa7170ffbdb 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -13,6 +13,28 @@ Compatibility: - Existing changelog entries retain the names that were accurate when those changes occurred. - Behavior is unchanged: this is a vocabulary and contract rename only. +## 2026-07-04: Canonicalize Generated Shell Type Name + +- Collapse the legacy JavaScript SDK generator's equivalent `Shell1V2` component into the canonical `ShellV2` contract. + +Compatibility: + +- The Shell wire shape is unchanged. Generated event and endpoint types now consistently reference `ShellV2`. + +## 2026-07-03: Add Execution Lifecycle, Retry, And Structured Session Errors + +- Replace live-only `session.execution.settled` and unused `session.retried` with durable v1 `session.execution.started`, `session.execution.succeeded`, `session.execution.failed`, `session.execution.interrupted`, and `session.retry.scheduled` events. +- Add an open `SessionError` wire envelope with dot-cased type values and the browser-safe `FinishReason` contract. +- Project retry state onto the current assistant and classify content-filter finishes as failed steps. +- Reuse one projected assistant across pre-output retry steps; each provider call remains a distinct step and consumes agent allowance. + +Compatibility: + +- Experimental V2 event, sequence, input, and message-projection rows are reset. Durable event contracts restart at v1. +- `SessionError.type` remains an open string so new error classifications do not require event-version or database migrations. Unknown fields are ignored by older decoders; richer public details require a separate compatibility design. +- Execution lifecycle events are historical observations of one process-local coordinator busy period. Unmatched starts never establish current liveness or recovery work; `/api/session/active` remains the current-process liveness authority. +- Scheduled retries are historical UI state after a crash and never trigger provider recovery. + ## 2026-07-03: Require Durable Envelope On Durable Events - Make the wire `durable` envelope required on durable event definitions. @@ -952,3 +974,17 @@ Compatibility: Compatibility: - V2 durable events and projections are experimental and are reset by `20260703190000_reset_v2_shell_event_payloads`; existing V2 event rows, event sequences, projected session messages, and admitted inputs are wiped. + +## 2026-07-03: Simplify Assistant Fragments And Provider State + +- Remove provider block IDs from current Session text and reasoning event payloads and projected content. A Session assistant step allows at most one open fragment of each kind; events carry a Session-assigned kind-specific ordinal so live updates and hydrated projections share the stable `(assistantMessageID, kind, ordinal)` reference. Tool correlation continues to use `callID`. +- Replace nested `providerMetadata` with opaque, provider-un-nested `state` at the Session boundary. Reasoning uses `state`; tool calls use `state`, settlements use `resultState`, and projected tools expose `providerState` and `providerResultState`. Replay re-nests state under the selected provider only for the same successful model. +- Flatten `executed` on tool events and projected tools, and remove the redundant tool name from `session.tool.called` because `session.tool.input.started` owns it. +- Rename `session.revert.committed.messageID` to `to`, paired with `session.forked.from`. +- Publish live-only `session.compaction.delta` events for accepted summary chunks while `session.compaction.ended.1` remains the durable full summary. +- OpenAI Responses closes output text on `response.output_text.done` or its message `response.output_item.done` boundary. Provider documentation and recorded stream shapes show sequential output items, not valid overlapping text or reasoning blocks of the same kind. + +Compatibility: + +- All changed durable definitions remain version 1. `20260703200000_reset_v2_session_events` performs the single reset for this Session event contract update, wiping experimental V2 events, sequences, projected messages, and admitted inputs. +- Promise, Effect, and legacy JavaScript SDK surfaces are regenerated from the simplified schemas. diff --git a/specs/v2/session.md b/specs/v2/session.md index 1fea99dcb66..8057a7a8cdc 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -21,7 +21,7 @@ sessions.prompt({ id?, sessionID, prompt, delivery?, resume? }) sessions.interrupt(sessionID) -> interrupts active execution on this process - -> waits for runner cleanup and settlement + -> waits for runner cleanup and a terminal lifecycle observation -> clears a coalesced follow-up wake already registered with this coordinator -> preserves durable inbox rows for a later wake or resume -> idle or missing Session is a no-op @@ -47,7 +47,15 @@ SessionExecution.resume(sessionID) `SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -The local runner issues one explicit `llm.stream(request)` per step, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured step allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across steps. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. +The local runner issues one explicit `llm.stream(request)` per step, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every owned tool fiber after provider-stream closure, and reloads projected history once before continuation. For every in-process step, `session.step.started` precedes its tool calls, every local and hosted call settles as `session.tool.success` or `session.tool.failed`, and only then may the runner publish the single terminal `session.step.ended` or `session.step.failed`. Streamed provider-error evidence is retained until this closeout; thrown provider failures and interruption use the same settlement-first ordering. Promoting any new user input resets the selected agent's configured step allowance; multiple steers promoted at one boundary reset it once. + +`callID` is unique only within its owning step, not across the Session. Tool events therefore carry `assistantMessageID`, and consumers correlate a call through the step that owns that assistant message rather than inventing a synthetic composite key. Before assembling a provider request, the runner's cross-drain `failInterruptedTools` recovery durably fails any tool still projected as pending or running from a previous process with `Tool execution interrupted`. This orphan-recovery sweep is the explicit nesting exception: it occurs in a later drain, but attributes every settlement to the original `assistantMessageID`; abandoned side effects are never silently replayed. + +`session.execution.started.1` and exactly one of `session.execution.succeeded.1`, `session.execution.failed.1`, or `session.execution.interrupted.1` observe one process-local coordinator busy period, including coalesced drains and joined resumes. These durable rows are history, not a durable execution identity: replay must never infer current liveness, recovery, grouping, or resumability from an unmatched start. A drain has no durable identity or transcript boundary. `/api/session/active` is the authority for current process-local liveness, and is empty after restart. User interruption records `reason: "user"`; owner-scope interruption defaults to `"shutdown"`; `"superseded"` is reserved for explicit replacement. + +Core retries only typed rate-limit, provider-internal, and transport failures before durable assistant text, reasoning, tool-call, tool-output, or tool-execution evidence. The initial call plus at most four retries use two-second exponential backoff, raised when a provider's `retryAfterMs` is larger. Every retry attempt remains a distinct step and consumes the selected agent's step allowance, while all pre-output attempts reuse one assistant message ID so retry state never creates empty transcript messages. Repeated `session.step.started.1` facts reopen that assistant projection idempotently. `session.retry.scheduled.1` is committed before each delay with the upcoming one-based attempt and absolute epoch-millisecond time, then projects onto `Assistant.retry`. The next `session.step.started.1` or terminal failure/interruption clears it. A scheduled retry surviving a crash is historical UI state only and never triggers recovery. + +A normalized `step-finish` with `content-filter` publishes `session.step.failed.1` with `provider.content-filter`, never `session.step.ended.1`. Any partial streamed content remains visible; a contentless filtered response still has a failed assistant projection. Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. @@ -124,8 +132,14 @@ Before each step, the runner estimates the complete model-visible request and co Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes. +The rolling summary is a continuation checkpoint with this complete heading order: `Objective`, `Important Details`, `Work State`, and `Next Move`. `Work State` records completed, active, and blocked work, while `Next Move` records the immediate and following actions. Every heading remains present even when its value is `(none)`. + `session.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next physical attempt, the runner observes that completed compaction and directly renders a fresh instruction baseline through `InstructionCheckpoint`. A failed or interrupted attempt therefore leaves the previous history boundary active. +Assistant text and reasoning follow a strict `started` / live-only `delta` / durable full-value `ended` lifecycle. A publisher permits at most one open fragment of each kind in a step and fails on a second start before the matching end. Provider block IDs remain internal to LLM adapters; each fragment event carries a Session-assigned kind-specific ordinal, matching the ordinal derived from projected content. UI identity is therefore the assistant message ID plus content kind and ordinal. Tool calls retain step-scoped `callID` because settlements and provider replay correlate through it. + +Provider continuation state is opaque and un-nested at the Session boundary. The publisher selects only the active model provider's entry from LLM provider metadata. Same-model replay re-nests that state under the current provider; model switches and failed assistant steps continue to suppress provider-native continuation state. + Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending step. When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical step with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up. @@ -160,7 +174,7 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o | Prompt/reference expansion | Configured-reference expansion | missing | Resolve aliases and emit durable model-visible reference context or failures. | | Prompt/reference expansion | Native synthetic expansion replay | partial | V2 replays synthetic messages but only the V1 compatibility path creates them. | -Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider. +Provider timeout and watchdog policy is intentionally deferred. Retry tuning beyond the narrow safe policy above remains separate work; the runner does not impose a universal provider-stream inactivity or absolute timeout. Inbox delivery is explicit: @@ -174,7 +188,7 @@ Execution has two entry points: Post-crash continuation recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model provider-dispatch ambiguity, required continuation, queued-input promotion, retry policy, and visible recovery status together. It must not assume an enclosing durable execution identity that the Session model does not otherwise need. -A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new step against that Location. +A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new step against that Location. Its durable lifecycle events are historical observations only; they do not replace the coordinator's process-local active registry. The coordinator's active registry is also the source for `sessions.active()`. It represents only foreground Session drains owned by the current process; background subagents and tasks do not add parent Sessions to this registry. The snapshot is runtime state and is empty after a process restart. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 6f09684a1ba..7c6a1371922 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -24,6 +24,8 @@ through legacy `SessionPrompt.loop(...)`: and issues one explicit `llm.stream(request)` step at a time - durable V2 projections record text, reasoning, provider failures, tool calls, tool results, and assistant output +- owned local tool fibers and unresolved hosted calls settle before their step's + single terminal event; cross-drain orphan recovery retains original assistant attribution - a scoped `ToolRegistry` advertises definitions and the first permission-checked `read` built-in - local continuation reloads projected history, and promoting new user input resets the selected agent's configured step allowance @@ -37,9 +39,6 @@ a FIFO until the Session would otherwise become idle and then promote one at a t Next reviewed slices: -- preserve eager structured local-tool settlement: durably record each complete - call, start its child execution immediately, await every settlement after the - step closes, then reload projected history once - revisit per-step tool-call limits, output truncation, and operational backpressure before broadening exposure; eager local execution is deliberately unbounded in the current local slice while SQLite publication stays serialized @@ -55,7 +54,7 @@ Next reviewed slices: ### Deferred durable continuation recovery -Do not infer that ambiguous provider work is safe to retry from an advisory wake. +Do not infer that ambiguous provider work is safe to retry from an advisory wake, an unmatched historical `session.execution.started` event, or a surviving `session.retry.scheduled` projection. The first inbox-driven runner intentionally omits outer physical-attempt markers until they have a concrete consumer and a complete recovery policy.