Compare commits
5 Commits
v2
...
execution-errors
| Author | SHA1 | Date | |
|---|---|---|---|
| e74453145d | |||
| 1b57c71eb9 | |||
| e263664963 | |||
| 47fdf60e76 | |||
| d5ca70fbc1 |
@@ -1006,23 +1006,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 }
|
||||
| {
|
||||
@@ -1060,7 +1057,37 @@ 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: "provider.rate-limit"
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number
|
||||
}
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
readonly time: {
|
||||
@@ -1072,7 +1099,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
|
||||
@@ -1080,7 +1107,56 @@ 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: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -1211,6 +1287,71 @@ 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: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: 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
|
||||
@@ -1320,7 +1461,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
|
||||
@@ -1342,7 +1483,33 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1352,7 +1519,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 id: string
|
||||
@@ -1361,12 +1528,7 @@ export type SessionLogOutput =
|
||||
readonly type: "session.text.ended"
|
||||
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 text: string
|
||||
}
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly text: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1378,8 +1540,7 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1392,9 +1553,8 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1436,12 +1596,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 }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1480,10 +1637,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 }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1497,32 +1652,77 @@ export type SessionLogOutput =
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: 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: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1588,7 +1788,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 }
|
||||
@@ -1699,23 +1899,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 }
|
||||
| {
|
||||
@@ -1753,7 +1950,37 @@ 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: "provider.rate-limit"
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number
|
||||
}
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
readonly time: {
|
||||
@@ -1765,7 +1992,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
|
||||
@@ -1773,7 +2000,56 @@ 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: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -1898,23 +2174,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 }
|
||||
| {
|
||||
@@ -1952,7 +2225,37 @@ 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: "provider.rate-limit"
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number
|
||||
}
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
readonly time: {
|
||||
@@ -1964,7 +2267,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
|
||||
@@ -1972,7 +2275,56 @@ 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: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -4485,14 +4837,63 @@ 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 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 outcome: "success" | "failure" | "interrupted"
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: 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
|
||||
@@ -4602,7 +5003,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
|
||||
@@ -4624,7 +5025,29 @@ export type EventSubscribeOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4634,7 +5057,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 id: string
|
||||
@@ -4642,12 +5065,7 @@ export type EventSubscribeOutput =
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.text.delta"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly textID: string
|
||||
readonly delta: string
|
||||
}
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly delta: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -4656,12 +5074,7 @@ export type EventSubscribeOutput =
|
||||
readonly type: "session.text.ended"
|
||||
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 text: string
|
||||
}
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly text: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -4673,8 +5086,7 @@ export type EventSubscribeOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4683,12 +5095,7 @@ export type EventSubscribeOutput =
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.reasoning.delta"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly delta: string
|
||||
}
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly delta: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -4700,9 +5107,8 @@ export type EventSubscribeOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4757,12 +5163,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 }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4801,10 +5204,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 }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4818,32 +5219,69 @@ export type EventSubscribeOutput =
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: 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: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4917,7 +5355,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
|
||||
|
||||
+2
@@ -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_event_fragments"),
|
||||
import("./migration/20260703210000_reset_v2_execution_errors"),
|
||||
])
|
||||
).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_event_fragments",
|
||||
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,14 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703210000_reset_v2_execution_errors",
|
||||
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
|
||||
@@ -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,
|
||||
@@ -119,9 +132,17 @@ const layer = Layer.effect(
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
|
||||
discard: true,
|
||||
}).pipe(
|
||||
Effect.forEach(
|
||||
pending.values(),
|
||||
(item) =>
|
||||
Deferred.fail(
|
||||
item.deferred,
|
||||
new RejectedError({ permission: item.request.action, resources: [...item.request.resources] }),
|
||||
),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.clear()
|
||||
@@ -201,6 +222,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 +253,12 @@ 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 })
|
||||
: new RejectedError({
|
||||
permission: existing.request.action,
|
||||
resources: [...existing.request.resources],
|
||||
}),
|
||||
)
|
||||
pending.delete(input.requestID)
|
||||
for (const [id, item] of pending) {
|
||||
@@ -240,7 +268,10 @@ const layer = Layer.effect(
|
||||
requestID: item.request.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
yield* Deferred.fail(
|
||||
item.deferred,
|
||||
new RejectedError({ permission: item.request.action, resources: [...item.request.resources] }),
|
||||
)
|
||||
pending.delete(id)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -89,11 +89,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* () {
|
||||
@@ -101,6 +101,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) => {
|
||||
@@ -130,7 +141,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({
|
||||
@@ -191,10 +205,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
|
||||
}),
|
||||
)
|
||||
@@ -230,25 +260,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
|
||||
})
|
||||
},
|
||||
@@ -278,7 +307,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({
|
||||
@@ -304,11 +334,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({
|
||||
@@ -327,11 +354,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({
|
||||
@@ -352,9 +376,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 },
|
||||
}),
|
||||
),
|
||||
@@ -363,21 +386,29 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
},
|
||||
"session.reasoning.delta": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
const match = latestReasoning(draft)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"session.reasoning.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
const match = latestReasoning(draft)
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.retry.scheduled": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.retry = {
|
||||
attempt: event.data.attempt,
|
||||
at: DateTime.makeUnsafe(event.data.at),
|
||||
error: castDraft(event.data.error),
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.retried": () => Effect.void,
|
||||
"session.compaction.started": () => Effect.void,
|
||||
"session.compaction.delta": () => Effect.void,
|
||||
"session.compaction.ended": (event) => {
|
||||
|
||||
@@ -622,6 +622,9 @@ const layer = Layer.effectDiscard(
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Skill.Activated, (event) =>
|
||||
@@ -648,7 +651,7 @@ const layer = Layer.effectDiscard(
|
||||
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||
db
|
||||
@@ -675,14 +678,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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, 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,17 +141,14 @@ 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", name: tool.name },
|
||||
executed: tool.executed === true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error | UserInterruptedError>) =>
|
||||
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
||||
|
||||
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
|
||||
@@ -171,6 +173,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 +187,7 @@ 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>()
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
@@ -228,21 +231,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,7 +255,12 @@ 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
|
||||
@@ -273,6 +283,15 @@ const layer = Layer.effect(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -319,7 +338,7 @@ 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 })))
|
||||
)
|
||||
@@ -331,8 +350,26 @@ const layer = Layer.effect(
|
||||
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.failUnsettledTools(
|
||||
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
|
||||
true,
|
||||
),
|
||||
)
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
@@ -342,27 +379,30 @@ const layer = Layer.effect(
|
||||
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 settledError =
|
||||
settled._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(settled.cause)) : undefined
|
||||
const permissionRejected = settledError 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"))
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||
// Match V1: dismissing a question halts the loop like an interruption.
|
||||
if (questionDismissed) return yield* Effect.interrupt
|
||||
if (questionDismissed || permissionRejected) return yield* new UserInterruptedError()
|
||||
}
|
||||
// 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 =
|
||||
settled._tag === "Failure" && !toolsInterrupted && !permissionRejected ? settled.cause : undefined
|
||||
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))
|
||||
}
|
||||
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
@@ -371,13 +411,21 @@ const layer = Layer.effect(
|
||||
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 (providerFailed)
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
if (stream._tag === "Success" && !providerFailed)
|
||||
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools(
|
||||
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
|
||||
return yield* Effect.failCause(settled.cause)
|
||||
const stepFailure = publisher.stepFailure()
|
||||
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||
return {
|
||||
_tag: "Completed",
|
||||
needsContinuation: !providerFailed && needsContinuation,
|
||||
@@ -398,8 +446,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,8 +479,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
|
||||
// drain here.
|
||||
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
|
||||
@@ -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 assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let assistantFailed = 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,15 +94,18 @@ 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, state?: Record<string, unknown>) => Effect.Effect<void>,
|
||||
single = false,
|
||||
) => {
|
||||
const chunks = new Map<string, string[]>()
|
||||
const start = (id: string) =>
|
||||
Effect.suspend(() => {
|
||||
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
|
||||
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
|
||||
chunks.set(id, [])
|
||||
return Effect.void
|
||||
})
|
||||
@@ -105,10 +116,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
current.push(value)
|
||||
return Effect.void
|
||||
})
|
||||
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.join(""), state)
|
||||
chunks.delete(id)
|
||||
})
|
||||
const flush = Effect.fnUntraced(function* () {
|
||||
@@ -117,26 +128,30 @@ 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) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
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, state) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
text: value,
|
||||
state,
|
||||
})
|
||||
}),
|
||||
true,
|
||||
)
|
||||
const toolInput = fragments("tool input", (callID, value) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -191,21 +206,21 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error) {
|
||||
if (assistantFailed) return
|
||||
yield* flush()
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
assistantActive = false
|
||||
assistantFailed = true
|
||||
stepFailure = error
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
error: { type: "unknown", message },
|
||||
error,
|
||||
})
|
||||
})
|
||||
|
||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||
message: string,
|
||||
error: SessionError.Error,
|
||||
hostedOnly = false,
|
||||
) {
|
||||
for (const [callID, tool] of tools) {
|
||||
@@ -215,11 +230,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
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,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -232,16 +244,18 @@ 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":
|
||||
retryEvidence = true
|
||||
yield* text.start(event.id)
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
textID: event.id,
|
||||
})
|
||||
return
|
||||
case "text-delta":
|
||||
@@ -249,7 +263,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
textID: event.id,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
@@ -257,12 +270,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* text.end(event.id)
|
||||
return
|
||||
case "reasoning-start":
|
||||
retryEvidence = true
|
||||
yield* reasoning.start(event.id)
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
reasoningID: event.id,
|
||||
providerMetadata: event.providerMetadata,
|
||||
state: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
case "reasoning-delta":
|
||||
@@ -270,14 +283,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
reasoningID: event.id,
|
||||
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 +312,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 +321,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 +343,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 +353,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
callID: event.id,
|
||||
error: result.error,
|
||||
result: event.result,
|
||||
provider,
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -353,12 +364,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 +382,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, name: event.name }
|
||||
: { 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" })
|
||||
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 })
|
||||
return
|
||||
}
|
||||
})
|
||||
@@ -397,9 +415,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
flush,
|
||||
failAssistant,
|
||||
failUnsettledTools,
|
||||
hasActiveAssistant: () => assistantActive,
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
hasRetryEvidence: () => retryEvidence,
|
||||
stepFailure: () => stepFailure,
|
||||
stepSettlement: () => stepSettlement,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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,
|
||||
retryAfterMs: cause.reason.retryAfterMs,
|
||||
}
|
||||
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,
|
||||
permission: cause.permission,
|
||||
resources: [...cause.resources],
|
||||
}
|
||||
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message, reason: "user" }
|
||||
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, reason: "user" }
|
||||
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) }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -88,7 +88,9 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -121,7 +121,9 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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}`, name: input.call.name } as const)
|
||||
: ({ type: "tool.unknown", message: `Unknown tool: ${input.call.name}`, name: 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}`, name: 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}`, name: input.call.name },
|
||||
})
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -18,8 +18,7 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED =
|
||||
"The command has not completed; it is now running in the background."
|
||||
const BACKGROUND_STARTED = "The command has not completed; it is now running in the background."
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
@@ -245,9 +244,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 {
|
||||
@@ -258,14 +257,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
|
||||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||
import resetExecutionErrorsMigration from "@opencode-ai/core/database/migration/20260703210000_reset_v2_execution_errors"
|
||||
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 +39,29 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("resets incompatible V2 execution and error 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, [resetExecutionErrorsMigration])
|
||||
|
||||
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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -73,6 +73,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
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 +109,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.")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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 closed wire type", () => {
|
||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "rate",
|
||||
retryAfterMs: 123,
|
||||
})
|
||||
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 structured permission rejection data without inventing resources", () => {
|
||||
const rejected = new PermissionV2.RejectedError({ permission: "external_directory", resources: [] })
|
||||
expect(toSessionError(rejected)).toEqual({
|
||||
type: "permission.rejected",
|
||||
message: "Permission rejected: external_directory",
|
||||
permission: "external_directory",
|
||||
resources: [],
|
||||
})
|
||||
expect(toSessionError(new ToolFailure({ message: rejected.message, error: rejected }))).toEqual({
|
||||
type: "permission.rejected",
|
||||
message: "Permission rejected: external_directory",
|
||||
permission: "external_directory",
|
||||
resources: [],
|
||||
})
|
||||
})
|
||||
|
||||
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" })
|
||||
})
|
||||
})
|
||||
@@ -95,7 +95,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),
|
||||
@@ -429,6 +429,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
|
||||
@@ -522,7 +589,6 @@ describe("SessionProjector", () => {
|
||||
yield* service.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
textID: "text-stale",
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
@@ -541,7 +607,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({
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
@@ -158,12 +155,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 +204,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 +219,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 +240,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 +255,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 +271,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 +312,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 +326,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 +342,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 +411,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 +434,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,37 @@ 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 fails a contentless step", 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", "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 },
|
||||
),
|
||||
)
|
||||
|
||||
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" },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -368,6 +372,20 @@ const providerUnavailable = () =>
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
})
|
||||
|
||||
const invalidRequest = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({ message: "Invalid request" }),
|
||||
})
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
|
||||
})
|
||||
|
||||
const setupOverflowRecovery = Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -455,7 +473,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 +493,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 +528,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 +561,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 +575,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 +604,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" } }
|
||||
@@ -1369,7 +1390,7 @@ describe("SessionRunnerLLM", () => {
|
||||
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([
|
||||
@@ -1415,7 +1436,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)
|
||||
@@ -1552,7 +1573,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 +1581,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" },
|
||||
@@ -1637,7 +1660,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1696,15 +1719,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 +1749,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 +1764,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 +1788,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 +1815,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 +1823,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 +2012,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 +2384,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 +2434,8 @@ describe("SessionRunnerLLM", () => {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
tool: "echo",
|
||||
input: { text: "stale" },
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
requests.length = 0
|
||||
response = []
|
||||
@@ -2420,7 +2451,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", name: "echo" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2462,9 +2496,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 +2511,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 +2697,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 +2760,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" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2769,7 +2803,7 @@ 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" },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -2812,13 +2846,80 @@ 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",
|
||||
permission: "edit",
|
||||
resources: ["src/index.ts"],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-permission",
|
||||
state: {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "permission.rejected",
|
||||
message: "Permission rejected: edit",
|
||||
permission: "edit",
|
||||
resources: ["src/index.ts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2860,7 +2961,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 +2972,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" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2942,7 +3044,7 @@ 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" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2983,7 +3085,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 +3120,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 +3248,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 +3267,43 @@ 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("does not recover context overflow after durable assistant output", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
@@ -3189,7 +3318,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 +3338,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
|
||||
@@ -3239,7 +3496,7 @@ 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(executions.slice(executionCount)).toEqual(["settled"])
|
||||
@@ -3264,7 +3521,7 @@ 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([
|
||||
@@ -3311,6 +3568,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 +3584,21 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).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 +3611,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 +3648,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" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,15 @@ 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",
|
||||
permission: "question",
|
||||
resources: ["*"],
|
||||
},
|
||||
})
|
||||
expect(capturedInput()).toBeUndefined()
|
||||
deny = false
|
||||
}),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,10 @@ const executionNode = makeGlobalNode({
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
text: "ok",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
@@ -171,9 +176,11 @@ const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.I
|
||||
})
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locationLayer = locations.get(location)
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
|
||||
yield* waitForTool(registry, ShellTool.name)
|
||||
return yield* body(registry).pipe(Effect.provide(locationLayer))
|
||||
return yield* Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, ShellTool.name)
|
||||
return yield* body(registry)
|
||||
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
|
||||
})
|
||||
|
||||
describe("ShellTool", () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,10 @@ const executionNode = makeGlobalNode({
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
text: childText,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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,19 +99,30 @@ 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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function nextFragmentID(kind: "text" | "reasoning", ordinals: Map<string, number>, messageID: string) {
|
||||
const ordinal = ordinals.get(messageID) ?? 0
|
||||
ordinals.set(messageID, ordinal + 1)
|
||||
return `${kind}:${ordinal}`
|
||||
}
|
||||
|
||||
export function currentFragmentID(kind: "text" | "reasoning", ordinals: Map<string, number>, messageID: string) {
|
||||
return `${kind}:${Math.max(0, (ordinals.get(messageID) ?? 1) - 1)}`
|
||||
}
|
||||
|
||||
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
|
||||
const status = part.state.status
|
||||
const text =
|
||||
@@ -141,6 +158,7 @@ type ToolTrack = {
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
started: number
|
||||
providerState?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ChildState = {
|
||||
@@ -157,6 +175,8 @@ type ChildState = {
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
textOrdinals: Map<string, number>
|
||||
reasoningOrdinals: Map<string, number>
|
||||
tools: Map<string, ToolTrack>
|
||||
finishedTools: Set<string>
|
||||
messageIDs: Set<string>
|
||||
@@ -225,6 +245,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)
|
||||
@@ -241,6 +262,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
projectedText: new Map(),
|
||||
reasoning: new Map(),
|
||||
projectedReasoning: new Map(),
|
||||
textOrdinals: new Map(),
|
||||
reasoningOrdinals: new Map(),
|
||||
tools: new Map(),
|
||||
finishedTools: new Set(),
|
||||
messageIDs: new Set(),
|
||||
@@ -304,11 +327,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)
|
||||
@@ -327,6 +346,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
child.projectedText.clear()
|
||||
child.reasoning.clear()
|
||||
child.projectedReasoning.clear()
|
||||
child.textOrdinals.clear()
|
||||
child.reasoningOrdinals.clear()
|
||||
child.finishedTools.clear()
|
||||
child.messageIDs.clear()
|
||||
child.callIDs.clear()
|
||||
@@ -337,36 +358,44 @@ 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
|
||||
}
|
||||
childTool(child, item, message.id)
|
||||
}
|
||||
child.textOrdinals.set(message.id, textOrdinal)
|
||||
child.reasoningOrdinals.set(message.id, reasoningOrdinal)
|
||||
if (message.error) {
|
||||
setFrame(child, `error:${message.id}`, {
|
||||
kind: "error",
|
||||
@@ -438,74 +467,90 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
input.emit()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.started") {
|
||||
nextFragmentID("text", child.textOrdinals, event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
const projected = child.projectedText.get(event.data.textID)
|
||||
const id = currentFragmentID("text", child.textOrdinals, event.data.assistantMessageID)
|
||||
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 = currentFragmentID("text", child.textOrdinals, event.data.assistantMessageID)
|
||||
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") {
|
||||
nextFragmentID("reasoning", child.reasoningOrdinals, event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const projected = child.projectedReasoning.get(event.data.reasoningID)
|
||||
const id = currentFragmentID("reasoning", child.reasoningOrdinals, event.data.assistantMessageID)
|
||||
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 = currentFragmentID("reasoning", child.reasoningOrdinals, event.data.assistantMessageID)
|
||||
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 +562,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 +594,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,7 +626,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
child.textOrdinals.delete(event.data.assistantMessageID)
|
||||
child.reasoningOrdinals.delete(event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
child.textOrdinals.delete(event.data.assistantMessageID)
|
||||
child.reasoningOrdinals.delete(event.data.assistantMessageID)
|
||||
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
|
||||
kind: "error",
|
||||
source: "system",
|
||||
@@ -589,9 +645,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 +683,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,13 +5,12 @@ import type {
|
||||
QuestionRequest,
|
||||
QuestionV2Request,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
V2Event,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { blockerStatus, pickBlockerView } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
|
||||
import { createSubagentTracker, currentFragmentID, legacyTool, nextFragmentID, toolCommit } from "./stream-v2.subagent"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterView,
|
||||
@@ -95,6 +94,7 @@ type ToolState = {
|
||||
input: Record<string, unknown>
|
||||
started: number
|
||||
running: boolean
|
||||
providerState?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -106,6 +106,8 @@ type State = {
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
textOrdinals: Map<string, number>
|
||||
reasoningOrdinals: Map<string, number>
|
||||
tools: Map<string, ToolState>
|
||||
finishedTools: Set<string>
|
||||
skillMessages: Set<string>
|
||||
@@ -288,6 +290,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
projectedText: new Map(),
|
||||
reasoning: new Map(),
|
||||
projectedReasoning: new Map(),
|
||||
textOrdinals: new Map(),
|
||||
reasoningOrdinals: new Map(),
|
||||
tools: new Map(),
|
||||
finishedTools: new Set(),
|
||||
skillMessages: new Set(),
|
||||
@@ -352,11 +356,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 +367,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 +444,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 +461,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,13 +480,15 @@ 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
|
||||
}
|
||||
if (render) renderTool(message.id, item)
|
||||
}
|
||||
state.textOrdinals.set(message.id, textOrdinal)
|
||||
state.reasoningOrdinals.set(message.id, reasoningOrdinal)
|
||||
if (render && message.error && !state.errors.has(message.id)) {
|
||||
state.errors.add(message.id)
|
||||
write([
|
||||
@@ -596,8 +603,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (owned) wait.resolve()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.started") {
|
||||
nextFragmentID("text", state.textOrdinals, event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
|
||||
const id = currentFragmentID("text", state.textOrdinals, event.data.assistantMessageID)
|
||||
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 +625,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 = currentFragmentID("text", state.textOrdinals, event.data.assistantMessageID)
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const previous = state.text.get(key) ?? ""
|
||||
if (event.data.text.length > previous.length)
|
||||
write([
|
||||
@@ -629,15 +642,20 @@ 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") {
|
||||
nextFragmentID("reasoning", state.reasoningOrdinals, event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
|
||||
const id = currentFragmentID("reasoning", state.reasoningOrdinals, event.data.assistantMessageID)
|
||||
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 +672,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 = currentFragmentID("reasoning", state.reasoningOrdinals, event.data.assistantMessageID)
|
||||
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 +689,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 +712,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 +729,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",
|
||||
@@ -753,6 +775,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
state.textOrdinals.delete(event.data.assistantMessageID)
|
||||
state.reasoningOrdinals.delete(event.data.assistantMessageID)
|
||||
const total =
|
||||
event.data.tokens.input +
|
||||
event.data.tokens.output +
|
||||
@@ -761,32 +785,45 @@ 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
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
state.textOrdinals.delete(event.data.assistantMessageID)
|
||||
state.reasoningOrdinals.delete(event.data.assistantMessageID)
|
||||
state.errors.add(event.data.assistantMessageID)
|
||||
if (state.wait) state.wait.failureRendered = true
|
||||
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()
|
||||
@@ -1046,6 +1083,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
state.projectedText.clear()
|
||||
state.reasoning.clear()
|
||||
state.projectedReasoning.clear()
|
||||
state.textOrdinals.clear()
|
||||
state.reasoningOrdinals.clear()
|
||||
state.tools.clear()
|
||||
state.finishedTools.clear()
|
||||
state.skillMessages.clear()
|
||||
|
||||
@@ -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,15 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
textID: "txt_1",
|
||||
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 +269,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 +364,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 +462,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 +712,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 },
|
||||
},
|
||||
],
|
||||
@@ -728,7 +731,6 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
textID: "txt_1",
|
||||
delta: "answer",
|
||||
},
|
||||
})
|
||||
@@ -741,7 +743,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 +756,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 +767,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 +816,6 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
reasoningID: "reasoning_1",
|
||||
text: "considering",
|
||||
},
|
||||
})
|
||||
@@ -824,6 +825,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 +912,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 +979,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 +1042,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 +1269,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 +1347,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 +1418,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 +1440,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 +1563,6 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child_a",
|
||||
textID: "txt_child",
|
||||
delta: "child answer",
|
||||
},
|
||||
})
|
||||
@@ -1519,8 +1572,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 +1630,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 +1688,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 +1709,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 +1724,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,6 @@ test.skip("text ended populates assistant text content", () => {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID: "text-1",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
@@ -115,7 +114,6 @@ test.skip("text ended populates assistant text content", () => {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID: "text-1",
|
||||
text: "hello assistant",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
@@ -123,7 +121,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 +174,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 +193,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 +204,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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
export * as SessionError from "./session-error.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
const Message = { message: Schema.String }
|
||||
|
||||
const ProviderRateLimit = Schema.Struct({
|
||||
type: Schema.Literal("provider.rate-limit"),
|
||||
...Message,
|
||||
retryAfterMs: Schema.Finite.pipe(optional),
|
||||
})
|
||||
const ProviderAuth = Schema.Struct({ type: Schema.Literal("provider.auth"), ...Message })
|
||||
const ProviderQuota = Schema.Struct({ type: Schema.Literal("provider.quota"), ...Message })
|
||||
const ProviderContentFilter = Schema.Struct({ type: Schema.Literal("provider.content-filter"), ...Message })
|
||||
const ProviderTransport = Schema.Struct({ type: Schema.Literal("provider.transport"), ...Message })
|
||||
const ProviderInternal = Schema.Struct({ type: Schema.Literal("provider.internal"), ...Message })
|
||||
const ProviderInvalidOutput = Schema.Struct({ type: Schema.Literal("provider.invalid-output"), ...Message })
|
||||
const ProviderInvalidRequest = Schema.Struct({ type: Schema.Literal("provider.invalid-request"), ...Message })
|
||||
const ProviderNoRoute = Schema.Struct({ type: Schema.Literal("provider.no-route"), ...Message })
|
||||
const ProviderUnknown = Schema.Struct({ type: Schema.Literal("provider.unknown"), ...Message })
|
||||
const PermissionRejected = Schema.Struct({
|
||||
type: Schema.Literal("permission.rejected"),
|
||||
...Message,
|
||||
permission: Schema.String,
|
||||
resources: Schema.Array(Schema.String),
|
||||
})
|
||||
const ToolUnknown = Schema.Struct({ type: Schema.Literal("tool.unknown"), ...Message, name: Schema.String })
|
||||
const ToolStale = Schema.Struct({
|
||||
type: Schema.Literal("tool.stale"),
|
||||
...Message,
|
||||
name: Schema.String.pipe(optional),
|
||||
})
|
||||
const ToolExecution = Schema.Struct({ type: Schema.Literal("tool.execution"), ...Message })
|
||||
const ToolResultMissing = Schema.Struct({
|
||||
type: Schema.Literal("tool.result-missing"),
|
||||
...Message,
|
||||
callID: Schema.String.pipe(optional),
|
||||
})
|
||||
const Aborted = Schema.Struct({
|
||||
type: Schema.Literal("aborted"),
|
||||
...Message,
|
||||
reason: Schema.Literals(["user", "shutdown", "timeout"]).pipe(optional),
|
||||
})
|
||||
const Unknown = Schema.Struct({
|
||||
type: Schema.Literal("unknown"),
|
||||
...Message,
|
||||
agent: Schema.String.pipe(optional),
|
||||
})
|
||||
|
||||
export const Error = Schema.Union([
|
||||
ProviderRateLimit,
|
||||
ProviderAuth,
|
||||
ProviderQuota,
|
||||
ProviderContentFilter,
|
||||
ProviderTransport,
|
||||
ProviderInternal,
|
||||
ProviderInvalidOutput,
|
||||
ProviderInvalidRequest,
|
||||
ProviderNoRoute,
|
||||
ProviderUnknown,
|
||||
PermissionRejected,
|
||||
ToolUnknown,
|
||||
ToolStale,
|
||||
ToolExecution,
|
||||
ToolResultMissing,
|
||||
Aborted,
|
||||
Unknown,
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Session.StructuredError" })
|
||||
export type Error = typeof Error.Type
|
||||
@@ -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,6 @@ export namespace Text {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
textID: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Started = typeof Started.Type
|
||||
@@ -255,7 +258,6 @@ export namespace Text {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
textID: Schema.String,
|
||||
delta: Schema.String,
|
||||
},
|
||||
})
|
||||
@@ -267,7 +269,6 @@ export namespace Text {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
textID: Schema.String,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
@@ -281,8 +282,7 @@ export namespace Reasoning {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
reasoningID: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(optional),
|
||||
state: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Started = typeof Started.Type
|
||||
@@ -293,7 +293,6 @@ export namespace Reasoning {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
reasoningID: Schema.String,
|
||||
delta: Schema.String,
|
||||
},
|
||||
})
|
||||
@@ -305,9 +304,8 @@ export namespace Reasoning {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
reasoningID: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(optional),
|
||||
state: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
@@ -357,12 +355,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 +386,8 @@ export namespace Tool {
|
||||
content: Schema.Array(ToolContent),
|
||||
outputPaths: Schema.Array(Schema.String).pipe(optional),
|
||||
result: Schema.Unknown.pipe(optional),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: ProviderMetadata.pipe(optional),
|
||||
}),
|
||||
executed: Schema.Boolean,
|
||||
resultState: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
@@ -404,39 +397,27 @@ export namespace Tool {
|
||||
...options,
|
||||
schema: {
|
||||
...ToolBase,
|
||||
error: UnknownError,
|
||||
error: SessionError.Error,
|
||||
result: Schema.Unknown.pipe(optional),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: ProviderMetadata.pipe(optional),
|
||||
}),
|
||||
executed: Schema.Boolean,
|
||||
resultState: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Failed = typeof Failed.Type
|
||||
}
|
||||
|
||||
export const RetryError = Schema.Struct({
|
||||
message: Schema.String,
|
||||
statusCode: Schema.Finite.pipe(optional),
|
||||
isRetryable: Schema.Boolean,
|
||||
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
responseBody: Schema.String.pipe(optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
}).annotate({
|
||||
identifier: "session.retry.error",
|
||||
})
|
||||
export interface RetryError extends Schema.Schema.Type<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 Started = Event.durable({
|
||||
@@ -481,7 +462,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 +474,10 @@ export const Definitions = Event.inventory(
|
||||
Forked,
|
||||
PromptPromoted,
|
||||
PromptAdmitted,
|
||||
ExecutionSettled,
|
||||
Execution.Started,
|
||||
Execution.Succeeded,
|
||||
Execution.Failed,
|
||||
Execution.Interrupted,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Skill.Activated,
|
||||
@@ -515,7 +499,7 @@ export const Definitions = Event.inventory(
|
||||
Tool.Progress,
|
||||
Tool.Success,
|
||||
Tool.Failed,
|
||||
Retried,
|
||||
RetryScheduled,
|
||||
Compaction.Started,
|
||||
Compaction.Delta,
|
||||
Compaction.Ended,
|
||||
|
||||
@@ -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,
|
||||
@@ -122,7 +123,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" })
|
||||
|
||||
@@ -136,11 +137,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,
|
||||
@@ -153,16 +152,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),
|
||||
@@ -174,6 +171,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,
|
||||
@@ -186,7 +190,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,
|
||||
@@ -194,7 +198,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),
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,7 +123,7 @@ describe("public event manifest", () => {
|
||||
"session.tool.failed.1",
|
||||
"session.reasoning.started.1",
|
||||
"session.reasoning.ended.1",
|
||||
"session.retried.1",
|
||||
"session.retry.scheduled.1",
|
||||
"session.compaction.started.1",
|
||||
"session.compaction.ended.1",
|
||||
"session.revert.staged.1",
|
||||
@@ -130,4 +136,32 @@ 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 })
|
||||
const reasoning = SessionEvent.Reasoning.Ended.data.make({
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
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,51 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { LLM, SessionError } from "../src/index.js"
|
||||
|
||||
describe("SessionError", () => {
|
||||
test("exports one identified closed union", () => {
|
||||
expect(SessionError.Error.ast.annotations?.identifier).toBe("Session.StructuredError")
|
||||
expect(Object.keys(SessionError).filter((key) => key !== "SessionError")).toEqual(["Error"])
|
||||
})
|
||||
|
||||
test("round trips every closed error type through JSON", () => {
|
||||
const values: SessionError.Error[] = [
|
||||
{ type: "provider.rate-limit", message: "Slow down", retryAfterMs: 2_500 },
|
||||
{ type: "provider.auth", message: "Authentication failed" },
|
||||
{ type: "provider.quota", message: "Quota exhausted" },
|
||||
{ type: "provider.content-filter", message: "Response blocked" },
|
||||
{ type: "provider.transport", message: "Connection failed" },
|
||||
{ type: "provider.internal", message: "Provider failed" },
|
||||
{ type: "provider.invalid-output", message: "Malformed response" },
|
||||
{ type: "provider.invalid-request", message: "Invalid request" },
|
||||
{ type: "provider.no-route", message: "No route" },
|
||||
{ type: "provider.unknown", message: "Unknown provider failure" },
|
||||
{ type: "permission.rejected", message: "Permission rejected", permission: "read", resources: ["a"] },
|
||||
{ type: "tool.unknown", message: "Unknown tool", name: "missing" },
|
||||
{ type: "tool.stale", message: "Stale tool", name: "old" },
|
||||
{ type: "tool.execution", message: "Tool failed" },
|
||||
{ type: "tool.result-missing", message: "Missing result", callID: "call_1" },
|
||||
{ type: "aborted", message: "Interrupted", reason: "user" },
|
||||
{ type: "unknown", message: "Unexpected", agent: "build" },
|
||||
]
|
||||
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("rejects unknown types and missing messages", () => {
|
||||
expect(() =>
|
||||
Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.timeout", message: "Timeout" }),
|
||||
).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth" })).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()
|
||||
})
|
||||
@@ -60,7 +60,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)1$/.test(
|
||||
name,
|
||||
) &&
|
||||
!reachable.has(name)
|
||||
@@ -100,17 +100,23 @@ 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)1 =/.test(
|
||||
generatedTypes,
|
||||
)
|
||||
) {
|
||||
throw new Error("Session history generated duplicate Session event variants")
|
||||
}
|
||||
const logTypesPatched = generatedTypes.replace(
|
||||
/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/,
|
||||
"$1number",
|
||||
)
|
||||
if (logTypesPatched === generatedTypes) {
|
||||
const duplicateSessionErrorStart = generatedTypes.indexOf("export type SessionStructuredError2 =")
|
||||
const duplicateSessionErrorEnd = generatedTypes.indexOf("\n\nexport type ", duplicateSessionErrorStart + 1)
|
||||
if (duplicateSessionErrorStart === -1 || duplicateSessionErrorEnd === -1) {
|
||||
throw new Error("Session structured error duplicate prune did not apply")
|
||||
}
|
||||
const sessionErrorTypesPatched =
|
||||
generatedTypes.slice(0, duplicateSessionErrorStart) + generatedTypes.slice(duplicateSessionErrorEnd + 2)
|
||||
const logTypesPatched = sessionErrorTypesPatched
|
||||
.replaceAll("SessionStructuredError2", "SessionStructuredError")
|
||||
.replace(/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/, "$1number")
|
||||
if (logTypesPatched === sessionErrorTypesPatched) {
|
||||
throw new Error("Session log numeric query patch did not apply")
|
||||
}
|
||||
const sessionListTypesPatched = logTypesPatched.replace(
|
||||
@@ -128,12 +134,15 @@ 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")
|
||||
}
|
||||
await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
|
||||
|
||||
const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
|
||||
|
||||
@@ -24,7 +24,10 @@ export type Event =
|
||||
| EventSessionForked
|
||||
| EventSessionPromptPromoted
|
||||
| EventSessionPromptAdmitted
|
||||
| EventSessionExecutionSettled
|
||||
| EventSessionExecutionStarted
|
||||
| EventSessionExecutionSucceeded
|
||||
| EventSessionExecutionFailed
|
||||
| EventSessionExecutionInterrupted
|
||||
| EventSessionContextUpdated
|
||||
| EventSessionSynthetic
|
||||
| EventSessionSkillActivated
|
||||
@@ -46,7 +49,7 @@ export type Event =
|
||||
| EventSessionToolProgress
|
||||
| EventSessionToolSuccess
|
||||
| EventSessionToolFailed
|
||||
| EventSessionRetried
|
||||
| EventSessionRetryScheduled
|
||||
| EventSessionCompactionStarted
|
||||
| EventSessionCompactionDelta
|
||||
| EventSessionCompactionEnded
|
||||
@@ -919,11 +922,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 +1018,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 +1039,7 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1024,7 +1048,6 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1033,7 +1056,6 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -1043,7 +1065,6 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -1053,8 +1074,7 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1063,7 +1083,6 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -1073,9 +1092,8 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1115,14 +1133,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 +1166,8 @@ export type GlobalEvent = {
|
||||
content: Array<LlmToolContent>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1164,21 +1177,21 @@ export type GlobalEvent = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.retried"
|
||||
type: "session.retry.scheduled"
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1227,7 +1240,7 @@ export type GlobalEvent = {
|
||||
type: "session.revert.committed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1723,6 +1736,10 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionForked
|
||||
| SyncEventSessionPromptPromoted
|
||||
| SyncEventSessionPromptAdmitted
|
||||
| SyncEventSessionExecutionStarted
|
||||
| SyncEventSessionExecutionSucceeded
|
||||
| SyncEventSessionExecutionFailed
|
||||
| SyncEventSessionExecutionInterrupted
|
||||
| SyncEventSessionContextUpdated
|
||||
| SyncEventSessionSynthetic
|
||||
| SyncEventSessionSkillActivated
|
||||
@@ -1741,7 +1758,7 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionToolProgress
|
||||
| SyncEventSessionToolSuccess
|
||||
| SyncEventSessionToolFailed
|
||||
| SyncEventSessionRetried
|
||||
| SyncEventSessionRetryScheduled
|
||||
| SyncEventSessionCompactionStarted
|
||||
| SyncEventSessionCompactionEnded
|
||||
| SyncEventSessionRevertStaged
|
||||
@@ -2891,6 +2908,10 @@ export type SessionDurableEvent =
|
||||
| SessionForked
|
||||
| SessionPromptPromoted
|
||||
| SessionPromptAdmitted
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
| SessionExecutionInterrupted
|
||||
| SessionContextUpdated
|
||||
| SessionSynthetic
|
||||
| SessionSkillActivated
|
||||
@@ -2909,7 +2930,7 @@ export type SessionDurableEvent =
|
||||
| SessionToolProgress
|
||||
| SessionToolSuccess
|
||||
| SessionToolFailed
|
||||
| SessionRetried
|
||||
| SessionRetryScheduled
|
||||
| SessionCompactionStarted
|
||||
| SessionCompactionEnded
|
||||
| SessionRevertStaged
|
||||
@@ -3032,7 +3053,10 @@ export type V2Event =
|
||||
| SessionForked
|
||||
| SessionPromptPromoted
|
||||
| SessionPromptAdmitted
|
||||
| SessionExecutionSettled
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
| SessionExecutionInterrupted
|
||||
| SessionContextUpdated
|
||||
| SessionSynthetic
|
||||
| SessionSkillActivated
|
||||
@@ -3054,7 +3078,7 @@ export type V2Event =
|
||||
| SessionToolProgress
|
||||
| SessionToolSuccess
|
||||
| SessionToolFailed
|
||||
| SessionRetried
|
||||
| SessionRetryScheduled
|
||||
| SessionCompactionStarted
|
||||
| SessionCompactionDelta
|
||||
| SessionCompactionEnded
|
||||
@@ -3271,15 +3295,86 @@ export type PromptAgentAttachment = {
|
||||
source?: PromptSource
|
||||
}
|
||||
|
||||
export type SessionErrorUnknown = {
|
||||
type: "unknown"
|
||||
message: string
|
||||
}
|
||||
export type SessionStructuredError =
|
||||
| {
|
||||
type: "provider.rate-limit"
|
||||
message: string
|
||||
retryAfterMs?: number
|
||||
}
|
||||
| {
|
||||
type: "provider.auth"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.quota"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.content-filter"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.transport"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.internal"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.invalid-output"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.invalid-request"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.no-route"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.unknown"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "permission.rejected"
|
||||
message: string
|
||||
permission: string
|
||||
resources: Array<string>
|
||||
}
|
||||
| {
|
||||
type: "tool.unknown"
|
||||
message: string
|
||||
name: string
|
||||
}
|
||||
| {
|
||||
type: "tool.stale"
|
||||
message: string
|
||||
name?: string
|
||||
}
|
||||
| {
|
||||
type: "tool.execution"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "tool.result-missing"
|
||||
message: string
|
||||
callID?: string
|
||||
}
|
||||
| {
|
||||
type: "aborted"
|
||||
message: string
|
||||
reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| {
|
||||
type: "unknown"
|
||||
message: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ToolTextContent = {
|
||||
@@ -3296,19 +3391,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 +3810,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 +3983,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 +4011,7 @@ export type SyncEventSessionStepFailed = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3887,7 +4027,6 @@ export type SyncEventSessionTextStarted = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3903,7 +4042,6 @@ export type SyncEventSessionTextEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -3920,8 +4058,7 @@ export type SyncEventSessionReasoningStarted = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3937,9 +4074,8 @@ export type SyncEventSessionReasoningEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3990,14 +4126,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 +4173,8 @@ export type SyncEventSessionToolSuccess = {
|
||||
content: Array<LlmToolContent>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4060,28 +4191,28 @@ export type SyncEventSessionToolFailed = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionRetried = {
|
||||
export type SyncEventSessionRetryScheduled = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.retried.1"
|
||||
type: "session.retry.scheduled.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4157,7 +4288,7 @@ export type SyncEventSessionRevertCommitted = {
|
||||
aggregateID: string
|
||||
data: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4386,15 +4517,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
|
||||
@@ -4440,7 +4569,7 @@ export type SessionMessageToolStateError = {
|
||||
structured: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
@@ -4448,11 +4577,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
|
||||
@@ -4466,6 +4593,12 @@ export type SessionMessageAssistantTool = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantRetry = {
|
||||
attempt: number
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionMessageAssistant = {
|
||||
id: string
|
||||
metadata?: {
|
||||
@@ -4484,7 +4617,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
|
||||
@@ -4495,7 +4628,8 @@ export type SessionMessageAssistant = {
|
||||
write: number
|
||||
}
|
||||
}
|
||||
error?: SessionErrorUnknown
|
||||
error?: SessionStructuredError
|
||||
retry?: SessionMessageAssistantRetry
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction = {
|
||||
@@ -4667,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
|
||||
@@ -4811,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
|
||||
@@ -4843,7 +5051,7 @@ export type SessionStepFailed = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4863,7 +5071,6 @@ export type SessionTextStarted = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4883,7 +5090,6 @@ export type SessionTextEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -4904,8 +5110,7 @@ export type SessionReasoningStarted = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4925,9 +5130,8 @@ export type SessionReasoningEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4990,14 +5194,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5048,10 +5249,8 @@ export type SessionToolSuccess = {
|
||||
content: Array<LlmToolContent>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5072,22 +5271,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
|
||||
@@ -5096,8 +5293,10 @@ export type SessionRetried = {
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5193,7 +5392,7 @@ export type SessionRevertCommitted = {
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5724,21 +5923,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
|
||||
@@ -5750,7 +5934,6 @@ export type SessionTextDelta = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -5766,7 +5949,6 @@ export type SessionReasoningDelta = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -6826,13 +7008,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6910,7 +7116,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
|
||||
@@ -6932,7 +7138,7 @@ export type EventSessionStepFailed = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6942,7 +7148,6 @@ export type EventSessionTextStarted = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6952,7 +7157,6 @@ export type EventSessionTextDelta = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -6963,7 +7167,6 @@ export type EventSessionTextEnded = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -6974,8 +7177,7 @@ export type EventSessionReasoningStarted = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6985,7 +7187,6 @@ export type EventSessionReasoningDelta = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -6996,9 +7197,8 @@ export type EventSessionReasoningEnded = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7042,14 +7242,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7080,10 +7277,8 @@ export type EventSessionToolSuccess = {
|
||||
content: Array<LlmToolContent>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7094,22 +7289,22 @@ export type EventSessionToolFailed = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionRetried = {
|
||||
export type EventSessionRetryScheduled = {
|
||||
id: string
|
||||
type: "session.retried"
|
||||
type: "session.retry.scheduled"
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7164,7 +7359,7 @@ export type EventSessionRevertCommitted = {
|
||||
type: "session.revert.committed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8079,21 +8274,17 @@ export type SessionMessageShell2 = {
|
||||
|
||||
export type SessionMessageAssistantText2 = {
|
||||
type: "text"
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata2 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState2 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantReasoning2 = {
|
||||
type: "reasoning"
|
||||
id: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata2
|
||||
state?: SessionMessageProviderState2
|
||||
time?: {
|
||||
created: number
|
||||
completed?: number
|
||||
@@ -8144,11 +8335,6 @@ export type SessionMessageToolStateCompleted2 = {
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
export type SessionErrorUnknown2 = {
|
||||
type: "unknown"
|
||||
message: string
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateError2 = {
|
||||
status: "error"
|
||||
input: {
|
||||
@@ -8158,7 +8344,7 @@ export type SessionMessageToolStateError2 = {
|
||||
structured: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
error: SessionErrorUnknown2
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
@@ -8166,11 +8352,9 @@ export type SessionMessageAssistantTool2 = {
|
||||
type: "tool"
|
||||
id: string
|
||||
name: string
|
||||
provider?: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata2
|
||||
resultMetadata?: LlmProviderMetadata2
|
||||
}
|
||||
executed?: boolean
|
||||
providerState?: SessionMessageProviderState2
|
||||
providerResultState?: SessionMessageProviderState2
|
||||
state:
|
||||
| SessionMessageToolStatePending2
|
||||
| SessionMessageToolStateRunning2
|
||||
@@ -8184,6 +8368,12 @@ export type SessionMessageAssistantTool2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantRetry2 = {
|
||||
attempt: number
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionMessageAssistant2 = {
|
||||
id: string
|
||||
metadata?: {
|
||||
@@ -8202,7 +8392,7 @@ export type SessionMessageAssistant2 = {
|
||||
end?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
finish?: string
|
||||
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost?: number
|
||||
tokens?: {
|
||||
input: number
|
||||
@@ -8213,7 +8403,8 @@ export type SessionMessageAssistant2 = {
|
||||
write: number
|
||||
}
|
||||
}
|
||||
error?: SessionErrorUnknown2
|
||||
error?: SessionStructuredError
|
||||
retry?: SessionMessageAssistantRetry2
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction2 = {
|
||||
@@ -8388,6 +8579,80 @@ export type SessionPromptAdmitted2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionStarted2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.started"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionSucceeded2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.succeeded"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionFailed2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.failed"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionInterrupted2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.interrupted"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "user" | "shutdown" | "superseded"
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionContextUpdated2 = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -8550,7 +8815,7 @@ export type SessionStepEnded2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
finish: string
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
@@ -8582,7 +8847,7 @@ export type SessionStepFailed2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown2
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8602,7 +8867,6 @@ export type SessionTextStarted2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8622,15 +8886,12 @@ export type SessionTextEnded2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata3 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState3 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionReasoningStarted2 = {
|
||||
@@ -8649,15 +8910,12 @@ export type SessionReasoningStarted2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata3
|
||||
state?: SessionMessageProviderState3
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata4 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState4 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionReasoningEnded2 = {
|
||||
@@ -8676,9 +8934,8 @@ export type SessionReasoningEnded2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata4
|
||||
state?: SessionMessageProviderState4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8724,10 +8981,8 @@ export type SessionToolInputEnded2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata5 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState5 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionToolCalled2 = {
|
||||
@@ -8747,14 +9002,11 @@ export type SessionToolCalled2 = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
tool: string
|
||||
input: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata5
|
||||
}
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8782,10 +9034,8 @@ export type SessionToolProgress2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata6 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState6 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionToolSuccess2 = {
|
||||
@@ -8811,17 +9061,13 @@ export type SessionToolSuccess2 = {
|
||||
content: Array<LlmToolContent2>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata6
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState6
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata7 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState7 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionToolFailed2 = {
|
||||
@@ -8841,35 +9087,20 @@ export type SessionToolFailed2 = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown2
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata7
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionRetryError2 = {
|
||||
message: string
|
||||
statusCode?: number
|
||||
isRetryable: boolean
|
||||
responseHeaders?: {
|
||||
[key: string]: string
|
||||
}
|
||||
responseBody?: string
|
||||
metadata?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionRetried2 = {
|
||||
export type SessionRetryScheduled2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.retried"
|
||||
type: "session.retry.scheduled"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
@@ -8878,8 +9109,10 @@ export type SessionRetried2 = {
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError2
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8975,7 +9208,7 @@ export type SessionRevertCommitted2 = {
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8987,6 +9220,10 @@ export type SessionDurableEventV2 =
|
||||
| SessionForked2
|
||||
| SessionPromptPromoted2
|
||||
| SessionPromptAdmitted2
|
||||
| SessionExecutionStarted2
|
||||
| SessionExecutionSucceeded2
|
||||
| SessionExecutionFailed2
|
||||
| SessionExecutionInterrupted2
|
||||
| SessionContextUpdated2
|
||||
| SessionSynthetic2
|
||||
| SessionSkillActivated2
|
||||
@@ -9005,7 +9242,7 @@ export type SessionDurableEventV2 =
|
||||
| SessionToolProgress2
|
||||
| SessionToolSuccess2
|
||||
| SessionToolFailed2
|
||||
| SessionRetried2
|
||||
| SessionRetryScheduled2
|
||||
| SessionCompactionStarted2
|
||||
| SessionCompactionEnded2
|
||||
| SessionRevertStaged2
|
||||
@@ -10223,21 +10460,6 @@ export type MessagePartRemoved2 = {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -10249,7 +10471,6 @@ export type SessionTextDelta2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -10265,7 +10486,6 @@ export type SessionReasoningDelta2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -11156,7 +11376,10 @@ export type V2EventV2 =
|
||||
| SessionForked2
|
||||
| SessionPromptPromoted2
|
||||
| SessionPromptAdmitted2
|
||||
| SessionExecutionSettled2
|
||||
| SessionExecutionStarted2
|
||||
| SessionExecutionSucceeded2
|
||||
| SessionExecutionFailed2
|
||||
| SessionExecutionInterrupted2
|
||||
| SessionContextUpdated2
|
||||
| SessionSynthetic2
|
||||
| SessionSkillActivated2
|
||||
@@ -11178,7 +11401,7 @@ export type V2EventV2 =
|
||||
| SessionToolProgress2
|
||||
| SessionToolSuccess2
|
||||
| SessionToolFailed2
|
||||
| SessionRetried2
|
||||
| SessionRetryScheduled2
|
||||
| SessionCompactionStarted2
|
||||
| SessionCompactionDelta2
|
||||
| SessionCompactionEnded2
|
||||
|
||||
@@ -51,6 +51,7 @@ 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>>
|
||||
message: Record<string, SessionMessage[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
question: Record<string, QuestionV2Request[]>
|
||||
@@ -69,6 +70,20 @@ function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
}
|
||||
|
||||
export function mergeActiveSessionStatus(
|
||||
active: Readonly<Record<string, unknown>>,
|
||||
current: Readonly<Record<string, DataSessionStatus>>,
|
||||
changed: ReadonlyMap<string, number>,
|
||||
revision: number,
|
||||
) {
|
||||
const status: Record<string, DataSessionStatus> = Object.fromEntries(
|
||||
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
|
||||
)
|
||||
for (const [sessionID, value] of Object.entries(current))
|
||||
if ((changed.get(sessionID) ?? 0) > revision) status[sessionID] = value
|
||||
return status
|
||||
}
|
||||
|
||||
type Mutable<T> =
|
||||
T extends ReadonlyArray<infer U> ? Mutable<U>[] : T extends object ? { -readonly [K in keyof T]: Mutable<T[K]> } : T
|
||||
|
||||
@@ -85,6 +100,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
info: {},
|
||||
family: {},
|
||||
status: {},
|
||||
compaction: {},
|
||||
message: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
@@ -100,6 +116,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
const statusChanged = new Map<string, number>()
|
||||
let statusRevision = 0
|
||||
let bootstrapping: Promise<void> | undefined
|
||||
|
||||
const message = {
|
||||
@@ -136,14 +154,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
|
||||
)
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -249,7 +265,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
break
|
||||
case "session.prompt.promoted": {
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
const existing = position === undefined ? undefined : draft[position]
|
||||
@@ -300,7 +315,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.shell.started":
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
@@ -311,7 +325,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.shell.ended":
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.shell(draft, event.data.shell.id)
|
||||
if (!match) return
|
||||
@@ -321,11 +334,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.step.started":
|
||||
setStore("session", "status", 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",
|
||||
@@ -338,7 +364,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.step.ended":
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
@@ -357,32 +382,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
|
||||
@@ -423,7 +442,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
|
||||
@@ -452,11 +472,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
|
||||
@@ -475,11 +492,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
|
||||
@@ -487,41 +501,57 @@ 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":
|
||||
statusChanged.set(event.data.sessionID, ++statusRevision)
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
break
|
||||
case "session.execution.settled":
|
||||
case "session.compaction.started":
|
||||
setStore("session", "compaction", event.data.sessionID, "")
|
||||
break
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted":
|
||||
statusChanged.set(event.data.sessionID, ++statusRevision)
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
if (store.session.compaction[event.data.sessionID] !== undefined)
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
break
|
||||
case "session.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
@@ -533,8 +563,10 @@ 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)
|
||||
break
|
||||
case "session.compaction.ended":
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
@@ -655,6 +687,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)
|
||||
@@ -840,6 +875,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapping) return bootstrapping
|
||||
const activeRevision = statusRevision
|
||||
bootstrapping = Promise.allSettled([
|
||||
sdk.api.session
|
||||
.list({
|
||||
@@ -858,15 +894,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
for (const session of response.data) registerSession(session.id)
|
||||
}),
|
||||
sdk.api.session
|
||||
.active()
|
||||
.then((active) =>
|
||||
setStore(
|
||||
"session",
|
||||
"status",
|
||||
Object.fromEntries(Object.keys(active.data).map((sessionID) => [sessionID, "running" as const])),
|
||||
),
|
||||
),
|
||||
sdk.api.session.active().then((active) => {
|
||||
setStore(
|
||||
"session",
|
||||
"status",
|
||||
mergeActiveSessionStatus(active.data, store.session.status, statusChanged, activeRevision),
|
||||
)
|
||||
}),
|
||||
result.location.refresh(),
|
||||
result.location.agent.refresh(),
|
||||
result.location.integration.refresh(),
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -915,6 +915,9 @@ export function Session() {
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={data.session.compaction(route.sessionID)}>
|
||||
{(text) => <CompactionMessage text={text()} />}
|
||||
</Show>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
@@ -1092,7 +1095,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 +1135,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 +1214,15 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={props.message.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>
|
||||
<box paddingLeft={3} marginTop={props.message.error ? 1 : 0}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
|
||||
@@ -1260,9 +1272,15 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
|
||||
)
|
||||
}
|
||||
|
||||
function CompactionMessage() {
|
||||
function CompactionMessage(props: { text?: string }) {
|
||||
const { theme } = useTheme()
|
||||
return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
|
||||
return (
|
||||
<box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive}>
|
||||
<Show when={props.text}>
|
||||
<text fg={theme.textMuted}>{props.text}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function statusLabel(status: "added" | "modified" | "deleted") {
|
||||
@@ -1543,6 +1561,15 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
||||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={props.message.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>
|
||||
<Switch>
|
||||
<Match when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3}>
|
||||
|
||||
@@ -116,6 +116,20 @@ export function createSessionRows(sessionID: Accessor<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)
|
||||
const ordinal = message?.type === "assistant" ? message.content.filter((part) => part.type === kind).length - 1 : 0
|
||||
return { messageID, partID: `${kind}:${Math.max(0, ordinal)}` }
|
||||
}
|
||||
|
||||
const isQueued = (messageID: string) => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
return message?.type === "user" && message.metadata?.queued === true
|
||||
@@ -145,25 +159,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)
|
||||
@@ -187,11 +206,13 @@ export function reduceSessionRows(messages: SessionMessage[]) {
|
||||
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 })
|
||||
}
|
||||
@@ -199,6 +220,15 @@ export function reduceSessionRows(messages: SessionMessage[]) {
|
||||
}, [])
|
||||
}
|
||||
|
||||
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) {
|
||||
return message.type === "user" && message.metadata?.queued === true
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -7,7 +7,8 @@ import { EventV2 } from "@opencode-ai/core/event"
|
||||
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 { DataProvider, mergeActiveSessionStatus, useData } from "../../../src/context/data"
|
||||
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"
|
||||
|
||||
@@ -27,6 +28,24 @@ function durable(sessionID: string, seq = 0, version = 1) {
|
||||
return { aggregateID: sessionID, seq, version }
|
||||
}
|
||||
|
||||
test("active bootstrap preserves newer lifecycle status", () => {
|
||||
expect(
|
||||
mergeActiveSessionStatus(
|
||||
{ "snapshot-active": {} },
|
||||
{ "started-during-bootstrap": "running", "stopped-during-bootstrap": "idle" },
|
||||
new Map([
|
||||
["started-during-bootstrap", 2],
|
||||
["stopped-during-bootstrap", 3],
|
||||
]),
|
||||
1,
|
||||
),
|
||||
).toEqual({
|
||||
"snapshot-active": "running",
|
||||
"started-during-bootstrap": "running",
|
||||
"stopped-during-bootstrap": "idle",
|
||||
})
|
||||
})
|
||||
|
||||
test("refreshes resources into reactive getters", async () => {
|
||||
const events = createEventStream()
|
||||
const location = {
|
||||
@@ -244,9 +263,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 />
|
||||
}
|
||||
|
||||
@@ -266,6 +287,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,
|
||||
@@ -278,8 +308,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,
|
||||
@@ -300,16 +328,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,
|
||||
@@ -322,8 +357,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,
|
||||
@@ -332,26 +365,146 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
assistantMessageID: "message-failed",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-failed", "message-failed")
|
||||
return assistant?.type === "assistant" && assistant.finish === "error"
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.finish === "error" &&
|
||||
assistant.error?.type === "provider.content-filter"
|
||||
)
|
||||
})
|
||||
expect(data.session.status("session-failed")).toBe("running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_execution_settled",
|
||||
id: "evt_failed_execution_failed",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
type: "session.execution.failed",
|
||||
durable: durable("session-failed", 1, 3),
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
outcome: "failure",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-failed") === "idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_execution_started",
|
||||
created: 0,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-retry"),
|
||||
data: { sessionID: "session-retry" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_step_started",
|
||||
created: 0,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-retry", 1, 2),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_scheduled",
|
||||
created: 0,
|
||||
type: "session.retry.scheduled",
|
||||
durable: durable("session-retry", 1, 3),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry?.attempt === 2
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_next_step",
|
||||
created: 2_000,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-retry", 1, 4),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry === undefined
|
||||
})
|
||||
await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_scheduled_again",
|
||||
created: 2_000,
|
||||
type: "session.retry.scheduled",
|
||||
durable: durable("session-retry", 1, 5),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
attempt: 3,
|
||||
at: 6_000,
|
||||
error: { type: "provider.transport", message: "Disconnected again" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry?.attempt === 3
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_interrupted",
|
||||
created: 2_000,
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("session-retry", 1, 6),
|
||||
data: { sessionID: "session-retry", reason: "shutdown" },
|
||||
})
|
||||
await wait(() => data.session.status("session-retry") === "idle")
|
||||
expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started",
|
||||
created: 0,
|
||||
type: "session.compaction.started",
|
||||
durable: durable("session-live", 2),
|
||||
data: { sessionID: "session-live", reason: "auto" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta_1",
|
||||
created: 0,
|
||||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-live", text: "Live " },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta_2",
|
||||
created: 0,
|
||||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-live", text: "summary" },
|
||||
})
|
||||
await wait(() => data.session.compaction("session-live") === "Live summary")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
created: 0,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable("session-live", 3),
|
||||
data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" },
|
||||
})
|
||||
await wait(() => data.session.compaction("session-live") === undefined)
|
||||
expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "Live summary",
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -844,9 +997,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, {
|
||||
@@ -859,7 +1012,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 },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -885,11 +1039,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",
|
||||
|
||||
@@ -6,19 +6,19 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
const messages: SessionMessage[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", id: "text-1", text: "Looking" },
|
||||
{ type: "text", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
|
||||
{ type: "text", id: "text-2", text: "Done" },
|
||||
{ type: "text", text: "Done" },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
@@ -30,7 +30,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -62,20 +62,38 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("assigns stable kind ordinals within an assistant message", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "reasoning", text: "Think" },
|
||||
{ type: "text", text: "Second" },
|
||||
{ type: "reasoning", text: "Check" },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("groups across empty assistant reasoning parts", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
|
||||
{ type: "reasoning", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{ type: "reasoning", id: "reasoning-2", text: "" },
|
||||
{ type: "reasoning", text: "" },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
@@ -95,9 +113,7 @@ test("completes exploration groups when another row follows", () => {
|
||||
])
|
||||
finished.finish = "stop"
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
||||
]),
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
||||
finished,
|
||||
]
|
||||
@@ -182,6 +198,17 @@ test("renders synthetic messages with descriptions", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||
const message = assistant("assistant-retry", [])
|
||||
message.retry = {
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
}
|
||||
|
||||
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# V2 Schema Changelog
|
||||
|
||||
## 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 the closed, dot-cased `SessionError` wire union and 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.
|
||||
- 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 +952,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; UI references use assistant message ID, kind, and ordinal. 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_event_fragments` wipes experimental V2 events, sequences, projected messages, and admitted inputs.
|
||||
- Promise, Effect, and legacy JavaScript SDK surfaces are regenerated from the simplified schemas.
|
||||
|
||||
+13
-3
@@ -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
|
||||
@@ -49,6 +49,12 @@ SessionExecution.resume(sessionID)
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
## Context Epochs
|
||||
@@ -116,6 +122,10 @@ Compaction keeps the full transcript durable while replacing its active model re
|
||||
|
||||
`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.
|
||||
|
||||
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; projected UI identity is the assistant message ID plus content kind and ordinal. Tool calls retain `callID` because settlements and provider replay correlate through it.
|
||||
|
||||
Provider continuation state is opaque and un-nested at the Session boundary. The publisher selects only the active model provider's entry from LLM provider metadata. Same-model replay re-nests that state under the current provider; model switches and failed assistant steps continue to suppress provider-native continuation state.
|
||||
|
||||
Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending step.
|
||||
|
||||
When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical step with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up.
|
||||
@@ -150,7 +160,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 +174,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.
|
||||
|
||||
|
||||
+1
-1
@@ -55,7 +55,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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user