feat(runtime): add user-controlled turn recovery

This commit is contained in:
Xubin Ren
2026-08-24 00:58:04 +08:00
parent ffa58aa5ef
commit 12029f8812
60 changed files with 4027 additions and 169 deletions
+132 -1
View File
@@ -54,6 +54,16 @@ export interface RuntimeControls {
canUseFullAccess: boolean
}
export type RecoveryStatus = "resuming" | "awaiting_user" | "recovered" | "failed"
export interface RecoveryState {
status: RecoveryStatus
recovery_id: string
reason?: string
attempts?: number
can_continue?: boolean
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| {
@@ -61,6 +71,7 @@ export type InboundEvent =
chat_id: string
model_preset?: string | null
usage?: TokenUsage
recovery_state?: RecoveryState
}
| {
event: "message_accepted"
@@ -118,6 +129,7 @@ export type InboundEvent =
turn_id?: string
}
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
| ({ event: "recovery_state"; chat_id: string } & RecoveryState)
| {
event: "session_updated"
chat_id: string
@@ -139,6 +151,12 @@ type OutboundEvent =
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| {
type: "webui_request"
request_id: string
action: string
payload: Record<string, unknown>
}
| {
type: "message"
chat_id: string
@@ -271,6 +289,7 @@ export interface SessionSummary {
updatedAt: string | null
runStartedAt: number | null
modelPreset: string | null
recoveryState?: RecoveryState | null
workspaceScope?: WorkspaceScopePayload | null
pinned: boolean
archived: boolean
@@ -297,6 +316,7 @@ const CHAT_EVENTS = new Set([
"turn_end",
"goal_status",
"goal_state",
"recovery_state",
"session_updated",
"turn_model_updated",
"error",
@@ -377,6 +397,34 @@ function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload {
&& optional(value.restrict_to_workspace, "boolean")
}
function isRecoveryState(value: unknown): value is RecoveryState {
return isRecord(value)
&& ["resuming", "awaiting_user", "recovered", "failed"].includes(String(value.status))
&& typeof value.recovery_id === "string"
&& optional(value.reason, "string")
&& optional(value.attempts, "number")
&& optional(value.can_continue, "boolean")
}
interface WebUIResponseEvent {
event: "webui_response"
request_id: string
ok: boolean
result?: unknown
error?: { status: number; message: string }
}
function decodeWebUIResponse(value: unknown): WebUIResponseEvent | null | undefined {
if (!isRecord(value) || value.event !== "webui_response") return undefined
if (typeof value.request_id !== "string" || typeof value.ok !== "boolean") return null
if (value.ok) return value as unknown as WebUIResponseEvent
return isRecord(value.error)
&& typeof value.error.status === "number"
&& typeof value.error.message === "string"
? value as unknown as WebUIResponseEvent
: null
}
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null
const record = value
@@ -407,7 +455,8 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
&& ((record.model_preset !== undefined
&& record.model_preset !== null
&& typeof record.model_preset !== "string")
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|| (record.recovery_state !== undefined && !isRecoveryState(record.recovery_state)))
) return null
if (
["user_message", "message", "delta", "reasoning_delta"].includes(name)
@@ -452,6 +501,7 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
if (name === "goal_state" && !isRecord(record.goal_state)) return null
if (name === "recovery_state" && !isRecoveryState(record)) return null
if (
name === "session_updated"
&& (!optional(record.scope, "string")
@@ -700,6 +750,9 @@ export async function fetchSessions(
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
? value.model_preset.trim()
: null,
...(isRecoveryState(value.recovery_state)
? { recoveryState: value.recovery_state }
: {}),
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
pinned: pinned.has(value.key),
archived: archived.has(value.key),
@@ -855,6 +908,11 @@ export class NanobotClient {
private closedByClient = false
private opening = false
private connectedOnce = false
private readonly pendingMutations = new Map<string, {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}>()
constructor(private readonly options: ClientOptions) {}
@@ -916,6 +974,7 @@ export class NanobotClient {
socket.addEventListener("close", () => {
if (this.socket !== socket) return
this.socket = null
this.rejectPendingMutations("gateway connection closed")
if (this.closedByClient) {
this.options.onStatus("closed")
return
@@ -931,6 +990,7 @@ export class NanobotClient {
const socket = this.socket
this.socket = null
socket?.close()
this.rejectPendingMutations("gateway connection closed")
}
send(content: string, options: MessageOptions = {}): string {
@@ -975,6 +1035,63 @@ export class NanobotClient {
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
}
updateRecovery(
action: "continue" | "dismiss",
chatId: string,
recoveryId: string,
): Promise<RecoveryState> {
return this.requestMutation<unknown>(`recovery.${action}`, {
chat_id: chatId,
recovery_id: recoveryId,
}).then((result) => {
if (!isRecoveryState(result)) throw new Error("gateway returned an invalid recovery state")
return result
})
}
private requestMutation<T>(
action: string,
payload: Record<string, unknown> = {},
timeoutMs = 20_000,
): Promise<T> {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway connection is not open"))
}
const requestId = crypto.randomUUID()
const frame = JSON.stringify({
type: "webui_request",
request_id: requestId,
action,
payload,
} satisfies OutboundEvent)
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingMutations.delete(requestId)
reject(new Error(`gateway request timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingMutations.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timer,
})
try {
this.socket?.send(frame)
} catch {
clearTimeout(timer)
this.pendingMutations.delete(requestId)
reject(new Error("could not send gateway request"))
}
})
}
private rejectPendingMutations(message: string): void {
for (const pending of this.pendingMutations.values()) {
clearTimeout(pending.timer)
pending.reject(new Error(message))
}
this.pendingMutations.clear()
}
private handleMessage(raw: string): void {
let value: unknown
try {
@@ -983,6 +1100,20 @@ export class NanobotClient {
this.options.onStatus("error", "gateway sent invalid JSON")
return
}
const response = decodeWebUIResponse(value)
if (response === null) {
this.options.onStatus("error", "gateway sent an invalid event")
return
}
if (response) {
const pending = this.pendingMutations.get(response.request_id)
if (!pending) return
clearTimeout(pending.timer)
this.pendingMutations.delete(response.request_id)
if (response.ok) pending.resolve(response.result)
else pending.reject(new Error(response.error?.message || "gateway request failed"))
return
}
const event = decodeInboundEvent(value)
if (event === undefined) return
if (event === null) {