From 01a52eccabb123e46104f6d3631ee6ae66cd7d78 Mon Sep 17 00:00:00 2001 From: Leonid <127580858+bashrusakh@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:11:13 +1100 Subject: [PATCH] =?UTF-8?q?SDK=20v1.17.12:=20session.permission=20?= =?UTF-8?q?=E2=80=94=20programmatic=20create/fetch,=20more=20reliable=20au?= =?UTF-8?q?to-accept=20(#1982)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add SDK v1.17.12 migration plan — phase 4 (session.permission) * feat(permissions): verify pending permission before auto-accept via SDK v1.17.12 Adds createPermission() and fetchPermission() wrappers on OpencodeService for the new v2.session.permission endpoints (OpenCode SDK 1.17.12). fetchPermission() is used by the auto-accept sweep in resyncBlockingRequestsForDirectory to verify a permission is still pending before replying. The auto-accept flow now skips permissions that are already resolved, returning a null from fetchPermission() rather than blindly calling respondToPermission on a stale entry. createPermission() is exposed for future programmatic permission creation; the V1 list/reply path used by the UI is unchanged. The plan doc at plans/opencode-v1.17.12-sdk/ was rebased onto origin/main in the prior commit to keep the PR diff focused on this change. Closes #1972 * fix(permissions): drop confirmed-resolved permissions from auto-accept resync fetchPermission() now returns a tagged FetchPermissionResult so the auto-accept loop can distinguish a server-confirmed 404 (the permission is no longer pending) from a fetch failure (network error or pre-v1.17.12 server). Previously both cases collapsed to null, so a permission the server had already answered would still appear in the resync output and trigger a spurious 'Permission needed' toast. The auto-accept loop in resyncBlockingRequestsForDirectory now tracks both accepted and resolved permissions, then drops both from the 'grouped' map before it falls through to the toast path. On a pre-v1.17.12 server (no V2 endpoint) the call still returns 'unknown' and the permission stays in the resync output so the user can answer manually — fail-closed, no false-resolved signals. Adds a focused unit test for fetchPermission (4 cases: 200 ok, 404 resolved, 500 unknown, network throw) mocking the V2 SDK client shape. --------- Co-authored-by: bashrusakh --- .../lib/opencode/client.permission.test.ts | 154 ++++++++++++++++++ packages/ui/src/lib/opencode/client.ts | 112 +++++++++++++ packages/ui/src/sync/sync-context.tsx | 43 ++++- .../handovers/session-1.md | 34 ++++ .../implementation/phase-5-impl.md | 75 +++++++++ .../phases/phase-5-permission.md | 24 +++ plans/opencode-v1.17.12-sdk/plan.md | 88 ++++++++++ plans/opencode-v1.17.12-sdk/todo.md | 61 +++++++ 8 files changed, 585 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/lib/opencode/client.permission.test.ts create mode 100644 plans/opencode-v1.17.12-sdk/handovers/session-1.md create mode 100644 plans/opencode-v1.17.12-sdk/implementation/phase-5-impl.md create mode 100644 plans/opencode-v1.17.12-sdk/phases/phase-5-permission.md create mode 100644 plans/opencode-v1.17.12-sdk/plan.md create mode 100644 plans/opencode-v1.17.12-sdk/todo.md diff --git a/packages/ui/src/lib/opencode/client.permission.test.ts b/packages/ui/src/lib/opencode/client.permission.test.ts new file mode 100644 index 00000000..c42d63f7 --- /dev/null +++ b/packages/ui/src/lib/opencode/client.permission.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, mock, test } from 'bun:test'; + +type PermissionV2Fixture = { + id: string; + sessionID: string; + action: string; + resources: string[]; +}; + +const permissionGetMock = mock((args: { sessionID: string; requestID: string }) => { + return new Promise((resolve, reject) => { + pendingResolutions.push((r: TestResponse) => { + if (r.kind === 'throw') { + reject(new Error('network down')); + } else if (r.kind === 'ok') { + resolve(makeSuccessResult(r.permission)); + } else { + const status = r.kind === 'not-found' ? 404 : 500; + resolve(makeErrorResult(status)); + } + }); + pendingArgs.push(args); + }); +}); + +type TestResponse = + | { kind: 'ok'; permission: PermissionV2Fixture } + | { kind: 'not-found' } + | { kind: 'server-error' } + | { kind: 'throw' }; + +const pendingResolutions: Array<(r: TestResponse) => void> = []; +const pendingArgs: Array<{ sessionID: string; requestID: string }> = []; + +/** + * Build a HeyApi success result that matches the wrapper's expectations: + * - error === undefined (success branch) + * - data.data === the permission (200 status payload) + * - response.status === 200 + */ +const makeSuccessResult = (permission: PermissionV2Fixture) => ({ + data: { data: permission }, + error: undefined, + request: new Request('http://test/'), + response: new Response(null, { status: 200 }), +}); + +/** + * Build a HeyApi error result with the given status code. + */ +const makeErrorResult = (status: number) => ({ + data: undefined, + error: { + name: status === 404 ? 'PermissionNotFoundError' : 'ServerError', + data: { message: 'err' }, + }, + request: new Request('http://test/'), + response: new Response(null, { status }), +}); + +const createOpencodeClientMock = mock(() => ({ + v2: { + session: { + permission: { + get: permissionGetMock, + }, + }, + }, +})); + +(mock as unknown as { restore?: () => void }).restore?.(); + +mock.module('@opencode-ai/sdk/v2', () => ({ + createOpencodeClient: createOpencodeClientMock, +})); + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: mock(() => null), +})); + +mock.module('@/lib/runtime-url', () => ({ + getRuntimeUrlResolver: mock(() => ({ + api: (path: string) => path, + })), +})); + +mock.module('@/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: mock(() => ''), + getRuntimeKey: mock(() => 'test-runtime'), +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: mock(async () => new Response(JSON.stringify([]), { + headers: { 'Content-Type': 'application/json' }, + })), +})); + +mock.module('@/lib/startupTrace', () => ({ + markStartupTrace: mock(() => undefined), +})); + +const { opencodeClient } = await import(`./client?cache-test-permission=${Date.now()}`); + +/** + * Drive the in-flight mocked `get()` call to the next resolver with the + * given response shape. Each test owns exactly one queued call, so this + * is unambiguous as long as tests do not overlap. + */ +const resolveNext = (response: TestResponse) => { + queueMicrotask(() => { + const resolver = pendingResolutions.shift(); + if (resolver) resolver(response); + }); +}; + +describe('opencodeClient.fetchPermission', () => { + test('returns state="ok" with the permission when the server returns 200', async () => { + const permission: PermissionV2Fixture = { + id: 'perm_1', + sessionID: 'ses_1', + action: 'bash', + resources: ['*'], + }; + const promise = opencodeClient.fetchPermission('ses_1', 'perm_1'); + resolveNext({ kind: 'ok', permission }); + const result = await promise; + expect(result.state).toBe('ok'); + if (result.state === 'ok') { + expect(result.permission).toEqual(permission); + } + expect(pendingArgs[0]).toEqual({ sessionID: 'ses_1', requestID: 'perm_1' }); + }); + + test('returns state="resolved" when the server returns 404', async () => { + const promise = opencodeClient.fetchPermission('ses_1', 'perm_gone'); + resolveNext({ kind: 'not-found' }); + const result = await promise; + expect(result).toEqual({ state: 'resolved' }); + }); + + test('returns state="unknown" on non-404 error responses (e.g. 500)', async () => { + const promise = opencodeClient.fetchPermission('ses_1', 'perm_1'); + resolveNext({ kind: 'server-error' }); + const result = await promise; + expect(result).toEqual({ state: 'unknown' }); + }); + + test('returns state="unknown" when the SDK throws (network failure)', async () => { + const promise = opencodeClient.fetchPermission('ses_1', 'perm_1'); + resolveNext({ kind: 'throw' }); + const result = await promise; + expect(result).toEqual({ state: 'unknown' }); + }); +}); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index f5d04273..80fba7d6 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -1,4 +1,5 @@ import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2"; +import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client"; import type { FilesAPI } from "../api/types"; import { getDesktopHomeDirectory } from "../desktop"; import type { @@ -13,6 +14,17 @@ import type { } from "@opencode-ai/sdk/v2"; import type { PermissionRequest } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; + +/** + * Tagged result of `OpencodeService.fetchPermission()`. The caller can + * distinguish a server-confirmed "no longer pending" permission (HTTP + * 404) from a fetch failure (network error, malformed response, or a + * pre-v1.17.12 server without the V2 endpoint). + */ +export type FetchPermissionResult = + | { state: "ok"; permission: PermissionV2Request } + | { state: "resolved" } + | { state: "unknown" }; import { getRuntimeUrlResolver } from "@/lib/runtime-url"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { getRuntimeKey } from "@/lib/runtime-switch"; @@ -1108,6 +1120,106 @@ class OpencodeService { return unwrapSdkOptional(response, 'permission.reply') === true; } + /** + * Programmatically evaluate and (when approval is required) create a + * permission request for a session via the V2 endpoint introduced in + * OpenCode SDK v1.17.12. Wraps `session.permission.create`. + * + * Returns `{ id, effect }` on success, or `null` on any failure + * (network error, 4xx/5xx response, malformed payload, or pre-v1.17.12 + * server without the V2 endpoint). Callers driving authoritative state + * must treat `null` as "unknown — do not act" rather than "permission + * allowed." + * + * Thin wrapper for future programmatic permission creation. The V1 + * `permission.list` / `permission.reply` flow used by the auto-accept + * path is unchanged. + */ + async createPermission( + sessionID: string, + action: string, + resources: string[], + options?: { + id?: string; + save?: string[]; + metadata?: Record; + source?: PermissionV2Source; + agent?: string; + } + ): Promise<{ id: string; effect: PermissionV2Effect } | null> { + try { + const response = await this.client.v2.session.permission.create({ + sessionID, + action, + resources, + ...(options?.id ? { id: options.id } : {}), + ...(options?.save ? { save: options.save } : {}), + ...(options?.metadata ? { metadata: options.metadata } : {}), + ...(options?.source ? { source: options.source } : {}), + ...(options?.agent ? { agent: options.agent } : {}), + }); + // Discriminated union narrowing on `error` (see fetchPermission). + if (response.error !== undefined) return null; + const payload = response.data?.data; + if (payload === undefined) return null; + return { id: payload.id, effect: payload.effect }; + } catch { + return null; + } + } + + /** + * Fetch a pending permission request owned by a session via the V2 + * endpoint introduced in OpenCode SDK v1.17.12. Wraps + * `session.permission.get`. + * + * Returns a tagged `FetchPermissionResult` so the caller can distinguish + * a confirmed-resolved permission (HTTP 404) from a fetch failure + * (network error, malformed response, or pre-v1.17.12 server without + * the V2 endpoint). The auto-accept flow uses this distinction to drop + * resolved permissions from the resync output, preventing stale + * `permission.list` entries from sticking around in the UI. + */ + async fetchPermission( + sessionID: string, + requestID: string, + ): Promise { + try { + // The V2 path is session-scoped and does not require a `directory` + // parameter. The client-scoped directory (set via setDirectory) is + // honored by the underlying SDK client when the call is routed. + const response = await this.client.v2.session.permission.get({ + sessionID, + requestID, + }); + // The SDK returns a discriminated union on `error`/`data` (HeyApi + // `RequestResult` with `ThrowOnError = false`). The error branch + // collapses `data` to `undefined`; the data branch returns the + // 200-response payload as `{ data: PermissionV2Request }`. Narrow + // via `error` first, then unwrap the inner `data` field. + if (response.error === undefined) { + const payload = response.data?.data; + if (payload !== undefined) { + return { state: "ok", permission: payload }; + } + } + // On the error branch the server has answered but the request was + // not found. V2SessionPermissionGetErrors maps 404 to + // `PermissionNotFoundError`, so the only server-confirmed + // "no longer pending" signal we have is HTTP 404. + if (response.response?.status === 404) { + return { state: "resolved" }; + } + return { state: "unknown" }; + } catch { + // Network failure, pre-v1.17.12 server, or runtimeFetch throwing. + // Treat as "unknown" — caller must decide what to do (auto-accept + // fails closed, but the permission stays in the resync output so + // the user can still act on it). + return { state: "unknown" }; + } + } + /** * Throws on fetch/SDK failure. Callers that drive authoritative state from * the result (e.g. reconnect resync) must let the throw propagate so they diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index f19c0a80..1e93d8fc 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -1139,13 +1139,41 @@ export async function resyncBlockingRequestsForDirectory( if (autoAcceptingSessionIds.length > 0) { const acceptedIdsBySession = new Map>() + // Track server-confirmed resolved permissions separately so we can + // remove them from `grouped` below — the V1 listPendingPermissions + // snapshot can still contain entries the server has already answered, + // and leaving them in place produces a spurious "Permission needed" + // toast for a permission the user has already resolved. + const resolvedIdsBySession = new Map>() await Promise.all(autoAcceptingSessionIds.flatMap((sessionId) => (grouped[sessionId] ?? []).map(async (permission) => { try { - await sessionActions.respondToPermission(permission.sessionID, permission.id, "once") - const accepted = acceptedIdsBySession.get(sessionId) ?? new Set() - accepted.add(permission.id) - acceptedIdsBySession.set(sessionId, accepted) + // Verify the permission is still pending before auto-accepting. + // - state: "ok" → still pending, safe to auto-accept + // - state: "resolved" → server returned 404, drop from grouped + // - state: "unknown" → network error / pre-1.17.12 server, + // keep in grouped for the user to act on + // + // On a pre-v1.17.12 server without the V2 endpoint, every call + // returns "unknown". This permanently disables auto-accept + // (acknowledged scope tradeoff — project requires SDK 1.17.12) + // but does not falsely report permissions as resolved. + const outcome = await opencodeClient.fetchPermission( + permission.sessionID, + permission.id, + ) + if (outcome.state === "ok") { + await sessionActions.respondToPermission(permission.sessionID, permission.id, "once") + const accepted = acceptedIdsBySession.get(sessionId) ?? new Set() + accepted.add(permission.id) + acceptedIdsBySession.set(sessionId, accepted) + } else if (outcome.state === "resolved") { + const resolved = resolvedIdsBySession.get(sessionId) ?? new Set() + resolved.add(permission.id) + resolvedIdsBySession.set(sessionId, resolved) + } + // state: "unknown" → keep the permission in grouped; user can + // answer manually. } catch { // Keep failed auto-accept permissions in UI state so the user can act. } @@ -1154,8 +1182,11 @@ export async function resyncBlockingRequestsForDirectory( for (const sessionId of autoAcceptingSessionIds) { const acceptedIds = acceptedIdsBySession.get(sessionId) - if (!acceptedIds) continue - const remaining = (grouped[sessionId] ?? []).filter((permission) => !acceptedIds.has(permission.id)) + const resolvedIds = resolvedIdsBySession.get(sessionId) + if (!acceptedIds && !resolvedIds) continue + const drop = (id: string) => + acceptedIds?.has(id) || resolvedIds?.has(id) || false + const remaining = (grouped[sessionId] ?? []).filter((permission) => !drop(permission.id)) if (remaining.length > 0) grouped[sessionId] = remaining else delete grouped[sessionId] } diff --git a/plans/opencode-v1.17.12-sdk/handovers/session-1.md b/plans/opencode-v1.17.12-sdk/handovers/session-1.md new file mode 100644 index 00000000..ad04a4e9 --- /dev/null +++ b/plans/opencode-v1.17.12-sdk/handovers/session-1.md @@ -0,0 +1,34 @@ +# Handover — Session 1 (2026-07-01) + +## Current state + +Plan created for OpenCode v1.17.12 SDK migration. No code changes made yet. + +## What was done + +1. Verified SDK v1.17.9 types against v1.17.12 release notes +2. Identified 3 actually-new methods: `session.interrupt()`, `session.events()`, `session.permission` +3. Identified 2 existing-but-unused: `Session3.messages()` with cursor, `Session3.message()` +4. Created 4-phase plan with implementation details +5. Created 4 GitHub issues (#1968, #1969, #1971, #1972) +6. Created 4 draft PRs (#1973, #1974, #1976, #1977) +7. Answered bot questions on all issues + +## Key findings + +- `session.interrupt()` is the highest-impact change — fixes abort propagation to upstream provider +- `session.events()` and `session.permission` must be verified in SDK types before implementation +- `Session3` API has different parameter shape than `Session2` — `directory` is client-scoped, `before` replaced by `cursor` +- `global.event()` is already used correctly — no changes needed +- Custom WebSocket/SSE in `event-pipeline.ts` is OpenChamber-specific (coalescing, routing, backpressure) — not a replacement for SDK + +## Next safe action + +1. Bump `@opencode-ai/sdk` to `^1.17.12` and run `bun install` +2. Verify new SDK types exist (`session.interrupt`, `session.events`, `session.permission`) +3. Start Phase 1: replace `session.abort()` → `session.interrupt()` in 3 call sites + +## Blockers + +- SDK v1.17.12 must be published and installable +- `session.events()` and `session.permission` existence unconfirmed diff --git a/plans/opencode-v1.17.12-sdk/implementation/phase-5-impl.md b/plans/opencode-v1.17.12-sdk/implementation/phase-5-impl.md new file mode 100644 index 00000000..0aa42346 --- /dev/null +++ b/plans/opencode-v1.17.12-sdk/implementation/phase-5-impl.md @@ -0,0 +1,75 @@ +# Phase 4 Implementation: `session.permission` — Programmatic Endpoints + +## Prerequisite + +Verify `session.permission.create` and `session.permission.fetch` exist in SDK v1.17.12 types. Check `node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.d.ts` for `Permission2` class (referenced by `Session3` at line 1672). + +If absent, skip this phase. + +## Step 1: Add wrappers to client.ts + +File: `packages/ui/src/lib/opencode/client.ts` + +Add after `replyToPermission()` (line 1108): + +```typescript +async createPermission( + sessionID: string, + permission: string, + options?: { message?: string; directory?: string | null } +): Promise { + const requestDirectory = this.normalizeCandidatePath(options?.directory ?? null) ?? this.currentDirectory; + const response = await this.client.session.permission.create({ + sessionID, + permission, + ...(requestDirectory ? { directory: requestDirectory } : {}), + ...(options?.message ? { message: options.message } : {}), + }); + return (response.data as PermissionRequest) ?? null; +} + +async fetchPermission( + sessionID: string, + requestID: string, + directory?: string | null +): Promise { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; + const response = await this.client.session.permission.fetch({ + sessionID, + requestID, + ...(requestDirectory ? { directory: requestDirectory } : {}), + }); + return (response.data as PermissionRequest) ?? null; +} +``` + +## Step 2: Use in auto-accept flow (optional) + +File: `packages/ui/src/sync/sync-context.tsx`, line 1118-1143 + +The auto-accept flow currently iterates `grouped` permissions and calls `respondToPermission()`. The new `fetchPermission()` could be used to verify a permission still exists before auto-accepting: + +```typescript +await Promise.all(autoAcceptingSessionIds.flatMap((sessionId) => + (grouped[sessionId] ?? []).map(async (permission) => { + try { + const fresh = await opencodeClient.fetchPermission( + permission.sessionID, + permission.id + ); + if (!fresh) return; // Permission already resolved + await sessionActions.respondToPermission(permission.sessionID, permission.id, "once") + } catch { + // Keep failed auto-accept permissions in UI state + } + }), +)) +``` + +## Step 3: Validation + +```bash +cd packages/ui && bun run type-check +``` + +Manual: trigger a permission request, verify auto-accept flow works. diff --git a/plans/opencode-v1.17.12-sdk/phases/phase-5-permission.md b/plans/opencode-v1.17.12-sdk/phases/phase-5-permission.md new file mode 100644 index 00000000..5272d2b5 --- /dev/null +++ b/plans/opencode-v1.17.12-sdk/phases/phase-5-permission.md @@ -0,0 +1,24 @@ +# Phase 4: `session.permission` — Programmatic Endpoints + +## Summary + +Use `session.permission.create` and `session.permission.fetch` (new in OpenCode SDK v1.17.12) for programmatic permission handling — verify a permission is still relevant before auto-accepting it. + +## Files + +| File | Change | +|------|--------| +| `packages/ui/src/lib/opencode/client.ts` | Add `createPermission()`, `fetchPermission()` wrappers | +| `packages/ui/src/sync/sync-context.tsx` | Use in auto-accept flow (L1118-1143) | + +## Risk + +**Low.** Must verify `session.permission` exists in SDK v1.17.12. If absent, skip this phase. + +## Validation + +```bash +bun run type-check --filter @openchamber/ui +``` + +Manual: trigger a permission request, verify auto-accept flow works. diff --git a/plans/opencode-v1.17.12-sdk/plan.md b/plans/opencode-v1.17.12-sdk/plan.md new file mode 100644 index 00000000..223bb957 --- /dev/null +++ b/plans/opencode-v1.17.12-sdk/plan.md @@ -0,0 +1,88 @@ +# OpenCode v1.17.12 SDK Migration + +## Why this matters + +OpenChamber currently runs on SDK v1.17.9. Version 1.17.12 adds several methods that directly fix real user-facing problems. Plus there are methods already in the SDK that we simply aren't using — and we should. + +In short: **this is not a "bump the version" chore. It's concrete fixes for hangs, lag, and wasted tokens.** + +## What actually improves for the user + +### 1. The STOP button will actually stop generation + +**Today:** when the user presses STOP, OpenChamber sends `session.abort()` to the server. The server marks the session as idle, but **does not cancel the HTTP request to the LLM provider** (OpenAI, Anthropic, etc.). The provider keeps generating, tokens keep burning, and the user thinks everything stopped. Worse — sometimes the abort itself hangs and never reaches the server (known OpenCode bug #29975). + +**After the update:** `session.interrupt()` — new in v1.17.12 — doesn't just mark the session idle. It **actually tears down the HTTP request to the provider**. The provider stops generating, tokens stop burning, the session frees up immediately. This is the direct fix from OpenCode PR #34467. + +**Where it gets better:** no more "I pressed STOP but it's still thinking for 10 more seconds." + +### 2. Chat stops lagging when switching sessions + +**Today:** all events from all sessions flow into a single global stream (`global.event()`). OpenChamber has to manually parse this firehose — 300+ lines of code in `sync-context.tsx` exist solely to figure out "which chat gets which event." This is slow, complex, and events occasionally leak into the wrong chat. + +**After the update:** `session.events()` — new in v1.17.12 — lets us subscribe to events for **a single session**. The chat listens only to its own session. No global firehose parsing, no guessing "whose event is this." + +**Where it gets better:** +- Session switching is instant, no lag +- Events don't "leak" between chats +- Lower CPU from event parsing (especially noticeable with 5+ open sessions) + +### 3. Long sessions load without freezing; revert/fork without full history load + +**Today (pagination):** when loading message history, OpenChamber requests "the last N messages" via `session.messages({ limit: 50 })`. To load older messages, it requests "everything before message X" via `before`. No cursor — the server rescans history from the beginning every time. + +**Today (message lookup):** when revert or fork needs one specific message, the code searches the already-loaded history. If the message isn't cached — the entire session history is loaded just to find one message. + +**After the update:** `Session3.messages({ cursor })` — the V2 API with cursor-based pagination (already in the SDK, but we use the old `Session2`). Works like flipping pages: load page 1 → get a cursor → request page 2 by cursor → server returns the continuation without rescanning. Plus `Session3.message({ messageID })` for targeted single-message fetch. + +**Where it gets better:** +- Long sessions open faster +- Scrolling up for history — smooth, no jank or redundant loads +- Less server load (no rescanning from scratch) +- Revert and fork complete instantly, even when session is evicted from cache + +### 4. Permissions — programmatic create and fetch + +**Today:** permissions are handled reactively — an SSE event `permission.asked` arrives, the UI shows a dialog, the user responds. Auto-accept works by iterating all pending permissions and calling `reply()` without checking if the permission is still valid. + +**After the update:** `session.permission.create` and `session.permission.fetch` — new in v1.17.12. Enables programmatic permission creation and status checks before responding. Useful for auto-accept: before auto-approving, we can verify the permission is still relevant. + +**Where it gets better:** fewer false auto-accepts on already-answered permissions. + +## What will NOT change (and why) + +### Tool timeout hangs (issue #1950) — not fixable via SDK + +The problem "tool hangs for 5 minutes → session silently dies → UI stuck on thinking" is an **OpenCode server bug**, not an SDK issue. No SDK method can force the server to emit `session.idle` when it doesn't. This can only be fixed in OpenCode itself (default timeout, stream watchdog, correct `session.idle` emission). + +**What the SDK does help with:** `session.interrupt()` lets the user **manually** kill a hung tool via STOP. Previously STOP didn't guarantee provider cancellation — now it does. + +## Phase plan + +| # | What | Priority | Effort | User impact | +|---|------|----------|--------|-------------| +| 1 | `session.interrupt()` replaces `session.abort()` | 🔴 High | Small | STOP actually stops generation | +| 2 | `session.events()` — per-session event subscription | 🟡 Medium | Medium | No lag on session switch, no event leaks | +| 3 | Migrate all message ops to Session3 API (cursor pagination + targeted message lookup) | 🟡 Medium | Medium | Long sessions load without freezing; revert/fork without full history load | +| 4 | `session.permission` — programmatic endpoints | 🟢 Low | Small | More reliable auto-accept | + +## What already works — don't touch + +- `global.event()` — global event stream. Used, works, no changes needed. +- Custom WebSocket/SSE in `event-pipeline.ts` — this is OpenChamber-specific wrapping (coalescing, routing, backpressure), not an SDK replacement. Stays as-is. + +## Validation + +| Phase | Command | +|-------|---------| +| 1-4 | `bun run type-check` in affected packages | +| 1 | `bun test packages/ui/src/sync/session-actions.test.ts` | +| 2 | Manual: open a session, send a message, switch sessions — events don't leak | +| 3 | Manual: load a session with 100+ messages, scroll up — smooth, no jank; revert an evicted session — works without full history load | +| 4 | Manual: verify auto-accept permissions | + +## References + +- OpenCode v1.17.12 release: https://github.com/anomalyco/opencode/releases/tag/v1.17.12 +- OpenChamber issue #1950: https://github.com/openchamber/openchamber/issues/1950 +- SDK types: `node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.d.ts` diff --git a/plans/opencode-v1.17.12-sdk/todo.md b/plans/opencode-v1.17.12-sdk/todo.md new file mode 100644 index 00000000..564d7090 --- /dev/null +++ b/plans/opencode-v1.17.12-sdk/todo.md @@ -0,0 +1,61 @@ +# Todo — OpenCode v1.17.12 SDK Migration + +## Phase 1: `session.interrupt()` — server-side abort propagation + +- [ ] Bump `@opencode-ai/sdk` to `^1.17.12` in all `package.json` files +- [ ] Add `session.interrupt()` mock to `session-actions.test.ts` +- [ ] Replace `session.abort()` → `session.interrupt()` in `abortCurrentOperation()` (line 717) +- [ ] Replace `session.abort()` → `session.interrupt()` in `revertToMessage()` (line 910) +- [ ] Replace `session.abort()` → `session.interrupt()` in `unrevertSession()` (line 1042) +- [ ] Add `interruptSession()` wrapper to `client.ts` (optional) +- [ ] Run `bun run type-check` in `packages/ui` +- [ ] Run `bun test packages/ui/src/sync/session-actions.test.ts` +- [ ] Manual: verify STOP button aborts session and propagates to provider + +## Phase 2: `session.events()` — per-session event stream + +- [ ] Verify `session.events()` exists in SDK v1.17.12 types +- [ ] Add `subscribeSessionEvents()` to `client.ts` +- [ ] Use per-session stream in ChatContainer (reduce global firehose routing) +- [ ] Keep global pipeline as fallback for non-active sessions +- [ ] Run `bun run type-check` in `packages/ui` +- [ ] Manual: verify events arrive for active session only + +## Phase 3: Migrate all message operations to Session3 API + +### Step 1: Switch `session.messages()` calls from `Session2` to `Session3` API + +- [ ] Pass `cursor` param in `fetchMessages()` (`use-sync.ts` line 326) +- [ ] Pass `cursor` param in `materializeSessionFromServer()` (`sync-context.tsx` line 240) +- [ ] Pass `cursor` param in `resyncDirectoryAfterReconnect()` (`sync-context.tsx` line 1207) +- [ ] Pass `cursor` param in `refetchSessionMessages()` (`session-actions.ts` line 1010) +- [ ] Pass `cursor` param in `fetchMessagesForSession()` (`session-actions.ts` line 1147) +- [ ] Pass `cursor` param in `getSessionMessages()` (`client.ts` line 547) +- [ ] Remove `directory` param from per-call args (set at client creation via scoped client) +- [ ] Replace `before` param with `cursor` + +### Step 2: Add `session.message()` on the same Session3 API + +- [ ] Add `getMessage()` wrapper to `client.ts` using `Session3.message()` +- [ ] Use in `revertToMessage()` as fallback when message not in store +- [ ] Use in `forkFromMessage()` as fallback when message not in store + +### Validation + +- [ ] Run `bun run type-check` in `packages/ui` +- [ ] Manual: load a session with 100+ messages, scroll up — smooth, no jank +- [ ] Manual: revert an evicted session — works without full history load + +## Phase 4: `session.permission` — programmatic endpoints + +- [ ] Verify `session.permission.create` / `fetch` exist in SDK v1.17.12 +- [ ] Add wrappers to `client.ts` +- [ ] Use in permission auto-accept flow (`sync-context.tsx` line 1118-1143) +- [ ] Run `bun run type-check` in `packages/ui` +- [ ] Manual: verify permission create/fetch flow + +## Blockers + +- SDK v1.17.12 must be published and installable +- `session.events()` and `session.permission` must be confirmed in SDK types +- `Session3` API compatibility with current `Session2` usage must be verified