Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/tui/src/component/dialog-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { useTheme } from "../context/theme"

export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const theme = useTheme()
const [query, setQuery] = createSignal("")

const connected = useConnected()
// An unfetched list is `undefined`; rendering it as empty claims no models exist.
const loading = createMemo(() => data.location.model.list() === undefined)
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])

Expand Down Expand Up @@ -130,6 +134,11 @@ export function DialogModel(props: { providerID?: string }) {
return (
<DialogSelect<ReturnType<typeof options>[number]["value"]>
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>{loading() ? "Loading models…" : "No models available"}</text>
</box>
}
actions={[
{
command: "model.dialog.provider",
Expand Down
9 changes: 9 additions & 0 deletions packages/tui/src/context/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,11 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
stream = controller
void (async () => {
let attempt = 0
let established = false
while (!abort.signal.aborted && !controller.signal.aborted) {
const result = await connect(controller.signal, attempt)
if (abort.signal.aborted || controller.signal.aborted) return
if (result.connectedAt !== undefined) established = true
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
attempt += 1
const message = errorMessage(result.error)
Expand All @@ -144,6 +146,13 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
error: message,
})
setConnection({ status: "reconnecting", attempt, error: message })
// An opening handshake that never completed usually means this process lost
// the race against its own startup work, not that the server moved, so retry
// the endpoint already resolved. Re-resolving here instead costs a full
// service ensure before any data can load. A stream that was established and
// then dropped skips this and re-resolves below, since a restarted server may
// now be on a different port.
if (!established && attempt === 1) continue
// Re-resolve the transport before retrying: the server may have
// moved (service restarted on a new port) or need starting. Static
// transports (--server, standalone) resolve to the same address.
Expand Down
8 changes: 7 additions & 1 deletion packages/tui/src/context/location.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ export function LocationProvider(props: ParentProps) {

function set(location?: LocationRef) {
setRef(location)
if (client.connection.status() === "connected") sync(location)
// Catalog reads are plain HTTP and do not depend on the event stream, so fetch
// immediately. Waiting for the handshake left the model and provider lists empty
// for as long as it took to connect, which reads as "no models exist".
sync(location)
}

// Resync after a reconnect, which may have missed updates. DataProvider drops the
// cached completion whenever the stream is down, so this is a no-op when the fetch
// above already succeeded and nothing was missed.
onCleanup(client.event.on("server.connected", () => sync(ref())))

return (
Expand Down
62 changes: 62 additions & 0 deletions packages/tui/test/cli/tui/data.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,68 @@ test("reconnects the event stream and resyncs active data", async () => {
}
})

test("loads the catalog before the event stream connects", async () => {
const events = createEventStream()
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
const calls = createFetch((url) => {
// Hold the handshake open so a catalog that waits on it cannot load.
if (url.pathname === "/api/event") return gate.then(() => events.v2())
if (url.pathname !== "/api/model") return
return json({
location: { directory, project: { id: "proj_test", directory } },
data: [
{
id: "model-gated",
providerID: "provider",
name: "Gated",
api: { type: "native" },
capabilities: { tools: false, input: [], output: [] },
cost: [],
limit: { context: 1, output: 1 },
request: { headers: {}, body: {} },
status: "active",
time: { released: 0 },
variants: [],
},
],
})
}, events)

let data!: ReturnType<typeof useData>
let client!: ReturnType<typeof useClient>

function Probe() {
data = useData()
client = useClient()
return <box />
}

const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))

try {
await wait(() => data.location.model.list()?.[0]?.id === "model-gated")
expect(client.connection.status()).not.toBe("connected")
release()
await wait(() => client.connection.status() === "connected", 4000)
expect(data.location.model.list()?.[0]?.id).toBe("model-gated")
} finally {
app.renderer.destroy()
}
})

test("completes exploration when a queued prompt is promoted", async () => {
const events = createEventStream()
const sessionID = "session-promotion"
Expand Down
35 changes: 34 additions & 1 deletion packages/tui/test/cli/tui/use-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ function update(version: string): OpenCodeEvent {
async function mount(
reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
log?: LogSink,
override?: Parameters<typeof createFetch>[0],
) {
const events = createEventStream()
const calls = createFetch(undefined, events)
const calls = createFetch(override, events)
const seen: OpenCodeEvent[] = []
const workspaces: Array<string | undefined> = []
let client!: ReturnType<typeof useClient>
Expand Down Expand Up @@ -216,6 +217,38 @@ describe("useEvent", () => {
}
})

test("retries an unestablished handshake before re-resolving the server", async () => {
const attempts: number[] = []
let opened = 0
const { app, client } = await mount(
async () => {
attempts.push(attempts.length + 1)
throw new Error("no server")
},
undefined,
(url, request) => {
if (url.pathname !== "/api/event") return
opened += 1
// Leave the opening handshake unanswered so `connectTimeout` aborts it.
if (opened > 1) return
return new Promise<Response>((_, reject) => {
request.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true })
})
},
)

try {
await wait(() => client.connection.status() === "connected", 8000)
// The endpoint was resolved moments earlier, so a handshake that never
// established must retry it directly rather than pay for a service ensure
// before any data can load.
expect(attempts).toEqual([])
expect(opened).toBe(2)
} finally {
app.renderer.destroy()
}
})

test("keeps the current client when reconnection fails", async () => {
let calls = 0
const { app, events, client, seen } = await mount(async () => {
Expand Down
Loading