mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(runtime): add user-controlled turn recovery
This commit is contained in:
+119
-1
@@ -7,7 +7,12 @@ import {
|
||||
} from "@opentui/core/testing"
|
||||
|
||||
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
|
||||
import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
|
||||
import type {
|
||||
MessageOptions,
|
||||
RecoveryState,
|
||||
SlashCommand,
|
||||
WorkspaceScopePayload,
|
||||
} from "./protocol"
|
||||
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
|
||||
|
||||
const options: AppOptions = {
|
||||
@@ -91,6 +96,13 @@ function client(
|
||||
setWorkspaceScope(scope: WorkspaceScopePayload) {
|
||||
scopes.push(scope)
|
||||
},
|
||||
updateRecovery(
|
||||
_action: "continue" | "dismiss",
|
||||
_chatId: string,
|
||||
recoveryId: string,
|
||||
): Promise<RecoveryState> {
|
||||
return Promise.resolve({ status: "recovered" as const, recovery_id: recoveryId })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,6 +915,112 @@ describe("NanobotTui layout", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("offers clickable recovery actions without letting a late response revive stale state", async () => {
|
||||
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
|
||||
const calls: Array<{ action: string; chatId: string; recoveryId: string }> = []
|
||||
let deferredResolve: ((state: RecoveryState) => void) | undefined
|
||||
const recoveryClient = client()
|
||||
recoveryClient.updateRecovery = (action, chatId, recoveryId) => {
|
||||
calls.push({ action, chatId, recoveryId })
|
||||
if (recoveryId === "recovery-1") {
|
||||
return Promise.resolve({ status: "resuming", recovery_id: recoveryId })
|
||||
}
|
||||
if (action === "dismiss") {
|
||||
return Promise.resolve({ status: "recovered", recovery_id: recoveryId })
|
||||
}
|
||||
return new Promise((resolve) => { deferredResolve = resolve })
|
||||
}
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
options,
|
||||
recoveryClient,
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
app.accept({
|
||||
event: "attached",
|
||||
chat_id: "chat",
|
||||
recovery_state: {
|
||||
status: "awaiting_user",
|
||||
recovery_id: "recovery-1",
|
||||
reason: "tool execution interrupted",
|
||||
},
|
||||
})
|
||||
const ui = app as unknown as {
|
||||
activeTurn: boolean
|
||||
composer: TextareaRenderable
|
||||
recoveryNotice: {
|
||||
visible: boolean
|
||||
dismiss: TextRenderable
|
||||
resume: TextRenderable
|
||||
}
|
||||
status: TextRenderable
|
||||
}
|
||||
|
||||
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("Task interrupted")
|
||||
expect(ui.activeTurn).toBe(false)
|
||||
expect(ui.composer.focused).toBe(true)
|
||||
|
||||
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
|
||||
await waitUntil(() => calls.length === 1 && ui.activeTurn)
|
||||
expect(calls[0]).toEqual({
|
||||
action: "continue",
|
||||
chatId: "chat",
|
||||
recoveryId: "recovery-1",
|
||||
})
|
||||
expect(ui.recoveryNotice.visible).toBe(false)
|
||||
expect(ui.status.plainText).toContain("Continuing")
|
||||
|
||||
app.accept({
|
||||
event: "recovery_state",
|
||||
chat_id: "chat",
|
||||
status: "awaiting_user",
|
||||
recovery_id: "recovery-2",
|
||||
})
|
||||
await setup.renderOnce()
|
||||
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
|
||||
await waitUntil(() => calls.length === 2)
|
||||
app.accept({
|
||||
event: "recovery_state",
|
||||
chat_id: "chat",
|
||||
status: "recovered",
|
||||
recovery_id: "recovery-2",
|
||||
})
|
||||
deferredResolve?.({ status: "resuming", recovery_id: "recovery-2" })
|
||||
await Bun.sleep(1)
|
||||
|
||||
expect(ui.recoveryNotice.visible).toBe(false)
|
||||
expect(ui.activeTurn).toBe(false)
|
||||
expect(ui.composer.focused).toBe(true)
|
||||
|
||||
app.accept({
|
||||
event: "recovery_state",
|
||||
chat_id: "chat",
|
||||
status: "awaiting_user",
|
||||
recovery_id: "recovery-3",
|
||||
})
|
||||
await setup.renderOnce()
|
||||
await setup.mockMouse.click(ui.recoveryNotice.dismiss.x + 1, ui.recoveryNotice.dismiss.y)
|
||||
await waitUntil(() => calls.length === 3 && !ui.recoveryNotice.visible)
|
||||
expect(calls[2]).toEqual({
|
||||
action: "dismiss",
|
||||
chatId: "chat",
|
||||
recoveryId: "recovery-3",
|
||||
})
|
||||
|
||||
app.accept({
|
||||
event: "recovery_state",
|
||||
chat_id: "chat",
|
||||
status: "awaiting_user",
|
||||
recovery_id: "recovery-4",
|
||||
can_continue: false,
|
||||
})
|
||||
await setup.renderOnce()
|
||||
expect(ui.recoveryNotice.resume.visible).toBe(false)
|
||||
expect(ui.recoveryNotice.dismiss.visible).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
|
||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||
const sent: string[] = []
|
||||
|
||||
+124
-1
@@ -34,6 +34,7 @@ import {
|
||||
type InboundEvent,
|
||||
type MentionCandidate,
|
||||
type MessageOptions,
|
||||
type RecoveryState,
|
||||
type SlashCommand,
|
||||
type SessionSummary,
|
||||
type TokenUsage,
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
} from "./mention-menu"
|
||||
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
||||
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
||||
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
|
||||
import { RuntimeControls } from "./runtime-controls"
|
||||
import {
|
||||
contextualFooterHints,
|
||||
@@ -107,6 +109,11 @@ interface ChatClient {
|
||||
newChat(scope?: WorkspaceScopePayload): void
|
||||
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
|
||||
setWorkspaceScope(scope: WorkspaceScopePayload): void
|
||||
updateRecovery(
|
||||
action: "continue" | "dismiss",
|
||||
chatId: string,
|
||||
recoveryId: string,
|
||||
): Promise<RecoveryState>
|
||||
}
|
||||
|
||||
interface Palette {
|
||||
@@ -295,6 +302,15 @@ function queuePreviewTheme(palette: Palette): QueuePreviewTheme {
|
||||
}
|
||||
}
|
||||
|
||||
function recoveryNoticeTheme(palette: Palette): RecoveryNoticeTheme {
|
||||
return {
|
||||
text: palette.text,
|
||||
muted: palette.muted,
|
||||
accent: palette.accent,
|
||||
error: palette.error,
|
||||
}
|
||||
}
|
||||
|
||||
function footerHintTheme(palette: Palette): FooterHintTheme {
|
||||
return {
|
||||
accent: palette.accent,
|
||||
@@ -380,6 +396,7 @@ export class NanobotTui {
|
||||
private readonly contextPanel: ContextPanel
|
||||
private readonly diffViewer: DiffViewer
|
||||
private readonly queuePreview: QueuePreview
|
||||
private readonly recoveryNotice: RecoveryNotice
|
||||
private readonly client: ChatClient
|
||||
private readonly shell: BoxRenderable
|
||||
private readonly title: BoxRenderable
|
||||
@@ -444,6 +461,8 @@ export class NanobotTui {
|
||||
private currentTask = ""
|
||||
private currentAction = ""
|
||||
private hostBlocked = false
|
||||
private recoveryState: RecoveryState | null = null
|
||||
private recoveryPending = false
|
||||
private hostWorkspace: string
|
||||
private hostBranch: string
|
||||
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
||||
@@ -495,6 +514,14 @@ export class NanobotTui {
|
||||
treeSitterClient,
|
||||
)
|
||||
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
||||
this.recoveryNotice = new RecoveryNotice(
|
||||
renderer,
|
||||
recoveryNoticeTheme(this.palette),
|
||||
{
|
||||
onContinue: () => void this.updateRecovery("continue"),
|
||||
onDismiss: () => void this.updateRecovery("dismiss"),
|
||||
},
|
||||
)
|
||||
this.client = client || new NanobotClient({
|
||||
...(options.bootstrapUrl
|
||||
? {
|
||||
@@ -723,6 +750,7 @@ export class NanobotTui {
|
||||
this.shell.add(this.runtimeControls.menuRoot)
|
||||
if (!host.hosted) this.shell.add(this.title)
|
||||
this.shell.add(this.queuePreview.root)
|
||||
this.shell.add(this.recoveryNotice.root)
|
||||
this.shell.add(this.composerFrame)
|
||||
this.shell.add(statusRow)
|
||||
this.shell.add(this.diffViewer.root)
|
||||
@@ -831,6 +859,12 @@ export class NanobotTui {
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
if (["/continue", "/dismiss"].includes(visibleContent.toLowerCase())) {
|
||||
this.clearComposer()
|
||||
this.commandMenu.hide()
|
||||
void this.updateRecovery(visibleContent.toLowerCase() === "/continue" ? "continue" : "dismiss")
|
||||
return
|
||||
}
|
||||
const completion = this.commandMenu.completion(visibleContent)
|
||||
if (completion) {
|
||||
this.setComposer(completion)
|
||||
@@ -946,7 +980,9 @@ export class NanobotTui {
|
||||
}
|
||||
const hydrationId = ++this.hydrationId
|
||||
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
|
||||
if (hydrationId === this.hydrationId) this.flushPendingEvents()
|
||||
if (hydrationId !== this.hydrationId) return
|
||||
this.applyRecoveryState(event.recovery_state ?? null)
|
||||
this.flushPendingEvents()
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1083,6 +1119,9 @@ export class NanobotTui {
|
||||
this.applyHostGoalState(event.goal_state)
|
||||
if (!this.activeTurn) this.reportHostResting()
|
||||
return
|
||||
case "recovery_state":
|
||||
this.applyRecoveryState(event)
|
||||
return
|
||||
case "turn_model_updated":
|
||||
if (typeof event.context_window_tokens === "number") {
|
||||
this.contextWindowTokens = event.context_window_tokens
|
||||
@@ -1191,6 +1230,85 @@ export class NanobotTui {
|
||||
for (const event of events || []) this.accept(event)
|
||||
}
|
||||
|
||||
private clearRecoveryState(): void {
|
||||
this.recoveryState = null
|
||||
this.recoveryPending = false
|
||||
this.recoveryNotice.hide()
|
||||
}
|
||||
|
||||
private applyRecoveryState(state: RecoveryState | null): void {
|
||||
if (!state) {
|
||||
this.clearRecoveryState()
|
||||
return
|
||||
}
|
||||
this.recoveryState = state
|
||||
this.recoveryPending = false
|
||||
if (state.status === "resuming") {
|
||||
this.recoveryNotice.hide()
|
||||
this.hostBlocked = false
|
||||
this.activeLabel = "Continuing"
|
||||
this.setCurrentAction("Continuing interrupted task")
|
||||
this.setActive(true)
|
||||
this.reportHostWorking()
|
||||
return
|
||||
}
|
||||
if (state.status === "awaiting_user" || state.status === "failed") {
|
||||
this.activeTurnId = null
|
||||
this.setActive(false)
|
||||
this.hostBlocked = true
|
||||
this.recoveryNotice.show(state)
|
||||
const detail = state.reason || (state.status === "failed"
|
||||
? "Recovery failed"
|
||||
: "Task interrupted")
|
||||
this.setCurrentAction(detail)
|
||||
this.status.content = "Waiting for recovery decision"
|
||||
this.host.reportState("blocked", detail)
|
||||
this.composer.focus()
|
||||
return
|
||||
}
|
||||
this.clearRecoveryState()
|
||||
this.activeTurnId = null
|
||||
this.hostBlocked = false
|
||||
this.setActive(false)
|
||||
if (this.ready) this.status.content = this.readyStatus()
|
||||
this.reportHostResting()
|
||||
}
|
||||
|
||||
private async updateRecovery(action: "continue" | "dismiss"): Promise<void> {
|
||||
const state = this.recoveryState
|
||||
if (
|
||||
!state
|
||||
|| (state.status !== "awaiting_user" && state.status !== "failed")
|
||||
|| (action === "continue" && state.can_continue === false)
|
||||
) {
|
||||
this.status.content = "No interrupted task"
|
||||
this.composer.focus()
|
||||
return
|
||||
}
|
||||
if (this.recoveryPending) return
|
||||
this.recoveryPending = true
|
||||
this.recoveryNotice.setBusy(true)
|
||||
this.status.content = action === "continue" ? "Continuing…" : "Dismissing…"
|
||||
try {
|
||||
const next = await this.client.updateRecovery(
|
||||
action,
|
||||
this.client.activeChatId,
|
||||
state.recovery_id,
|
||||
)
|
||||
if (this.recoveryState?.recovery_id === state.recovery_id) {
|
||||
this.applyRecoveryState(next)
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.recoveryState?.recovery_id !== state.recovery_id) return
|
||||
this.recoveryPending = false
|
||||
this.recoveryNotice.setBusy(false)
|
||||
this.status.content = error instanceof Error ? error.message : String(error)
|
||||
this.host.reportState("blocked", state.reason || "Task interrupted")
|
||||
} finally {
|
||||
this.composer.focus()
|
||||
}
|
||||
}
|
||||
|
||||
private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
|
||||
this.options.apiUrl = apiUrl
|
||||
this.options.apiToken = apiToken
|
||||
@@ -1576,6 +1694,7 @@ export class NanobotTui {
|
||||
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
||||
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
||||
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
|
||||
this.recoveryNotice.setTheme(recoveryNoticeTheme(this.palette))
|
||||
this.updateComposerAppearance()
|
||||
this.composer.textColor = this.palette.text
|
||||
this.composer.focusedTextColor = this.palette.text
|
||||
@@ -2001,6 +2120,7 @@ export class NanobotTui {
|
||||
this.sessionTitle = sessionLabel(current)
|
||||
this.applySessionModel(current)
|
||||
this.applySessionScope(current)
|
||||
this.applyRecoveryState(current.recoveryState ?? null)
|
||||
this.updateTitle()
|
||||
}
|
||||
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||
@@ -2027,6 +2147,7 @@ export class NanobotTui {
|
||||
this.sessionTitle = sessionLabel(session)
|
||||
this.applySessionModel(session)
|
||||
this.applySessionScope(session)
|
||||
this.applyRecoveryState(session.recoveryState ?? null)
|
||||
this.updateTitle()
|
||||
this.closeSessions()
|
||||
this.status.content = this.readyStatus()
|
||||
@@ -2039,6 +2160,7 @@ export class NanobotTui {
|
||||
this.closeSessions()
|
||||
try {
|
||||
this.ready = false
|
||||
this.clearRecoveryState()
|
||||
this.clearPromptQueue()
|
||||
this.sessionMetadataId += 1
|
||||
this.clearHostContext()
|
||||
@@ -2073,6 +2195,7 @@ export class NanobotTui {
|
||||
this.clearComposer()
|
||||
try {
|
||||
this.ready = false
|
||||
this.clearRecoveryState()
|
||||
this.clearPromptQueue()
|
||||
this.sessionMetadataId += 1
|
||||
this.clearHostContext()
|
||||
|
||||
@@ -595,6 +595,62 @@ describe("gateway protocol", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("sends stale-safe recovery mutations and validates their response", async () => {
|
||||
const original = globalThis.WebSocket
|
||||
let socket: FakeSocket | undefined
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor() {
|
||||
super()
|
||||
socket = this
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const statuses: string[] = []
|
||||
const client = new NanobotClient({
|
||||
url: "ws://nanobot.test/ws",
|
||||
onEvent: () => undefined,
|
||||
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
|
||||
})
|
||||
client.connect()
|
||||
if (!socket) throw new Error("socket was not created")
|
||||
|
||||
const result = client.updateRecovery("continue", "chat", "recovery-1")
|
||||
const request = JSON.parse(socket.sent.at(-1) || "{}") as {
|
||||
request_id: string
|
||||
action: string
|
||||
payload: Record<string, string>
|
||||
}
|
||||
expect(request).toMatchObject({
|
||||
type: "webui_request",
|
||||
action: "recovery.continue",
|
||||
payload: { chat_id: "chat", recovery_id: "recovery-1" },
|
||||
})
|
||||
socket.emit("message", { data: JSON.stringify({
|
||||
event: "webui_response",
|
||||
request_id: request.request_id,
|
||||
ok: true,
|
||||
result: { status: "resuming", recovery_id: "recovery-1", attempts: 1 },
|
||||
}) })
|
||||
expect(await result).toEqual({
|
||||
status: "resuming",
|
||||
recovery_id: "recovery-1",
|
||||
attempts: 1,
|
||||
})
|
||||
expect(statuses).not.toContain("error:gateway sent an invalid event")
|
||||
|
||||
const interrupted = client.updateRecovery("dismiss", "chat", "recovery-2")
|
||||
socket.emit("close")
|
||||
await expect(interrupted).rejects.toThrow("gateway connection closed")
|
||||
client.close()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("reports when the bounded history snapshot omits earlier turns", async () => {
|
||||
const original = globalThis.fetch
|
||||
let requested = ""
|
||||
|
||||
+132
-1
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
RGBA,
|
||||
StyledText,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type CliRenderer,
|
||||
type TextChunk,
|
||||
} from "@opentui/core"
|
||||
|
||||
import type { RecoveryState } from "./protocol"
|
||||
|
||||
export interface RecoveryNoticeTheme {
|
||||
text: string
|
||||
muted: string
|
||||
accent: string
|
||||
error: string
|
||||
}
|
||||
|
||||
interface RecoveryNoticeOptions {
|
||||
onContinue: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
/** A quiet action surface for a gateway-owned interrupted turn. */
|
||||
export class RecoveryNotice {
|
||||
readonly root: BoxRenderable
|
||||
private readonly message: TextRenderable
|
||||
private readonly dismiss: TextRenderable
|
||||
private readonly resume: TextRenderable
|
||||
private state: RecoveryState | null = null
|
||||
private busy = false
|
||||
|
||||
constructor(
|
||||
renderer: CliRenderer,
|
||||
private theme: RecoveryNoticeTheme,
|
||||
options: RecoveryNoticeOptions,
|
||||
) {
|
||||
this.root = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-recovery-notice",
|
||||
width: "100%",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
visible: false,
|
||||
backgroundColor: RGBA.defaultBackground(),
|
||||
})
|
||||
this.message = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-recovery-message",
|
||||
width: "auto",
|
||||
minWidth: 0,
|
||||
flexGrow: 1,
|
||||
height: 1,
|
||||
truncate: true,
|
||||
selectable: false,
|
||||
})
|
||||
this.dismiss = this.action(renderer, "dismiss", "Dismiss", options.onDismiss)
|
||||
this.resume = this.action(renderer, "continue", "Continue", options.onContinue, true)
|
||||
this.root.add(this.message)
|
||||
this.root.add(this.dismiss)
|
||||
this.root.add(this.resume)
|
||||
}
|
||||
|
||||
get visible(): boolean {
|
||||
return this.root.visible
|
||||
}
|
||||
|
||||
show(state: RecoveryState): void {
|
||||
this.state = state
|
||||
this.busy = false
|
||||
this.root.visible = true
|
||||
this.render()
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.state = null
|
||||
this.busy = false
|
||||
this.root.visible = false
|
||||
}
|
||||
|
||||
setBusy(busy: boolean): void {
|
||||
this.busy = busy
|
||||
if (this.visible) this.render()
|
||||
}
|
||||
|
||||
setTheme(theme: RecoveryNoticeTheme): void {
|
||||
this.theme = theme
|
||||
if (this.visible) this.render()
|
||||
}
|
||||
|
||||
private action(
|
||||
renderer: CliRenderer,
|
||||
id: string,
|
||||
label: string,
|
||||
callback: () => void,
|
||||
primary = false,
|
||||
): TextRenderable {
|
||||
return new TextRenderable(renderer, {
|
||||
id: `nanobot-tui-recovery-${id}`,
|
||||
content: label,
|
||||
width: label.length,
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
selectable: false,
|
||||
onMouseOver: () => {
|
||||
if (this.busy) return
|
||||
const target = primary ? this.resume : this.dismiss
|
||||
target.attributes = TextAttributes.BOLD | TextAttributes.UNDERLINE
|
||||
},
|
||||
onMouseOut: () => this.render(),
|
||||
onMouseDown: (event) => {
|
||||
if (event.button !== 0 || this.busy) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
renderer.clearSelection()
|
||||
callback()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.state) return
|
||||
const failed = this.state.status === "failed"
|
||||
const contextUnavailable = this.state.can_continue === false
|
||||
const title = failed ? "Recovery failed" : "Task interrupted"
|
||||
const detail = failed
|
||||
? "Review the task before continuing"
|
||||
: contextUnavailable
|
||||
? "Saved context unavailable"
|
||||
: "Tools will not replay automatically"
|
||||
this.message.content = new StyledText([
|
||||
chunk("△ ", failed ? this.theme.error : this.theme.accent),
|
||||
chunk(title, this.theme.text, true),
|
||||
chunk(` · ${detail}`, this.theme.muted),
|
||||
])
|
||||
this.dismiss.fg = RGBA.fromHex(this.busy ? this.theme.muted : this.theme.text)
|
||||
this.resume.visible = !contextUnavailable
|
||||
this.resume.fg = RGBA.fromHex(this.busy ? this.theme.muted : this.theme.accent)
|
||||
this.dismiss.attributes = 0
|
||||
this.resume.attributes = TextAttributes.BOLD
|
||||
}
|
||||
}
|
||||
|
||||
function chunk(text: string, color: string, bold = false): TextChunk {
|
||||
return {
|
||||
__isChunk: true,
|
||||
text,
|
||||
fg: RGBA.fromHex(color),
|
||||
attributes: bold ? TextAttributes.BOLD : 0,
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ const sessions: SessionSummary[] = [
|
||||
updatedAt: "2026-08-12T10:00:00Z",
|
||||
runStartedAt: null,
|
||||
modelPreset: null,
|
||||
recoveryState: {
|
||||
status: "awaiting_user",
|
||||
recovery_id: "recovery-two",
|
||||
reason: "tool execution interrupted",
|
||||
},
|
||||
pinned: false,
|
||||
archived: false,
|
||||
},
|
||||
@@ -53,7 +58,7 @@ describe("SessionMenu", () => {
|
||||
|
||||
menu.update("release stable", 6)
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("Release checklist")
|
||||
expect(setup.captureCharFrame()).toContain("△ Release checklist")
|
||||
expect(menu.choose()?.chatId).toBe("two")
|
||||
})
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ export class SessionMenu {
|
||||
session.chatId,
|
||||
session.workspaceScope?.project_name || "",
|
||||
session.workspaceScope?.project_path || "",
|
||||
session.recoveryState?.status || "",
|
||||
session.recoveryState?.reason || "",
|
||||
].join(" "),
|
||||
render: (session) => {
|
||||
const age = updatedLabel(session.updatedAt)
|
||||
@@ -54,7 +56,11 @@ export class SessionMenu {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
|
||||
const interrupted = session.recoveryState?.status === "awaiting_user"
|
||||
|| session.recoveryState?.status === "failed"
|
||||
const marker = interrupted
|
||||
? "△ "
|
||||
: session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
|
||||
return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}`
|
||||
},
|
||||
emptyText: "No matching sessions",
|
||||
|
||||
Reference in New Issue
Block a user