SDK v1.17.12: session.permission — programmatic create/fetch, more reliable auto-accept (#1982)
* 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 <bashrusakh@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
bashrusakh
parent
6d7ea82d86
commit
01a52eccab
@@ -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<unknown>((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' });
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<FetchPermissionResult> {
|
||||
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
|
||||
|
||||
@@ -1139,13 +1139,41 @@ export async function resyncBlockingRequestsForDirectory(
|
||||
|
||||
if (autoAcceptingSessionIds.length > 0) {
|
||||
const acceptedIdsBySession = new Map<string, Set<string>>()
|
||||
// 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<string, Set<string>>()
|
||||
await Promise.all(autoAcceptingSessionIds.flatMap((sessionId) =>
|
||||
(grouped[sessionId] ?? []).map(async (permission) => {
|
||||
try {
|
||||
// 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<string>()
|
||||
accepted.add(permission.id)
|
||||
acceptedIdsBySession.set(sessionId, accepted)
|
||||
} else if (outcome.state === "resolved") {
|
||||
const resolved = resolvedIdsBySession.get(sessionId) ?? new Set<string>()
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<PermissionRequest | null> {
|
||||
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<PermissionRequest | null> {
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user