fix(tui): fail fast on invalid session startup (#23837)

This commit is contained in:
Shoubhit Dash 2026-04-22 16:35:13 +05:30 committed by Aiden Cline
commit 6196b81e0a
5 changed files with 97 additions and 21 deletions

View file

@ -3,6 +3,8 @@ import { UI } from "@/cli/ui"
import { tui } from "./app" import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { errorMessage } from "@/util/error"
import { validateSession } from "./validate-session"
export const AttachCommand = cmd({ export const AttachCommand = cmd({
command: "attach <url>", command: "attach <url>",
@ -65,6 +67,20 @@ export const AttachCommand = cmd({
return { Authorization: auth } return { Authorization: auth }
})() })()
const config = await TuiConfig.get() const config = await TuiConfig.get()
try {
await validateSession({
url: args.url,
sessionID: args.session,
directory,
headers,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
}
await tui({ await tui({
url: args.url, url: args.url,
config, config,

View file

@ -68,6 +68,7 @@ import { Flag } from "@/flag/flag"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts" import parsers from "../../../../../../parsers-config.ts"
import * as Clipboard from "../../util/clipboard" import * as Clipboard from "../../util/clipboard"
import { errorMessage } from "@/util/error"
import { Toast, useToast } from "../../ui/toast" import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx" import { useKV } from "../../context/kv.tsx"
import * as Editor from "../../util/editor" import * as Editor from "../../util/editor"
@ -180,31 +181,43 @@ export function Session() {
const toast = useToast() const toast = useToast()
const sdk = useSDK() const sdk = useSDK()
createEffect(async () => { createEffect(() => {
const previousWorkspace = project.workspace.current() const sessionID = route.sessionID
const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true }) void (async () => {
if (!result.data) { const previousWorkspace = project.workspace.current()
const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
if (!result.data) {
toast.show({
message: `Session not found: ${sessionID}`,
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
return
}
if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)
// Sync all the data for this workspace. Note that this
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
try {
await sync.bootstrap({ fatal: false })
} catch {}
}
await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({ toast.show({
message: `Session not found: ${route.sessionID}`, message: errorMessage(error),
variant: "error", variant: "error",
duration: 5000,
}) })
navigate({ type: "home" }) navigate({ type: "home" })
return })
}
if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)
// Sync all the data for this workspace. Note that this
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
try {
await sync.bootstrap({ fatal: false })
} catch (e) {}
}
await sync.session.sync(route.sessionID)
if (scroll) scroll.scrollBy(100_000)
}) })
let lastSwitch: string | undefined = undefined let lastSwitch: string | undefined = undefined

View file

@ -16,6 +16,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui" import { TuiConfig } from "./config/tui"
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process" import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
import { validateSession } from "./validate-session"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@ -202,6 +203,19 @@ export const TuiThreadCommand = cmd({
events: createEventSource(client), events: createEventSource(client),
} }
try {
await validateSession({
url: transport.url,
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
}
setTimeout(() => { setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {}) client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.() }, 1000).unref?.()

View file

@ -0,0 +1,24 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema"
export async function validateSession(input: {
url: string
sessionID?: string
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
}) {
if (!input.sessionID) return
const result = SessionID.zod.safeParse(input.sessionID)
if (!result.success) {
throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
}
await createOpencodeClient({
baseUrl: input.url,
directory: input.directory,
fetch: input.fetch,
headers: input.headers,
}).session.get({ sessionID: result.data }, { throwOnError: true })
}

View file

@ -26,6 +26,15 @@ export function errorMessage(error: unknown): string {
return error.message return error.message
} }
if (
isRecord(error) &&
isRecord(error.data) &&
typeof error.data.message === "string" &&
error.data.message
) {
return error.data.message
}
const text = String(error) const text = String(error)
if (text && text !== "[object Object]") return text if (text && text !== "[object Object]") return text