Skip to content

Commit 9a9386e

Browse files
authored
refactor(client): group experimental SDK methods under client.experimental (#59072)
1 parent fe04f0e commit 9a9386e

59 files changed

Lines changed: 3456 additions & 1916 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,17 @@ const table = sqliteTable("session", {
169169
})
170170
```
171171

172+
### Experimental API
173+
174+
- An experimental endpoint lives at `/api/experimental/<group>/...`, its OpenAPI identifier is `experimental.<group>.<method>`, and it lands in the SDK as `client.experimental.<group>.<method>`. Its description says "Experimental: may change without compatibility guarantees."
175+
- `packages/protocol/test/experimental-identifiers.test.ts` enforces the path and identifier match in both directions; the SDK namespace follows from the path.
176+
- An experimental field or parameter on a stable endpoint goes under an `experimental` key (`experimental.<name>`), the same way config does (`config.experimental.*`).
177+
- New endpoints and new default behaviors start experimental.
178+
- To graduate one, move the route out of `/api/experimental`, drop the `experimental.` prefix, and note it in `CHANGELOG.md`.
179+
- Experimental HTTP routes change without aliases: rename or remove the route, then update the callers.
180+
- A breaking move of shipped SDK or plugin API (including moving it under `experimental`) keeps the old path and type names as deprecated aliases of the new ones until the next minor, each marked `@deprecated` with the replacement and "Removed in the next minor". Generated SDK moves get them from the codegen (`compile(..., { aliases: true })` in `packages/client/script/generate.ts`), not by hand.
181+
- Experimental plugin hooks are named `experimental.*` (`experimental.ws.handshake`).
182+
172183
## Testing
173184

174185
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.

‎packages/app/src/home/projects/controller.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export function createHomeProjectsController(home: HomeController) {
123123
Schema.fromJsonString(Schema.Record(Schema.String, Schema.Json)),
124124
)(await file.text())
125125

126-
const api = home.server.context(conn).sdk.api.session
126+
const api = home.server.context(conn).sdk.api.experimental.session
127127

128128
// SAFETY: the import endpoint validates the transcript, including exports written by earlier releases.
129129
const imported = await api.import({

‎packages/app/src/providers/connect/mcp.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type McpControls = {
1313
change: (name: string, enabled: boolean) => void
1414
}
1515

16-
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
16+
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => void) {
1717
const data = useData()
1818
const serverSDK = useServerSDK()
1919
const platform = usePlatform()
@@ -27,19 +27,22 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
2727

2828
return useMutation(() => ({
2929
mutationFn: async (input: string | { name: string; enabled: boolean; directory?: string }) => {
30-
const name = typeof input === "string" ? input : input.name
31-
const ref = typeof input !== "string" && input.directory ? { directory: input.directory } : location()
30+
// A bare name toggles the server; an object sets an explicit state.
31+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- discriminates this function's own input union
32+
const request: { name: string; enabled?: boolean; directory?: string } = typeof input === "string" ? { name: input } : input
33+
const name = request.name
34+
const ref = request.directory ? { directory: request.directory } : location()
3235
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
3336

34-
if (!server || (server.status.status === "pending" && typeof input === "string")) return
35-
const enabled = typeof input === "string" ? server.status.status !== "connected" : input.enabled
37+
if (!server || (server.status.status === "pending" && request.enabled === undefined)) return
38+
const enabled = request.enabled ?? server.status.status !== "connected"
3639

3740
if (!enabled) {
38-
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
41+
await serverSDK.api.experimental.mcp.disconnect({ server: name, location: ref })
3942
}
4043

4144
if (enabled && server.status.status !== "needs_auth") {
42-
await serverSDK.api.mcp.connect({ server: name, location: ref })
45+
await serverSDK.api.experimental.mcp.connect({ server: name, location: ref })
4346
}
4447

4548
data.location.mcp.server.invalidate(ref)

‎packages/app/src/runtime/server/sync.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
244244
if (update.time.archived) return rest
245245

246246
const previous = projects.find((project) => project.id === update.id)
247+
247248
// The wire payload carries no worktrees; keep the inventory this project already loaded.
248249
const next = withWorktreeInventory(
249250
previous ? updateProjectInfo(previous, update) : normalizeProjectInfo(update),
@@ -314,7 +315,7 @@ function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
314315
const updateConfigMutation = useMutation(() => ({
315316
mutationFn: async (config: Config) => {
316317
// TODO: Restore config updates when the V2 client exposes a config API.
317-
// await serverSDK.api.config.update({ config })
318+
// await serverSDK.api.experimental.config.update({ config })
318319
throw new Error(`Config updates are unavailable: ${Object.keys(config).length} fields were not saved`)
319320
},
320321
onSuccess: () => {
@@ -343,10 +344,10 @@ function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
343344
await toggleMcp({
344345
status,
345346
connect: async () => {
346-
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
347+
await serverSDK.api.experimental.mcp.connect({ server: name, location: { directory: key } })
347348
},
348349
disconnect: async () => {
349-
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
350+
await serverSDK.api.experimental.mcp.disconnect({ server: name, location: { directory: key } })
350351
},
351352
authenticate: async () => {
352353
const server = (await serverSDK.api.mcp.list({ location: { directory: key } })).data.find(

‎packages/app/src/session/revert.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export function createSessionRevert(input: {
8888
// Like the TUI, stop at the first failure instead of waiting on work that was never interrupted.
8989
if (!(await request(() => server.api.session.interrupt({ sessionID })))) return
9090

91-
if (!(await request(() => server.api.session.wait({ sessionID })))) return
91+
if (!(await request(() => server.api.experimental.session.wait({ sessionID })))) return
9292

9393
// Read before staging: the staged event can land before the request resolves.
9494
const prior = input.session.data.revertMessageID()

‎packages/app/src/settings/general/controllers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function createServerShellController(server: Accessor<ServerConnection.An
5959
const previous = state.latest
6060
actions.mutate({ ...previous, shell: value || undefined })
6161
void serverCtx()
62-
.sdk.api.config.update({ shell: value || null })
62+
.sdk.api.experimental.config.update({ shell: value || null })
6363
.catch((error) => {
6464
actions.mutate(previous)
6565
showToast({

‎packages/app/src/voice/tools.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export class VoiceTools {
120120

121121
const seen = new Set<string>()
122122
const tabs = new Set(this.screen.tabs())
123+
123124
// Best match first; among equal matches, top-level sessions with an open tab, then the most recent.
124125
const rank = (session: SessionInfo) =>
125126
(search.score(session) ?? 0) * 4 + (session.parentID ? 0 : 2) + (tabs.has(session.id) ? 1 : 0)
@@ -300,7 +301,7 @@ export class VoiceTools {
300301

301302
if (call.name === "read_session" || call.name === "wait_for_session") {
302303
if (call.name === "wait_for_session") {
303-
await api.session.wait({ sessionID }, { signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)]) })
304+
await api.experimental.session.wait({ sessionID }, { signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)]) })
304305
}
305306

306307
const [session, messages] = await Promise.all([

‎packages/app/src/voice/transport.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ export class VoiceFailure extends Error {
88
}
99
}
1010

11+
/** A client event sent over the Live data channel; the transport adds `event_id`. */
12+
export type VoiceClientEvent =
13+
| { type: "session.input_audio.mute" | "session.input_audio.unmute" | "response.create" }
14+
| { type: "session.update"; session: { delegation: { type: "responses"; responses: { instructions: string } } } }
15+
| { type: "response.item.create"; item: { type: "function_call_output"; call_id: string; output: string } }
16+
| { type: "session.commentary.append"; delegation_id: null; content: string }
17+
1118
export class VoiceTransport {
1219
readonly abort = new AbortController()
1320
private peer = new RTCPeerConnection({ iceServers: [], bundlePolicy: "max-bundle", rtcpMuxPolicy: "require" })
@@ -78,7 +85,7 @@ export class VoiceTransport {
7885
async join(viewing: { id: string; title?: string }, tail: string) {
7986
const signal = AbortSignal.any([this.abort.signal, AbortSignal.timeout(35_000)])
8087
await this.context.resume()
81-
const offered = await this.inputOptions.api.voice.models({ signal })
88+
const offered = await this.inputOptions.api.experimental.voice.models({ signal })
8289

8390
if (
8491
!offered.models.some(
@@ -106,7 +113,7 @@ export class VoiceTransport {
106113
})
107114
signal.throwIfAborted()
108115

109-
const response = await this.inputOptions.api.voice.session(
116+
const response = await this.inputOptions.api.experimental.voice.session(
110117
{
111118
model: "gpt-live-1",
112119
session: {
@@ -153,7 +160,7 @@ export class VoiceTransport {
153160
this.timer = setInterval(() => this.inputOptions.levels(this.level(this.input), this.level(this.output)), 100)
154161
}
155162

156-
send(event: object) {
163+
send(event: VoiceClientEvent) {
157164
if (this.ended || this.channel.readyState !== "open") throw new VoiceFailure("dropped")
158165
this.channel.send(JSON.stringify({ event_id: crypto.randomUUID(), ...event }))
159166
}

‎packages/app/src/workspaces/files/model.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,14 +298,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
298298
const file = path.normalize(input)
299299

300300
if (!file || path.absolute(file)) return
301-
void serverSDK.api.file.watch({ path: file, location: { directory: scope() } }).catch(() => {})
301+
void serverSDK.api.experimental.file.watch({ path: file, location: { directory: scope() } }).catch(() => {})
302302
}
303303

304304
const unwatch = (input: string) => {
305305
const file = path.normalize(input)
306306

307307
if (!file || path.absolute(file)) return
308-
void serverSDK.api.file.unwatch({ path: file, location: { directory: scope() } }).catch(() => {})
308+
void serverSDK.api.experimental.file.unwatch({ path: file, location: { directory: scope() } }).catch(() => {})
309309
}
310310

311311
createEffect(() => {

‎packages/cli/src/acp/sessions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export const make = Effect.fnUntraced(function* (input: {
101101
const config = mcpConfig(server)
102102
if (isDeepStrictEqual(registered.get(server.name), config)) return Effect.void
103103
registered.set(server.name, config)
104-
return input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }).pipe(
104+
return input.client.experimental.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }).pipe(
105105
Effect.catch(ACPClient.classify),
106106
Effect.onError(() => Effect.sync(() => registered.delete(server.name))),
107107
Effect.uninterruptible,

0 commit comments

Comments
 (0)