Compare commits

...

11 Commits

Author SHA1 Message Date
Kit Langton 41ffe60dab fix(tui): align compaction separator states 2026-07-04 21:55:33 -04:00
Kit Langton beecb243bc fix(core): harden compaction admission 2026-07-04 20:13:43 -04:00
Kit Langton 69d95f29d4 feat(core): add durable compaction barrier 2026-07-04 20:08:41 -04:00
Kit Langton 7e2011dffb fix(sdk): stabilize session event identities 2026-07-04 12:20:41 -04:00
Kit Langton 918005849b refactor(schema): keep session errors extensible 2026-07-04 12:20:41 -04:00
Kit Langton 28b70d7b38 refactor(core): consolidate session event reset 2026-07-04 12:20:41 -04:00
Kit Langton a49e6c37b2 fix(core): enforce step settlement ordering 2026-07-04 12:20:40 -04:00
Kit Langton 77e1702ea2 fix(core): preserve denied permission context 2026-07-04 12:20:40 -04:00
Kit Langton fc20cd1e84 feat(core): add execution lifecycle and retries 2026-07-04 12:20:40 -04:00
Kit Langton c0758b0e90 fix(cli): simplify live event tracking 2026-07-04 12:20:01 -04:00
Kit Langton 7ef42ac296 refactor(schema): simplify session fragment state 2026-07-04 12:20:01 -04:00
98 changed files with 6927 additions and 4466 deletions
+5 -2
View File
@@ -163,8 +163,11 @@ export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.sessio
export type SessionShellOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
export type Endpoint4_13Input = {
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
readonly id?: Endpoint4_13Request["payload"]["id"]
}
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>["data"]
export type SessionCompactOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
@@ -221,9 +221,15 @@ const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12I
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
type Endpoint4_13Input = {
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
readonly id?: Endpoint4_13Request["payload"]["id"]
}
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
@@ -540,16 +540,17 @@ export function make(options: ClientOptions) {
requestOptions,
),
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
request<SessionCompactOutput>(
request<{ readonly data: SessionCompactOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
successStatus: 204,
declaredStatuses: [404, 409, 503, 500, 400, 401],
empty: true,
body: { id: input["id"] },
successStatus: 200,
declaredStatuses: [409, 404, 400, 401],
empty: false,
},
requestOptions,
),
).then((value) => value.data),
wait: (input: SessionWaitInput, requestOptions?: RequestOptions) =>
request<SessionWaitOutput>(
{
+211 -115
View File
@@ -74,14 +74,6 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
readonly message: string
}
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
@@ -90,6 +82,14 @@ export type ServiceUnavailableError = {
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
readonly message: string
}
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
@@ -873,9 +873,21 @@ export type SessionShellInput = {
export type SessionShellOutput = void
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionCompactInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: string | undefined }["id"]
}
export type SessionCompactOutput = void
export type SessionCompactOutput = {
readonly data: {
readonly type: "compaction"
readonly admittedSeq: number
readonly id: string
readonly sessionID: string
readonly timeCreated: number
readonly handledSeq?: number
}
}["data"]
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -1007,23 +1019,20 @@ export type SessionContextOutput = {
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 }
| {
@@ -1061,7 +1070,7 @@ export type SessionContextOutput = {
| { 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: {
@@ -1073,7 +1082,7 @@ export type SessionContextOutput = {
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: string
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -1081,10 +1090,16 @@ 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"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
@@ -1212,6 +1227,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
@@ -1321,7 +1375,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
@@ -1343,7 +1397,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 }
}
}
| {
@@ -1353,7 +1407,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
@@ -1365,7 +1419,7 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly ordinal: number
readonly text: string
}
}
@@ -1379,8 +1433,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 }
}
}
| {
@@ -1393,9 +1447,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 }
}
}
| {
@@ -1437,12 +1491,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 }
}
}
| {
@@ -1481,10 +1532,8 @@ export type SessionLogOutput =
>
readonly outputPaths?: ReadonlyArray<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 }
}
}
| {
@@ -1498,34 +1547,36 @@ 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 }
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.admitted"
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 inputID: string }
}
| {
readonly id: string
readonly created: number
@@ -1549,6 +1600,15 @@ export type SessionLogOutput =
readonly recent: string
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.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 id: string
readonly created: number
@@ -1589,7 +1649,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 }
@@ -1701,23 +1761,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 }
| {
@@ -1755,7 +1812,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: {
@@ -1767,7 +1824,7 @@ export type SessionMessageOutput = {
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: string
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -1775,10 +1832,16 @@ 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"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
@@ -1901,23 +1964,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 }
| {
@@ -1955,7 +2015,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: {
@@ -1967,7 +2027,7 @@ export type MessageListOutput = {
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: string
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -1975,10 +2035,16 @@ 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"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
@@ -4488,13 +4554,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
@@ -4605,7 +4695,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
@@ -4627,7 +4717,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 }
}
}
| {
@@ -4637,7 +4727,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
@@ -4648,7 +4738,7 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly ordinal: number
readonly delta: string
}
}
@@ -4662,7 +4752,7 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly ordinal: number
readonly text: string
}
}
@@ -4676,8 +4766,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 }
}
}
| {
@@ -4689,7 +4779,7 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly ordinal: number
readonly delta: string
}
}
@@ -4703,9 +4793,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 }
}
}
| {
@@ -4760,12 +4850,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 }
}
}
| {
@@ -4804,10 +4891,8 @@ export type EventSubscribeOutput =
>
readonly outputPaths?: ReadonlyArray<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 }
}
}
| {
@@ -4821,34 +4906,36 @@ 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 }
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.admitted"
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 inputID: string }
}
| {
readonly id: string
readonly created: number
@@ -4880,6 +4967,15 @@ export type EventSubscribeOutput =
readonly recent: string
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.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 id: string
readonly created: number
@@ -4920,7 +5016,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
+13
View File
@@ -89,6 +89,9 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
}
if (url.endsWith("/compact")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(compactionAdmission)))
}
if (url.includes("/context")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
}
@@ -218,6 +221,16 @@ const admission = {
},
}
const compactionAdmission = {
data: {
type: "compaction",
admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
},
}
const modelSwitchedMessage = {
id: "msg_model",
type: "model-switched",
+11
View File
@@ -193,6 +193,7 @@ test("session methods use the public HTTP contract", async () => {
})
}
if (url.includes("/prompt")) return Response.json(admission)
if (url.endsWith("/compact")) return Response.json(compactionAdmission)
if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active"))
@@ -317,6 +318,16 @@ const admission = {
},
}
const compactionAdmission = {
data: {
type: "compaction",
admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
},
}
const modelSwitchedMessage = {
id: "msg_model",
type: "model-switched",
+32 -4
View File
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "96e9fe64-d810-4102-8f79-3317a88bb6d2",
"id": "afa00750-fad8-473a-b877-13ff35ea3411",
"prevIds": [
"22e57fed-b9b8-4e94-a3b4-f94bece680a8"
"96e9fe64-d810-4102-8f79-3317a88bb6d2"
],
"ddl": [
{
@@ -1012,13 +1012,23 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_input"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "prompt",
"entityType": "columns",
"table": "session_input"
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -2066,6 +2076,10 @@
"value": "promoted_seq",
"isExpression": false
},
{
"value": "type",
"isExpression": false
},
{
"value": "delivery",
"isExpression": false
@@ -2078,7 +2092,21 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_input_session_pending_delivery_seq_idx",
"name": "session_input_session_pending_type_delivery_seq_idx",
"entityType": "indexes",
"table": "session_input"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": true,
"where": "\"session_input\".\"type\" = 'compaction' and \"session_input\".\"promoted_seq\" is null",
"origin": "manual",
"name": "session_input_session_pending_compaction_idx",
"entityType": "indexes",
"table": "session_input"
},
+2
View File
@@ -44,5 +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/20260704161518_durable_session_inbox"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -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
@@ -0,0 +1,43 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260704161518_durable_session_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`INSERT INTO \`__new_session_input\`(\`id\`, \`session_id\`, \`type\`, \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`) SELECT \`id\`, \`session_id\`, 'prompt', \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` FROM \`session_input\`;`,
)
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_delivery_seq_idx\`;`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
+7 -3
View File
@@ -170,8 +170,9 @@ export default {
CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`prompt\` text NOT NULL,
\`delivery\` text NOT NULL,
\`type\` text NOT NULL,
\`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
@@ -259,7 +260,10 @@ export default {
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
+22 -5
View File
@@ -59,7 +59,14 @@ export type AskResult = typeof AskResult.Type
export const Event = Permission.Event
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {
permission: Schema.String,
resources: Schema.Array(Schema.String),
}) {
override get message() {
return `Permission rejected: ${this.permission}`
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
@@ -67,7 +74,13 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
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<NotFoundError>()("PermissionV2.NotFoundError", {
requestID: ID,
@@ -117,9 +130,11 @@ const layer = Layer.effect(
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>()
const rejected = (request: Request) =>
new RejectedError({ permission: request.action, resources: [...request.resources] })
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, rejected(item.request)), {
discard: true,
}).pipe(
Effect.ensuring(
@@ -201,6 +216,8 @@ const layer = Layer.effect(
if (result.effect === "deny") {
return yield* new DeniedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
})
}
if (result.effect === "allow") return
@@ -230,7 +247,7 @@ const layer = Layer.effect(
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
input.message ? new CorrectedError({ feedback: input.message }) : rejected(existing.request),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
@@ -240,7 +257,7 @@ const layer = Layer.effect(
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.fail(item.deferred, rejected(item.request))
pending.delete(id)
}
return
+22 -13
View File
@@ -33,7 +33,6 @@ import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionCompaction } from "./session/compaction"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
@@ -94,6 +93,7 @@ type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
}
@@ -119,6 +119,13 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionConflictError>()(
"Session.CompactionConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
@@ -133,6 +140,7 @@ export type Error =
| MessageDecodeError
| OperationUnavailableError
| PromptConflictError
| CompactionConflictError
| BusyError
| SkillNotFoundError
| CommandV2.NotFoundError
@@ -220,7 +228,7 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
) => Effect.Effect<SessionInput.Compaction, NotFoundError | CompactionConflictError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
@@ -623,19 +631,20 @@ const layer = Layer.effect(
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
const session = yield* result.get(input.sessionID)
// TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions.
if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID })
const context = yield* store.context(input.sessionID)
const compacted = yield* Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
return yield* compaction.compactManual({ session, messages: context })
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* SessionInput.admitCompaction(db, events, {
id: inputID,
sessionID: input.sessionID,
}).pipe(
Effect.provide(locations.get(session.location)),
Effect.catch(() => Effect.succeed(false)),
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" })
return undefined
yield* execution.wake(input.sessionID)
return admitted
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
+18 -10
View File
@@ -18,32 +18,32 @@ const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_OUTPUT_TOKENS = 4_096
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Goal
## Continuation Goal
- [single-sentence task summary]
## Constraints & Preferences
## Operating Constraints
- [user constraints, preferences, specs, or "(none)"]
## Progress
### Done
### Completed
- [completed work or "(none)"]
### In Progress
### In Flight
- [current work or "(none)"]
### Blocked
- [blockers or "(none)"]
## Key Decisions
## Decisions To Preserve
- [decision and why, or "(none)"]
## Next Steps
## Resume From Here
- [ordered next actions or "(none)"]
## Critical Context
## Context To Preserve
- [important technical facts, errors, open questions, or "(none)"]
## Relevant Files
## Working Files
- [file or directory path: why it matters, or "(none)"]
</template>
@@ -225,7 +225,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),
@@ -262,7 +268,9 @@ const make = (dependencies: Dependencies) => {
if (context === undefined || context <= 0) return false
const selected = select(input.messages, config.tokens)
if (!selected) return false
const previousSummary = input.messages.find((message) => message.type === "compaction")
const previousSummary = input.messages.find(
(message) => message.type === "compaction" && message.status === "completed",
)
const hasHead = selected.head.length > 0
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
const forcedShortContext = input.force && !hasHead
+18
View File
@@ -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<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
@@ -10,3 +11,20 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
}
}
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
error: SessionError.Error,
}) {
override get message() {
return this.error.message
}
}
export class UserInterruptedError extends Schema.TaggedErrorClass<UserInterruptedError>()(
"Session.UserInterruptedError",
{},
) {
override get message() {
return "Session interrupted by user"
}
}
+48 -19
View File
@@ -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<void, SessionRunner.RunError>, 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<SessionSchema.ID, SessionRunner.RunError>({
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
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,
+8 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, ne, or, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
@@ -14,7 +14,13 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
return yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
+214 -44
View File
@@ -2,9 +2,10 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { KeyedMutex } from "../effect/keyed-mutex"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { Prompt } from "./prompt"
@@ -13,30 +14,77 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
export { Admitted, Delivery }
export { Admitted, Compaction, Delivery, Entry, PromptEntry }
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
Admitted.make({
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
const base = {
admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction")
return Compaction.make({
...base,
type: "compaction",
...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }),
})
if (!row.prompt || !row.delivery) throw new LifecycleConflict({ id: base.id })
return PromptEntry.make({
...base,
type: "prompt",
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
timeCreated: DateTime.makeUnsafe(row.time_created),
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
})
}
const toAdmitted = (entry: PromptEntry): Admitted =>
Admitted.make({
admittedSeq: entry.admittedSeq,
id: entry.id,
sessionID: entry.sessionID,
prompt: entry.prompt,
delivery: entry.delivery,
timeCreated: entry.timeCreated,
...(entry.promotedSeq === undefined ? {} : { promotedSeq: entry.promotedSeq }),
})
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "compaction"),
isNull(SessionInputTable.promoted_seq),
),
)
.orderBy(asc(SessionInputTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "compaction" ? entry : undefined
})
export const admit = Effect.fn("SessionInput.admit")(function* (
db: DatabaseService,
@@ -49,7 +97,10 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
},
) {
const existing = yield* find(db, input.id)
if (existing !== undefined) return existing
if (existing !== undefined) {
if (existing.type !== "prompt") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return toAdmitted(existing)
}
return yield* events
.publish(SessionEvent.PromptAdmitted, {
inputID: input.id,
@@ -73,11 +124,54 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
),
),
Effect.catchDefect((defect) =>
find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))),
find(db, input.id).pipe(
Effect.flatMap((stored) =>
stored?.type === "prompt" ? Effect.succeed(toAdmitted(stored)) : Effect.die(defect),
),
),
),
)
})
export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(function* (
db: DatabaseService,
events: EventV2.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
const pending = yield* pendingCompaction(db, input.sessionID)
if (pending) return pending
return yield* events
.publish(SessionEvent.Compaction.Admitted, {
inputID: input.id,
sessionID: input.sessionID,
})
.pipe(
Effect.flatMap((event) => {
if (event.durable === undefined)
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
return pendingCompaction(db, input.sessionID).pipe(
Effect.flatMap((stored) =>
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
),
)
}),
Effect.catchDefect((defect) =>
pendingCompaction(db, input.sessionID).pipe(
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
),
),
)
}),
)
})
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
db: DatabaseService,
input: {
@@ -101,6 +195,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
.values({
id: input.id,
session_id: input.sessionID,
type: "prompt",
admitted_seq: input.admittedSeq,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
@@ -113,6 +208,44 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "compaction",
admitted_seq: input.admittedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.returning()
.get()
.pipe(Effect.orDie)
if (stored) {
const entry = fromRow(stored)
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
}
const pending = yield* pendingCompaction(db, input.sessionID)
if (pending) return pending
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* (
db: DatabaseService,
input: {
@@ -121,6 +254,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
readonly promotedSeq: number
},
) {
if (yield* pendingCompaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const updated = yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.promotedSeq })
@@ -128,6 +262,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
and(
eq(SessionInputTable.id, input.id),
eq(SessionInputTable.session_id, input.sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq),
),
)
@@ -136,29 +271,58 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
.pipe(Effect.orDie)
if (updated) {
const stored = fromRow(updated)
if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
if (stored.type !== "prompt" || stored.sessionID !== input.sessionID)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
}
// Every PromptPromoted event is published from an admitted inbox row, so a missing or
// divergent row on replay is an invariant violation.
const stored = yield* find(db, input.id)
if (!stored || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq)
if (
!stored ||
stored.type !== "prompt" ||
stored.sessionID !== input.sessionID ||
stored.promotedSeq !== input.promotedSeq
)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
})
export const settleCompaction = Effect.fn("SessionInput.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID; readonly handledSeq: number },
) {
const updated = yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.handledSeq })
.where(
and(
eq(SessionInputTable.session_id, input.sessionID),
eq(SessionInputTable.type, "compaction"),
isNull(SessionInputTable.promoted_seq),
),
)
.returning()
.get()
.pipe(Effect.orDie)
if (updated) {
const stored = fromRow(updated)
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
}
return undefined
})
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
delivery: Delivery,
) {
if (yield* pendingCompaction(db, sessionID)) return false
const row = yield* db
.select({ id: SessionInputTable.id })
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, delivery),
),
@@ -181,42 +345,44 @@ export const equivalent = (
input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
const matchesProjection = (
input: Admitted,
expected: {
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
readonly timeCreated: DateTime.Utc
},
) =>
equivalent(input, expected) &&
DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
const publish = Effect.fn("SessionInput.publish")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
) {
for (const row of rows) {
const id = SessionMessage.ID.make(row.id)
yield* events
.publish(SessionEvent.PromptPromoted, {
sessionID,
inputID: id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? find(db, id).pipe(
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
)
: Effect.die(defect),
),
return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () {
if (yield* pendingCompaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type !== "prompt") return Effect.die(new LifecycleConflict({ id: entry.id }))
return events
.publish(SessionEvent.PromptPromoted, {
sessionID,
inputID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? find(db, entry.id).pipe(
Effect.flatMap((stored) =>
stored?.type === "prompt" && stored.promotedSeq !== undefined
? Effect.void
: Effect.die(defect),
),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
}
return rows.length
return rows.length
}),
)
})
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
@@ -224,12 +390,14 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
if (yield* pendingCompaction(db, sessionID)) return 0
const rows = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"),
),
@@ -245,12 +413,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
if (yield* pendingCompaction(db, sessionID)) return false
const row = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "queue"),
),
+127 -38
View File
@@ -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"
@@ -16,8 +16,10 @@ export interface Adapter {
readonly getShell: (
shellID: SessionMessage.Shell["shell"]["id"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
}
@@ -26,6 +28,10 @@ export function memory(state: MemoryState): Adapter {
state.messages.findLastIndex((message) => message.id === messageID)
const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const compactionIndex = () =>
state.messages.findLastIndex(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
@@ -62,6 +68,13 @@ export function memory(state: MemoryState): Adapter {
})
})
},
getCompaction() {
return Effect.sync(() => {
const index = compactionIndex()
const message = state.messages[index]
return message?.type === "compaction" ? message : undefined
})
},
updateAssistant(assistant) {
return Effect.sync(() => {
const index = assistantIndex(assistant.id)
@@ -80,6 +93,12 @@ export function memory(state: MemoryState): Adapter {
state.messages[index] = shell
})
},
updateCompaction(compaction) {
return Effect.sync(() => {
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
if (index >= 0) state.messages[index] = compaction
})
},
appendMessage(message) {
return Effect.sync(() => {
state.messages.push(message)
@@ -99,11 +118,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 +130,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 +174,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.context.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
@@ -205,10 +238,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
}),
)
@@ -244,25 +293,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
})
},
@@ -292,7 +340,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({
@@ -318,11 +367,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({
@@ -341,11 +387,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({
@@ -366,9 +409,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 },
}),
),
@@ -377,36 +419,83 @@ 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.retried": () => Effect.void,
"session.compaction.started": () => Effect.void,
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return adapter.appendMessage(
"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.compaction.admitted": (event) =>
adapter.appendMessage(
SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id),
id: event.data.inputID,
type: "compaction",
status: "queued",
metadata: event.metadata,
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
}),
)
),
"session.compaction.started": (event) =>
Effect.gen(function* () {
if (event.data.reason !== "manual") return
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "running" })
}),
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return Effect.gen(function* () {
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
if (current) {
yield* adapter.updateCompaction({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})
return
}
yield* adapter.appendMessage(
SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "completed",
metadata: event.metadata,
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
}),
)
})
},
"session.compaction.failed": () =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "failed" })
}),
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void,
+69 -8
View File
@@ -243,6 +243,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor),
copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1),
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
),
)
.orderBy(asc(SessionMessageTable.seq))
@@ -292,11 +293,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.values(
inputRows.flatMap((row) => {
const id = idMap.get(row.id)
return id
return id && row.type === "prompt"
? [
{
id,
session_id: event.data.sessionID,
type: "prompt" as const,
prompt: row.prompt,
delivery: row.delivery,
admitted_seq: row.admitted_seq,
@@ -426,8 +428,30 @@ function run(db: DatabaseService, event: MessageEvent) {
return message.type === "shell" ? message : undefined
})
},
getCompaction() {
return Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = decodeRow(row)
return message.type === "compaction" ? message : undefined
})
},
updateAssistant: updateMessage,
updateShell: updateMessage,
updateCompaction: updateMessage,
appendMessage,
}
yield* SessionMessageUpdater.update(adapter, event)
@@ -634,6 +658,23 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* events.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const admitted = yield* SessionInput.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
if (admitted.id !== event.data.inputID) return
yield* run(db, event)
}),
)
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.ContextUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) =>
@@ -660,8 +701,31 @@ 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.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
db
.update(SessionTable)
@@ -687,14 +751,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(
+1 -1
View File
@@ -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,
})
})
+28 -13
View File
@@ -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<Key, E> {
export interface Coordinator<Key, E, Reason = never> {
/** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>>
/** Starts an execution while idle, or joins the active execution and returns its exit. */
@@ -11,7 +11,7 @@ export interface Coordinator<Key, E> {
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key) => Effect.Effect<void>
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
@@ -23,11 +23,13 @@ export interface Coordinator<Key, E> {
* 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<E> = {
type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
pendingWake: boolean
stopping: boolean
settling: boolean
interruptionReason?: Reason
}
/**
@@ -41,19 +43,21 @@ type Execution<E> = {
* waiters get this exit
* ```
*/
export const make = <Key, E>(options: {
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/**
* 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<void, E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
Effect.gen(function* () {
const executions = new Map<Key, Execution<E>>()
const executions = new Map<Key, Execution<E, Reason>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> =>
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
@@ -66,15 +70,25 @@ export const make = <Key, E>(options: {
)
const start = (key: Key, force: boolean) => {
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: false }
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
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 = <Key, E>(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<E>, exit: Exit.Exit<void, E>) => {
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
@@ -112,12 +126,13 @@ export const make = <Key, E>(options: {
start(key, false)
})
const interrupt = (key: Key): Effect.Effect<void> =>
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
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)
})
+8 -2
View File
@@ -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 { SystemContext } from "../../system-context/index"
import type { ToolOutputStore } from "../../tool-output-store"
export type RunError =
LLMError | SessionRunnerModel.Error | MessageDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| StepFailedError
| UserInterruptedError
| SystemContext.InitializationBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
+202 -72
View File
@@ -10,7 +10,8 @@ import {
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, 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"
@@ -31,6 +32,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"
@@ -43,6 +45,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.
@@ -53,10 +58,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`.
@@ -65,7 +70,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.
@@ -86,7 +91,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
@@ -136,19 +141,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<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
@@ -171,6 +170,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)
@@ -184,7 +184,8 @@ const layer = Layer.effect(
loadSystemContext(agent, session.id),
session.id,
)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
let needsContinuation = false
let currentStep = step
if (promotion) {
@@ -219,7 +220,10 @@ const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined,
})
// Automatic compaction completed; rebuild the request from compacted history.
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
if (
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
)
return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
@@ -228,21 +232,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 = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
serialized(publisher.publish(event, outputPaths))
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], 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
}
@@ -250,33 +256,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())),
@@ -319,65 +341,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 questionDismissed = settled._tag === "Failure" && isQuestionRejected(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 questionDismissed = settledCauses.some(isQuestionRejected)
const permissionRejected = settledCauses.some(
(cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError,
)
if (questionDismissed || streamInterrupted || toolsInterrupted) {
if (questionDismissed || permissionRejected || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
// Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed) 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) && !isQuestionRejected(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)
// Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed || 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,
@@ -398,8 +473,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
@@ -408,12 +506,36 @@ const layer = Layer.effect(
}
})
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
// drain here.
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
) {
const pending = yield* SessionInput.pendingCompaction(db, sessionID)
if (!pending) return false
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted) && compacted.value) return true
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
return true
}),
)
})
// 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
}) {
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (!input.force && !hasSteer && !hasQueue) return
@@ -437,11 +559,19 @@ const layer = Layer.effect(
}
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
}
shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue")
promotion = shouldRun ? "queue" : undefined
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
shouldRun = hasSteer || hasQueue
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
}
})
@@ -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<string, unknown>; 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<typeof tokens> } | undefined
let retryEvidence = false
let stepFailure: SessionError.Error | undefined
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof tokens>
}
| 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<void>,
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false,
) => {
const chunks = new Map<string, string[]>()
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>()
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<string, unknown>) {
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<string> = [],
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,
+67
View File
@@ -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<RetryableFailure | SessionRunner.RunError>(),
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,
),
)
@@ -21,6 +21,11 @@ const media = (file: FileAttachment): ContentPart => ({
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = (
provider: string,
state: Record<string, unknown> | 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)
@@ -31,7 +36,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,
})
@@ -40,14 +45,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,
})
}
@@ -56,11 +61,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,
})
}
@@ -78,17 +83,22 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
{
type: "reasoning",
text: item.text,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
providerMetadata: reuseProviderMetadata ? providerMetadata(model.provider, 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.provider, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
const result = toolResult(
item,
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
reuseProviderMetadata
? providerMetadata(model.provider, item.providerResultState ?? item.providerState)
: undefined,
)
return result ? [call, result] : [call]
})
@@ -98,9 +108,14 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
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.provider, item.providerResultState ?? item.providerState)
: undefined,
),
)
.filter((message) => message !== undefined)
.map(Message.tool)
@@ -146,6 +161,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
case "assistant":
return assistant(message, model)
case "compaction":
if (message.status !== "completed") return []
return [
Message.make({
id: message.id,
+9 -3
View File
@@ -1,4 +1,5 @@
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
import { sql } from "drizzle-orm"
import { directoryColumn, pathColumn } from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
@@ -146,8 +147,9 @@ export const SessionInputTable = sqliteTable(
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>().notNull(),
type: text().$type<SessionInput.Entry["type"]>().notNull(),
prompt: text({ mode: "json" }).$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>(),
admitted_seq: integer().notNull(),
promoted_seq: integer(),
time_created: integer()
@@ -155,12 +157,16 @@ export const SessionInputTable = sqliteTable(
.$default(() => Date.now()),
},
(table) => [
index("session_input_session_pending_delivery_seq_idx").on(
index("session_input_session_pending_type_delivery_seq_idx").on(
table.session_id,
table.promoted_seq,
table.type,
table.delivery,
table.admitted_seq,
),
uniqueIndex("session_input_session_pending_compaction_idx")
.on(table.session_id)
.where(sql`${table.type} = 'compaction' and ${table.promoted_seq} is null`),
uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
],
@@ -0,0 +1,56 @@
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.DeniedError || cause instanceof PermissionV2.RejectedError)
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.UnsupportedApiError
)
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) }
}
+5 -5
View File
@@ -73,12 +73,12 @@ export const Plugin = {
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
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 = {
@@ -150,7 +150,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)
@@ -180,11 +180,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",
+2 -1
View File
@@ -111,8 +111,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 }),
),
)
+1 -1
View File
@@ -100,7 +100,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 }),
),
),
}),
+1 -1
View File
@@ -131,7 +131,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 }),
),
),
}),
+1 -1
View File
@@ -67,7 +67,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(
question
.ask({
+4 -2
View File
@@ -106,7 +106,9 @@ export const Plugin = {
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root)
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe(
@@ -130,7 +132,7 @@ export const Plugin = {
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message })
return new ToolFailure({ message, error })
}),
)
},
+34 -10
View File
@@ -12,6 +12,8 @@ import { definition, permission, registrationEntries, settle, type AnyTool, type
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
@@ -40,6 +42,7 @@ export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
readonly error?: SessionError.Error
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@@ -60,9 +63,15 @@ const registryLayer = Layer.effect(
type: "error" as const,
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
},
error: advertised
? ({ type: "tool.stale", message: `Stale tool call: ${input.call.name}` } as const)
: ({ type: "tool.unknown", message: `Unknown tool: ${input.call.name}` } as const),
}
if (advertised && registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
return {
result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` },
error: { type: "tool.stale" as const, message: `Stale tool call: ${input.call.name}` },
}
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
@@ -73,22 +82,33 @@ const registryLayer = Layer.effect(
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
}).pipe(
const pending = yield* settle(
registration.tool,
{ ...input.call, input: beforeEvent.input },
{
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
},
).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
if ("result" in pending) {
settlement = pending
} else {
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
output: pending.output,
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
result.type === "error"
@@ -115,6 +135,7 @@ 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 } : {}),
}
})
@@ -157,7 +178,10 @@ const registryLayer = Layer.effect(
settle: (input) => {
const registration = registrations.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}` },
})
},
}
}),
+10 -5
View File
@@ -247,9 +247,9 @@ export const Plugin = {
}
}
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
)
const result = yield* runtime.job
.block({ id: job.id, sessionID: context.sessionID })
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
@@ -260,14 +260,19 @@ export const Plugin = {
...(warnings.length ? { warnings } : {}),
}
}
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return {
...(yield* settleShell()),
...(warnings.length ? { warnings } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
),
}),
})
.pipe(Effect.orDie)
+6 -2
View File
@@ -104,7 +104,9 @@ export const Plugin = {
const parent = yield* runtime.session
.get(context.sessionID)
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
const agent = yield* agents.resolve(input.agent)
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
@@ -123,7 +125,9 @@ export const Plugin = {
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
const background = input.background === true
+1 -1
View File
@@ -46,7 +46,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 }))),
}),
})
.pipe(Effect.orDie)
+1 -1
View File
@@ -170,7 +170,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 }))),
}),
})
.pipe(Effect.orDie)
+5 -1
View File
@@ -243,7 +243,11 @@ export const Plugin = {
provider,
text: text ?? NO_RESULTS,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
)
},
}),
})
+1 -1
View File
@@ -83,7 +83,7 @@ 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",
),
+60 -3
View File
@@ -15,6 +15,8 @@ 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 durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260704161518_durable_session_inbox"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@@ -38,6 +40,29 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
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")
@@ -83,13 +108,14 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
),
).toEqual([
{ name: "event_aggregate_seq_idx" },
{ name: "event_aggregate_type_seq_idx" },
{ name: "session_input_session_admitted_seq_idx" },
{ name: "session_input_session_pending_delivery_seq_idx" },
{ name: "session_input_session_pending_compaction_idx" },
{ name: "session_input_session_pending_type_delivery_seq_idx" },
{ name: "session_input_session_promoted_seq_idx" },
{ name: "session_message_session_seq_idx" },
{ name: "session_message_session_time_created_id_idx" },
@@ -258,7 +284,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`,
sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`,
)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
@@ -319,6 +345,37 @@ describe("DatabaseMigration", () => {
)
})
test("preserves admitted prompts while generalizing the durable inbox", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
)
yield* DatabaseMigration.applyOnly(db, [durableSessionInboxMigration])
expect(
yield* db.all(
sql`SELECT id, type, prompt, delivery, admitted_seq, promoted_seq FROM session_input ORDER BY admitted_seq`,
),
).toEqual([
{
id: "input",
type: "prompt",
prompt: '{"text":"hello"}',
delivery: "steer",
admitted_seq: 4,
promoted_seq: null,
},
])
}),
)
})
test("resets incompatible projected Session messages before adding sequence order", async () => {
await run(
Effect.gen(function* () {
+16 -7
View File
@@ -74,7 +74,7 @@ const it = testEffect(
)
describe("SessionV2.compact", () => {
it.effect("manually compacts the active session context", () =>
it.effect("durably admits and coalesces manual compaction", () =>
Effect.gen(function* () {
requests = []
const session = yield* SessionV2.Service
@@ -94,13 +94,22 @@ describe("SessionV2.compact", () => {
inputID: messageID,
})
yield* session.compact({ sessionID: created.id })
expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.CompactionConflictError",
inputID: messageID,
})
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("Please compact this session history.")
expect(yield* session.context(created.id)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual session summary", recent: "" },
])
expect(second.id).toBe(first.id)
expect(requests).toHaveLength(0)
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
})
}),
)
})
+33 -1
View File
@@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
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"
@@ -68,11 +68,38 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64)
})
test("compaction prompt requires the continuation checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
"## Continuation Goal",
"## Operating Constraints",
"## Progress",
"### Completed",
"### In Flight",
"### Blocked",
"## Decisions To Preserve",
"## Resume From Here",
"## Context To Preserve",
"## Working Files",
])
expect(prompt).toContain("single-sentence task summary")
expect(prompt).toContain("user constraints, preferences, specs")
expect(prompt).toContain("completed work")
expect(prompt).toContain("current work")
expect(prompt).toContain("blockers")
expect(prompt).toContain("decision and why")
expect(prompt).toContain("ordered next actions")
expect(prompt).toContain("important technical facts, errors, open questions")
expect(prompt).toContain("file or directory path: why it matters")
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 = {
@@ -108,7 +135,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.")
+88
View File
@@ -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 rejected = new PermissionV2.RejectedError({ permission: "external_directory", resources: [] })
expect(toSessionError(rejected)).toEqual({
type: "permission.rejected",
message: "Permission rejected: external_directory",
})
expect(toSessionError(new ToolFailure({ message: rejected.message, error: rejected }))).toEqual({
type: "permission.rejected",
message: "Permission rejected: 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])
})
})
@@ -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" })
})
})
+70 -3
View File
@@ -96,7 +96,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),
@@ -432,6 +432,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
@@ -525,7 +592,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
@@ -544,7 +611,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({
+3 -1
View File
@@ -233,7 +233,9 @@ describe("SessionV2.prompt", () => {
expect(message.prompt.files).toEqual([
{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
const stored = yield* admitted(message.id)
expect(stored?.type).toBe("prompt")
if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files)
}),
)
@@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => {
Effect.gen(function* () {
const failure = new Error("failed")
const defect = new Error("defect")
const settled: Exit.Exit<void, Error>[] = []
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<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
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<void>()
const release = yield* Deferred.make<void>()
const reasons: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
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<void>()
const interrupted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
const reasons: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
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<void>()
const secondStarted = yield* Deferred.make<void>()
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<void>()
const idle = yield* Deferred.make<void>()
let drains = 0
let starts = 0
const settled: Exit.Exit<void, never>[] = []
const coordinator = yield* SessionRunCoordinator.make<string, never>({
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)
}),
@@ -28,17 +28,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" },
}),
]),
],
@@ -104,8 +101,9 @@ describe("toLLMMessages", () => {
}),
SessionMessage.Compaction.make({
id: id("compaction"),
type: "compaction",
reason: "auto",
type: "compaction",
status: "completed",
reason: "auto",
summary: "Earlier work",
recent: "Recent work",
time: { created },
@@ -158,12 +156,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",
@@ -208,11 +205,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" },
@@ -225,7 +220,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" },
@@ -245,7 +241,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" } },
{
@@ -260,14 +256,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" },
},
{
@@ -276,14 +272,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: {} },
@@ -317,9 +313,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 },
@@ -332,7 +327,7 @@ Recent work
{
type: "reasoning",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
})
@@ -348,19 +343,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" },
@@ -420,19 +412,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" },
@@ -446,11 +435,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" },
@@ -47,6 +47,7 @@ const capture = () => {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
},
provider: "openai",
}),
}
}
@@ -90,7 +91,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 })))
@@ -98,6 +99,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))
@@ -114,7 +128,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(),
@@ -122,7 +136,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" })
})
@@ -135,3 +149,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" },
})
})
+704 -77
View File
@@ -4,8 +4,10 @@ import {
LLMError,
LLMEvent,
Model,
ToolFailure,
TransportReason,
InvalidRequestReason,
RateLimitReason,
type LLMClientShape,
type LLMRequest,
} from "@opencode-ai/llm"
@@ -26,6 +28,7 @@ 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 { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionInput } from "@opencode-ai/core/session/input"
@@ -61,7 +64,8 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
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 { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"
import { TestClock } from "effect/testing"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -153,7 +157,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.
@@ -368,6 +375,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
@@ -405,6 +426,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
@@ -455,7 +504,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,
@@ -475,7 +524,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,
@@ -510,6 +559,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},`)
@@ -542,6 +592,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"])
@@ -555,10 +606,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) =>
@@ -583,7 +635,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" } }
@@ -1264,6 +1316,97 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs one durable compaction barrier before later steer and queued prompts", () =>
Effect.gen(function* () {
yield* setup
requests.length = 0
currentModel = recoveryModel
const session = yield* SessionV2.Service
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
responses = [
fragmentFixture("text", "text-active", ["Active complete"]).completeEvents,
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
fragmentFixture("text", "text-steer", ["Steer complete"]).completeEvents,
fragmentFixture("text", "text-queue", ["Queue complete"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Active work" }), resume: false })
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
const first = yield* session.compact({ sessionID })
const second = yield* session.compact({ sessionID })
expect(second.id).toBe(first.id)
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({
id: first.id,
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "queued",
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer after compaction" }), resume: false })
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Queue after compaction" }),
delivery: "queue",
resume: false,
})
expect(yield* SessionInput.hasPending((yield* Database.Service).db, sessionID, "steer")).toBe(false)
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(active)
expect(requests).toHaveLength(4)
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])).toContain("Steer after compaction")
expect(userTexts(requests[3])).toContain("Queue after compaction")
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "completed",
summary: "durable summary",
})
}),
)
it.effect("releases queued prompts when durable compaction fails", () =>
Effect.gen(function* () {
yield* setup
requests.length = 0
currentModel = recoveryModel
const session = yield* SessionV2.Service
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
responses = [
fragmentFixture("text", "text-active-failure", ["Active complete"]).completeEvents,
[],
fragmentFixture("text", "text-after-failure", ["Continued"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Active work" }), resume: false })
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
const compaction = yield* session.compact({ sessionID })
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Continue after failure" }),
delivery: "queue",
resume: false,
})
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(active)
expect(requests).toHaveLength(3)
expect(userTexts(requests[2])).toContain("Continue after failure")
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
})
}),
)
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
Effect.gen(function* () {
yield* setup
@@ -1279,7 +1422,7 @@ describe("SessionRunnerLLM", () => {
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Goal\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Continuation Goal\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({
@@ -1290,22 +1433,22 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain("## Goal")
expect(userTexts(requests[0])[0]).toContain("## Continuation Goal")
expect(userTexts(requests[1])).toHaveLength(1)
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Goal\n- Preserve the task\n</summary>")
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Continuation Goal\n- Preserve the task\n</summary>")
expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
const context = yield* (yield* SessionStore.Service).context(sessionID)
expect(context.map((message) => message.type)).toEqual(["compaction", "assistant"])
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Goal\n- Preserve the task",
summary: "## Continuation Goal\n- Preserve the task",
})
requests.length = 0
executions.length = 0
responses = [
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-summary-2", ["## Continuation Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
]
yield* session.prompt({
@@ -1317,12 +1460,12 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
"<previous-summary>\n## Goal\n- Preserve the task\n</previous-summary>",
"<previous-summary>\n## Continuation Goal\n- Preserve the task\n</previous-summary>",
)
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
type: "compaction",
summary: "## Goal\n- Preserve the updated task",
summary: "## Continuation Goal\n- Preserve the updated task",
})
}),
)
@@ -1335,17 +1478,17 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
],
fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Continuation Goal\n- Recover overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("## Goal")
expect(userTexts(requests[2])[0]).toContain("<summary>\n## Goal\n- Recover overflow\n</summary>")
expect(userTexts(requests[1])[0]).toContain("## Continuation Goal")
expect(userTexts(requests[2])[0]).toContain("<summary>\n## Continuation Goal\n- Recover overflow\n</summary>")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Goal\n- Recover overflow" },
{ type: "compaction", summary: "## Continuation Goal\n- Recover overflow" },
{ type: "assistant", finish: "stop" },
])
yield* replaySessionProjection(sessionID)
@@ -1365,11 +1508,11 @@ describe("SessionRunnerLLM", () => {
]
responses = [
overflow(),
fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Continuation Goal\n- Recover once"]).completeEvents,
overflow(),
]
yield* session.prompt({ sessionID, prompt: 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([
@@ -1393,7 +1536,7 @@ describe("SessionRunnerLLM", () => {
}),
)
responses = [
fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Continuation Goal\n- Recover raw overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
@@ -1401,7 +1544,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Goal\n- Recover raw overflow" },
{ type: "compaction", summary: "## Continuation Goal\n- Recover raw overflow" },
{ type: "assistant", finish: "stop" },
])
}),
@@ -1415,7 +1558,7 @@ describe("SessionRunnerLLM", () => {
[LLMEvent.providerError({ message: "summary unavailable" })],
]
yield* session.prompt({ sessionID, prompt: 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)
@@ -1432,7 +1575,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setupOverflowRecovery
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
fragmentFixture("text", "text-summary", ["## Goal\n- Interrupted"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Continuation Goal\n- Interrupted"]).completeEvents,
]
const firstGate = yield* Deferred.make<void>()
const summaryGate = yield* Deferred.make<void>()
@@ -1552,7 +1695,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",
@@ -1560,14 +1703,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" },
@@ -1618,7 +1763,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",
@@ -1637,7 +1783,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",
])
}),
)
@@ -1696,15 +1849,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" }),
@@ -1717,11 +1879,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" },
},
],
},
@@ -1732,11 +1894,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" } },
},
])
}),
@@ -1756,14 +1918,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" }),
@@ -1783,7 +1945,7 @@ describe("SessionRunnerLLM", () => {
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "hosted-search" } },
providerMetadata: { fake: { itemId: "hosted-search" } },
},
{
type: "tool-result",
@@ -1791,7 +1953,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" } },
},
])
}),
@@ -1980,7 +2142,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" }] },
])
}),
)
@@ -2352,7 +2514,7 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
responses = undefined
response = []
streamFailure = providerUnavailable()
streamFailure = invalidRequest()
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
@@ -2402,9 +2564,8 @@ describe("SessionRunnerLLM", () => {
sessionID,
assistantMessageID,
callID: "call-interrupted",
tool: "echo",
input: { text: "stale" },
provider: { executed: false },
executed: false,
})
requests.length = 0
response = []
@@ -2420,7 +2581,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" },
},
},
],
},
@@ -2462,9 +2626,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 = []
@@ -2477,7 +2641,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" } },
])
@@ -2663,7 +2827,7 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
responses = undefined
response = []
streamFailure = providerUnavailable()
streamFailure = invalidRequest()
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
@@ -2726,7 +2890,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" }] },
])
}),
)
@@ -2759,7 +2923,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",
@@ -2769,13 +2934,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",
])
}),
)
@@ -2812,13 +2984,76 @@ 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 rejected: edit",
error: new PermissionV2.RejectedError({ permission: "edit", resources: ["src/index.ts"] }),
}),
}),
})
yield* session.prompt({ sessionID, prompt: 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 rejected: edit",
},
content: [
{
type: "tool",
id: "call-permission",
state: {
status: "error",
error: {
type: "permission.rejected",
message: "Permission rejected: edit",
},
},
},
],
},
])
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1")
}),
)
@@ -2860,7 +3095,8 @@ describe("SessionRunnerLLM", () => {
const exit = yield* Fiber.join(run)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
if (exit._tag === "Failure")
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBeInstanceOf(UserInterruptedError)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Ask then stop" },
@@ -2870,7 +3106,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" } },
},
],
},
@@ -2900,7 +3136,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",
@@ -2909,6 +3146,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",
])
}),
)
@@ -2934,7 +3178,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",
@@ -2942,11 +3187,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)
@@ -2983,7 +3235,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)
@@ -3018,12 +3270,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" } },
},
],
},
@@ -3146,12 +3398,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" } },
])
}),
)
@@ -3165,16 +3417,79 @@ 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: 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: Prompt.make({ text: "Tool before blocked response" }), resume: false })
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
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
@@ -3189,7 +3504,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([
@@ -3209,18 +3524,146 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false })
const failure = providerUnavailable()
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: 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: 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: 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: 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: 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
@@ -3233,16 +3676,32 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
const executionCount = executions.length
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
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",
])
}),
)
@@ -3264,16 +3723,50 @@ 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: 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" })
}),
)
@@ -3292,16 +3785,113 @@ 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: 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: Prompt.make({ text: "Fail unresolved tools" }), resume: false })
const failure = invalidRequest()
const providerFailed = yield* Deferred.make<void>()
toolExecutionGate = yield* Deferred.make<void>()
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
@@ -3311,6 +3901,7 @@ describe("SessionRunnerLLM", () => {
prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }),
resume: false,
})
requests.length = 0
const failure = providerUnavailable()
responseStream = Stream.concat(
Stream.fromIterable([
@@ -3326,20 +3917,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
@@ -3352,9 +3955,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: 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" }),
@@ -3367,8 +3992,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" },
],
},
])
@@ -3418,6 +4043,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)
@@ -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: {
+3 -2
View File
@@ -107,6 +107,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],
@@ -147,7 +148,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],
@@ -197,7 +198,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)
+9 -1
View File
@@ -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.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
+9 -1
View File
@@ -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.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
+18 -2
View File
@@ -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.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
@@ -72,7 +82,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
}),
+13 -1
View File
@@ -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.DeniedError({ rules: [] })))),
}).pipe(
Effect.andThen(
allow
? Effect.void
: Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
),
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
+11 -4
View File
@@ -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.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction
? Effect.fail(
new PermissionV2.DeniedError({
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, {
+11 -1
View File
@@ -55,7 +55,17 @@ describe("SkillTool", () => {
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
+2 -3
View File
@@ -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, {
+11 -1
View File
@@ -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.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
+9 -1
View File
@@ -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.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
@@ -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" ||
+2 -2
View File
@@ -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<typeof TextVerbosity>
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"])
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -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(
@@ -165,8 +165,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
@@ -190,11 +190,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,
@@ -208,18 +209,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
@@ -257,10 +259,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
}
@@ -287,7 +289,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 },
@@ -314,7 +316,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 },
@@ -349,16 +351,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
}
}
@@ -38,54 +38,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 },
},
}
}
@@ -93,15 +99,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 },
},
}
}
@@ -141,6 +148,7 @@ type ToolTrack = {
name: string
input: Record<string, unknown>
started: number
providerState?: Record<string, unknown>
}
type ChildState = {
@@ -225,6 +233,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const pendingEvents = new Map<string, V2Event[]>()
const hydrations = new Map<string, Promise<void>>()
let selected: string | undefined
const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}`
const ensureChild = (sessionID: string): ChildState => {
const existing = children.get(sessionID)
@@ -304,11 +313,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)
@@ -337,31 +342,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
}
@@ -438,74 +449,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
@@ -517,17 +542,19 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (event.type === "session.tool.called") {
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,
{
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 },
},
@@ -547,7 +574,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",
@@ -577,6 +606,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",
@@ -589,9 +619,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()
}
@@ -613,8 +657,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") {
@@ -5,7 +5,6 @@ import type {
QuestionRequest,
QuestionV2Request,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantTool,
V2Event,
} from "@opencode-ai/sdk/v2"
@@ -95,6 +94,7 @@ type ToolState = {
input: Record<string, unknown>
started: number
running: boolean
providerState?: Record<string, unknown>
}
type State = {
@@ -352,11 +352,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const part = legacyTool({
sessionID: input.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
if (item.state.status === "running") {
@@ -367,6 +363,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input: item.state.input,
started: item.time.ran ?? item.time.created,
running: true,
providerState: item.providerState,
})
write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` })
return
@@ -443,9 +440,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (message.type !== "assistant") return
state.messageIDs.add(message.id)
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
if (item.type === "text") {
const key = streamPartKey(message.id, item.id)
const id = `text:${textOrdinal++}`
const key = streamPartKey(message.id, id)
const sent = state.text.get(key)?.length ?? 0
state.text.set(key, item.text)
if (render) state.projectedText.set(key, item.text)
@@ -457,13 +457,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: item.id,
partID: id,
},
])
continue
}
if (item.type === "reasoning") {
const key = streamPartKey(message.id, item.id)
const id = `reasoning:${reasoningOrdinal++}`
const key = streamPartKey(message.id, id)
const sent = state.reasoning.get(key)?.length ?? 0
state.reasoning.set(key, item.text)
if (render) state.projectedReasoning.set(key, item.text)
@@ -475,7 +476,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: item.id,
partID: id,
},
])
continue
@@ -596,8 +597,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (owned) wait.resolve()
return
}
if (event.type === "session.text.started") {
return
}
if (event.type === "session.text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const id = `text:${event.data.ordinal}`
const key = streamPartKey(event.data.assistantMessageID, id)
const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -613,13 +618,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: event.data.delta,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
partID: id,
},
])
return
}
if (event.type === "session.text.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const id = `text:${event.data.ordinal}`
const key = streamPartKey(event.data.assistantMessageID, id)
const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length)
write([
@@ -629,15 +635,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: event.data.text.slice(previous.length),
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
partID: id,
},
])
state.text.set(key, event.data.text)
state.projectedText.delete(key)
return
}
if (event.type === "session.reasoning.started") {
return
}
if (event.type === "session.reasoning.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const id = `reasoning:${event.data.ordinal}`
const key = streamPartKey(event.data.assistantMessageID, id)
const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -654,13 +664,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
partID: id,
},
])
return
}
if (event.type === "session.reasoning.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const id = `reasoning:${event.data.ordinal}`
const key = streamPartKey(event.data.assistantMessageID, id)
const previous = state.reasoning.get(key) ?? ""
if (input.thinking && event.data.text.length > previous.length)
write([
@@ -670,7 +681,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
partID: id,
},
])
state.reasoning.set(key, event.data.text)
@@ -693,8 +704,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const item: SessionMessageAssistantTool = {
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 },
}
@@ -709,7 +721,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
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",
@@ -761,7 +775,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.data.tokens.cache.write
const usage = total > 0 ? total.toLocaleString() : ""
write([], {
phase: event.data.finish === "tool-calls" ? "running" : "idle",
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
})
return
@@ -772,21 +785,33 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
return
}
if (event.type === "session.execution.settled") {
if (event.type === "session.execution.started") {
write([], { phase: "running" })
return
}
if (
event.type === "session.execution.succeeded" ||
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted"
) {
write([], { phase: "idle", status: "" })
const current = state.wait
if (!current || (!current.promoted && !current.interrupted)) return
state.wait = undefined
if (current.interrupted) {
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
current.resolve()
return
}
if (event.data.outcome === "failure") {
if (event.type === "session.execution.failed") {
if (current.failureRendered) {
current.resolve()
return
}
current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed"))
current.reject(new Error(errorMessage(event.data.error)))
return
}
if (event.type === "session.execution.interrupted") {
current.reject(new Error(`Session interrupted: ${event.data.reason}`))
return
}
current.resolve()
@@ -32,11 +32,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" },
}
}
@@ -13,6 +13,19 @@ function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thin
})
}
function shell(status: "running" | "exited" = "running") {
return {
id: "sh_1",
status,
command: "pwd",
cwd: "/tmp",
shell: "/bin/sh",
file: "/tmp/shell-output",
metadata: {},
time: { started: 1, completed: status === "exited" ? 2 : undefined },
}
}
function assistant(id: string, extra: Record<string, unknown> = {}) {
return {
type: "message.updated",
@@ -332,9 +345,7 @@ describe("run session data", () => {
type: "session.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
shell: shell(),
},
})
@@ -342,10 +353,10 @@ describe("run session data", () => {
expect.objectContaining({
kind: "tool",
phase: "start",
partID: "shell:call-1",
partID: "shell:sh_1",
tool: "bash",
shell: {
callID: "call-1",
callID: "sh_1",
command: "pwd",
},
}),
@@ -356,9 +367,8 @@ describe("run session data", () => {
type: "session.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
shell: shell("exited"),
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
},
})
@@ -366,12 +376,12 @@ describe("run session data", () => {
expect.objectContaining({
kind: "tool",
phase: "progress",
partID: "shell:call-1",
partID: "shell:sh_1",
tool: "bash",
text: "/tmp/demo\n",
toolState: "completed",
shell: {
callID: "call-1",
callID: "sh_1",
command: "pwd",
},
}),
@@ -383,9 +393,7 @@ describe("run session data", () => {
type: "session.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
shell: shell(),
},
}).data
@@ -395,7 +403,7 @@ describe("run session data", () => {
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
callID: "sh_1",
tool: "bash",
state: {
status: "running",
@@ -412,9 +420,8 @@ describe("run session data", () => {
type: "session.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
shell: shell("exited"),
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
},
}).data
@@ -424,7 +431,7 @@ describe("run session data", () => {
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
callID: "sh_1",
tool: "bash",
state: {
status: "completed",
@@ -449,7 +456,7 @@ describe("run session data", () => {
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
callID: "sh_1",
tool: "bash",
state: {
status: "running",
@@ -466,9 +473,7 @@ describe("run session data", () => {
type: "session.shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
callID: "call-1",
command: "pwd",
shell: shell(),
},
}).commits,
).toEqual([])
@@ -478,7 +483,7 @@ describe("run session data", () => {
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
callID: "sh_1",
tool: "bash",
state: {
status: "completed",
@@ -500,9 +505,8 @@ describe("run session data", () => {
type: "session.shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
shell: shell("exited"),
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
},
}).commits,
).toEqual([])
@@ -214,15 +214,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
@@ -269,8 +270,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({
@@ -363,8 +365,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({
@@ -460,8 +463,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({
@@ -709,7 +713,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 },
},
],
@@ -721,6 +725,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,
@@ -728,7 +743,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
textID: "txt_1",
ordinal: 0,
delta: "answer",
},
})
@@ -741,7 +756,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] })
@@ -754,8 +769,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 },
},
@@ -765,8 +780,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 },
},
@@ -814,7 +829,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
reasoningID: "reasoning_1",
ordinal: 0,
text: "considering",
},
})
@@ -824,6 +839,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())
@@ -865,8 +926,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
@@ -931,8 +993,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
@@ -993,8 +1056,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
@@ -1219,8 +1283,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({
@@ -1296,8 +1361,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
@@ -1366,8 +1432,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)
@@ -1387,8 +1454,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
@@ -1509,7 +1577,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
textID: "txt_child",
ordinal: 0,
delta: "child answer",
},
})
@@ -1519,8 +1587,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()
@@ -1576,8 +1645,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?.()
@@ -1633,6 +1703,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,
@@ -1642,9 +1724,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({
@@ -1658,15 +1739,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)
@@ -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", () => {
+4 -3
View File
@@ -372,15 +372,16 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, SessionBusyError, ServiceUnavailableError, UnknownError],
payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional) }),
success: Schema.Struct({ data: SessionInput.Compaction }),
error: [ConflictError, SessionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
summary: "Compact session",
description: "Compact a session conversation.",
description: "Queue a durable session compaction request.",
}),
),
)
+1
View File
@@ -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"
+3
View File
@@ -8,6 +8,9 @@ export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schem
})
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
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<typeof ToolTextContent> {}
export const ToolTextContent = Schema.Struct({
type: Schema.Literal("text"),
+9
View File
@@ -0,0 +1,9 @@
export * as SessionError from "./session-error.js"
import { Schema } from "effect"
export interface Error extends Schema.Schema.Type<typeof Error> {}
export const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
}).annotate({ identifier: "Session.StructuredError" })
+76 -67
View File
@@ -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 ContextUpdated = Event.durable({
type: "session.context.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,41 +403,39 @@ 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<typeof RetryError> {}
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 Admitted = Event.durable({
type: "session.compaction.admitted",
...options,
schema: {
...Base,
inputID: SessionMessage.ID,
},
})
export type Admitted = typeof Admitted.Type
export const Started = Event.durable({
type: "session.compaction.started",
...options,
@@ -469,6 +466,13 @@ export namespace Compaction {
},
})
export type Ended = typeof Ended.Type
export const Failed = Event.durable({
type: "session.compaction.failed",
...options,
schema: Base,
})
export type Failed = typeof Failed.Type
}
export namespace RevertEvent {
@@ -481,7 +485,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 +497,10 @@ export const Definitions = Event.inventory(
Forked,
PromptPromoted,
PromptAdmitted,
ExecutionSettled,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
Execution.Interrupted,
ContextUpdated,
Synthetic,
Skill.Activated,
@@ -515,10 +522,12 @@ export const Definitions = Event.inventory(
Tool.Progress,
Tool.Success,
Tool.Failed,
Retried,
RetryScheduled,
Compaction.Admitted,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
Compaction.Failed,
RevertEvent.Staged,
RevertEvent.Cleared,
RevertEvent.Committed,
+19
View File
@@ -21,3 +21,22 @@ export const Admitted = Schema.Struct({
timeCreated: DateTimeUtcFromMillis,
promotedSeq: NonNegativeInt.pipe(optional),
}).annotate({ identifier: "SessionInput.Admitted" })
export interface PromptEntry extends Schema.Schema.Type<typeof PromptEntry> {}
export const PromptEntry = Schema.Struct({
type: Schema.Literal("prompt"),
...Admitted.fields,
}).annotate({ identifier: "SessionInput.PromptEntry" })
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
export const Compaction = Schema.Struct({
type: Schema.Literal("compaction"),
admittedSeq: NonNegativeInt,
id: SessionMessage.ID,
sessionID: SessionID,
timeCreated: DateTimeUtcFromMillis,
handledSeq: NonNegativeInt.pipe(optional),
}).annotate({ identifier: "SessionInput.Compaction" })
export const Entry = Schema.Union([PromptEntry, Compaction]).pipe(Schema.toTaggedUnion("type"))
export type Entry = typeof Entry.Type
+25 -19
View File
@@ -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<typeof UnknownError> {}
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<typeof AgentSelected> {}
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<typeof AssistantText> {}
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<typeof AssistantReasoning> {}
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<typeof AssistantRetry> {}
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<typeof Assistant> {}
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),
@@ -205,6 +210,7 @@ export const Assistant = Schema.Struct({
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
export const Compaction = Schema.Struct({
type: Schema.Literal("compaction"),
status: Schema.Literals(["queued", "running", "completed", "failed"]),
reason: Schema.Literals(["auto", "manual"]),
summary: Schema.String,
recent: Schema.String,
+23 -1
View File
@@ -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"
@@ -7,6 +7,7 @@ import { Project } from "../src/project.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"
@@ -67,4 +68,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")
})
})
+38 -1
View File
@@ -16,6 +16,8 @@ import { FileSystemV1 } from "../src/filesystem-v1.js"
import { IdeEvent } from "../src/ide-event.js"
import { McpEvent } from "../src/mcp-event.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"
@@ -99,6 +101,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.context.updated.1",
"session.synthetic.1",
"session.skill.activated.1",
@@ -117,9 +123,11 @@ 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.admitted.1",
"session.compaction.started.1",
"session.compaction.ended.1",
"session.compaction.failed.1",
"session.revert.staged.1",
"session.revert.cleared.1",
"session.revert.committed.1",
@@ -130,4 +138,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)
})
})
@@ -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()
})
+147 -5
View File
@@ -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|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|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|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|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|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|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|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|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<string, string>()
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,122 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum
source.paths = rewriteRefs(source.paths, renames) as Record<string, unknown> | undefined
}
function normalizeComponentNames(document: OpenApiDocument) {
let schemas = document.components?.schemas
if (!schemas) return
for (const name of Object.keys(schemas)) {
if (!Object.hasOwn(schemas, name)) continue
const next = componentTypeName(name)
if (next === name) continue
if (schemas[next] !== undefined) {
if (JSON.stringify(normalizeSchema(schemas[name])) !== JSON.stringify(normalizeSchema(schemas[next]))) continue
const renames = new Map([[name, next]])
schemas = rewriteRefs(schemas, renames) as Record<string, unknown>
delete schemas[name]
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
continue
}
const renames = new Map([[name, next]])
schemas = rewriteRefs(schemas, renames) as Record<string, unknown>
schemas[next] = schemas[name]
delete schemas[name]
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
}
document.components = { ...document.components, schemas }
}
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<string, unknown>
delete rewritten[duplicate]
document.components = { ...document.components, schemas: rewritten }
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | 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))
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<string, string>): unknown {
if (Array.isArray(value)) return value.map((item) => rewriteRefs(item, renames))
if (typeof value !== "object" || value === null) return value
+47 -31
View File
@@ -76,8 +76,8 @@ import type {
FindTextResponses,
FormatterStatusErrors,
FormatterStatusResponses,
FormCreatePayload2,
FormReply2,
FormCreatePayloadV2,
FormReply,
GlobalConfigGetErrors,
GlobalConfigGetResponses,
GlobalConfigUpdateErrors,
@@ -92,7 +92,7 @@ import type {
GlobalUpgradeResponses,
InstanceDisposeErrors,
InstanceDisposeResponses,
LocationRef2,
LocationRefV2,
LspStatusErrors,
LspStatusResponses,
McpAddErrors,
@@ -113,7 +113,7 @@ import type {
McpRemoteConfig,
McpStatusErrors,
McpStatusResponses,
ModelRef2,
ModelRef,
MoveSessionDestination,
OutputFormat,
Part as Part2,
@@ -130,8 +130,8 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
PermissionV2Reply2,
PermissionV2Source2,
PermissionV2Reply,
PermissionV2SourceV2,
ProjectCommands,
ProjectCurrentErrors,
ProjectCurrentResponses,
@@ -144,9 +144,9 @@ import type {
ProjectListResponses,
ProjectUpdateErrors,
ProjectUpdateResponses,
PromptAgentAttachment2,
PromptInputFileAttachment2,
PromptInputV2,
PromptAgentAttachment,
PromptInput,
PromptInputFileAttachment,
ProviderAuthErrors,
ProviderAuthResponses,
ProviderListErrors,
@@ -178,14 +178,14 @@ import type {
QuestionRejectResponses,
QuestionReplyErrors,
QuestionReplyResponses,
QuestionV2Reply2,
QuestionV2Reply,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
SessionChildrenResponses,
SessionCommandErrors,
SessionCommandResponses,
SessionContextEntryKey2,
SessionContextEntryKeyV2,
SessionCreateErrors,
SessionCreateResponses,
SessionDeleteErrors,
@@ -458,7 +458,7 @@ import type {
VcsDiffResponses,
VcsGetErrors,
VcsGetResponses,
VcsMode2,
VcsMode,
VcsStatusErrors,
VcsStatusResponses,
WorktreeCreateErrors,
@@ -5290,7 +5290,7 @@ export class Entry extends HeyApiClient {
public remove<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
key: SessionContextEntryKey2
key: SessionContextEntryKeyV2
},
options?: Options<never, ThrowOnError>,
) {
@@ -5324,7 +5324,7 @@ export class Entry extends HeyApiClient {
public put<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
key: SessionContextEntryKey2
key: SessionContextEntryKeyV2
value?: unknown
},
options?: Options<never, ThrowOnError>,
@@ -5393,7 +5393,7 @@ export class Form extends HeyApiClient {
public create<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
formCreatePayload: FormCreatePayload2
formCreatePayloadV2: FormCreatePayloadV2
},
options?: Options<never, ThrowOnError>,
) {
@@ -5403,7 +5403,7 @@ export class Form extends HeyApiClient {
{
args: [
{ in: "path", key: "sessionID" },
{ key: "formCreatePayload", map: "body" },
{ key: "formCreatePayloadV2", map: "body" },
],
},
],
@@ -5491,7 +5491,7 @@ export class Form extends HeyApiClient {
parameters: {
sessionID: string
formID: string
formReply: FormReply2
formReply: FormReply
},
options?: Options<never, ThrowOnError>,
) {
@@ -5591,7 +5591,7 @@ export class Permission2 extends HeyApiClient {
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source2
source?: PermissionV2SourceV2
agent?: string | null
},
options?: Options<never, ThrowOnError>,
@@ -5672,7 +5672,7 @@ export class Permission2 extends HeyApiClient {
parameters: {
sessionID: string
requestID: string
reply?: PermissionV2Reply2
reply?: PermissionV2Reply
message?: string | null
},
options?: Options<never, ThrowOnError>,
@@ -5740,7 +5740,7 @@ export class Question2 extends HeyApiClient {
parameters: {
sessionID: string
requestID: string
questionV2Reply: QuestionV2Reply2
questionV2Reply: QuestionV2Reply
},
options?: Options<never, ThrowOnError>,
) {
@@ -5861,8 +5861,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<never, ThrowOnError>,
) {
@@ -6004,7 +6004,7 @@ export class Session3 extends HeyApiClient {
public switchModel<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
model?: ModelRef2
model?: ModelRef
},
options?: Options<never, ThrowOnError>,
) {
@@ -6079,7 +6079,7 @@ export class Session3 extends HeyApiClient {
parameters: {
sessionID: string
id?: string | null
prompt?: PromptInputV2
prompt?: PromptInput
delivery?: "steer" | "queue" | null
resume?: boolean | null
},
@@ -6123,9 +6123,9 @@ export class Session3 extends HeyApiClient {
command?: string
arguments?: string | null
agent?: string | null
model?: ModelRef2 | null
files?: Array<PromptInputFileAttachment2>
agents?: Array<PromptAgentAttachment2>
model?: ModelRef | null
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
delivery?: "steer" | "queue" | null
resume?: boolean | null
},
@@ -6282,19 +6282,35 @@ export class Session3 extends HeyApiClient {
/**
* Compact session
*
* Compact a session conversation.
* Queue a durable session compaction request.
*/
public compact<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
id?: string | null
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "body", key: "id" },
],
},
],
)
return (options?.client ?? this.client).post<V2SessionCompactResponses, V2SessionCompactErrors, ThrowOnError>({
url: "/api/session/{sessionID}/compact",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
@@ -6557,7 +6573,7 @@ export class Generate extends HeyApiClient {
workspace?: string | null
} | null
prompt?: string
model?: ModelRef2 | null
model?: ModelRef | null
},
options?: Options<never, ThrowOnError>,
) {
@@ -8011,7 +8027,7 @@ export class Vcs2 extends HeyApiClient {
directory?: string | null
workspace?: string | null
} | null
mode: VcsMode2
mode: VcsMode
context?: string | null
},
options?: Options<never, ThrowOnError>,
+2676 -3279
View File
@@ -24,7 +24,10 @@ export type Event =
| EventSessionForked
| EventSessionPromptPromoted
| EventSessionPromptAdmitted
| EventSessionExecutionSettled
| EventSessionExecutionStarted
| EventSessionExecutionSucceeded
| EventSessionExecutionFailed
| EventSessionExecutionInterrupted
| EventSessionContextUpdated
| EventSessionSynthetic
| EventSessionSkillActivated
@@ -46,10 +49,12 @@ export type Event =
| EventSessionToolProgress
| EventSessionToolSuccess
| EventSessionToolFailed
| EventSessionRetried
| EventSessionRetryScheduled
| EventSessionCompactionAdmitted
| EventSessionCompactionStarted
| EventSessionCompactionDelta
| EventSessionCompactionEnded
| EventSessionCompactionFailed
| EventSessionRevertStaged
| EventSessionRevertCleared
| EventSessionRevertCommitted
@@ -919,11 +924,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"
}
}
| {
@@ -994,7 +1020,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
@@ -1015,7 +1041,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionStructuredError
}
}
| {
@@ -1024,7 +1050,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
}
}
| {
@@ -1033,7 +1059,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
delta: string
}
}
@@ -1043,7 +1069,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
text: string
}
}
@@ -1053,8 +1079,8 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
ordinal: number
state?: SessionMessageProviderState
}
}
| {
@@ -1063,7 +1089,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
ordinal: number
delta: string
}
}
@@ -1073,9 +1099,9 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
ordinal: number
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
| {
@@ -1115,14 +1141,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
}
}
| {
@@ -1151,10 +1174,8 @@ export type GlobalEvent = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
| {
@@ -1164,21 +1185,29 @@ 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
}
}
| {
id: string
type: "session.compaction.admitted"
properties: {
sessionID: string
inputID: string
}
}
| {
@@ -1207,6 +1236,13 @@ export type GlobalEvent = {
recent: string
}
}
| {
id: string
type: "session.compaction.failed"
properties: {
sessionID: string
}
}
| {
id: string
type: "session.revert.staged"
@@ -1227,7 +1263,7 @@ export type GlobalEvent = {
type: "session.revert.committed"
properties: {
sessionID: string
messageID: string
to: string
}
}
| {
@@ -1723,6 +1759,10 @@ export type GlobalEvent = {
| SyncEventSessionForked
| SyncEventSessionPromptPromoted
| SyncEventSessionPromptAdmitted
| SyncEventSessionExecutionStarted
| SyncEventSessionExecutionSucceeded
| SyncEventSessionExecutionFailed
| SyncEventSessionExecutionInterrupted
| SyncEventSessionContextUpdated
| SyncEventSessionSynthetic
| SyncEventSessionSkillActivated
@@ -1741,9 +1781,11 @@ export type GlobalEvent = {
| SyncEventSessionToolProgress
| SyncEventSessionToolSuccess
| SyncEventSessionToolFailed
| SyncEventSessionRetried
| SyncEventSessionRetryScheduled
| SyncEventSessionCompactionAdmitted
| SyncEventSessionCompactionStarted
| SyncEventSessionCompactionEnded
| SyncEventSessionCompactionFailed
| SyncEventSessionRevertStaged
| SyncEventSessionRevertCleared
| SyncEventSessionRevertCommitted
@@ -2891,6 +2933,10 @@ export type SessionDurableEvent =
| SessionForked
| SessionPromptPromoted
| SessionPromptAdmitted
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionContextUpdated
| SessionSynthetic
| SessionSkillActivated
@@ -2909,9 +2955,11 @@ export type SessionDurableEvent =
| SessionToolProgress
| SessionToolSuccess
| SessionToolFailed
| SessionRetried
| SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted
| SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
@@ -3032,7 +3080,10 @@ export type V2Event =
| SessionForked
| SessionPromptPromoted
| SessionPromptAdmitted
| SessionExecutionSettled
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionContextUpdated
| SessionSynthetic
| SessionSkillActivated
@@ -3054,10 +3105,12 @@ export type V2Event =
| SessionToolProgress
| SessionToolSuccess
| SessionToolFailed
| SessionRetried
| SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted
| SessionCompactionDelta
| SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
@@ -3271,15 +3324,13 @@ export type PromptAgentAttachment = {
source?: PromptSource
}
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 = {
@@ -3296,19 +3347,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"
@@ -3728,6 +3766,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 SyncEventSessionContextUpdated = {
type: "sync"
id: string
@@ -3843,7 +3939,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
@@ -3871,7 +3967,7 @@ export type SyncEventSessionStepFailed = {
data: {
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionStructuredError
}
}
}
@@ -3887,7 +3983,7 @@ export type SyncEventSessionTextStarted = {
data: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
}
}
}
@@ -3903,7 +3999,7 @@ export type SyncEventSessionTextEnded = {
data: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
text: string
}
}
@@ -3920,8 +4016,8 @@ export type SyncEventSessionReasoningStarted = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
ordinal: number
state?: SessionMessageProviderState
}
}
}
@@ -3937,9 +4033,9 @@ export type SyncEventSessionReasoningEnded = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
ordinal: number
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
}
@@ -3990,14 +4086,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
}
}
}
@@ -4040,10 +4133,8 @@ export type SyncEventSessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
}
@@ -4060,28 +4151,43 @@ 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
}
}
}
export type SyncEventSessionCompactionAdmitted = {
type: "sync"
id: string
syncEvent: {
type: "session.compaction.admitted.1"
id: string
seq: number
aggregateID: string
data: {
sessionID: string
inputID: string
}
}
}
@@ -4118,6 +4224,20 @@ export type SyncEventSessionCompactionEnded = {
}
}
export type SyncEventSessionCompactionFailed = {
type: "sync"
id: string
syncEvent: {
type: "session.compaction.failed.1"
id: string
seq: number
aggregateID: string
data: {
sessionID: string
}
}
}
export type SyncEventSessionRevertStaged = {
type: "sync"
id: string
@@ -4157,7 +4277,7 @@ export type SyncEventSessionRevertCommitted = {
aggregateID: string
data: {
sessionID: string
messageID: string
to: string
}
}
}
@@ -4288,6 +4408,15 @@ export type SessionInputAdmitted = {
promotedSeq?: number
}
export type SessionInputCompaction = {
type: "compaction"
admittedSeq: number
id: string
sessionID: string
timeCreated: number
handledSeq?: number
}
export type SessionMessageAgentSelected = {
id: string
metadata?: {
@@ -4387,15 +4516,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
@@ -4441,7 +4568,7 @@ export type SessionMessageToolStateError = {
structured: {
[key: string]: unknown
}
error: SessionErrorUnknown
error: SessionStructuredError
result?: unknown
}
@@ -4449,11 +4576,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
@@ -4467,6 +4592,12 @@ export type SessionMessageAssistantTool = {
}
}
export type SessionMessageAssistantRetry = {
attempt: number
at: number
error: SessionStructuredError
}
export type SessionMessageAssistant = {
id: string
metadata?: {
@@ -4485,7 +4616,7 @@ export type SessionMessageAssistant = {
end?: string
files?: Array<string>
}
finish?: string
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
cost?: number
tokens?: {
input: number
@@ -4496,11 +4627,13 @@ export type SessionMessageAssistant = {
write: number
}
}
error?: SessionErrorUnknown
error?: SessionStructuredError
retry?: SessionMessageAssistantRetry
}
export type SessionMessageCompaction = {
type: "compaction"
status: "queued" | "running" | "completed" | "failed"
reason: "auto" | "manual"
summary: string
recent: string
@@ -4668,6 +4801,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 SessionContextUpdated = {
id: string
created: number
@@ -4812,7 +5019,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
@@ -4844,7 +5051,7 @@ export type SessionStepFailed = {
data: {
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionStructuredError
}
}
@@ -4864,7 +5071,7 @@ export type SessionTextStarted = {
data: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
}
}
@@ -4884,7 +5091,7 @@ export type SessionTextEnded = {
data: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
text: string
}
}
@@ -4905,8 +5112,8 @@ export type SessionReasoningStarted = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
ordinal: number
state?: SessionMessageProviderState
}
}
@@ -4926,9 +5133,9 @@ export type SessionReasoningEnded = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
ordinal: number
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
@@ -4991,14 +5198,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
}
}
@@ -5049,10 +5253,8 @@ export type SessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
@@ -5073,22 +5275,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
@@ -5097,8 +5297,29 @@ export type SessionRetried = {
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
attempt: number
error: SessionRetryError
at: number
error: SessionStructuredError
}
}
export type SessionCompactionAdmitted = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.compaction.admitted"
durable: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
sessionID: string
inputID: string
}
}
@@ -5142,6 +5363,24 @@ export type SessionCompactionEnded = {
}
}
export type SessionCompactionFailed = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.compaction.failed"
durable: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
sessionID: string
}
}
export type SessionRevertStaged = {
id: string
created: number
@@ -5194,7 +5433,7 @@ export type SessionRevertCommitted = {
location?: LocationRef
data: {
sessionID: string
messageID: string
to: string
}
}
@@ -5725,21 +5964,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
@@ -5751,7 +5975,7 @@ export type SessionTextDelta = {
data: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
delta: string
}
}
@@ -5767,7 +5991,7 @@ export type SessionReasoningDelta = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
ordinal: number
delta: string
}
}
@@ -6827,13 +7051,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"
}
}
@@ -6911,7 +7159,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
@@ -6933,7 +7181,7 @@ export type EventSessionStepFailed = {
properties: {
sessionID: string
assistantMessageID: string
error: SessionErrorUnknown
error: SessionStructuredError
}
}
@@ -6943,7 +7191,7 @@ export type EventSessionTextStarted = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
}
}
@@ -6953,7 +7201,7 @@ export type EventSessionTextDelta = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
delta: string
}
}
@@ -6964,7 +7212,7 @@ export type EventSessionTextEnded = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
ordinal: number
text: string
}
}
@@ -6975,8 +7223,8 @@ export type EventSessionReasoningStarted = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
ordinal: number
state?: SessionMessageProviderState
}
}
@@ -6986,7 +7234,7 @@ export type EventSessionReasoningDelta = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
ordinal: number
delta: string
}
}
@@ -6997,9 +7245,9 @@ export type EventSessionReasoningEnded = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
ordinal: number
text: string
providerMetadata?: LlmProviderMetadata
state?: SessionMessageProviderState
}
}
@@ -7043,14 +7291,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
}
}
@@ -7081,10 +7326,8 @@ export type EventSessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
executed: boolean
resultState?: SessionMessageProviderState
}
}
@@ -7095,22 +7338,31 @@ 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
}
}
export type EventSessionCompactionAdmitted = {
id: string
type: "session.compaction.admitted"
properties: {
sessionID: string
inputID: string
}
}
@@ -7143,6 +7395,14 @@ export type EventSessionCompactionEnded = {
}
}
export type EventSessionCompactionFailed = {
id: string
type: "session.compaction.failed"
properties: {
sessionID: string
}
}
export type EventSessionRevertStaged = {
id: string
type: "session.revert.staged"
@@ -7165,7 +7425,7 @@ export type EventSessionRevertCommitted = {
type: "session.revert.committed"
properties: {
sessionID: string
messageID: string
to: string
}
}
@@ -7711,11 +7971,6 @@ export type BadRequestError = {
}
}
export type UnauthorizedErrorV2 = {
_tag: "UnauthorizedError"
message: string
}
export type InvalidRequestErrorV2 = {
_tag: "InvalidRequestError"
message: string
@@ -7723,112 +7978,6 @@ 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<PermissionV2Rule2>
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<FileDiff2>
}
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
}
/**
* Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent.
*/
@@ -7837,7 +7986,7 @@ export type SessionWatermarksV2 = {
}
export type SessionsResponseV2 = {
data: Array<SessionV2Info2>
data: Array<SessionV2InfoV2>
watermarks: SessionWatermarksV2
cursor: {
previous?: string | null
@@ -7845,11 +7994,6 @@ export type SessionsResponseV2 = {
}
}
export type InvalidCursorErrorV2 = {
_tag: "InvalidCursorError"
message: string
}
export type InvalidRequestError1 = {
_tag: "InvalidRequestError"
message: string
@@ -7857,101 +8001,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 PromptSource2 = {
start: number
end: number
text: string
}
export type PromptInputFileAttachment2 = {
uri: string
name?: string
description?: string
source?: PromptSource2
}
export type PromptAgentAttachment2 = {
name: string
source?: PromptSource2
}
export type PromptInputV2 = {
text: string
files?: Array<PromptInputFileAttachment2>
agents?: Array<PromptAgentAttachment2>
}
export type PromptFileAttachment2 = {
uri: string
mime: string
name?: string
description?: string
source?: PromptSource2
}
export type PromptV2 = {
text: string
files?: Array<PromptFileAttachment2>
agents?: Array<PromptAgentAttachment2>
}
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 +8019,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<PromptFileAttachment2>
agents?: Array<PromptAgentAttachment2>
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,975 +8037,8 @@ 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<LlmToolContent2>
}
export type SessionMessageToolStateCompleted2 = {
status: "completed"
input: {
[key: string]: unknown
}
attachments?: Array<PromptFileAttachment2>
content: Array<LlmToolContent2>
outputPaths?: Array<string>
structured: {
[key: string]: unknown
}
result?: unknown
}
export type SessionErrorUnknown2 = {
type: "unknown"
message: string
}
export type SessionMessageToolStateError2 = {
status: "error"
input: {
[key: string]: unknown
}
content: Array<LlmToolContent2>
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<SessionMessageAssistantText2 | SessionMessageAssistantReasoning2 | SessionMessageAssistantTool2>
snapshot?: {
start?: string
end?: string
files?: Array<string>
}
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
/**
* Context entry key (lowercase alphanumerics plus . _ -)
*/
export type SessionContextEntryKey2 = string
export type SessionContextEntryInfo2 = {
key: SessionContextEntryKey2
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 SessionContextUpdated2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.context.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<string>
}
}
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<LlmToolContent2>
}
}
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<LlmToolContent2>
outputPaths?: Array<string>
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
| SessionContextUpdated2
| 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<SessionMessage2>
data: Array<SessionMessage>
watermark?: number
cursor: {
previous?: string | null
@@ -9036,588 +8046,6 @@ export type SessionMessagesResponseV2 = {
}
}
export type ModelApi2 =
| {
id: string
type: "aisdk"
package: string
url?: string
settings?: {
[key: string]: unknown
}
}
| {
id: string
type: "native"
url?: string
settings: {
[key: string]: unknown
}
}
export type ModelCapabilities2 = {
tools: boolean
input: Array<string>
output: Array<string>
}
export type ModelCost2 = {
tier?: {
type: "context"
size: number
}
input: number
output: number
cache: {
read: number
write: number
}
}
export type ModelV2Info2 = {
id: string
providerID: string
family?: string
name: string
api: ModelApi2
capabilities: ModelCapabilities2
request: {
settings: ProviderSettings2
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
variant?: string
}
variants: Array<{
id: string
settings: ProviderSettings2
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
}>
time: {
released: number
}
cost: Array<ModelCost2>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: {
context: number
input?: number
output: number
}
}
export type GenerateTextResponseV2 = {
data: {
text: string
}
}
export type ProviderAisdk2 = {
type: "aisdk"
package: string
url?: string
settings?: {
[key: string]: unknown
}
}
export type ProviderNative2 = {
type: "native"
url?: string
settings: {
[key: string]: unknown
}
}
export type ProviderApi2 = ProviderAisdk2 | ProviderNative2
export type ProviderV2Info2 = {
id: string
integrationID?: string
name: string
disabled?: boolean
api: ProviderApi2
request: ProviderRequest2
}
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<IntegrationTextPrompt2 | IntegrationSelectPrompt2>
}
export type IntegrationKeyMethod2 = {
type: "key"
label?: string
}
export type IntegrationEnvMethod2 = {
type: "env"
names: Array<string>
}
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<IntegrationMethod2>
connections: Array<ConnectionInfo2>
}
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 ProjectCurrent2 = {
id: string
directory: string
}
export type ProjectDirectory2 = {
directory: string
strategy?: string
}
export type ProjectDirectories2 = Array<ProjectDirectory2>
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<FormWhen2>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
minLength?: number
maxLength?: number
pattern?: string
placeholder?: string
default?: string
options?: Array<FormOption2>
custom?: boolean
}
export type FormNumberField3 = {
key: string
title?: string
description?: string
required?: boolean
when?: Array<FormWhen2>
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<FormWhen2>
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<FormWhen2>
type: "boolean"
default?: boolean
}
export type FormMultiselectField2 = {
key: string
title?: string
description?: string
required?: boolean
when?: Array<FormWhen2>
type: "multiselect"
options: Array<FormOption2>
minItems?: number
maxItems?: number
custom?: boolean
default?: Array<string>
}
export type FormFormInfo2 = {
id: string
sessionID: string
title?: string
metadata?: FormMetadata2
mode: "form"
fields: Array<FormStringField2 | FormNumberField3 | FormIntegerField3 | FormBooleanField2 | FormMultiselectField2>
}
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<string>
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<string>
save?: Array<string>
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<unknown>
}
export type IntegrationUpdated2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "integration.updated"
location?: LocationRef2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
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<unknown>
}
export type AgentUpdated2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "agent.updated"
location?: LocationRef2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
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<PermissionRuleV2>
export type SessionV2 = {
id: string
slug: string
@@ -9630,7 +8058,7 @@ export type SessionV2 = {
additions: number
deletions: number
files: number
diffs?: Array<SnapshotFileDiffV2>
diffs?: Array<SnapshotFileDiff>
}
cost?: number
tokens?: {
@@ -9662,7 +8090,7 @@ export type SessionV2 = {
compacting?: number
archived?: number
}
permission?: PermissionRulesetV2
permission?: PermissionRuleset
revert?: {
messageID: string
partID?: string
@@ -9671,74 +8099,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
}
@@ -9753,7 +8120,7 @@ export type UserMessageV2 = {
summary?: {
title?: string | null
body?: string | null
diffs: Array<SnapshotFileDiffV2>
diffs: Array<SnapshotFileDiff>
} | null
agent: string
model: {
@@ -9767,14 +8134,6 @@ export type UserMessageV2 = {
} | null
}
export type ProviderAuthErrorV2 = {
name: "ProviderAuthError"
data: {
providerID: string
message: string
}
}
export type UnknownError1V2 = {
name: "UnknownError"
data: {
@@ -9792,13 +8151,6 @@ export type MessageOutputLengthErrorV2 = {
| Array<unknown>
}
export type MessageAbortedErrorV2 = {
name: "MessageAbortedError"
data: {
message: string
}
}
export type StructuredOutputErrorV2 = {
name: "StructuredOutputError"
data: {
@@ -9815,13 +8167,6 @@ export type ContextOverflowErrorV2 = {
}
}
export type ContentFilterErrorV2 = {
name: "ContentFilterError"
data: {
message: string
}
}
export type ApiErrorV2 = {
name: "APIError"
data: {
@@ -9847,13 +8192,13 @@ export type AssistantMessageV2 = {
completed?: number | null
}
error?:
| ProviderAuthErrorV2
| ProviderAuthError
| UnknownError1V2
| MessageOutputLengthErrorV2
| MessageAbortedErrorV2
| MessageAbortedError
| StructuredOutputErrorV2
| ContextOverflowErrorV2
| ContentFilterErrorV2
| ContentFilterError
| ApiErrorV2
| null
parentID: string
@@ -9882,46 +8227,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
@@ -9969,18 +8274,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
@@ -9993,7 +8286,7 @@ export type RangeV2 = {
}
export type SymbolSourceV2 = {
text: FilePartSourceTextV2
text: FilePartSourceText
type: "symbol"
path: string
range: RangeV2
@@ -10001,15 +8294,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
@@ -10018,15 +8302,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 = {
@@ -10076,8 +8352,6 @@ export type ToolStateErrorV2 = {
}
}
export type ToolStateV2 = ToolStatePendingV2 | ToolStateRunningV2 | ToolStateCompletedV2 | ToolStateErrorV2
export type ToolPartV2 = {
id: string
sessionID: string
@@ -10085,7 +8359,7 @@ export type ToolPartV2 = {
type: "tool"
callID: string
tool: string
state: ToolStateV2
state: ToolState
metadata?: {
[key: string]: unknown
} | null
@@ -10171,273 +8445,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<unknown>
}
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<string>
save?: Array<string>
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 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<unknown>
}
export type ConfigUpdated2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "config.updated"
location?: LocationRef2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
export type SkillUpdated2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "skill.updated"
location?: LocationRef2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
export type PtyV2 = {
id: string
title: string
@@ -10449,353 +8456,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<QuestionV2Option2>
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<QuestionV2Info2>
tool?: QuestionV2Tool2
}
}
export type QuestionV2Answer2 = Array<string>
export type QuestionV2Replied2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "question.v2.replied"
location?: LocationRef2
data: {
sessionID: string
requestID: string
answers: Array<QuestionV2Answer2>
}
}
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<FormWhen12>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
minLength?: number
maxLength?: number
pattern?: string
placeholder?: string
default?: string
options?: Array<FormOption2>
custom?: boolean
}
export type FormNumberField12 = {
key: string
title?: string
description?: string
required?: boolean
when?: Array<FormWhen12>
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<FormWhen12>
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<FormWhen12>
type: "boolean"
default?: boolean
}
export type FormMultiselectField1 = {
key: string
title?: string
description?: string
required?: boolean
when?: Array<FormWhen12>
type: "multiselect"
options: Array<FormOption2>
minItems?: number
maxItems?: number
custom?: boolean
default?: Array<string>
}
export type FormFormInfo1 = {
id: string
sessionID: string
title?: string
metadata?: FormMetadata1
mode: "form"
fields: Array<FormStringField1 | FormNumberField12 | FormIntegerField12 | FormBooleanField1 | FormMultiselectField1>
}
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<string>
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<TodoV2>
}
}
export type SessionStatusV2 =
| {
type: "idle"
@@ -10825,47 +8485,1994 @@ 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<QuestionOption>
/**
* 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
| SessionContextUpdatedV2
| SessionSyntheticV2
| SessionSkillActivatedV2
| SessionShellStartedV2
| SessionShellEndedV2
| SessionStepStartedV2
| SessionStepEndedV2
| SessionStepFailedV2
| SessionTextStartedV2
| SessionTextDeltaV2
| SessionTextEndedV2
| SessionReasoningStartedV2
| SessionReasoningDeltaV2
| SessionReasoningEndedV2
| SessionToolInputStartedV2
| SessionToolInputDeltaV2
| SessionToolInputEndedV2
| SessionToolCalledV2
| SessionToolProgressV2
| SessionToolSuccessV2
| SessionToolFailedV2
| SessionRetryScheduledV2
| SessionCompactionAdmittedV2
| SessionCompactionStartedV2
| SessionCompactionDeltaV2
| SessionCompactionEndedV2
| SessionCompactionFailedV2
| SessionRevertStagedV2
| SessionRevertClearedV2
| SessionRevertCommittedV2
| FilesystemChangedV2
| ReferenceUpdatedV2
| PermissionV2AskedV2
| PermissionV2RepliedV2
| PluginAddedV2
| 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<FileDiffV2>
}
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 SessionInputAdmittedV2 = {
admittedSeq: number
id: string
sessionID: string
prompt: Prompt
delivery: "steer" | "queue"
timeCreated: number
promotedSeq?: number
}
export type SessionInputCompactionV2 = {
type: "compaction"
admittedSeq: number
id: string
sessionID: string
timeCreated: number
handledSeq?: 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<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
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<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
snapshot?: {
start?: string
end?: string
files?: Array<string>
}
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"
status: "queued" | "running" | "completed" | "failed"
reason: "auto" | "manual"
summary: string
recent: string
id: string
metadata?: {
[key: string]: unknown
}
time: {
created: number
}
}
/**
* Context entry key (lowercase alphanumerics plus . _ -)
*/
export type SessionContextEntryKeyV2 = 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 SessionContextUpdatedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.context.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<string>
}
}
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<LlmToolContent>
}
}
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<LlmToolContent>
outputPaths?: Array<string>
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 SessionCompactionAdmittedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.compaction.admitted"
durable: {
aggregateID: string
seq: number
version: number
}
location?: LocationRefV2
data: {
sessionID: string
inputID: string
}
}
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 SessionCompactionFailedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.compaction.failed"
durable: {
aggregateID: string
seq: number
version: number
}
location?: LocationRefV2
data: {
sessionID: 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 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<FormWhenV2>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
minLength?: number
maxLength?: number
pattern?: string
placeholder?: string
default?: string
options?: Array<FormOption>
custom?: boolean
}
export type FormMultiselectFieldV2 = {
key: string
title?: string
description?: string
required?: boolean
when?: Array<FormWhenV2>
type: "multiselect"
options: Array<FormOption>
minItems?: number
maxItems?: number
custom?: boolean
default?: Array<string>
}
export type FormFormInfoV2 = {
id: string
sessionID: string
title?: string
metadata?: FormMetadata
mode: "form"
fields: Array<FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2>
}
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<string>
export type PermissionV2SourceV2 = {
type: "tool"
messageID: string
callID: string
}
export type PermissionV2RequestV2 = {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
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<unknown>
}
export type IntegrationUpdatedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "integration.updated"
location?: LocationRefV2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
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<unknown>
}
export type AgentUpdatedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "agent.updated"
location?: LocationRefV2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
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<unknown>
}
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<string>
save?: Array<string>
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 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<unknown>
}
export type ConfigUpdatedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "config.updated"
location?: LocationRefV2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
export type SkillUpdatedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "skill.updated"
location?: LocationRefV2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
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<QuestionV2Info>
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<QuestionV2Answer>
}
}
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<FormWhen1>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
minLength?: number
maxLength?: number
pattern?: string
placeholder?: string
default?: string
options?: Array<FormOption>
custom?: boolean
}
export type FormBooleanField1 = {
key: string
title?: string
description?: string
required?: boolean
when?: Array<FormWhen1>
type: "boolean"
default?: boolean
}
export type FormMultiselectField1 = {
key: string
title?: string
description?: string
required?: boolean
when?: Array<FormWhen1>
type: "multiselect"
options: Array<FormOption>
minItems?: number
maxItems?: number
custom?: boolean
default?: Array<string>
}
export type FormFormInfo1 = {
id: string
sessionID: string
title?: string
metadata?: FormMetadata1
mode: "form"
fields: Array<FormStringField1 | FormNumberField1 | FormIntegerField1 | FormBooleanField1 | FormMultiselectField1>
}
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<Todo>
}
}
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"
@@ -10889,14 +10496,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
@@ -10905,14 +10512,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
@@ -10921,66 +10528,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
@@ -10997,14 +10604,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
@@ -11012,53 +10619,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<QuestionOptionV2>
/**
* 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
@@ -11070,8 +10638,6 @@ export type QuestionAsked2 = {
}
}
export type QuestionAnswerV2 = Array<string>
export type QuestionRepliedV2 = {
id: string
created: number
@@ -11079,11 +10645,11 @@ export type QuestionRepliedV2 = {
[key: string]: unknown
}
type: "question.replied"
location?: LocationRef2
location?: LocationRefV2
data: {
sessionID: string
requestID: string
answers: Array<QuestionAnswerV2>
answers: Array<QuestionAnswer>
}
}
@@ -11094,31 +10660,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
}
@@ -11129,7 +10695,7 @@ export type V2EventServerConnected = {
metadata?: {
[key: string]: unknown
} | null
location?: LocationRef2 | null
location?: LocationRefV2 | null
type: "server.connected"
data:
| {
@@ -11138,102 +10704,10 @@ export type V2EventServerConnected = {
| Array<unknown>
}
export type V2EventV2 =
| ModelsDevRefreshed2
| IntegrationUpdated2
| IntegrationConnectionUpdated2
| CatalogUpdated2
| AgentUpdated2
| SessionCreated2
| SessionUpdated2
| SessionDeleted2
| MessageUpdated2
| MessageRemoved2
| MessagePartUpdated2
| MessagePartRemoved2
| SessionAgentSelected2
| SessionModelSelected2
| SessionMoved2
| SessionRenamed2
| SessionForked2
| SessionPromptPromoted2
| SessionPromptAdmitted2
| SessionExecutionSettled2
| SessionContextUpdated2
| 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
| 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
/**
* Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee.
*/
export type EventLogHint2 = {
export type EventLogHintV2 = {
type: "log.hint"
aggregateID: string
seq: number
@@ -11242,94 +10716,23 @@ export type EventLogHint2 = {
/**
* Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe.
*/
export type EventLogSweepRequired2 = {
export type EventLogSweepRequiredV2 = {
type: "log.sweep_required"
}
export type EventLogChange2 = EventLogHint2 | EventLogSweepRequired2
export type EventLogChangeStream2 = 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<QuestionV2Info2>
tool?: QuestionV2Tool2
}
export type QuestionV2Reply2 = {
/**
* User answers in order of questions (each answer is an array of selected labels)
*/
answers: Array<QuestionV2Answer2>
}
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<QuestionV2Info>
tool?: QuestionV2Tool
}
export type VcsFileStatusV2 = {
@@ -11339,8 +10742,6 @@ export type VcsFileStatusV2 = {
status: "added" | "deleted" | "modified"
}
export type VcsMode2 = "working" | "branch"
export type AuthRemoveData = {
body?: never
path: {
@@ -15489,7 +14890,7 @@ export type V2HealthGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors]
@@ -15525,7 +14926,7 @@ export type V2LocationGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2LocationGetError = V2LocationGetErrors[keyof V2LocationGetErrors]
@@ -15534,7 +14935,7 @@ export type V2LocationGetResponses = {
/**
* Location.Info
*/
200: LocationInfo2
200: LocationInfoV2
}
export type V2LocationGetResponse = V2LocationGetResponses[keyof V2LocationGetResponses]
@@ -15559,7 +14960,7 @@ export type V2AgentListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2AgentListError = V2AgentListErrors[keyof V2AgentListErrors]
@@ -15569,8 +14970,8 @@ export type V2AgentListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<AgentV2Info2>
location: LocationInfoV2
data: Array<AgentV2InfoV2>
}
}
@@ -15596,7 +14997,7 @@ export type V2PluginListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2PluginListError = V2PluginListErrors[keyof V2PluginListErrors]
@@ -15606,8 +15007,8 @@ export type V2PluginListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<PluginInfo2>
location: LocationInfoV2
data: Array<PluginInfo>
}
}
@@ -15640,11 +15041,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]
@@ -15662,8 +15063,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
@@ -15678,7 +15079,7 @@ export type V2SessionCreateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2SessionCreateError = V2SessionCreateErrors[keyof V2SessionCreateErrors]
@@ -15688,7 +15089,7 @@ export type V2SessionCreateResponses = {
* Success
*/
200: {
data: SessionV2Info2
data: SessionV2InfoV2
}
}
@@ -15709,7 +15110,7 @@ export type V2SessionActiveErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2SessionActiveError = V2SessionActiveErrors[keyof V2SessionActiveErrors]
@@ -15720,7 +15121,7 @@ export type V2SessionActiveResponses = {
*/
200: {
data: {
[key: string]: unknown | SessionActiveV2
[key: string]: unknown | SessionActive
}
watermarks: SessionWatermarksV2
}
@@ -15745,11 +15146,11 @@ export type V2SessionGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionGetError = V2SessionGetErrors[keyof V2SessionGetErrors]
@@ -15759,7 +15160,7 @@ export type V2SessionGetResponses = {
* Success
*/
200: {
data: SessionV2Info2
data: SessionV2InfoV2
}
}
@@ -15784,11 +15185,11 @@ export type V2SessionForkErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError | MessageNotFoundError
*/
404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2
404: MessageNotFoundError | SessionNotFoundError
}
export type V2SessionForkError = V2SessionForkErrors[keyof V2SessionForkErrors]
@@ -15798,7 +15199,7 @@ export type V2SessionForkResponses = {
* Success
*/
200: {
data: SessionV2Info2
data: SessionV2InfoV2
}
}
@@ -15823,11 +15224,11 @@ export type V2SessionSwitchAgentErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors]
@@ -15843,7 +15244,7 @@ export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V
export type V2SessionSwitchModelData = {
body: {
model: ModelRef2
model: ModelRef
}
path: {
sessionID: string
@@ -15860,11 +15261,11 @@ export type V2SessionSwitchModelErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors]
@@ -15897,11 +15298,11 @@ export type V2SessionRenameErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionRenameError = V2SessionRenameErrors[keyof V2SessionRenameErrors]
@@ -15918,7 +15319,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
}
@@ -15937,11 +15338,11 @@ export type V2SessionPromptErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
/**
* ConflictError
*/
@@ -15955,7 +15356,7 @@ export type V2SessionPromptResponses = {
* Success
*/
200: {
data: SessionInputAdmitted2
data: SessionInputAdmittedV2
}
}
@@ -15967,9 +15368,9 @@ export type V2SessionCommandData = {
command: string
arguments?: string | null
agent?: string | null
model?: ModelRef2 | null
files?: Array<PromptInputFileAttachment2>
agents?: Array<PromptAgentAttachment2>
model?: ModelRef | null
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
delivery?: "steer" | "queue" | null
resume?: boolean | null
}
@@ -15988,11 +15389,11 @@ export type V2SessionCommandErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError | CommandNotFoundError
*/
404: CommandNotFoundErrorV2 | SessionNotFoundErrorV2
404: CommandNotFoundError | SessionNotFoundError
/**
* ConflictError
*/
@@ -16000,7 +15401,7 @@ export type V2SessionCommandErrors = {
/**
* CommandEvaluationError
*/
500: CommandEvaluationErrorV2
500: CommandEvaluationError
}
export type V2SessionCommandError = V2SessionCommandErrors[keyof V2SessionCommandErrors]
@@ -16010,7 +15411,7 @@ export type V2SessionCommandResponses = {
* Success
*/
200: {
data: SessionInputAdmitted2
data: SessionInputAdmittedV2
}
}
@@ -16037,11 +15438,11 @@ export type V2SessionSkillErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError | SkillNotFoundError
*/
404: SkillNotFoundErrorV2 | SessionNotFoundErrorV2
404: SkillNotFoundError | SessionNotFoundError
}
export type V2SessionSkillError = V2SessionSkillErrors[keyof V2SessionSkillErrors]
@@ -16078,11 +15479,11 @@ export type V2SessionSyntheticErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionSyntheticError = V2SessionSyntheticErrors[keyof V2SessionSyntheticErrors]
@@ -16116,11 +15517,11 @@ export type V2SessionShellErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionShellError = V2SessionShellErrors[keyof V2SessionShellErrors]
@@ -16135,7 +15536,9 @@ export type V2SessionShellResponses = {
export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses]
export type V2SessionCompactData = {
body?: never
body: {
id?: string | null
}
path: {
sessionID: string
}
@@ -16151,32 +15554,26 @@ export type V2SessionCompactErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
/**
* SessionBusyError
* ConflictError
*/
409: SessionBusyErrorV2
/**
* UnknownError
*/
500: UnknownErrorV2
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableErrorV2
409: ConflictErrorV2
}
export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors]
export type V2SessionCompactResponses = {
/**
* <No Content>
* Success
*/
204: void
200: {
data: SessionInputCompactionV2
}
}
export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses]
@@ -16198,11 +15595,11 @@ export type V2SessionWaitErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
/**
* ServiceUnavailableError
*/
@@ -16240,15 +15637,15 @@ export type V2SessionRevertStageErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* MessageNotFoundError | SessionNotFoundError
*/
404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2
404: MessageNotFoundError | SessionNotFoundError
/**
* SessionBusyError
*/
409: SessionBusyErrorV2
409: SessionBusyError
/**
* UnknownError
*/
@@ -16262,7 +15659,7 @@ export type V2SessionRevertStageResponses = {
* Success
*/
200: {
data: RevertState2
data: RevertStateV2
}
}
@@ -16285,15 +15682,15 @@ export type V2SessionRevertClearErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
/**
* SessionBusyError
*/
409: SessionBusyErrorV2
409: SessionBusyError
/**
* UnknownError
*/
@@ -16328,15 +15725,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]
@@ -16367,11 +15764,11 @@ export type V2SessionContextErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
/**
* UnknownError
*/
@@ -16385,7 +15782,7 @@ export type V2SessionContextResponses = {
* Success
*/
200: {
data: Array<SessionMessage2>
data: Array<SessionMessage>
}
}
@@ -16408,11 +15805,11 @@ export type V2SessionContextEntryListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionContextEntryListError = V2SessionContextEntryListErrors[keyof V2SessionContextEntryListErrors]
@@ -16422,7 +15819,7 @@ export type V2SessionContextEntryListResponses = {
* Success
*/
200: {
data: Array<SessionContextEntryInfo2>
data: Array<SessionContextEntryInfo>
}
}
@@ -16433,7 +15830,7 @@ export type V2SessionContextEntryRemoveData = {
body?: never
path: {
sessionID: string
key: SessionContextEntryKey2
key: SessionContextEntryKeyV2
}
query?: never
url: "/api/session/{sessionID}/context-entry/{key}"
@@ -16447,11 +15844,11 @@ export type V2SessionContextEntryRemoveErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionContextEntryRemoveError =
@@ -16473,7 +15870,7 @@ export type V2SessionContextEntryPutData = {
}
path: {
sessionID: string
key: SessionContextEntryKey2
key: SessionContextEntryKeyV2
}
query?: never
url: "/api/session/{sessionID}/context-entry/{key}"
@@ -16487,11 +15884,11 @@ export type V2SessionContextEntryPutErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionContextEntryPutError = V2SessionContextEntryPutErrors[keyof V2SessionContextEntryPutErrors]
@@ -16526,11 +15923,11 @@ export type V2SessionLogErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionLogError = V2SessionLogErrors[keyof V2SessionLogErrors]
@@ -16542,7 +15939,7 @@ export type V2SessionLogResponses = {
200: {
id: string | null
event: string
data: SessionLogItemStreamV2
data: SessionLogItemStream
}
}
@@ -16565,11 +15962,11 @@ export type V2SessionInterruptErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionInterruptError = V2SessionInterruptErrors[keyof V2SessionInterruptErrors]
@@ -16600,11 +15997,11 @@ export type V2SessionBackgroundErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionBackgroundError = V2SessionBackgroundErrors[keyof V2SessionBackgroundErrors]
@@ -16636,11 +16033,11 @@ export type V2SessionMessageErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError | MessageNotFoundError
*/
404: MessageNotFoundErrorV2 | SessionNotFoundErrorV2
404: MessageNotFoundError | SessionNotFoundError
}
export type V2SessionMessageError = V2SessionMessageErrors[keyof V2SessionMessageErrors]
@@ -16650,7 +16047,7 @@ export type V2SessionMessageResponses = {
* Success
*/
200: {
data: SessionMessage2
data: SessionMessage
}
}
@@ -16679,15 +16076,15 @@ export type V2SessionMessagesErrors = {
/**
* InvalidCursorError | InvalidRequestError
*/
400: InvalidCursorErrorV2 | InvalidRequestErrorV2
400: InvalidCursorError | InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
/**
* UnknownError
*/
@@ -16725,7 +16122,7 @@ export type V2ModelListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
@@ -16739,8 +16136,8 @@ export type V2ModelListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<ModelV2Info2>
location: LocationInfoV2
data: Array<ModelV2Info>
}
}
@@ -16766,7 +16163,7 @@ export type V2ModelDefaultErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
@@ -16780,8 +16177,8 @@ export type V2ModelDefaultResponses = {
* Success
*/
200: {
location: LocationInfo2
data: ModelV2Info2 | null
location: LocationInfoV2
data: ModelV2Info | null
}
}
@@ -16790,7 +16187,7 @@ export type V2ModelDefaultResponse = V2ModelDefaultResponses[keyof V2ModelDefaul
export type V2GenerateTextData = {
body: {
prompt: string
model?: ModelRef2 | null
model?: ModelRef | null
}
path?: never
query?: {
@@ -16810,7 +16207,7 @@ export type V2GenerateTextErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
@@ -16823,7 +16220,7 @@ export type V2GenerateTextResponses = {
/**
* GenerateTextResponse
*/
200: GenerateTextResponseV2
200: GenerateTextResponse
}
export type V2GenerateTextResponse = V2GenerateTextResponses[keyof V2GenerateTextResponses]
@@ -16848,7 +16245,7 @@ export type V2ProviderListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
@@ -16862,8 +16259,8 @@ export type V2ProviderListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<ProviderV2Info2>
location: LocationInfoV2
data: Array<ProviderV2Info>
}
}
@@ -16891,11 +16288,11 @@ export type V2ProviderGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* ProviderNotFoundError
*/
404: ProviderNotFoundErrorV2
404: ProviderNotFoundError
/**
* ServiceUnavailableError
*/
@@ -16909,8 +16306,8 @@ export type V2ProviderGetResponses = {
* Success
*/
200: {
location: LocationInfo2
data: ProviderV2Info2
location: LocationInfoV2
data: ProviderV2Info
}
}
@@ -16936,7 +16333,7 @@ export type V2IntegrationListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2IntegrationListError = V2IntegrationListErrors[keyof V2IntegrationListErrors]
@@ -16946,8 +16343,8 @@ export type V2IntegrationListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<IntegrationInfo2>
location: LocationInfoV2
data: Array<IntegrationInfo>
}
}
@@ -16975,7 +16372,7 @@ export type V2IntegrationGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2IntegrationGetError = V2IntegrationGetErrors[keyof V2IntegrationGetErrors]
@@ -16985,8 +16382,8 @@ export type V2IntegrationGetResponses = {
* Success
*/
200: {
location: LocationInfo2
data: IntegrationInfo2 | null
location: LocationInfoV2
data: IntegrationInfo | null
}
}
@@ -17017,7 +16414,7 @@ export type V2IntegrationConnectKeyErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2IntegrationConnectKeyError = V2IntegrationConnectKeyErrors[keyof V2IntegrationConnectKeyErrors]
@@ -17059,7 +16456,7 @@ export type V2IntegrationConnectOauthErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2IntegrationConnectOauthError = V2IntegrationConnectOauthErrors[keyof V2IntegrationConnectOauthErrors]
@@ -17069,8 +16466,8 @@ export type V2IntegrationConnectOauthResponses = {
* Success
*/
200: {
location: LocationInfo2
data: IntegrationAttempt2
location: LocationInfoV2
data: IntegrationAttempt
}
}
@@ -17099,7 +16496,7 @@ export type V2IntegrationAttemptCancelErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2IntegrationAttemptCancelError = V2IntegrationAttemptCancelErrors[keyof V2IntegrationAttemptCancelErrors]
@@ -17136,7 +16533,7 @@ export type V2IntegrationAttemptStatusErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2IntegrationAttemptStatusError = V2IntegrationAttemptStatusErrors[keyof V2IntegrationAttemptStatusErrors]
@@ -17146,8 +16543,8 @@ export type V2IntegrationAttemptStatusResponses = {
* Success
*/
200: {
location: LocationInfo2
data: IntegrationAttemptStatus2
location: LocationInfoV2
data: IntegrationAttemptStatus
}
}
@@ -17178,7 +16575,7 @@ export type V2IntegrationAttemptCompleteErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2IntegrationAttemptCompleteError =
@@ -17214,7 +16611,7 @@ export type V2McpListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2McpListError = V2McpListErrors[keyof V2McpListErrors]
@@ -17224,8 +16621,8 @@ export type V2McpListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<McpServer2>
location: LocationInfoV2
data: Array<McpServer>
}
}
@@ -17253,7 +16650,7 @@ export type V2CredentialRemoveErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2CredentialRemoveError = V2CredentialRemoveErrors[keyof V2CredentialRemoveErrors]
@@ -17291,7 +16688,7 @@ export type V2CredentialUpdateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2CredentialUpdateError = V2CredentialUpdateErrors[keyof V2CredentialUpdateErrors]
@@ -17325,7 +16722,7 @@ export type V2ProjectCurrentErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2ProjectCurrentError = V2ProjectCurrentErrors[keyof V2ProjectCurrentErrors]
@@ -17334,7 +16731,7 @@ export type V2ProjectCurrentResponses = {
/**
* Project.Current
*/
200: ProjectCurrent2
200: ProjectCurrent
}
export type V2ProjectCurrentResponse = V2ProjectCurrentResponses[keyof V2ProjectCurrentResponses]
@@ -17361,7 +16758,7 @@ export type V2ProjectDirectoriesErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2ProjectDirectoriesError = V2ProjectDirectoriesErrors[keyof V2ProjectDirectoriesErrors]
@@ -17370,7 +16767,7 @@ export type V2ProjectDirectoriesResponses = {
/**
* Project.Directories
*/
200: ProjectDirectories2
200: ProjectDirectories
}
export type V2ProjectDirectoriesResponse = V2ProjectDirectoriesResponses[keyof V2ProjectDirectoriesResponses]
@@ -17395,7 +16792,7 @@ export type V2FormRequestListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2FormRequestListError = V2FormRequestListErrors[keyof V2FormRequestListErrors]
@@ -17405,8 +16802,8 @@ export type V2FormRequestListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<FormFormInfo2 | FormUrlInfo2>
location: LocationInfoV2
data: Array<FormFormInfoV2 | FormUrlInfoV2>
}
}
@@ -17429,11 +16826,11 @@ export type V2SessionFormListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionFormListError = V2SessionFormListErrors[keyof V2SessionFormListErrors]
@@ -17443,14 +16840,14 @@ export type V2SessionFormListResponses = {
* Success
*/
200: {
data: Array<FormFormInfo2 | FormUrlInfo2>
data: Array<FormFormInfoV2 | FormUrlInfoV2>
}
}
export type V2SessionFormListResponse = V2SessionFormListResponses[keyof V2SessionFormListResponses]
export type V2SessionFormCreateData = {
body: FormCreatePayload2
body: FormCreatePayloadV2
path: {
sessionID: string
}
@@ -17466,11 +16863,11 @@ export type V2SessionFormCreateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
/**
* ConflictError
*/
@@ -17484,7 +16881,7 @@ export type V2SessionFormCreateResponses = {
* Success
*/
200: {
data: FormFormInfo2 | FormUrlInfo2
data: FormFormInfoV2 | FormUrlInfoV2
}
}
@@ -17508,11 +16905,11 @@ export type V2SessionFormGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError | FormNotFoundError
*/
404: FormNotFoundErrorV2 | SessionNotFoundErrorV2
404: FormNotFoundError | SessionNotFoundError
}
export type V2SessionFormGetError = V2SessionFormGetErrors[keyof V2SessionFormGetErrors]
@@ -17522,7 +16919,7 @@ export type V2SessionFormGetResponses = {
* Success
*/
200: {
data: FormFormInfo2 | FormUrlInfo2
data: FormFormInfoV2 | FormUrlInfoV2
}
}
@@ -17546,11 +16943,11 @@ export type V2SessionFormStateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError | FormNotFoundError
*/
404: FormNotFoundErrorV2 | SessionNotFoundErrorV2
404: FormNotFoundError | SessionNotFoundError
}
export type V2SessionFormStateError = V2SessionFormStateErrors[keyof V2SessionFormStateErrors]
@@ -17560,14 +16957,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
@@ -17580,19 +16977,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]
@@ -17624,15 +17021,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]
@@ -17666,7 +17063,7 @@ export type V2PermissionRequestListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors]
@@ -17676,8 +17073,8 @@ export type V2PermissionRequestListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<PermissionV2Request2>
location: LocationInfoV2
data: Array<PermissionV2RequestV2>
}
}
@@ -17700,7 +17097,7 @@ export type V2PermissionSavedListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors]
@@ -17710,7 +17107,7 @@ export type V2PermissionSavedListResponses = {
* Success
*/
200: {
data: Array<PermissionSavedInfo2>
data: Array<PermissionSavedInfo>
}
}
@@ -17733,7 +17130,7 @@ export type V2PermissionSavedRemoveErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors]
@@ -17764,11 +17161,11 @@ export type V2SessionPermissionListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors]
@@ -17778,7 +17175,7 @@ export type V2SessionPermissionListResponses = {
* Success
*/
200: {
data: Array<PermissionV2Request2>
data: Array<PermissionV2RequestV2>
}
}
@@ -17793,7 +17190,7 @@ export type V2SessionPermissionCreateData = {
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source2
source?: PermissionV2SourceV2
agent?: string | null
}
path: {
@@ -17811,11 +17208,11 @@ export type V2SessionPermissionCreateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionPermissionCreateError = V2SessionPermissionCreateErrors[keyof V2SessionPermissionCreateErrors]
@@ -17827,7 +17224,7 @@ export type V2SessionPermissionCreateResponses = {
200: {
data: {
id: string
effect: PermissionV2Effect2
effect: PermissionV2Effect
}
}
}
@@ -17853,11 +17250,11 @@ export type V2SessionPermissionGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError | PermissionNotFoundError
*/
404: PermissionNotFoundErrorV2 | SessionNotFoundErrorV2
404: PermissionNotFoundError | SessionNotFoundError
}
export type V2SessionPermissionGetError = V2SessionPermissionGetErrors[keyof V2SessionPermissionGetErrors]
@@ -17867,7 +17264,7 @@ export type V2SessionPermissionGetResponses = {
* Success
*/
200: {
data: PermissionV2Request2
data: PermissionV2RequestV2
}
}
@@ -17875,7 +17272,7 @@ export type V2SessionPermissionGetResponse = V2SessionPermissionGetResponses[key
export type V2SessionPermissionReplyData = {
body: {
reply: PermissionV2Reply2
reply: PermissionV2Reply
message?: string | null
}
path: {
@@ -17894,11 +17291,11 @@ export type V2SessionPermissionReplyErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError | PermissionNotFoundError
*/
404: PermissionNotFoundErrorV2 | SessionNotFoundErrorV2
404: PermissionNotFoundError | SessionNotFoundError
}
export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors]
@@ -17933,7 +17330,7 @@ export type V2FsReadErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2FsReadError = V2FsReadErrors[keyof V2FsReadErrors]
@@ -17968,7 +17365,7 @@ export type V2FsListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2FsListError = V2FsListErrors[keyof V2FsListErrors]
@@ -17978,8 +17375,8 @@ export type V2FsListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<FileSystemEntry2>
location: LocationInfoV2
data: Array<FileSystemEntry>
}
}
@@ -18008,7 +17405,7 @@ export type V2FsFindErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2FsFindError = V2FsFindErrors[keyof V2FsFindErrors]
@@ -18018,8 +17415,8 @@ export type V2FsFindResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<FileSystemEntry2>
location: LocationInfoV2
data: Array<FileSystemEntry>
}
}
@@ -18045,7 +17442,7 @@ export type V2CommandListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2CommandListError = V2CommandListErrors[keyof V2CommandListErrors]
@@ -18055,8 +17452,8 @@ export type V2CommandListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<CommandV2Info2>
location: LocationInfoV2
data: Array<CommandV2Info>
}
}
@@ -18082,7 +17479,7 @@ export type V2SkillListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2SkillListError = V2SkillListErrors[keyof V2SkillListErrors]
@@ -18092,8 +17489,8 @@ export type V2SkillListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<SkillV2Info2>
location: LocationInfoV2
data: Array<SkillV2Info>
}
}
@@ -18114,7 +17511,7 @@ export type V2EventSubscribeErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2EventSubscribeError = V2EventSubscribeErrors[keyof V2EventSubscribeErrors]
@@ -18143,7 +17540,7 @@ export type V2EventChangesErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2EventChangesError = V2EventChangesErrors[keyof V2EventChangesErrors]
@@ -18155,7 +17552,7 @@ export type V2EventChangesResponses = {
200: {
id: string | null
event: string
data: EventLogChangeStream2
data: EventLogChangeStream
}
}
@@ -18181,7 +17578,7 @@ export type V2PtyListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2PtyListError = V2PtyListErrors[keyof V2PtyListErrors]
@@ -18191,7 +17588,7 @@ export type V2PtyListResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: Array<PtyV2>
}
}
@@ -18226,7 +17623,7 @@ export type V2PtyCreateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2PtyCreateError = V2PtyCreateErrors[keyof V2PtyCreateErrors]
@@ -18236,7 +17633,7 @@ export type V2PtyCreateResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: PtyV2
}
}
@@ -18265,11 +17662,11 @@ export type V2PtyRemoveErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* PtyNotFoundError
*/
404: PtyNotFoundErrorV2
404: PtyNotFoundError
}
export type V2PtyRemoveError = V2PtyRemoveErrors[keyof V2PtyRemoveErrors]
@@ -18305,11 +17702,11 @@ export type V2PtyGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* PtyNotFoundError
*/
404: PtyNotFoundErrorV2
404: PtyNotFoundError
}
export type V2PtyGetError = V2PtyGetErrors[keyof V2PtyGetErrors]
@@ -18319,7 +17716,7 @@ export type V2PtyGetResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: PtyV2
}
}
@@ -18354,11 +17751,11 @@ export type V2PtyUpdateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* PtyNotFoundError
*/
404: PtyNotFoundErrorV2
404: PtyNotFoundError
}
export type V2PtyUpdateError = V2PtyUpdateErrors[keyof V2PtyUpdateErrors]
@@ -18368,7 +17765,7 @@ export type V2PtyUpdateResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: PtyV2
}
}
@@ -18397,15 +17794,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 +17812,8 @@ export type V2PtyConnectTokenResponses = {
* Success
*/
200: {
location: LocationInfo2
data: PtyTicketConnectToken2
location: LocationInfoV2
data: PtyTicketConnectTokenV2
}
}
@@ -18444,15 +17841,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 +17883,7 @@ export type V2ShellListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2ShellListError = V2ShellListErrors[keyof V2ShellListErrors]
@@ -18496,7 +17893,7 @@ export type V2ShellListResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: Array<ShellV2>
}
}
@@ -18530,7 +17927,7 @@ export type V2ShellCreateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2ShellCreateError = V2ShellCreateErrors[keyof V2ShellCreateErrors]
@@ -18540,7 +17937,7 @@ export type V2ShellCreateResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: ShellV2
}
}
@@ -18569,11 +17966,11 @@ export type V2ShellRemoveErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* ShellNotFoundError
*/
404: ShellNotFoundErrorV2
404: ShellNotFoundError
}
export type V2ShellRemoveError = V2ShellRemoveErrors[keyof V2ShellRemoveErrors]
@@ -18609,11 +18006,11 @@ export type V2ShellGetErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* ShellNotFoundError
*/
404: ShellNotFoundErrorV2
404: ShellNotFoundError
}
export type V2ShellGetError = V2ShellGetErrors[keyof V2ShellGetErrors]
@@ -18623,7 +18020,7 @@ export type V2ShellGetResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: ShellV2
}
}
@@ -18654,11 +18051,11 @@ export type V2ShellOutputErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* ShellNotFoundError
*/
404: ShellNotFoundErrorV2
404: ShellNotFoundError
}
export type V2ShellOutputError = V2ShellOutputErrors[keyof V2ShellOutputErrors]
@@ -18668,7 +18065,7 @@ export type V2ShellOutputResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: {
output: string
cursor: number
@@ -18700,7 +18097,7 @@ export type V2QuestionRequestListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2QuestionRequestListError = V2QuestionRequestListErrors[keyof V2QuestionRequestListErrors]
@@ -18710,8 +18107,8 @@ export type V2QuestionRequestListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<QuestionV2Request2>
location: LocationInfoV2
data: Array<QuestionV2RequestV2>
}
}
@@ -18734,11 +18131,11 @@ export type V2SessionQuestionListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundErrorV2
404: SessionNotFoundError
}
export type V2SessionQuestionListError = V2SessionQuestionListErrors[keyof V2SessionQuestionListErrors]
@@ -18748,14 +18145,14 @@ export type V2SessionQuestionListResponses = {
* Success
*/
200: {
data: Array<QuestionV2Request2>
data: Array<QuestionV2RequestV2>
}
}
export type V2SessionQuestionListResponse = V2SessionQuestionListResponses[keyof V2SessionQuestionListResponses]
export type V2SessionQuestionReplyData = {
body: QuestionV2Reply2
body: QuestionV2Reply
path: {
sessionID: string
requestID: string
@@ -18772,11 +18169,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 +18205,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 +18243,7 @@ export type V2ReferenceListErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2ReferenceListError = V2ReferenceListErrors[keyof V2ReferenceListErrors]
@@ -18856,8 +18253,8 @@ export type V2ReferenceListResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<ReferenceInfo2>
location: LocationInfoV2
data: Array<ReferenceInfo>
}
}
@@ -18888,7 +18285,7 @@ export type V2ProjectCopyRemoveErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2ProjectCopyRemoveError = V2ProjectCopyRemoveErrors[keyof V2ProjectCopyRemoveErrors]
@@ -18928,7 +18325,7 @@ export type V2ProjectCopyCreateErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2ProjectCopyCreateError = V2ProjectCopyCreateErrors[keyof V2ProjectCopyCreateErrors]
@@ -18937,7 +18334,7 @@ export type V2ProjectCopyCreateResponses = {
/**
* ProjectCopy.Copy
*/
200: ProjectCopyCopy2
200: ProjectCopyCopy
}
export type V2ProjectCopyCreateResponse = V2ProjectCopyCreateResponses[keyof V2ProjectCopyCreateResponses]
@@ -18964,7 +18361,7 @@ export type V2ProjectCopyRefreshErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2ProjectCopyRefreshError = V2ProjectCopyRefreshErrors[keyof V2ProjectCopyRefreshErrors]
@@ -18998,7 +18395,7 @@ export type V2VcsStatusErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2VcsStatusError = V2VcsStatusErrors[keyof V2VcsStatusErrors]
@@ -19008,7 +18405,7 @@ export type V2VcsStatusResponses = {
* Success
*/
200: {
location: LocationInfo2
location: LocationInfoV2
data: Array<VcsFileStatusV2>
}
}
@@ -19023,7 +18420,7 @@ export type V2VcsDiffData = {
directory?: string | null
workspace?: string | null
} | null
mode: VcsMode2
mode: VcsMode
context?: string | null
}
url: "/api/vcs/diff"
@@ -19037,7 +18434,7 @@ export type V2VcsDiffErrors = {
/**
* UnauthorizedError
*/
401: UnauthorizedErrorV2
401: UnauthorizedError
}
export type V2VcsDiffError = V2VcsDiffErrors[keyof V2VcsDiffErrors]
@@ -19047,8 +18444,8 @@ export type V2VcsDiffResponses = {
* Success
*/
200: {
location: LocationInfo2
data: Array<SnapshotFileDiffV2>
location: LocationInfoV2
data: Array<SnapshotFileDiff>
}
}
+19 -37
View File
@@ -344,44 +344,26 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.compact",
Effect.fn(function* (ctx) {
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `Session ${error.operation} is not available yet`,
service: `session.${error.operation}`,
}),
),
),
Effect.catchTag(
"Session.BusyError",
(error) =>
new SessionBusyError({
sessionID: error.sessionID,
message: `Session is busy: ${error.sessionID}`,
}),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message during compaction").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
return {
data: yield* session.compact({ sessionID: ctx.params.sessionID, id: ctx.payload.id }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
}),
)
return HttpApiSchema.NoContent.make()
),
Effect.catchTag("Session.CompactionConflictError", (error) =>
Effect.fail(
new ConflictError({
message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`,
resource: error.inputID,
}),
),
),
),
}
}),
)
.handle(
+117 -47
View File
@@ -51,6 +51,8 @@ type Data = {
// session ID in that family, including the key itself once its info arrives.
family: Record<string, string[]>
status: Record<string, DataSessionStatus>
compaction: Partial<Record<string, string>>
compactionReason: Partial<Record<string, "auto" | "manual">>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
@@ -85,6 +87,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
info: {},
family: {},
status: {},
compaction: {},
compactionReason: {},
message: {},
permission: {},
question: {},
@@ -137,20 +141,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID)
return item?.type === "shell" ? item : undefined
},
compaction(messages: SessionMessage[]) {
const item = messages.findLast(
(item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"),
)
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool =>
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,
)
},
}
@@ -267,7 +275,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
@@ -321,7 +328,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),
@@ -332,7 +338,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
@@ -342,11 +347,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",
@@ -359,7 +377,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
@@ -378,32 +395,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
@@ -444,7 +455,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
@@ -473,11 +485,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
@@ -496,11 +505,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
@@ -508,41 +514,77 @@ 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.admitted":
message.update(event.data.sessionID, (draft, index) => {
if (message.compaction(draft)) return
message.append(draft, index, {
id: event.data.inputID,
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
})
})
break
case "session.compaction.started":
setStore("session", "compaction", event.data.sessionID, "")
setStore("session", "compactionReason", event.data.sessionID, event.data.reason)
if (event.data.reason === "manual")
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.status = "running"
})
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)
if (store.session.compactionReason[event.data.sessionID] !== undefined)
setStore("session", "compactionReason", 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])
@@ -554,12 +596,29 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.compaction.delta":
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
if (store.session.compactionReason[event.data.sessionID] === "manual")
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.summary += event.data.text
})
break
case "session.compaction.ended":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft, index) => {
const current = event.data.reason === "manual" ? message.compaction(draft) : undefined
if (current) {
current.status = "completed"
current.reason = event.data.reason
current.summary = event.data.text
current.recent = event.data.recent
return
}
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "compaction",
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
@@ -567,6 +626,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.compaction.failed":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.status = "failed"
})
break
case "permission.v2.asked":
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
setStore("session", "permission", event.data.sessionID, [
@@ -676,6 +743,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status(sessionID: string) {
return store.session.status[sessionID] ?? "idle"
},
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)
@@ -27,8 +27,8 @@ function sessionErrorMessage(error: SessionError) {
}
const tui: TuiPlugin = async (api) => {
const active = new Set<string>()
const errored = new Set<string>()
const terminal = new Set<string>()
const questions = new Set<string>()
const permissions = new Set<string>()
@@ -57,14 +57,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
@@ -74,28 +73,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")
})
+88 -8
View File
@@ -21,7 +21,7 @@ import { useProject } from "../../context/project"
import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner } from "../../component/spinner"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
@@ -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)
@@ -171,6 +171,16 @@ export function Session() {
})
onCleanup(() => setEpilogue())
const messages = sessionMessages
const transientCompaction = createMemo(() => {
if (
messages().some(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
)
return
const text = data.session.compaction(route.sessionID)
return text === undefined ? undefined : { text }
})
const descendantSessionIDs = createMemo(() => {
if (session()?.parentID) return []
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
@@ -915,6 +925,9 @@ export function Session() {
/>
)}
</For>
<Show when={transientCompaction()}>
{(compaction) => <CompactionMessage status="running" text={compaction().text} />}
</Show>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
@@ -1081,7 +1094,7 @@ function SessionMessageView(props: { message: SessionMessage }) {
</Show>
</Match>
<Match when={props.message.type === "compaction"}>
<CompactionMessage />
<CompactionMessage message={props.message as Extract<SessionMessage, { type: "compaction" }>} />
</Match>
</Switch>
)
@@ -1092,7 +1105,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
const part = createMemo(() => {
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 (
<Show when={part()}>
@@ -1132,7 +1145,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]
})
@@ -1211,6 +1224,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<box paddingLeft={3} marginTop={props.message.error ? 1 : 0}>
<text>
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
@@ -1261,9 +1275,59 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
)
}
function CompactionMessage() {
const { theme } = useTheme()
return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
function CompactionMessage(props: {
message?: Extract<SessionMessage, { type: "compaction" }>
status?: "running"
text?: string
}) {
const ctx = use()
const kv = useKV()
const { theme, syntax } = useTheme()
const status = () => props.message?.status ?? props.status
const text = () => props.message?.summary ?? props.text ?? ""
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted)
const border = () => (status() === "queued" ? theme.border : color())
return (
<box>
<box flexDirection="row" alignItems="center">
<box border={["top"]} borderColor={border()} flexGrow={1} />
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
<Switch>
<Match when={status() === "running"}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}></text>}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
</Show>
</Match>
<Match when={status() === "completed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "failed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "queued"}>
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>{status() === "queued" ? "Compaction queued" : "Compaction"}</text>
</box>
<box border={["top"]} borderColor={border()} flexGrow={1} />
</box>
<Show when={text().trim()}>
<box paddingTop={1} paddingLeft={3}>
<markdown
syntaxStyle={syntax()}
streaming={status() === "running"}
internalBlockMode="top-level"
content={text().trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
</box>
</Show>
</box>
)
}
function statusLabel(status: "added" | "modified" | "deleted") {
@@ -1544,6 +1608,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<Switch>
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
@@ -1563,6 +1628,21 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const { theme } = useTheme()
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3} marginTop={1}>
<text fg={theme.textMuted}>
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
</text>
</box>
)}
</Show>
)
}
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
+59 -18
View File
@@ -77,7 +77,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
.list(sessionID())
.flatMap((message) =>
message.type === "user"
? [{ id: message.id, created: message.time.created, queued: message.metadata?.queued === true }]
? [{ id: message.id, created: message.time.created, state: message.metadata?.queued ? "queued" : "visible" }]
: message.type === "compaction"
? [{ id: message.id, created: message.time.created, state: message.status }]
: [],
),
() => setRows(reconcile(reduce())),
@@ -88,9 +90,10 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
const queued = isQueued(messageID)
const index = queued ? draft.length : queuedStart(draft)
if (!queued) completePrevious(draft, index)
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
const index = message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
if (!pending) completePrevious(draft, index)
draft.splice(index, 0, { type: "message", messageID })
}),
)
@@ -131,13 +134,28 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
const isQueued = (messageID: string) => {
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 latestFragmentRef = (messageID: string, kind: "text" | "reasoning") => {
const message = data.session.message.get(sessionID(), messageID)
return message?.type === "user" && message.metadata?.queued === true
const ordinal = message?.type === "assistant" ? message.content.filter((part) => part.type === kind).length - 1 : 0
return { messageID, partID: `${kind}:${Math.max(0, ordinal)}` }
}
const isPending = (messageID: string) => {
const message = data.session.message.get(sessionID(), messageID)
if (message?.type === "user") return message.metadata?.queued === true
return message?.type === "compaction" && (message.status === "queued" || message.status === "running")
}
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex((row) => row.type === "message" && isQueued(row.messageID))
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
return index === -1 ? rows.length : index
}
@@ -149,6 +167,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
}
const subscriptions = [
data.on("session.prompt.admitted", input),
data.on("session.compaction.admitted", input),
data.on("session.context.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
@@ -159,25 +178,30 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.model.selected", message),
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 })
if (event.data.sessionID === sessionID()) appendPart(latestFragmentRef(event.data.assistantMessageID, "text"))
}),
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(latestFragmentRef(event.data.assistantMessageID, "text"))
}),
data.on("session.reasoning.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
appendPart(latestFragmentRef(event.data.assistantMessageID, "reasoning"))
}),
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(latestFragmentRef(event.data.assistantMessageID, "reasoning"))
}),
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)
@@ -192,25 +216,42 @@ export function createSessionRows(sessionID: Accessor<string>) {
}
export function reduceSessionRows(messages: SessionMessage[]) {
return [...messages.filter((message) => !isQueuedMessage(message)), ...messages.filter(isQueuedMessage)].reduce<
SessionRow[]
>((rows, message) => {
const pendingCompactions = messages.filter(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
const queuedUsers = messages.filter(isQueuedMessage)
const pending = new Set([...pendingCompactions, ...queuedUsers].map((message) => message.id))
return [...messages.filter((message) => !pending.has(message.id)), ...pendingCompactions, ...queuedUsers].reduce<SessionRow[]>(
(rows, message) => {
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (!isQueuedMessage(message)) completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
return rows
}
const ordinals = { text: 0, reasoning: 0 }
message.content.forEach((part) => {
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 })
}
return rows
}, [])
},
[],
)
}
export function resolvePart(message: SessionMessageAssistant, partID: string) {
const tool = message.content.find((part) => 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 isQueuedMessage(message: SessionMessage) {
@@ -49,6 +49,7 @@ async function setup() {
state: {
session: {
get: (sessionID: string) => sessions[sessionID],
status: () => ({ type: "busy" }),
},
},
}),
@@ -87,46 +88,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" },
},
}
@@ -187,12 +176,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([
{
@@ -201,6 +190,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" },
},
])
})
@@ -208,8 +203,8 @@ describe("internal notifications TUI plugin", () => {
const harness = await setup()
harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-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([
{
@@ -230,14 +225,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" },
},
@@ -247,20 +242,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([
{
+201 -33
View File
@@ -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: {}, watermarks: {} }))
@@ -353,9 +348,11 @@ test("tracks session status from active sessions and execution events", async ()
return json({ data: { "session-active": { type: "running" } }, watermarks: {} })
}, events)
let data!: ReturnType<typeof useData>
let rows!: SessionRow[]
function Probe() {
data = useData()
rows = createSessionRows(() => "session-retry")
return <box />
}
@@ -375,6 +372,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,
@@ -387,8 +393,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,
@@ -409,16 +413,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,
@@ -431,8 +442,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,
@@ -441,26 +450,186 @@ 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_admitted",
created: 0,
type: "session.compaction.admitted",
durable: durable("session-manual", 1),
data: { sessionID: "session-manual", inputID: "message-compaction" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "queued"
})
emitEvent(events, {
id: "evt_manual_compaction_started",
created: 1,
type: "session.compaction.started",
durable: durable("session-manual", 2),
data: { sessionID: "session-manual", reason: "manual" },
})
emitEvent(events, {
id: "evt_manual_compaction_delta",
created: 2,
type: "session.compaction.delta",
data: { sessionID: "session-manual", text: "Streamed summary" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.summary === "Streamed summary"
})
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
type: "session.compaction.ended",
durable: durable("session-manual", 3),
data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "completed"
})
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()
}
@@ -964,9 +1133,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, {
@@ -979,7 +1148,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 },
},
})
@@ -1005,11 +1175,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",
+66 -10
View File
@@ -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,46 @@ 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" }])
})
test("places a pending compaction barrier before every queued user message", () => {
const queued = (id: string, text: string, created: number): SessionMessage => ({
type: "user",
id,
text,
metadata: { queued: true },
time: { created },
})
const messages: SessionMessage[] = [
queued("user-before", "Before", 1),
{
type: "compaction",
id: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
time: { created: 2 },
},
queued("user-after", "After", 3),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "compaction" },
{ type: "message", messageID: "user-before" },
{ type: "message", messageID: "user-after" },
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
type: "assistant",
+36
View File
@@ -1,5 +1,27 @@
# V2 Schema Changelog
## 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.
@@ -939,3 +961,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.
+20 -7
View File
@@ -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
@@ -32,7 +32,7 @@ sessions.active()
-> absence means inactive; activity is not durable across process restarts
```
`session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts.
`session_input` is the typed durable admission inbox for prompts and Session control operations. `PromptAdmitted` records accepted user input; `Compaction.Admitted` records one coalesced manual compaction barrier. Admitted prompts remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. A pending compaction blocks all unpromoted prompts, runs before the Session would otherwise become idle, and releases the backlog only after its durable ended or failed event settles the barrier. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts.
`admittedSeq` is the durable Session event sequence of `PromptAdmitted`. Clients may use the admission event to represent queued input before `Prompted` makes it part of visible conversation history.
@@ -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.
@@ -102,7 +110,6 @@ Current Context Epoch follow-ups:
- Add configured, remote, and nested instruction sources with explicit precedence and removal semantics.
- Add durable post-crash continuation recovery for promoted or provider-dispatched work.
- Add explicit manual compaction on top of automatic request-budget compaction.
- Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth.
- Consider watcher-backed per-file caching only if measurements show direct safe-boundary observation is too expensive.
- Expose plugin-defined Context Sources only after plugin reload and scoped cleanup semantics are designed.
@@ -114,7 +121,13 @@ 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.
`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 Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active.
The rolling summary is a continuation checkpoint with this complete heading order: `Continuation Goal`, `Operating Constraints`, `Progress` (`Completed`, `In Flight`, `Blocked`), `Decisions To Preserve`, `Resume From Here`, `Context To Preserve`, and `Working Files`. Every heading remains present even when its value is `(none)`.
`session.compaction.admitted.1` durably records a manual request and projects its queued transcript row. `session.compaction.started.1` identifies the attempt and transforms that row into a running divider. Compaction deltas are live-only progress rendered beneath it. `session.compaction.ended.1` durably stores the final summary and serialized recent context, completes the same row, and settles the manual barrier. `session.compaction.failed.1` settles an unsuccessful manual barrier without changing the previous history boundary. On the next physical attempt, the runner observes a completed compaction and directly renders a fresh Context Epoch baseline.
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.
@@ -150,7 +163,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:
@@ -164,7 +177,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.
+3 -4
View File
@@ -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.