diff --git a/apps/app/scripts/session-render-state.test.ts b/apps/app/scripts/session-render-state.test.ts index 510108a47..1bff3ede0 100644 --- a/apps/app/scripts/session-render-state.test.ts +++ b/apps/app/scripts/session-render-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { UIMessage } from "ai"; -import type { iPolloWorkSessionSnapshot } from "../src/app/lib/ipollowork-server"; +import type { ConversationSnapshot } from "../src/react-app/domains/session/engine/conversation-engine"; import { deriveRenderedSessionMessages, resolveRenderedSessionSnapshot, @@ -9,55 +9,38 @@ import { import { applyRevertCursor, reconcileTranscriptMessages, - resolveForkBoundaryId, } from "../src/react-app/domains/session/sync/transcript-reconcile"; -import { describeOpencodeSessionError } from "../src/react-app/domains/session/sync/usechat-adapter"; +import { resolveOpenCodeForkBoundaryId } from "../src/react-app/domains/session/engine/opencode-conversation-mapper"; +import { describeOpencodeSessionError } from "../src/react-app/domains/session/engine/opencode-message-adapter"; function snapshotWithMessages( messages: Array<{ id: string; role: "user" | "assistant"; text: string; created?: number }>, sessionId = "ses_test", -): iPolloWorkSessionSnapshot { +): ConversationSnapshot { return { session: { id: sessionId, - parentID: undefined, title: "Test session", time: { created: 1, updated: 2 }, - share: undefined, - version: "0", }, - messages: messages.map((message, index) => ({ - info: { - id: message.id, - role: message.role, - sessionID: sessionId, - time: { created: message.created ?? index + 1 }, - }, - parts: [ - { - id: `part_${message.id}`, - type: "text", - text: message.text, - sessionID: sessionId, - messageID: message.id, - }, - ], - })), + messages: messages.map((message, index) => + uiMessage(message.id, message.role, message.text, message.created ?? index + 1), + ), todos: [], status: { type: "idle" }, - } as unknown as iPolloWorkSessionSnapshot; + }; } function uiMessage(id: string, role: "user" | "assistant", text: string, created?: number): UIMessage { return { id, role, - ...(typeof created === "number" ? { metadata: { opencode: { created } } } : {}), + ...(typeof created === "number" ? { metadata: { ipollowork: { created } } } : {}), parts: [{ type: "text", text, state: "done" }], }; } -function snapshotWithText(text: string, sessionId = "ses_test"): iPolloWorkSessionSnapshot { +function snapshotWithText(text: string, sessionId = "ses_test"): ConversationSnapshot { return snapshotWithMessages([{ id: "msg_user", role: "user", text }], sessionId); } @@ -378,7 +361,7 @@ describe("deriveRenderedSessionMessages with revert", () => { { id: "msg_3", role: "user", text: "turn two" }, { id: "msg_4", role: "assistant", text: "answer two" }, ]); - (snapshot.session as { revert?: { messageID: string } }).revert = { messageID: "msg_3" }; + snapshot.session.revertMessageId = "msg_3"; const rendered = deriveRenderedSessionMessages({ transcriptState: [], @@ -389,7 +372,7 @@ describe("deriveRenderedSessionMessages with revert", () => { }); }); -describe("resolveForkBoundaryId", () => { +describe("resolveOpenCodeForkBoundaryId", () => { const transcript = [ uiMessage("msg_1", "user", "turn one"), uiMessage("msg_2", "assistant", "answer one"), @@ -398,16 +381,16 @@ describe("resolveForkBoundaryId", () => { ]; it("returns the next message so the fork includes the branch point", () => { - expect(resolveForkBoundaryId(transcript, "msg_2")).toBe("msg_3"); - expect(resolveForkBoundaryId(transcript, "msg_3")).toBe("msg_4"); + expect(resolveOpenCodeForkBoundaryId(transcript, "msg_2")).toBe("msg_3"); + expect(resolveOpenCodeForkBoundaryId(transcript, "msg_3")).toBe("msg_4"); }); it("returns null when branching at the last message (fork everything)", () => { - expect(resolveForkBoundaryId(transcript, "msg_4")).toBeNull(); + expect(resolveOpenCodeForkBoundaryId(transcript, "msg_4")).toBeNull(); }); it("returns null for unknown ids instead of corrupting the boundary", () => { - expect(resolveForkBoundaryId(transcript, "msg_missing")).toBeNull(); + expect(resolveOpenCodeForkBoundaryId(transcript, "msg_missing")).toBeNull(); }); it("skips synthetic session-error messages when picking the boundary", () => { @@ -416,8 +399,8 @@ describe("resolveForkBoundaryId", () => { uiMessage("session-error:msg_2", "assistant", "boom"), ...transcript.slice(2), ]; - expect(resolveForkBoundaryId(withSynthetic, "msg_2")).toBe("msg_3"); + expect(resolveOpenCodeForkBoundaryId(withSynthetic, "msg_2")).toBe("msg_3"); // Branching at the synthetic message itself falls through to the next real message. - expect(resolveForkBoundaryId(withSynthetic, "session-error:msg_2")).toBe("msg_3"); + expect(resolveOpenCodeForkBoundaryId(withSynthetic, "session-error:msg_2")).toBe("msg_3"); }); }); diff --git a/apps/app/src/app/lib/opencode-session.ts b/apps/app/src/app/lib/opencode-session.ts deleted file mode 100644 index c5abff018..000000000 --- a/apps/app/src/app/lib/opencode-session.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Typed helpers for OpenCode session operations. - * - * The OpenCode SDK (v2) exposes `session.abort`, `session.revert`, - * `session.unrevert`, `session.shell`, and `command.list` as typed methods. - * This module provides thin wrappers that avoid `as any` casts by using the - * SDK types directly, and adds feature-detection for newer API surface - * (e.g. `shellAsync`) that may not be present in older SDK versions. - */ -import type { Session } from "@opencode-ai/sdk/v2/client"; -import type { Client, ModelRef } from "../types"; -import { unwrap } from "./opencode"; - -// --------------------------------------------------------------------------- -// Session helpers -// --------------------------------------------------------------------------- - -/** - * Abort an active session. Resolves to the server's answer: `true` when a - * live run was aborted, `false` when nothing matched (already idle, or the - * session lives in a different project/directory scope). Callers that show - * "stopped" feedback must check the result — a `200: false` response is NOT - * a successful stop (#2014). - * - * Pass the workspace `directory` whenever the prompt was sent through a - * directory-scoped client; omitting it can resolve the abort against the - * server's default project where no matching run exists. - */ -export async function abortSession( - client: Client, - sessionID: string, - directory?: string, -): Promise { - return unwrap(await client.session.abort({ sessionID, directory })) === true; -} - -/** - * Abort an active session, swallowing transport errors (useful before - * revert/undo). Returns `false` when the abort failed or nothing was - * aborted, so callers can avoid declaring success on a no-op. - */ -export async function abortSessionSafe( - client: Client, - sessionID: string, - directory?: string, -): Promise { - try { - return await abortSession(client, sessionID, directory); - } catch { - // The session may already be idle or the server unreachable; callers - // treat `false` as "nothing was aborted". - return false; - } -} - -/** - * Revert a session to a specific message boundary. - */ -export async function revertSession( - client: Client, - sessionID: string, - messageID: string, -): Promise { - return unwrap(await client.session.revert({ sessionID, messageID })) as Session; -} - -/** - * Fork a session at a specific message, creating a new session branch. - */ -export async function forkSession( - client: Client, - sessionID: string, - messageID?: string, -): Promise { - return unwrap(await client.session.fork({ sessionID, messageID })) as Session; -} - -/** - * Restore all previously reverted messages in a session. - */ -export async function unrevertSession( - client: Client, - sessionID: string, -): Promise { - return unwrap(await client.session.unrevert({ sessionID })) as Session; -} - -/** - * Archive or unarchive a session via OpenCode's native `time.archived` field. - * - * Passing `archived: true` stamps the current time; `false` clears it (sends - * `0`, which the server treats as "not archived"). Archiving is a non-destructive - * primitive — the session and its context are preserved, which matters for orgs - * with audit/retention policies that forbid deletion. - */ -export async function setSessionArchived( - client: Client, - sessionID: string, - archived: boolean, - directory?: string, -): Promise { - unwrap( - await client.session.update({ - sessionID, - directory, - time: { archived: archived ? Date.now() : 0 }, - }), - ); -} - -/** - * Compact/summarize a long session to reduce context size. - * Uses `session.summarize` when available and falls back to `/compact` command. - */ -export async function compactSession( - client: Client, - sessionID: string, - model: ModelRef, - options?: { directory?: string }, -): Promise { - const session = client.session as { summarize?: (input: { - sessionID: string; - directory?: string; - providerID: string; - modelID: string; - }) => Promise }; - - if (typeof session.summarize === "function") { - const result = await session.summarize({ - sessionID, - directory: options?.directory, - providerID: model.providerID, - modelID: model.modelID, - }); - assertNoClientError(result); - return; - } - - const modelString = `${model.providerID}/${model.modelID}`; - const result = await client.session.command({ - sessionID, - command: "compact", - arguments: "", - model: modelString, - directory: options?.directory, - }); - assertNoClientError(result); -} - -// --------------------------------------------------------------------------- -// Shell execution -// --------------------------------------------------------------------------- - -/** - * Execute a shell command in a session. Uses `shell` from the SDK. - * Falls back to `promptAsync` with a `!` prefix if `shell` is unavailable. - */ -export async function shellInSession( - client: Client, - sessionID: string, - command: string, - options?: { model?: { providerID: string; modelID: string }; agent?: string; variant?: string }, -): Promise { - const result = await client.session.shell({ sessionID, command }); - assertNoClientError(result); -} - -// --------------------------------------------------------------------------- -// Command listing -// --------------------------------------------------------------------------- - -export type CommandListItem = { - id: string; - name: string; - description?: string; - source?: "command" | "mcp" | "skill"; -}; - -/** - * List available slash commands for a workspace. - */ -export async function listCommands( - client: Client, - directory?: string, -): Promise { - try { - const result = await client.command.list({ directory }); - const list = result?.data ?? []; - if (!Array.isArray(list)) return []; - return list.map((cmd: Record) => ({ - id: `cmd:${cmd.name}`, - name: String(cmd.name ?? ""), - description: cmd.description ? String(cmd.description) : undefined, - source: cmd.source as CommandListItem["source"], - })); - } catch { - return []; - } -} - -// --------------------------------------------------------------------------- -// Internal -// --------------------------------------------------------------------------- - -function assertNoClientError(result: unknown): void { - const maybe = result as { error?: unknown } | null | undefined; - if (!maybe || maybe.error === undefined) return; - const message = - maybe.error instanceof Error - ? maybe.error.message - : typeof maybe.error === "string" - ? maybe.error - : JSON.stringify(maybe.error); - throw new Error(message || "Unknown error"); -} diff --git a/apps/app/src/app/lib/opencode.ts b/apps/app/src/app/lib/opencode.ts index d1142561e..bfb8517bf 100644 --- a/apps/app/src/app/lib/opencode.ts +++ b/apps/app/src/app/lib/opencode.ts @@ -189,32 +189,6 @@ async function wrapiPolloWorkRead( } } -function shouldFallbackToLegacySessionRead(error: unknown): boolean { - if (!(error instanceof iPolloWorkServerError)) return false; - return error.status === 404 || error.status === 405 || error.status === 501; -} - -async function wrapiPolloWorkReadWithFallback( - url: string, - read: () => Promise, - fallback: () => Promise>, - options?: { throwOnError?: boolean }, -): Promise> { - try { - return createSyntheticResult(url, "GET", { ok: true, data: await read() }); - } catch (error) { - if (!shouldFallbackToLegacySessionRead(error)) { - if (options?.throwOnError) throw error; - return createSyntheticResult(url, "GET", { - ok: false, - error, - status: error instanceof iPolloWorkServerError ? error.status : 500, - }); - } - return fallback(); - } -} - async function fetchWithTimeout( fetchImpl: typeof globalThis.fetch, input: RequestInfo | URL, @@ -376,8 +350,6 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod ipolloworkMount && auth?.token ? createiPolloWorkServerClient({ baseUrl: ipolloworkMount.baseUrl, token: auth.token }) : null; - // TODO(2026-04-12): remove the old-server compatibility path here once all - // iPolloWork servers expose the workspace-scoped session read APIs. const sessionOverrides = session as any as { list: (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => Promise>; get: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise>; @@ -398,10 +370,9 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod if (parameters?.search?.trim()) query.set("search", parameters.search.trim()); if (typeof parameters?.limit === "number") query.set("limit", String(parameters.limit)); const url = `${ipolloworkMount.baseUrl}/workspace/${encodeURIComponent(ipolloworkMount.workspaceId)}/sessions${query.size ? `?${query.toString()}` : ""}`; - return wrapiPolloWorkReadWithFallback( + return wrapiPolloWorkRead( url, async () => (await ipolloworkSessionClient.listSessions(ipolloworkMount.workspaceId, parameters)).items, - () => listOriginal(parameters, options), options, ); }; @@ -412,10 +383,9 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod return getOriginal(parameters, options); } const url = `${ipolloworkMount.baseUrl}/workspace/${encodeURIComponent(ipolloworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}`; - return wrapiPolloWorkReadWithFallback( + return wrapiPolloWorkRead( url, async () => (await ipolloworkSessionClient.getSession(ipolloworkMount.workspaceId, parameters.sessionID)).item, - () => getOriginal(parameters, options), options, ); }; @@ -428,13 +398,12 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod const query = new URLSearchParams(); if (typeof parameters.limit === "number") query.set("limit", String(parameters.limit)); const url = `${ipolloworkMount.baseUrl}/workspace/${encodeURIComponent(ipolloworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/messages${query.size ? `?${query.toString()}` : ""}`; - return wrapiPolloWorkReadWithFallback( + return wrapiPolloWorkRead( url, async () => (await ipolloworkSessionClient.getSessionMessages(ipolloworkMount.workspaceId, parameters.sessionID, { limit: parameters.limit, })).items, - () => messagesOriginal(parameters, options), options, ); }; @@ -445,10 +414,9 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod return todoOriginal(parameters, options); } const url = `${ipolloworkMount.baseUrl}/workspace/${encodeURIComponent(ipolloworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/snapshot`; - return wrapiPolloWorkReadWithFallback( + return wrapiPolloWorkRead( url, async () => (await ipolloworkSessionClient.getSessionSnapshot(ipolloworkMount.workspaceId, parameters.sessionID)).item.todos, - () => todoOriginal(parameters, options), options, ); }; diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index 4a1c93ed8..4657cc706 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -1,9 +1,6 @@ import type { Message, Part, - PermissionRequest as ApiPermissionRequest, - PermissionV2Request, - QuestionRequest, Session, } from "@opencode-ai/sdk/v2/client"; import type { createClient } from "./lib/opencode"; @@ -404,17 +401,6 @@ export type ReloadTrigger = { path?: string; }; -export type PendingPermission = Omit & { - always: unknown; - receivedAt: number; - protocol: "legacy" | "v2"; - v2?: Pick; -}; - -export type PendingQuestion = QuestionRequest & { - receivedAt: number; -}; - export type TodoItem = { id: string; content: string; diff --git a/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx b/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx index 319e9f25c..1d26fee89 100644 --- a/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx +++ b/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx @@ -19,7 +19,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { t } from "@/i18n"; -import type { PendingPermission } from "@/app/types"; +import type { ConversationPermission } from "../engine/conversation-engine"; import { cn } from "@/lib/utils"; type PermissionPresentation = { @@ -40,7 +40,7 @@ type PermissionDetail = { }; type PermissionApprovalModalProps = { - permission: PendingPermission; + permission: ConversationPermission; busy?: boolean; respondPermission?: (requestID: string, reply: "once" | "always" | "reject") => void; safeStringify?: (value: unknown) => string; @@ -214,9 +214,9 @@ function isFocusableElement(element: HTMLElement) { return style.display !== "none" && style.visibility !== "hidden"; } -function describePermissionRequest(permission: PendingPermission): PermissionPresentation { - const patterns = permission.patterns.filter((pattern) => pattern.trim().length > 0); - if (permission.permission === "doom_loop") { +function describePermissionRequest(permission: ConversationPermission): PermissionPresentation { + const patterns = permission.resources.filter((pattern) => pattern.trim().length > 0); + if (permission.kind === "doom_loop") { const tool = permission.metadata && typeof permission.metadata === "object" && typeof permission.metadata.tool === "string" ? permission.metadata.tool @@ -234,12 +234,12 @@ function describePermissionRequest(permission: PendingPermission): PermissionPre }; } - const copy = permissionCopy(permission.permission); - const isExternalDirectory = permission.permission === "external_directory"; + const copy = permissionCopy(permission.kind); + const isExternalDirectory = permission.kind === "external_directory"; return { title: copy.title, message: copy.message, - permissionLabel: readablePermissionLabel(permission.permission), + permissionLabel: readablePermissionLabel(permission.kind), scopeLabel: isExternalDirectory ? t("session.permission_detail_path") : t("session.scope_label"), scopeValue: patterns.join(", ") || t("session.permission_scope_empty"), isDoomLoop: false, diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 255c8f9fb..e8964acf2 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -25,14 +25,13 @@ import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import type { BootPhase } from "../../../../app/lib/startup-boot"; import { openDesktopPath, pickDirectory, revealDesktopItemInDir, saveFile, type WorkspaceInfo } from "../../../../app/lib/desktop"; import type { - ComposerDraft, - PendingPermission, - PendingQuestion, - ProviderListItem, + ComposerDraft, + ProviderListItem, TodoItem, WorkspaceConnectionState, ProjectSessionList, } from "../../../../app/types"; +import type { ConversationPermission, ConversationQuestion } from "../engine/conversation-engine"; import { ConversationOutputPanel, ConversationOutputTrigger } from "@/components/chat/artifact"; import { buildSessionMarkdown, sessionMarkdownFilename } from "@/components/chat/utils"; import { @@ -243,11 +242,11 @@ export type SessionPageProps = { todos: TodoItem[]; sessionLoadingById: (sessionId: string | null) => boolean; providerAuthModal?: ProviderAuthModalProps | null; - activePermission?: PendingPermission | null; + activePermission?: ConversationPermission | null; permissionReplyBusy?: boolean; respondPermission?: (requestID: string, reply: "once" | "always" | "reject") => void; safeStringify?: (value: unknown) => string; - activeQuestion?: PendingQuestion | null; + activeQuestion?: ConversationQuestion | null; questionReplyBusy?: boolean; respondQuestion?: (requestID: string, answers: string[][]) => void; notFoundMessage?: string | null; diff --git a/apps/app/src/react-app/domains/session/control/session-control-actions.ts b/apps/app/src/react-app/domains/session/control/session-control-actions.ts index 5ee288998..5c57104c9 100644 --- a/apps/app/src/react-app/domains/session/control/session-control-actions.ts +++ b/apps/app/src/react-app/domains/session/control/session-control-actions.ts @@ -1,19 +1,18 @@ /** @jsxImportSource react */ import { useMemo } from "react"; -import type { createClient } from "../../../../app/lib/opencode"; import type { iPolloWorkServerClient, iPolloWorkWorkspaceInfo } from "../../../../app/lib/ipollowork-server"; -import { setSessionArchived } from "../../../../app/lib/opencode-session"; import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; import { useSessionPinStore } from "../sidebar/session-pin-store"; +import type { ConversationEngineConnection } from "../engine/conversation-engine"; type SessionLike = { id?: string; - title?: string; + title?: string | null; time?: { - updated?: number; - created?: number; + updated?: number | null; + created?: number | null; }; }; @@ -29,7 +28,7 @@ type UseSessionControlActionsInput = { selectedSessionId: string | null; canCreateTask: boolean; ipolloworkClient: iPolloWorkServerClient | null; - opencodeClient: ReturnType | null; + conversation: ConversationEngineConnection | null; navigateToSession: (sessionId: string) => void; navigateToSessionRoot: () => void; createTaskInWorkspace: (workspaceId: string) => Promise | unknown; @@ -72,7 +71,7 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { navigateToSessionRoot, openModelPicker, ipolloworkClient, - opencodeClient, + conversation, refreshRouteState, selectedSessionId, selectedWorkspaceId, @@ -144,24 +143,20 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { { name: "sessionId", type: "string", required: true, description: "Session ID from session.list_sessions." }, { name: "title", type: "string", required: true, description: "New session title." }, ], - disabled: !opencodeClient, + disabled: !conversation, execute: async (args) => { const sessionId = stringArg(args, "sessionId"); const title = stringArg(args, "title"); if (!sessionId) return { ok: false, error: "sessionId is required" }; if (!title) return { ok: false, error: "title is required" }; - if (!opencodeClient) return { ok: false, error: "OpenCode client is not connected" }; + if (!conversation) return { ok: false, error: "Conversation engine is not connected" }; const targetWorkspace = findSessionWorkspace(workspaces, sessionsByWorkspaceId, sessionId); - await opencodeClient.session.update({ - sessionID: sessionId, - title, - directory: targetWorkspace?.path || selectedWorkspaceRoot || undefined, - }); + await conversation.rename(sessionId, title, targetWorkspace?.path || selectedWorkspaceRoot || undefined); await refreshRouteState(); return { ok: true, sessionId, title }; }, - }), [opencodeClient, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); + }), [conversation, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); useControlAction(renameSessionControlAction); const deleteSessionControlAction = useMemo(() => ({ @@ -238,18 +233,18 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { { name: "sessionId", type: "string", required: true, description: "Session ID." }, { name: "archived", type: "boolean", required: true, description: "true to archive, false to unarchive." }, ], - disabled: !opencodeClient, + disabled: !conversation, execute: async (args) => { const sessionId = stringArg(args, "sessionId"); const archived = booleanArg(args, "archived"); if (!sessionId) return { ok: false, error: "sessionId is required" }; - if (!opencodeClient) return { ok: false, error: "OpenCode client is not connected" }; + if (!conversation) return { ok: false, error: "Conversation engine is not connected" }; const targetWorkspace = findSessionWorkspace(workspaces, sessionsByWorkspaceId, sessionId); - await setSessionArchived(opencodeClient, sessionId, archived, targetWorkspace?.path || selectedWorkspaceRoot || undefined); + await conversation.setArchived(sessionId, archived, targetWorkspace?.path || selectedWorkspaceRoot || undefined); await refreshRouteState(); return { ok: true, sessionId, archived }; }, - }), [opencodeClient, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); + }), [conversation, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); useControlAction(archiveControlAction); } diff --git a/apps/app/src/react-app/domains/session/engine/conversation-engine.ts b/apps/app/src/react-app/domains/session/engine/conversation-engine.ts new file mode 100644 index 000000000..b3ec9a91b --- /dev/null +++ b/apps/app/src/react-app/domains/session/engine/conversation-engine.ts @@ -0,0 +1,200 @@ +import type { UIMessage, UIMessageChunk } from "ai"; + +import type { ModelRef, SlashCommandOption, TodoItem } from "@/app/types"; + +export type ConversationStatus = + | { type: "idle" } + | { type: "busy" } + | { type: "retry"; attempt: number; message: string; next: number }; + +export type ConversationSession = { + [key: string]: unknown; + id: string; + title: string; + slug?: string | null; + parentID?: string | null; + directory?: string | null; + time?: { + created?: number | null; + updated?: number | null; + archived?: number | null; + }; + revertMessageId?: string | null; +}; + +export type ConversationSnapshot = { + session: ConversationSession; + messages: UIMessage[]; + todos: TodoItem[]; + status: ConversationStatus; +}; + +export type ConversationPermission = { + id: string; + sessionId: string; + kind: string; + resources: string[]; + remember: string[]; + metadata: Record; + receivedAt: number; + native: unknown; +}; + +export type ConversationQuestionOption = { + label: string; + description?: string; +}; + +export type ConversationQuestionInfo = { + header?: string; + question: string; + options: ConversationQuestionOption[]; + multiple?: boolean; + custom?: boolean; +}; + +export type ConversationQuestion = { + id: string; + sessionId: string; + questions: ConversationQuestionInfo[]; + receivedAt: number; + native: unknown; +}; + +export type ConversationAgent = { + name: string; + description?: string; + hidden?: boolean; + mode?: string; +}; + +export type ConversationPromptPart = + | { type: "text"; text: string; synthetic?: boolean } + | { type: "file"; mime: string; url: string; filename?: string } + | { type: "agent"; name: string }; + +export type ConversationMessageChunk = Extract< + UIMessageChunk, + { type: "text-delta" | "reasoning-delta" } +>; + +export type ConversationEvent = + | { type: "session.updated"; sessionId: string; info: ConversationSession } + | { type: "session.deleted"; sessionId: string } + | { type: "session.error"; sessionId: string; errorText: string } + | { type: "session.compaction"; sessionId: string; running: boolean } + | { type: "session.status"; sessionId: string; status: ConversationStatus } + | { type: "session.idle"; sessionId: string } + | { type: "todo.updated"; sessionId: string; todos: TodoItem[] } + | { type: "permission.asked"; permission: ConversationPermission } + | { type: "permission.replied"; sessionId: string; requestId: string } + | { type: "question.asked"; question: ConversationQuestion } + | { type: "question.replied"; sessionId: string; requestId: string } + | { type: "message.upsert"; sessionId: string; message: UIMessage } + | { type: "message.removed"; sessionId: string; messageId: string } + | { + type: "message.parts"; + sessionId: string; + messageId: string; + partId: string; + parts: UIMessage["parts"]; + visibleAssistantOutput: boolean; + } + | { + type: "message.chunk"; + sessionId: string; + messageId: string; + chunk: ConversationMessageChunk; + }; + +export type ConversationSubscribeInput = { + signal: AbortSignal; + onEvent: (event: ConversationEvent) => void; +}; + +export type ConversationPromptInput = { + sessionId: string; + parts: ConversationPromptPart[]; + model?: ModelRef; + agent?: string; + variant?: string; + reasoningEffort?: string; + system?: string; +}; + +export interface ConversationEngineConnection { + mapSnapshot(snapshot: unknown): ConversationSnapshot; + subscribe(input: ConversationSubscribeInput): Promise; + listPermissions(input: { sessionId: string; directory?: string }): Promise; + replyPermission(input: { + permission: ConversationPermission; + reply: "once" | "always" | "reject"; + directory?: string; + }): Promise; + listQuestions(input: { sessionId: string; directory?: string }): Promise; + replyQuestion(input: { + question: ConversationQuestion; + answers: string[][]; + directory?: string; + }): Promise; + create(directory?: string): Promise; + abort(sessionId: string, directory?: string): Promise; + revert(sessionId: string, messageId: string): Promise; + fork(input: { + sessionId: string; + messageId: string | null; + messages: UIMessage[]; + }): Promise; + rename(sessionId: string, title: string, directory?: string): Promise; + setArchived(sessionId: string, archived: boolean, directory?: string): Promise; + shell(sessionId: string, command: string): Promise; + runCommand(input: { + sessionId: string; + command: string; + arguments: string; + model?: ModelRef; + directory?: string; + reasoningEffort?: string; + }): Promise; + sendPrompt(input: ConversationPromptInput): Promise; + listCommands(directory?: string): Promise; + listAgents(): Promise; + searchFiles(query: string, directory?: string): Promise; +} + +export interface ConversationEngineAdapter { + readonly id: string; + connect(input: { + baseUrl: string; + token?: string; + directory?: string; + }): ConversationEngineConnection; +} + +export class ConversationEngineAdapterRegistry { + readonly #adapters: ReadonlyMap; + readonly #defaultEngineId: string; + + constructor(defaultEngineId: string, adapters: readonly ConversationEngineAdapter[]) { + this.#defaultEngineId = defaultEngineId; + const entries = new Map(); + for (const adapter of adapters) { + const id = adapter.id.trim(); + if (!id) throw new Error("Conversation engine adapter ID is required"); + if (entries.has(id)) throw new Error(`Duplicate conversation engine adapter: ${id}`); + entries.set(id, adapter); + } + this.#adapters = entries; + } + + get(id?: string | null): ConversationEngineAdapter { + const resolved = id?.trim() || this.#defaultEngineId; + const adapter = this.#adapters.get(resolved); + if (!adapter) throw new Error(`Conversation engine is not registered: ${resolved}`); + return adapter; + } + + ids(): string[] { + return [...this.#adapters.keys()]; + } +} diff --git a/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts b/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts new file mode 100644 index 000000000..cadcfe50e --- /dev/null +++ b/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts @@ -0,0 +1,199 @@ +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import { createClient, unwrap } from "@/app/lib/opencode"; +import type { Client } from "@/app/types"; +import { + ConversationEngineAdapterRegistry, + type ConversationEngineAdapter, + type ConversationEngineConnection, + type ConversationPermission, +} from "./conversation-engine"; +import { + isOpenCodeV2Permission as isV2Permission, + mapOpenCodeConversationEvent as mapEvent, + mapOpenCodeConversationSnapshot as mapSnapshot, + mapOpenCodeLegacyPermission as mapLegacyPermission, + mapOpenCodeQuestion as mapQuestion, + mapOpenCodeSession as mapSession, + mapOpenCodeV2Permission as mapV2Permission, + resolveOpenCodeForkBoundaryId, +} from "./opencode-conversation-mapper"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasMethod(value: unknown, name: string) { + return isRecord(value) && typeof value[name] === "function"; +} + +function isOpenCodeClient(value: unknown): value is Client { + if (!isRecord(value)) return false; + return ( + isRecord(value.session) && + hasMethod(value.session, "abort") && + hasMethod(value.session, "promptAsync") && + isRecord(value.event) && + hasMethod(value.event, "subscribe") + ); +} + +function openCodeConnection(input: { baseUrl: string; token?: string; directory?: string }): ConversationEngineConnection { + const client = createClient(input.baseUrl, input.directory, { + token: input.token, + mode: "ipollowork", + }); + if (!isOpenCodeClient(client)) throw new Error("OpenCode conversation client is unavailable"); + + return { + mapSnapshot, + async subscribe(input) { + const subscription = await client.event.subscribe(undefined, { signal: input.signal }); + for await (const raw of subscription.stream) { + if (input.signal.aborted) return; + const event = mapEvent(raw); + if (event) input.onEvent(event); + } + }, + async listPermissions(input) { + const receivedAt = Date.now(); + const permissions: ConversationPermission[] = []; + let readSucceeded = false; + try { + permissions.push( + ...unwrap(await client.permission.list({ directory: input.directory })) + .map((permission) => mapLegacyPermission(permission, receivedAt)), + ); + readSucceeded = true; + } catch {} + try { + permissions.push( + ...unwrap(await client.v2.session.permission.list({ sessionID: input.sessionId })).data + .map((permission) => mapV2Permission(permission, receivedAt)), + ); + readSucceeded = true; + } catch {} + if (!readSucceeded) throw new Error("Could not read pending permissions"); + return permissions.filter((permission) => permission.sessionId === input.sessionId); + }, + async replyPermission(input) { + if (isV2Permission(input.permission.native)) { + const result = await client.v2.session.permission.reply({ + sessionID: input.permission.sessionId, + requestID: input.permission.id, + reply: input.reply, + }); + if (result.error !== undefined) unwrap(result); + return; + } + unwrap(await client.permission.reply({ + requestID: input.permission.id, + reply: input.reply, + directory: input.directory, + })); + }, + async listQuestions(input) { + const receivedAt = Date.now(); + return unwrap(await client.question.list({ directory: input.directory })) + .filter((question) => question.sessionID === input.sessionId) + .map((question) => mapQuestion(question, receivedAt)); + }, + async replyQuestion(input) { + unwrap(await client.question.reply({ + requestID: input.question.id, + answers: input.answers, + directory: input.directory, + })); + }, + async create(directory) { + return mapSession(unwrap(await client.session.create({ directory }))); + }, + async abort(sessionId, directory) { + return unwrap(await client.session.abort({ sessionID: sessionId, directory })) === true; + }, + async revert(sessionId, messageId) { + return mapSession(unwrap(await client.session.revert({ sessionID: sessionId, messageID: messageId }))); + }, + async fork(input) { + return mapSession(unwrap(await client.session.fork({ + sessionID: input.sessionId, + messageID: resolveOpenCodeForkBoundaryId(input.messages, input.messageId) ?? undefined, + }))); + }, + async rename(sessionId, title, directory) { + unwrap(await client.session.update({ sessionID: sessionId, title, directory })); + }, + async setArchived(sessionId, archived, directory) { + unwrap(await client.session.update({ + sessionID: sessionId, + directory, + time: { archived: archived ? Date.now() : 0 }, + })); + }, + async shell(sessionId, command) { + const result = await client.session.shell({ sessionID: sessionId, command }); + if (result.error !== undefined) unwrap(result); + }, + async runCommand(input) { + const result = await client.session.command({ + sessionID: input.sessionId, + command: input.command, + arguments: input.arguments, + model: input.model ? `${input.model.providerID}/${input.model.modelID}` : undefined, + directory: input.directory, + ...(input.reasoningEffort ? { reasoning_effort: input.reasoningEffort } : {}), + }); + if (result.error !== undefined) unwrap(result); + }, + async sendPrompt(input) { + const result = await client.session.promptAsync({ + sessionID: input.sessionId, + parts: input.parts, + model: input.model, + agent: input.agent, + ...(input.reasoningEffort + ? { reasoning_effort: input.reasoningEffort } + : input.variant + ? { variant: input.variant } + : {}), + ...(input.system ? { system: input.system } : {}), + }); + if (result.error !== undefined) unwrap(result); + }, + async listCommands(directory) { + try { + const list = (await client.command.list({ directory }))?.data ?? []; + if (!Array.isArray(list)) return []; + return list.map((command) => ({ + id: `cmd:${command.name}`, + name: String(command.name ?? ""), + description: command.description ? String(command.description) : undefined, + source: command.source, + })); + } catch { + return []; + } + }, + async listAgents() { + return unwrap(await client.app.agents()).map((agent) => ({ + name: agent.name, + description: agent.description, + hidden: agent.hidden, + mode: agent.mode, + })); + }, + async searchFiles(query, directory) { + return unwrap(await client.find.files({ query, dirs: "true", limit: 50, directory })); + }, + }; +} + +export const openCodeConversationEngineAdapter: ConversationEngineAdapter = { + id: DEFAULT_ENGINE_ID, + connect: openCodeConnection, +}; + +export const conversationEngineAdapters = new ConversationEngineAdapterRegistry( + DEFAULT_ENGINE_ID, + [openCodeConversationEngineAdapter], +); diff --git a/apps/app/src/react-app/domains/session/engine/opencode-conversation-mapper.ts b/apps/app/src/react-app/domains/session/engine/opencode-conversation-mapper.ts new file mode 100644 index 000000000..69a96125d --- /dev/null +++ b/apps/app/src/react-app/domains/session/engine/opencode-conversation-mapper.ts @@ -0,0 +1,351 @@ +import type { UIMessage } from "ai"; +import type { + Part, + PermissionRequest, + PermissionV2Request, + QuestionRequest, + Session, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client"; + +import type { iPolloWorkSessionSnapshot } from "@/app/lib/ipollowork-server"; +import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX, type TodoItem } from "@/app/types"; +import { normalizeEvent } from "@/app/utils"; +import type { + ConversationEvent, + ConversationPermission, + ConversationQuestion, + ConversationSession, + ConversationSnapshot, + ConversationStatus, +} from "./conversation-engine"; +import { + describeOpencodeSessionError, + mapOpencodePartToUIParts, + opencodePartHasVisibleAssistantOutput, + snapshotToUIMessages, +} from "./opencode-message-adapter"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function mapOpenCodeSession(session: Session): ConversationSession { + return { + ...session, + id: session.id, + title: session.title ?? "", + slug: session.slug, + parentID: session.parentID, + directory: session.directory, + time: session.time, + revertMessageId: session.revert?.messageID ?? null, + }; +} + +function mapStatus(status: SessionStatus): ConversationStatus { + if (status.type === "retry") { + return { + type: "retry", + attempt: status.attempt, + message: status.message, + next: status.next, + }; + } + return { type: status.type }; +} + +function mapTodos(sessionId: string, todos: Todo[]): TodoItem[] { + return todos.map((todo, index) => ({ + id: `${sessionId}:${index}:${todo.content}`, + content: todo.content, + status: todo.status, + priority: todo.priority, + })); +} + +function v2PermissionKind(action: string): string { + if (action === "external_directory" || action.endsWith(".external_directory")) return "external_directory"; + if (action === "file.read") return "read"; + if (action === "file.edit" || action === "file.write") return "edit"; + return action; +} + +export function mapOpenCodeLegacyPermission( + permission: PermissionRequest, + receivedAt: number, +): ConversationPermission { + return { + id: permission.id, + sessionId: permission.sessionID, + kind: permission.permission, + resources: permission.patterns, + remember: Array.isArray(permission.always) + ? permission.always.filter((value): value is string => typeof value === "string") + : [], + metadata: permission.metadata ?? {}, + receivedAt, + native: permission, + }; +} + +export function mapOpenCodeV2Permission( + permission: PermissionV2Request, + receivedAt: number, +): ConversationPermission { + const metadata: Record = { + ...(permission.metadata ?? {}), + action: permission.action, + }; + if (permission.save?.length) metadata.save = permission.save.join(", "); + if (permission.source) metadata.tool = { + messageID: permission.source.messageID, + callID: permission.source.callID, + }; + return { + id: permission.id, + sessionId: permission.sessionID, + kind: v2PermissionKind(permission.action), + resources: permission.resources, + remember: permission.save ?? [], + metadata, + receivedAt, + native: permission, + }; +} + +export function isOpenCodeV2Permission(value: unknown): value is PermissionV2Request { + return isRecord(value) && typeof value.action === "string" && Array.isArray(value.resources); +} + +export function mapOpenCodeQuestion( + question: QuestionRequest, + receivedAt: number, +): ConversationQuestion { + return { + id: question.id, + sessionId: question.sessionID, + questions: question.questions.map((item) => ({ + header: item.header, + question: item.question, + options: item.options.map((option) => ({ + label: option.label, + description: option.description, + })), + multiple: item.multiple, + custom: item.custom, + })), + receivedAt, + native: question, + }; +} + +function messageFromInfo(info: { + id: string; + role: UIMessage["role"]; + time?: { created?: number; completed?: number }; +}): UIMessage { + const created = info.time?.created; + const completed = info.time?.completed; + return { + id: info.id, + role: info.role, + ...(typeof created === "number" || typeof completed === "number" + ? { metadata: { ipollowork: { + ...(typeof created === "number" ? { created } : {}), + ...(typeof completed === "number" ? { completed } : {}), + } } } + : {}), + parts: [], + }; +} + +function sessionIdFromProperties(properties: unknown) { + if (!isRecord(properties)) return ""; + return typeof properties.sessionID === "string" ? properties.sessionID : ""; +} + +export function mapOpenCodeConversationEvent(raw: unknown): ConversationEvent | null { + const event = normalizeEvent(raw); + if (!event) return null; + const properties = event.properties; + + if (event.type === "session.updated") { + if (!isRecord(properties) || !isRecord(properties.info)) return null; + const info = properties.info as unknown as Session; + const sessionId = typeof properties.sessionID === "string" ? properties.sessionID : info.id; + return sessionId ? { type: "session.updated", sessionId, info: mapOpenCodeSession(info) } : null; + } + + if (event.type === "session.deleted") { + if (!isRecord(properties)) return null; + const info = isRecord(properties.info) ? properties.info : null; + const sessionId = typeof properties.sessionID === "string" + ? properties.sessionID + : typeof info?.id === "string" + ? info.id + : ""; + return sessionId ? { type: "session.deleted", sessionId } : null; + } + + if (event.type === "session.error") { + const sessionId = sessionIdFromProperties(properties); + if (!sessionId) return null; + return { + type: "session.error", + sessionId, + errorText: describeOpencodeSessionError(isRecord(properties) ? properties.error : undefined), + }; + } + + if (event.type === "session.next.compaction.started") { + const sessionId = sessionIdFromProperties(properties); + return sessionId ? { type: "session.compaction", sessionId, running: true } : null; + } + + if (event.type === "session.next.compaction.ended" || event.type === "session.compacted") { + const sessionId = sessionIdFromProperties(properties); + return sessionId ? { type: "session.compaction", sessionId, running: false } : null; + } + + if (event.type === "session.status") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || !isRecord(properties.status)) return null; + return { + type: "session.status", + sessionId: properties.sessionID, + status: mapStatus(properties.status as SessionStatus), + }; + } + + if (event.type === "session.idle") { + const sessionId = sessionIdFromProperties(properties); + return sessionId ? { type: "session.idle", sessionId } : null; + } + + if (event.type === "todo.updated") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || !Array.isArray(properties.todos)) return null; + return { + type: "todo.updated", + sessionId: properties.sessionID, + todos: mapTodos(properties.sessionID, properties.todos as Todo[]), + }; + } + + if (event.type === "permission.asked") { + const permission = properties as PermissionRequest; + if (!permission?.id || !permission.sessionID) return null; + return { type: "permission.asked", permission: mapOpenCodeLegacyPermission(permission, Date.now()) }; + } + + if (event.type === "permission.v2.asked") { + const permission = properties as PermissionV2Request; + if (!permission?.id || !permission.sessionID) return null; + return { type: "permission.asked", permission: mapOpenCodeV2Permission(permission, Date.now()) }; + } + + if (event.type === "permission.replied" || event.type === "permission.v2.replied") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || typeof properties.requestID !== "string") return null; + return { type: "permission.replied", sessionId: properties.sessionID, requestId: properties.requestID }; + } + + if (event.type === "question.asked") { + const question = properties as QuestionRequest; + if (!question?.id || !question.sessionID) return null; + return { type: "question.asked", question: mapOpenCodeQuestion(question, Date.now()) }; + } + + if (event.type === "question.replied" || event.type === "question.rejected") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || typeof properties.requestID !== "string") return null; + return { type: "question.replied", sessionId: properties.sessionID, requestId: properties.requestID }; + } + + if (event.type === "message.updated") { + if (!isRecord(properties) || !isRecord(properties.info)) return null; + const info = properties.info; + if ( + typeof info.id !== "string" || + typeof info.sessionID !== "string" || + (info.role !== "user" && info.role !== "assistant" && info.role !== "system") + ) return null; + return { + type: "message.upsert", + sessionId: info.sessionID, + message: messageFromInfo({ + id: info.id, + role: info.role, + time: isRecord(info.time) + ? { + created: typeof info.time.created === "number" ? info.time.created : undefined, + completed: typeof info.time.completed === "number" ? info.time.completed : undefined, + } + : undefined, + }), + }; + } + + if (event.type === "message.removed") { + if (!isRecord(properties) || typeof properties.sessionID !== "string" || typeof properties.messageID !== "string") return null; + return { type: "message.removed", sessionId: properties.sessionID, messageId: properties.messageID }; + } + + if (event.type === "message.part.updated") { + if (!isRecord(properties) || !isRecord(properties.part)) return null; + const part = properties.part as unknown as Part; + if (!part.id || !part.sessionID || !part.messageID) return null; + return { + type: "message.parts", + sessionId: part.sessionID, + messageId: part.messageID, + partId: part.id, + parts: mapOpencodePartToUIParts(part), + visibleAssistantOutput: opencodePartHasVisibleAssistantOutput(part), + }; + } + + if (event.type === "message.part.delta") { + if ( + !isRecord(properties) || + typeof properties.sessionID !== "string" || + typeof properties.messageID !== "string" || + typeof properties.partID !== "string" || + typeof properties.delta !== "string" || + !properties.delta + ) return null; + return { + type: "message.chunk", + sessionId: properties.sessionID, + messageId: properties.messageID, + chunk: { type: "text-delta", id: properties.partID, delta: properties.delta }, + }; + } + + return null; +} + +export function mapOpenCodeConversationSnapshot(snapshot: unknown): ConversationSnapshot { + const source = snapshot as iPolloWorkSessionSnapshot; + if (!source?.session?.id || !Array.isArray(source.messages) || !Array.isArray(source.todos)) { + throw new Error("OpenCode returned an invalid session snapshot"); + } + return { + session: mapOpenCodeSession(source.session), + messages: snapshotToUIMessages(source), + todos: mapTodos(source.session.id, source.todos), + status: mapStatus(source.status), + }; +} + +export function resolveOpenCodeForkBoundaryId( + messages: UIMessage[], + messageId: string | null, +): string | null { + if (!messageId) return null; + const index = messages.findIndex((message) => message.id === messageId); + if (index < 0) return null; + for (let candidateIndex = index + 1; candidateIndex < messages.length; candidateIndex += 1) { + const candidate = messages[candidateIndex]; + if (candidate && !candidate.id.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX)) return candidate.id; + } + return null; +} diff --git a/apps/app/src/react-app/domains/session/sync/usechat-adapter.ts b/apps/app/src/react-app/domains/session/engine/opencode-message-adapter.ts similarity index 75% rename from apps/app/src/react-app/domains/session/sync/usechat-adapter.ts rename to apps/app/src/react-app/domains/session/engine/opencode-message-adapter.ts index 5757acf1a..32465f101 100644 --- a/apps/app/src/react-app/domains/session/sync/usechat-adapter.ts +++ b/apps/app/src/react-app/domains/session/engine/opencode-message-adapter.ts @@ -13,7 +13,7 @@ import { parseDynamicToolUIPart, parseStructuredOutputUIPart, STRUCTURED_OUTPUT_TOOL, -} from "./parse-tool-parts"; +} from "./opencode-tool-parts"; function recordValue(value: unknown, key: string) { if (!value || typeof value !== "object") return undefined; @@ -107,21 +107,21 @@ export function createSessionErrorUIMessage(turnKey: string, text: string, optio return { id, role: "assistant", - ...(typeof created === "number" ? { metadata: { opencode: { created } } } : {}), + ...(typeof created === "number" ? { metadata: { ipollowork: { created } } } : {}), parts: [{ type: "text", text, state: "done", - providerMetadata: { opencode: { partId: `${id}:text` } }, + providerMetadata: { ipollowork: { partId: `${id}:text` } }, }], }; } function fileProviderMetadata(part: FilePart) { if (part.source) { - return { opencode: { partId: part.id, source: part.source } }; + return { ipollowork: { partId: part.id, source: part.source } }; } - return { opencode: { partId: part.id } }; + return { ipollowork: { partId: part.id } }; } function getTextPartValue(part: Part) { @@ -149,7 +149,7 @@ function mapFileSourcePart(part: FilePart): UIMessage["parts"][number] | null { if (!source) return null; const sourceId = `${part.id}:source`; - const providerMetadata = { opencode: { partId: sourceId, sourcePartId: part.id, source } }; + const providerMetadata = { ipollowork: { partId: sourceId, sourcePartId: part.id, source } }; if (source.type === "resource") { if (source.uri.startsWith("http://")) { @@ -174,6 +174,72 @@ function mapFileParts(part: FilePart): UIMessage["parts"] { return [mapFilePart(part)]; } +export function opencodePartHasVisibleAssistantOutput(part: Part) { + if (part.type === "text" && part.synthetic) return false; + if (part.type === "text" && part.ignored) return false; + const partType = String(part.type); + if ("text" in part && typeof part.text === "string" && part.text.trim().length > 0) return true; + return partType === "tool" || partType === "file" || partType === "agent"; +} + +export function mapOpencodePartToUIParts(part: Part): UIMessage["parts"] { + if (part.type === "text") { + if (part.synthetic) { + const metadataParts: UIMessage["parts"] = []; + const selection = parseDesignAiSelectionDisplayMetadata(part.text); + if (selection) metadataParts.push({ + type: "data-design-selection" as const, + data: { ...selection, partId: `${part.id}:design-selection` }, + }); + const animations = parseHyperframesAnimationDisplayMetadata(part.text); + if (animations) metadataParts.push({ + type: "data-animation-references" as const, + data: { items: animations, partId: `${part.id}:animation-references` }, + }); + const voice = parseVideoVoiceDisplayMetadata(part.text); + if (voice) metadataParts.push({ + type: "data-voice-reference" as const, + data: { ...voice, partId: `${part.id}:voice-reference` }, + }); + const illustration = parseVideoIllustrationDisplayMetadata(part.text); + if (illustration) metadataParts.push({ + type: "data-illustration-reference" as const, + data: { ...illustration, partId: `${part.id}:illustration-reference` }, + }); + return metadataParts; + } + if (part.ignored) return []; + return [{ + type: "text", + text: getTextPartValue(part), + state: "done", + providerMetadata: { ipollowork: { partId: part.id } }, + }]; + } + if (part.type === "reasoning") { + return [{ + type: "reasoning", + text: getTextPartValue(part), + state: "done", + providerMetadata: { ipollowork: { partId: part.id } }, + }]; + } + if (part.type === "file") return mapFileParts(part); + if (part.type === "tool") return mapSnapshotToolParts(part); + if (part.type === "agent") { + return [{ + type: "text", + text: part.name ? `@${part.name}` : "@agent", + state: "done", + providerMetadata: { ipollowork: { partId: part.id } }, + }]; + } + if (part.type === "step-start") { + return [{ type: "step-start" }]; + } + return []; +} + function mapSnapshotToolParts(part: ToolPart): UIMessage["parts"] { if (part.tool === STRUCTURED_OUTPUT_TOOL) { const mapped = parseStructuredOutputUIPart(part); @@ -200,72 +266,12 @@ export function snapshotToUIMessages(snapshot: iPolloWorkSessionSnapshot): UIMes id: message.info.id, role: message.info.role, ...(typeof created === "number" || typeof completed === "number" - ? { metadata: { opencode: { + ? { metadata: { ipollowork: { ...(typeof created === "number" ? { created } : {}), ...(typeof completed === "number" ? { completed } : {}), } } } : {}), - parts: message.parts.flatMap((part) => { - if (part.type === "text") { - if (part.synthetic) { - const metadataParts: UIMessage["parts"] = []; - const selection = parseDesignAiSelectionDisplayMetadata(part.text); - if (selection) metadataParts.push({ - type: "data-design-selection" as const, - data: { ...selection, partId: `${part.id}:design-selection` }, - }); - const animations = parseHyperframesAnimationDisplayMetadata(part.text); - if (animations) metadataParts.push({ - type: "data-animation-references" as const, - data: { items: animations, partId: `${part.id}:animation-references` }, - }); - const voice = parseVideoVoiceDisplayMetadata(part.text); - if (voice) metadataParts.push({ - type: "data-voice-reference" as const, - data: { ...voice, partId: `${part.id}:voice-reference` }, - }); - const illustration = parseVideoIllustrationDisplayMetadata(part.text); - if (illustration) metadataParts.push({ - type: "data-illustration-reference" as const, - data: { ...illustration, partId: `${part.id}:illustration-reference` }, - }); - return metadataParts; - } - if (part.ignored) return []; - return [{ - type: "text", - text: getTextPartValue(part), - state: "done" as const, - providerMetadata: { opencode: { partId: part.id } }, - }]; - } - if (part.type === "reasoning") { - return [{ - type: "reasoning", - text: getTextPartValue(part), - state: "done" as const, - providerMetadata: { opencode: { partId: part.id } }, - }]; - } - if (part.type === "file") { - return mapFileParts(part); - } - if (part.type === "tool") { - return mapSnapshotToolParts(part); - } - if (part.type === "agent") { - return [{ - type: "text", - text: part.name ? `@${part.name}` : "@agent", - state: "done", - providerMetadata: { opencode: { partId: part.id } }, - }]; - } - if (part.type === "step-start") { - return [{ type: "step-start", providerMetadata: { opencode: { partId: part.id } } }]; - } - return []; - }), + parts: message.parts.flatMap(mapOpencodePartToUIParts), }; // Surface a failed turn as its own synthetic error message keyed by the diff --git a/apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts b/apps/app/src/react-app/domains/session/engine/opencode-tool-parts.ts similarity index 85% rename from apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts rename to apps/app/src/react-app/domains/session/engine/opencode-tool-parts.ts index 5e7662a1b..5f9ac9353 100644 --- a/apps/app/src/react-app/domains/session/sync/parse-tool-parts.ts +++ b/apps/app/src/react-app/domains/session/engine/opencode-tool-parts.ts @@ -1,5 +1,5 @@ -import type { DynamicToolUIPart, TextUIPart } from "ai"; import type { ToolPart } from "@opencode-ai/sdk/v2/client"; +import type { DynamicToolUIPart, TextUIPart } from "ai"; import { safeStringify } from "@/app/utils"; @@ -28,7 +28,7 @@ export function parseStructuredOutputUIPart(part: ToolPart): TextUIPart | null { type: "text", text, state: part.state.status === "completed" ? "done" : "streaming", - providerMetadata: { opencode: { partId: `structured-output-${part.callID}`, toolPartId: part.id } }, + providerMetadata: { ipollowork: { partId: `structured-output-${part.callID}`, toolPartId: part.id } }, }; } @@ -45,7 +45,7 @@ export function parseDynamicToolUIPart(part: ToolPart): DynamicToolUIPart | null state: "output-error", input: part.state.input, errorText: part.state.error, - callProviderMetadata: { opencode: { partId: part.id } }, + callProviderMetadata: { ipollowork: { partId: part.id } }, }; } @@ -57,7 +57,7 @@ export function parseDynamicToolUIPart(part: ToolPart): DynamicToolUIPart | null state: "output-available", input: part.state.input, output: part.state.output, - callProviderMetadata: { opencode: { partId: part.id } }, + callProviderMetadata: { ipollowork: { partId: part.id } }, }; } @@ -73,6 +73,6 @@ export function parseDynamicToolUIPart(part: ToolPart): DynamicToolUIPart | null toolCallId: part.callID, state: "input-streaming", input: part.state.input, - callProviderMetadata: { opencode: { partId: part.id } }, + callProviderMetadata: { ipollowork: { partId: part.id } }, }; } diff --git a/apps/app/src/react-app/domains/session/modals/question-modal.tsx b/apps/app/src/react-app/domains/session/modals/question-modal.tsx index b0dcfc395..9c7fc616f 100644 --- a/apps/app/src/react-app/domains/session/modals/question-modal.tsx +++ b/apps/app/src/react-app/domains/session/modals/question-modal.tsx @@ -1,13 +1,13 @@ /** @jsxImportSource react */ import { useEffect, useReducer } from "react"; -import type { QuestionInfo } from "@opencode-ai/sdk/v2/client"; import { Check, ChevronRight, HelpCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { t } from "@/i18n"; +import type { ConversationQuestionInfo } from "../engine/conversation-engine"; export type QuestionPanelProps = { - questions: QuestionInfo[]; + questions: ConversationQuestionInfo[]; busy: boolean; onReply: (answers: string[][]) => void; }; diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index 644d0745a..c391f6632 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -1,6 +1,5 @@ /** @jsxImportSource react */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import type { Agent } from "@opencode-ai/sdk/v2/client"; import { AppWindowMac, ArrowUp, Check, ChevronDown, ChevronRight, FileText, ListTodo, Plus, Plug, Settings, Square, Terminal, X, Zap } from "lucide-react"; import fuzzysort from "fuzzysort"; import { toast } from "@/components/ui/sonner"; @@ -8,7 +7,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { IPOLLOWORK_EXTENSION_CATALOG, type McpDirectoryInfo } from "@/app/constants"; import type { CloudImportedPlugin, CloudImportedPluginFile } from "@/app/cloud/import-state"; import type { iPolloWorkPluginPackageItem } from "@/app/lib/ipollowork-server"; -import type { ComposerAttachment, McpServerEntry, McpStatusMap, ModelRef, SkillCard, SlashCommandOption } from "@/app/types"; +import type { ComposerAttachment, McpServerEntry, McpStatusMap, ModelRef, SkillCard, SlashCommandOption } from "@/app/types"; +import type { ConversationAgent } from "../../engine/conversation-engine"; import { formatBytes } from "@/app/utils"; import { t } from "@/i18n"; import { isiPolloWorkExtensionEnabled, isiPolloWorkExtensionHidden, IPOLLOWORK_EXTENSION_STATE_CHANGED } from "@/react-app/domains/settings/extension-state"; @@ -73,7 +73,7 @@ type ComposerProps = { modelBehaviorOptions?: { value: string | null; label: string }[]; onModelVariantChange: (value: string | null) => void; selectedAgent: string | null; - listAgents: () => Promise; + listAgents: () => Promise; onSelectAgent: (agent: string | null) => void; listCommands: () => Promise; listSkills?: () => Promise; diff --git a/apps/app/src/react-app/domains/session/surface/debug-panel.tsx b/apps/app/src/react-app/domains/session/surface/debug-panel.tsx index 1e90274e5..3f3ed2d5e 100644 --- a/apps/app/src/react-app/domains/session/surface/debug-panel.tsx +++ b/apps/app/src/react-app/domains/session/surface/debug-panel.tsx @@ -1,10 +1,10 @@ /** @jsxImportSource react */ -import type { iPolloWorkSessionSnapshot } from "../../../../app/lib/ipollowork-server"; import type { SessionRenderModel } from "../sync/transition-controller"; +import type { ConversationSnapshot } from "../engine/conversation-engine"; export function SessionDebugPanel(props: { model: SessionRenderModel; - snapshot: iPolloWorkSessionSnapshot | null; + snapshot: ConversationSnapshot | null; }) { return (
diff --git a/apps/app/src/react-app/domains/session/surface/session-render-state.ts b/apps/app/src/react-app/domains/session/surface/session-render-state.ts index 7804bbdee..a790a566b 100644 --- a/apps/app/src/react-app/domains/session/surface/session-render-state.ts +++ b/apps/app/src/react-app/domains/session/surface/session-render-state.ts @@ -1,14 +1,13 @@ import type { UIMessage } from "ai"; -import type { iPolloWorkSessionSnapshot } from "../../../../app/lib/ipollowork-server"; import { mergeSnapshotAndLiveMessages } from "../sync/message-merge"; import { applyRevertCursor } from "../sync/transcript-reconcile"; -import { snapshotToUIMessages } from "../sync/usechat-adapter"; +import type { ConversationSnapshot } from "../engine/conversation-engine"; export function resolveRenderedSessionSnapshot(input: { sessionId: string; - currentSnapshot: iPolloWorkSessionSnapshot | null | undefined; - cachedRendered: { sessionId: string; snapshot: iPolloWorkSessionSnapshot } | null | undefined; + currentSnapshot: ConversationSnapshot | null | undefined; + cachedRendered: { sessionId: string; snapshot: ConversationSnapshot } | null | undefined; }) { if (input.currentSnapshot?.session.id === input.sessionId) { return input.currentSnapshot; @@ -24,13 +23,13 @@ export function resolveRenderedSessionSnapshot(input: { export function deriveRenderedSessionMessages(input: { transcriptState: UIMessage[] | null | undefined; - snapshot: iPolloWorkSessionSnapshot | null | undefined; + snapshot: ConversationSnapshot | null | undefined; }) { - const revertMessageId = (input.snapshot?.session as any)?.revert?.messageID ?? null; + const revertMessageId = input.snapshot?.session.revertMessageId ?? null; const liveMessages = input.transcriptState ?? []; const snapshotMessages = input.snapshot && input.snapshot.messages.length > 0 - ? snapshotToUIMessages(input.snapshot) + ? input.snapshot.messages : []; // Render the server snapshot as the history floor and layer live stream diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 2e5562f1a..130672ad1 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -1,15 +1,13 @@ /** @jsxImportSource react */ -import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import type { UIMessage } from "ai"; -import { useQuery } from "@tanstack/react-query"; -import type { SessionStatus } from "@opencode-ai/sdk/v2/client"; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import type { UIMessage } from "ai"; +import { useQuery } from "@tanstack/react-query"; import type { TemplateCatalogItem } from "@ipollowork/types/templates"; import { Check, Minimize2, X } from "lucide-react"; import { toast } from "@/components/ui/sonner"; -import { captureAnalyticsEvent } from "@/app/lib/analytics"; -import { createClient, unwrap } from "@/app/lib/opencode"; -import { abortSessionSafe } from "@/app/lib/opencode-session"; +import { captureAnalyticsEvent } from "@/app/lib/analytics"; +import { createClient, unwrap } from "@/app/lib/opencode"; import { t } from "@/i18n"; import { readWorkspaceCloudImports, type CloudImportedPlugin } from "@/app/cloud/import-state"; import type { @@ -18,7 +16,6 @@ import type { HyperframesEffectVariableValues, iPolloWorkPluginPackageItem, iPolloWorkServerClient, - iPolloWorkSessionSnapshot, } from "@/app/lib/ipollowork-server"; import { hyperframesAnimationDisplayMetadata, @@ -30,11 +27,17 @@ import type { McpServerEntry, McpStatusMap, ModelRef, - PendingPermission, - PendingQuestion, SkillCard, TodoItem, -} from "@/app/types"; +} from "@/app/types"; +import type { + ConversationAgent, + ConversationEngineConnection, + ConversationPermission, + ConversationQuestion, + ConversationSnapshot, + ConversationStatus, +} from "../engine/conversation-engine"; import { publishInspectorSlice, recordInspectorEvent, @@ -94,7 +97,6 @@ import { statusKey as reactStatusKey, transcriptKey as reactTranscriptKey, } from "@/react-app/domains/session/sync/session-sync"; -import { resolveForkBoundaryId } from "@/react-app/domains/session/sync/transcript-reconcile"; import { getComposerAttachments, getComposerDraft, @@ -118,7 +120,7 @@ import { } from "@/react-app/domains/settings/pages/environment-variable-provider"; const EMPTY_TRANSCRIPT: UIMessage[] = []; -const IDLE_STATUS: SessionStatus = { type: "idle" }; +const IDLE_STATUS: ConversationStatus = { type: "idle" }; const DEFAULT_COMPOSER_CONTROL_TEXT = "Help me outline the next iPolloWork task."; const SESSION_SURFACE_SELECTOR = "[data-session-surface-id]"; const STALLED_SESSION_WARNING_MS = 90_000; @@ -164,8 +166,9 @@ function videoDeliveryValidationOutput(response: unknown): VideoDeliveryValidati return { valid: output.valid, issues }; } -export type SessionSurfaceProps = { - client: iPolloWorkServerClient; +export type SessionSurfaceProps = { + client: iPolloWorkServerClient; + conversation: ConversationEngineConnection; environmentClient?: iPolloWorkServerClient | null; workspaceId: string; workspaceRoot: string; @@ -190,7 +193,7 @@ export type SessionSurfaceProps = { onModelVariantChange: (value: string | null) => void; onConfigureTokenStar?: () => void; selectedAgent: string | null; - listAgents: () => Promise; + listAgents: () => Promise; onSelectAgent: (agent: string | null) => void; listCommands: () => Promise; recentFiles: string[]; @@ -198,10 +201,10 @@ export type SessionSurfaceProps = { isRemoteWorkspace: boolean; isSandboxWorkspace: boolean; todos?: TodoItem[]; - activePermission?: PendingPermission | null; + activePermission?: ConversationPermission | null; permissionReplyBusy?: boolean; respondPermission?: (requestID: string, reply: "once" | "always" | "reject") => void; - activeQuestion?: PendingQuestion | null; + activeQuestion?: ConversationQuestion | null; questionReplyBusy?: boolean; respondQuestion?: (requestID: string, answers: string[][]) => void; safeStringify?: (value: unknown) => string; @@ -222,7 +225,7 @@ export type SessionSurfaceProps = { onRequestDesignTemplates?: () => void; onOpenSettingsSection?: ((section: "commands" | "skills" | "mcps" | "plugins" | "providers") => void) | undefined; onRevertToMessage?: (messageId: string, sessionId: string) => Promise; - onForkAtMessage?: (messageId: string | null, sessionId: string) => void; + onForkAtMessage?: (messageId: string, sessionId: string, messages: UIMessage[]) => void; onOpenTarget?: (target: OpenTarget, options?: OpenTargetOptions, sessionId?: string) => void; onConversationMessagesChange?: (sessionId: string, messages: UIMessage[]) => void; onLoadSettled?: (sessionId: string) => void; @@ -283,7 +286,7 @@ function resolveFindOwnerSessionId() { return firstMountedSessionSurfaceId(); } -function statusLabel(snapshot: iPolloWorkSessionSnapshot | undefined, busy: boolean) { +function statusLabel(snapshot: ConversationSnapshot | undefined, busy: boolean) { if (busy) return t("session.status_running"); if (snapshot?.status.type === "busy") return t("session.status_running"); if (snapshot?.status.type === "retry") return t("session.status_retrying", { message: snapshot.status.message }); @@ -347,8 +350,8 @@ function sessionProgressFingerprint(messages: UIMessage[]) { function latestAssistantMessageCompleted(messages: UIMessage[]) { const latest = messages.findLast((message) => message.role === "assistant"); if (!latest) return false; - const metadata = latest.metadata as { opencode?: { completed?: unknown } } | undefined; - return typeof metadata?.opencode?.completed === "number"; + const metadata = latest.metadata as { ipollowork?: { completed?: unknown } } | undefined; + return typeof metadata?.ipollowork?.completed === "number"; } function TodoPanel(props: { todos: TodoItem[] }) { @@ -621,7 +624,7 @@ export function SessionSurface(props: SessionSurfaceProps) { const [sending, setSending] = useState(false); const [showDelayedLoading, setShowDelayedLoading] = useState(false); const [awaitingAssistantBaseline, setAwaitingAssistantBaseline] = useState(null); - const [rendered, setRendered] = useState<{ sessionId: string; snapshot: iPolloWorkSessionSnapshot } | null>(null); + const [rendered, setRendered] = useState<{ sessionId: string; snapshot: ConversationSnapshot } | null>(null); const [toolSkills, setToolSkills] = useState([]); const [toolMcpServers, setToolMcpServers] = useState([]); const [toolMcpStatus, setToolMcpStatus] = useState(null); @@ -713,9 +716,11 @@ export function SessionSurface(props: SessionSurfaceProps) { () => reactStatusKey(props.workspaceId, props.sessionId), [props.workspaceId, props.sessionId], ); - const snapshotQuery = useQuery({ - queryKey: snapshotQueryKey, - queryFn: async () => (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item, + const snapshotQuery = useQuery({ + queryKey: snapshotQueryKey, + queryFn: async () => props.conversation.mapSnapshot( + (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item, + ), staleTime: 500, }); @@ -1248,18 +1253,20 @@ export function SessionSurface(props: SessionSurfaceProps) { // passes the workspace root), so the abort must target the same scope — // without it the server resolves the default project, finds no live run, // and answers `200: false` while the stream keeps going (#2014). - const aborted = await abortSessionSafe( - opencodeClient, - props.sessionId, - props.workspaceRoot.trim() || undefined, - ); + let aborted = false; + try { + aborted = await props.conversation.abort( + props.sessionId, + props.workspaceRoot.trim() || undefined, + ); + } catch {} if (!aborted) { setError({ message: t("session.stop_failed") }); return; } captureAnalyticsEvent("task_run_stopped", {}); await snapshotQuery.refetch(); - }, [chatStreaming, opencodeClient, props.sessionId, props.workspaceRoot, snapshotQuery.refetch]); + }, [chatStreaming, props.conversation, props.sessionId, props.workspaceRoot, snapshotQuery.refetch]); const handleDismissError = useCallback(() => { setError(null); @@ -1660,11 +1667,9 @@ export function SessionSurface(props: SessionSurfaceProps) { void props.onRevertToMessage?.(messageId, props.sessionId); }, [props.onRevertToMessage, props.sessionId]); - const handleForkAtMessage = useCallback((messageId: string) => { - // OpenCode's fork copies messages strictly before the given id, so pass - // the next real message to make the branch include the clicked message. - props.onForkAtMessage?.(resolveForkBoundaryId(renderedMessages, messageId), props.sessionId); - }, [props.onForkAtMessage, props.sessionId, renderedMessages]); + const handleForkAtMessage = useCallback((messageId: string) => { + props.onForkAtMessage?.(messageId, props.sessionId, renderedMessages); + }, [props.onForkAtMessage, props.sessionId, renderedMessages]); const handleEditUserMessage = useCallback((messageId: string, text: string) => { void (async () => { diff --git a/apps/app/src/react-app/domains/session/sync/message-merge.ts b/apps/app/src/react-app/domains/session/sync/message-merge.ts index 955b60dbc..c72c4b48e 100644 --- a/apps/app/src/react-app/domains/session/sync/message-merge.ts +++ b/apps/app/src/react-app/domains/session/sync/message-merge.ts @@ -35,12 +35,12 @@ function mergeSnapshotMessageWithCached(snapshotMessage: UIMessage, cachedMessag function messageCreated(message: UIMessage) { const metadata = message.metadata; - if (!metadata || typeof metadata !== "object" || !("opencode" in metadata)) return null; + if (!metadata || typeof metadata !== "object" || !("ipollowork" in metadata)) return null; - const opencode = metadata.opencode; - if (!opencode || typeof opencode !== "object" || !("created" in opencode)) return null; + const ipollowork = metadata.ipollowork; + if (!ipollowork || typeof ipollowork !== "object" || !("created" in ipollowork)) return null; - const created = opencode.created; + const created = ipollowork.created; return typeof created === "number" ? created : null; } diff --git a/apps/app/src/react-app/domains/session/sync/prompt-file-parts.ts b/apps/app/src/react-app/domains/session/sync/prompt-file-parts.ts index a1f3d6aad..88aee322e 100644 --- a/apps/app/src/react-app/domains/session/sync/prompt-file-parts.ts +++ b/apps/app/src/react-app/domains/session/sync/prompt-file-parts.ts @@ -1,4 +1,6 @@ -import type { FilePartInput } from "@opencode-ai/sdk/v2/client"; +import type { ConversationPromptPart } from "../engine/conversation-engine"; + +type ConversationFilePart = Extract; const FIRST_LINE_LOCAL_PATH_RE = /(?:file:\/\/[^\s"'`<>]+|~\/[^\s"'`<>]+|[A-Za-z]:[\\/][^\s"'`<>]+|(?(); for (const match of firstLine.matchAll(FIRST_LINE_LOCAL_PATH_RE)) { diff --git a/apps/app/src/react-app/domains/session/sync/runtime-sync.tsx b/apps/app/src/react-app/domains/session/sync/runtime-sync.tsx index 0c8e80431..b8f7f3b83 100644 --- a/apps/app/src/react-app/domains/session/sync/runtime-sync.tsx +++ b/apps/app/src/react-app/domains/session/sync/runtime-sync.tsx @@ -1,24 +1,24 @@ /** @jsxImportSource react */ import { useEffect } from "react"; -import type { SessionStatus } from "@opencode-ai/sdk/v2/client"; import { ensureWorkspaceSessionSync, trackWorkspaceSessionsSync } from "./session-sync"; +import type { ConversationEngineConnection, ConversationStatus } from "../engine/conversation-engine"; type ReactSessionRuntimeProps = { workspaceId: string; sessionId: string | null; - opencodeBaseUrl: string; - ipolloworkToken: string; + connection: ConversationEngineConnection; + connectionKey: string; onSessionUpdated?: (update: { sessionId: string; info: Record }) => void; - onSessionStatus?: (update: { sessionId: string; status: SessionStatus }) => void; + onSessionStatus?: (update: { sessionId: string; status: ConversationStatus }) => void; }; export function ReactSessionRuntime(props: ReactSessionRuntimeProps) { useEffect(() => { const input = { workspaceId: props.workspaceId, - baseUrl: props.opencodeBaseUrl, - ipolloworkToken: props.ipolloworkToken, + connection: props.connection, + connectionKey: props.connectionKey, onSessionUpdated: props.onSessionUpdated, onSessionStatus: props.onSessionStatus, }; @@ -28,7 +28,7 @@ export function ReactSessionRuntime(props: ReactSessionRuntimeProps) { releaseSessions(); releaseWorkspace(); }; - }, [props.workspaceId, props.sessionId, props.opencodeBaseUrl, props.ipolloworkToken, props.onSessionUpdated, props.onSessionStatus]); + }, [props.workspaceId, props.sessionId, props.connection, props.connectionKey, props.onSessionUpdated, props.onSessionStatus]); return null; } diff --git a/apps/app/src/react-app/domains/session/sync/session-sync.ts b/apps/app/src/react-app/domains/session/sync/session-sync.ts index 1f2969ebd..82278359b 100644 --- a/apps/app/src/react-app/domains/session/sync/session-sync.ts +++ b/apps/app/src/react-app/domains/session/sync/session-sync.ts @@ -1,35 +1,33 @@ import type { UIMessage } from "ai"; -import type { FilePart, Part, PermissionRequest, PermissionV2Request, QuestionRequest, Session, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"; import { getReactQueryClient } from "../../../infra/query-client"; import { captureAnalyticsEvent, takeTaskRunStart } from "@/app/lib/analytics"; import { trackTaskCompleted, trackTaskFailed } from "@/app/lib/den-telemetry"; -import { createClient } from "@/app/lib/opencode"; -import { normalizeEvent } from "@/app/utils"; -import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX, type OpencodeEvent, type PendingPermission, type PendingQuestion } from "@/app/types"; -import { createSessionErrorUIMessage, describeOpencodeSessionError, snapshotToUIMessages } from "./usechat-adapter"; -import { - parseDynamicToolUIPart, - parseStructuredOutputUIPart, - STRUCTURED_OUTPUT_TOOL, -} from "./parse-tool-parts"; -import type { iPolloWorkSessionSnapshot } from "@/app/lib/ipollowork-server"; +import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "@/app/types"; import { applyRevertCursor, reconcileTranscriptMessages } from "./transcript-reconcile"; import { useSessionActivityStore, } from "../status/session-activity-store"; import { notifyDesktopEvent } from "../../../shell/desktop-notifications"; -import { parseDesignAiSelectionDisplayMetadata } from "@ipollowork/design-studio"; -import { parseHyperframesAnimationDisplayMetadata } from "@/app/lib/hyperframes-effect-params"; -import { parseVideoVoiceDisplayMetadata } from "../video/video-voice"; -import { parseVideoIllustrationDisplayMetadata } from "../video/video-illustration"; - -type SyncOptions = { +import type { + ConversationEvent, + ConversationEngineConnection, + ConversationPermission, + ConversationQuestion, + ConversationSession, + ConversationSnapshot, + ConversationStatus, +} from "../engine/conversation-engine"; + +type SyncScope = { workspaceId: string; - baseUrl: string; - ipolloworkToken: string; + connectionKey: string; +}; + +type SyncOptions = SyncScope & { + connection: ConversationEngineConnection; onSessionUpdated?: (update: { sessionId: string; info: Record }) => void; - onSessionStatus?: (update: { sessionId: string; status: SessionStatus }) => void; + onSessionStatus?: (update: { sessionId: string; status: ConversationStatus }) => void; }; type PendingDelta = { @@ -41,7 +39,7 @@ type PendingDelta = { }; type SyncEntry = { - input: SyncOptions; + input: SyncScope; refs: number; dispose: () => void; disposeTimer: ReturnType | null; @@ -59,7 +57,7 @@ type SyncEntry = { deltaFlushScheduled: boolean; }; -const idleStatus: SessionStatus = { type: "idle" }; +const idleStatus: ConversationStatus = { type: "idle" }; const syncs = new Map(); const retainedSessionTtlMs = 10 * 60_000; const idleRetainedSessionTtlMs = 10_000; @@ -77,8 +75,8 @@ export const permissionKey = (workspaceId: string, sessionId: string) => export const questionKey = (workspaceId: string, sessionId: string) => ["react-session-questions", workspaceId, sessionId] as const; -function syncKey(input: SyncOptions) { - return `${input.workspaceId}:${input.baseUrl}:${input.ipolloworkToken}`; +function syncKey(input: SyncScope) { + return `${input.workspaceId}:${input.connectionKey}`; } function getErrorStatus(error: unknown) { @@ -101,23 +99,7 @@ function isTrackedSession(entry: SyncEntry, sessionId: string) { return (entry.trackedSessionRefs.get(sessionId) ?? 0) > 0 || entry.retainedSessionTimers.has(sessionId); } -function getSessionUpdatedInfo(event: OpencodeEvent) { - if (event.type !== "session.updated") return null; - const props = event.properties; - if (!props || typeof props !== "object") return null; - const record = props as { sessionID?: unknown; info?: unknown }; - const info = record.info; - if (!info || typeof info !== "object") return null; - const sessionId = typeof record.sessionID === "string" - ? record.sessionID - : typeof (info as { id?: unknown }).id === "string" - ? (info as { id: string }).id - : ""; - if (!sessionId) return null; - return { sessionId, info: info as Record }; -} - -function isLiveStatus(status: SessionStatus | null | undefined) { +function isLiveStatus(status: ConversationStatus | null | undefined) { return status?.type === "busy" || status?.type === "retry"; } @@ -140,25 +122,11 @@ function assistantOutputAfterLatestUser(messages: UIMessage[]) { return messages.slice(lastUserIndex + 1).some(messageHasVisibleAssistantOutput); } -function sessionIdFromProperties(properties: unknown) { - if (!properties || typeof properties !== "object") return ""; - const sessionID = (properties as { sessionID?: unknown }).sessionID; - return typeof sessionID === "string" ? sessionID : ""; -} - -function sessionErrorFromProperties(properties: unknown) { - if (!properties || typeof properties !== "object") return undefined; - return (properties as { error?: unknown }).error; -} - -function permissionNotificationDetail(permission: PermissionRequest | PermissionV2Request) { - if ("action" in permission) { - return `A session is waiting for permission to ${permission.action.replace(/[._-]/g, " ")}.`; - } - return `A session is waiting for ${permission.permission} permission.`; +function permissionNotificationDetail(permission: ConversationPermission) { + return `A session is waiting for ${permission.kind.replace(/[._-]/g, " ")} permission.`; } -function questionNotificationText(question: QuestionRequest) { +function questionNotificationText(question: ConversationQuestion) { const prompt = question.questions.find((item) => item.question.trim())?.question.trim(); return prompt ? `Question: ${prompt}` : undefined; } @@ -177,15 +145,21 @@ function latestAssistantMessageId(messages: UIMessage[]) { return null; } -function partHasVisibleAssistantOutput(part: Part) { - if (part.type === "text" && part.synthetic) return false; - if (part.type === "text" && part.ignored) return false; - const partType = String(part.type); - if ("text" in part && typeof part.text === "string" && part.text.trim().length > 0) return true; - return partType === "tool" || partType === "file" || partType === "agent"; +function createSessionErrorUIMessage(turnKey: string, text: string): UIMessage { + const id = `${SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX}${turnKey}`; + return { + id, + role: "assistant", + parts: [{ + type: "text", + text, + state: "done", + providerMetadata: { ipollowork: { partId: `${id}:text` } }, + }], + }; } -function clearTrackedSession(input: SyncOptions, entry: SyncEntry, sessionId: string) { +function clearTrackedSession(input: SyncScope, entry: SyncEntry, sessionId: string) { entry.trackedSessionRefs.delete(sessionId); const retainedTimer = entry.retainedSessionTimers.get(sessionId); if (retainedTimer) clearTimeout(retainedTimer); @@ -200,7 +174,7 @@ function clearTrackedSession(input: SyncOptions, entry: SyncEntry, sessionId: st } } -export function destroyWorkspaceSessionResources(input: SyncOptions, sessionId: string) { +export function destroyWorkspaceSessionResources(input: SyncScope, sessionId: string) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) return; @@ -220,7 +194,7 @@ export function destroyWorkspaceSessionResources(input: SyncOptions, sessionId: } } -function retainSession(input: SyncOptions, entry: SyncEntry, sessionId: string, ttlMs = retainedSessionTtlMs) { +function retainSession(input: SyncScope, entry: SyncEntry, sessionId: string, ttlMs = retainedSessionTtlMs) { const existing = entry.retainedSessionTimers.get(sessionId); if (existing) clearTimeout(existing); entry.retainedSessionTimers.set(sessionId, setTimeout(() => { @@ -240,89 +214,39 @@ function disposeWorkspaceSync(key: string, entry: SyncEntry) { if (syncs.get(key) === entry) syncs.delete(key); } -function releaseRetainedSessionSoon(input: SyncOptions, entry: SyncEntry, sessionId: string) { +function releaseRetainedSessionSoon(input: SyncScope, entry: SyncEntry, sessionId: string) { if (!entry.retainedSessionTimers.has(sessionId)) return; retainSession(input, entry, sessionId, idleRetainedSessionTtlMs); } -type PermissionSeed = PermissionRequest | PermissionV2Request; - -function isV2PermissionRequest(permission: PermissionSeed): permission is PermissionV2Request { - return "action" in permission; -} - -function legacyPermissionWithReceivedAt(permission: PermissionRequest, receivedAt: number): PendingPermission { - return { ...permission, receivedAt, protocol: "legacy" }; -} - -function v2PermissionKind(action: string): string { - if (action === "external_directory") return "external_directory"; - if (action.endsWith(".external_directory")) return "external_directory"; - if (action === "file.read") return "read"; - if (action === "file.edit" || action === "file.write") return "edit"; - return action; -} - -function v2PermissionWithReceivedAt(permission: PermissionV2Request, receivedAt: number): PendingPermission { - const metadata: Record = { - ...(permission.metadata ?? {}), - action: permission.action, - }; - if (permission.save?.length) metadata.save = permission.save.join(", "); - return { - id: permission.id, - sessionID: permission.sessionID, - permission: v2PermissionKind(permission.action), - patterns: permission.resources, - metadata, - always: permission.save ?? [], - ...(permission.source ? { tool: { messageID: permission.source.messageID, callID: permission.source.callID } } : {}), - receivedAt, - protocol: "v2", - v2: { - action: permission.action, - resources: permission.resources, - ...(permission.save ? { save: permission.save } : {}), - }, - }; -} - -function permissionWithReceivedAt(permission: PermissionSeed, receivedAt: number): PendingPermission { - return isV2PermissionRequest(permission) - ? v2PermissionWithReceivedAt(permission, receivedAt) - : legacyPermissionWithReceivedAt(permission, receivedAt); -} - -function questionWithReceivedAt(question: QuestionRequest, receivedAt: number): PendingQuestion { - return { ...question, receivedAt }; -} - -function sortPermissions(a: PendingPermission, b: PendingPermission) { +function sortPermissions(a: ConversationPermission, b: ConversationPermission) { return a.receivedAt - b.receivedAt || a.id.localeCompare(b.id); } -function sortQuestions(a: PendingQuestion, b: PendingQuestion) { +function sortQuestions(a: ConversationQuestion, b: ConversationQuestion) { return a.receivedAt - b.receivedAt || a.id.localeCompare(b.id); } export function seedPermissionState( workspaceId: string, sessionId: string, - permissions: PermissionSeed[], + permissions: ConversationPermission[], options: { snapshotStartedAt?: number } = {}, ) { useSessionActivityStore.getState().replaceWaitingRequests( workspaceId, sessionId, "permission", - permissions.flatMap((permission) => permission.sessionID === sessionId ? [permission.id] : []), + permissions.flatMap((permission) => permission.sessionId === sessionId ? [permission.id] : []), ); const queryClient = getReactQueryClient(); const now = Date.now(); - queryClient.setQueryData(permissionKey(workspaceId, sessionId), (current = []) => { + queryClient.setQueryData(permissionKey(workspaceId, sessionId), (current = []) => { const receivedAtById = new Map(current.map((permission) => [permission.id, permission.receivedAt])); const seeded = permissions.flatMap((permission) => - permission.sessionID === sessionId ? [permissionWithReceivedAt(permission, receivedAtById.get(permission.id) ?? now)] : [], + permission.sessionId === sessionId + ? [{ ...permission, receivedAt: receivedAtById.get(permission.id) ?? now }] + : [], ); const seededIds = new Set(seeded.map((permission) => permission.id)); const snapshotStartedAt = options.snapshotStartedAt; @@ -330,7 +254,7 @@ export function seedPermissionState( typeof snapshotStartedAt === "number" ? current.filter( (permission) => - permission.sessionID === sessionId && + permission.sessionId === sessionId && permission.receivedAt > snapshotStartedAt && !seededIds.has(permission.id), ) @@ -342,21 +266,23 @@ export function seedPermissionState( export function seedQuestionState( workspaceId: string, sessionId: string, - questions: QuestionRequest[], + questions: ConversationQuestion[], options: { snapshotStartedAt?: number } = {}, ) { useSessionActivityStore.getState().replaceWaitingRequests( workspaceId, sessionId, "question", - questions.flatMap((question) => question.sessionID === sessionId ? [question.id] : []), + questions.flatMap((question) => question.sessionId === sessionId ? [question.id] : []), ); const queryClient = getReactQueryClient(); const now = Date.now(); - queryClient.setQueryData(questionKey(workspaceId, sessionId), (current = []) => { + queryClient.setQueryData(questionKey(workspaceId, sessionId), (current = []) => { const receivedAtById = new Map(current.map((question) => [question.id, question.receivedAt])); const seeded = questions.flatMap((question) => - question.sessionID === sessionId ? [questionWithReceivedAt(question, receivedAtById.get(question.id) ?? now)] : [], + question.sessionId === sessionId + ? [{ ...question, receivedAt: receivedAtById.get(question.id) ?? now }] + : [], ); const seededIds = new Set(seeded.map((question) => question.id)); const snapshotStartedAt = options.snapshotStartedAt; @@ -364,7 +290,7 @@ export function seedQuestionState( typeof snapshotStartedAt === "number" ? current.filter( (question) => - question.sessionID === sessionId && + question.sessionId === sessionId && question.receivedAt > snapshotStartedAt && !seededIds.has(question.id), ) @@ -373,128 +299,6 @@ export function seedQuestionState( }); } -function fileProviderMetadata(part: FilePart) { - if (part.source) { - return { opencode: { partId: part.id, source: part.source } }; - } - return { opencode: { partId: part.id } }; -} - -function toFileUIPart(part: FilePart): UIMessage["parts"][number] { - return { - type: "file", - url: part.url, - filename: part.filename, - mediaType: part.mime, - providerMetadata: fileProviderMetadata(part), - }; -} - -function toFileSourceUIPart(part: FilePart): UIMessage["parts"][number] | null { - const source = part.source; - if (!source) return null; - - const sourceId = `${part.id}:source`; - const providerMetadata = { opencode: { partId: sourceId, sourcePartId: part.id, source } }; - - if (source.type === "resource") { - if (source.uri.startsWith("http://")) { - return { type: "source-url", sourceId, url: source.uri, title: source.uri, providerMetadata }; - } - if (source.uri.startsWith("https://")) { - return { type: "source-url", sourceId, url: source.uri, title: source.uri, providerMetadata }; - } - return { type: "source-document", sourceId, mediaType: part.mime, title: source.uri, providerMetadata }; - } - - if (source.type === "symbol") { - return { type: "source-document", sourceId, mediaType: part.mime, title: source.name, filename: source.path, providerMetadata }; - } - - return { type: "source-document", sourceId, mediaType: part.mime, title: source.path, filename: source.path, providerMetadata }; -} - -function toFileUIParts(part: FilePart): UIMessage["parts"] { - const sourcePart = toFileSourceUIPart(part); - if (sourcePart) return [toFileUIPart(part), sourcePart]; - return [toFileUIPart(part)]; -} - -function toUIPart(part: Part): UIMessage["parts"][number] | null { - if (part.type === "text") { - if (part.synthetic) return null; - if (part.ignored) return null; - return { - type: "text", - text: part.text, - state: "done", - providerMetadata: { opencode: { partId: part.id } }, - }; - } - if (part.type === "reasoning") { - return { - type: "reasoning", - text: part.text, - state: "done", - providerMetadata: { opencode: { partId: part.id } }, - }; - } - if (part.type === "file") { - return toFileUIPart(part); - } - if (part.type === "tool") { - if (part.tool === STRUCTURED_OUTPUT_TOOL) { - return parseStructuredOutputUIPart(part); - } - return parseDynamicToolUIPart(part); - } - if (part.type === "agent") { - return { - type: "text", - text: part.name ? `@${part.name}` : "@agent", - state: "done", - providerMetadata: { opencode: { partId: part.id } }, - }; - } - if (part.type === "step-start") return { type: "step-start" }; - return null; -} - -function toUIParts(part: Part): UIMessage["parts"] { - if (part.type === "file") return toFileUIParts(part); - if (part.type === "text" && part.synthetic) { - const metadataParts: UIMessage["parts"] = []; - const selection = parseDesignAiSelectionDisplayMetadata(part.text); - if (selection) metadataParts.push({ - type: "data-design-selection" as const, - data: { ...selection, partId: `${part.id}:design-selection` }, - }); - const animations = parseHyperframesAnimationDisplayMetadata(part.text); - if (animations) metadataParts.push({ - type: "data-animation-references" as const, - data: { items: animations, partId: `${part.id}:animation-references` }, - }); - const voice = parseVideoVoiceDisplayMetadata(part.text); - if (voice) metadataParts.push({ - type: "data-voice-reference" as const, - data: { ...voice, partId: `${part.id}:voice-reference` }, - }); - const illustration = parseVideoIllustrationDisplayMetadata(part.text); - if (illustration) metadataParts.push({ - type: "data-illustration-reference" as const, - data: { ...illustration, partId: `${part.id}:illustration-reference` }, - }); - return metadataParts; - } - const mapped = toUIPart(part); - if (!mapped) return []; - if (part.type === "tool" && part.tool === STRUCTURED_OUTPUT_TOOL) return [mapped]; - if (part.type === "tool" && part.state.status === "completed" && part.state.attachments) { - return [mapped, ...part.state.attachments.flatMap(toFileUIParts)]; - } - return [mapped]; -} - function getPartMetadataId(part: UIMessage["parts"][number]) { if (part.type === "data-design-selection" || part.type === "data-animation-references" || part.type === "data-voice-reference" || part.type === "data-illustration-reference") { const partId = part.data && typeof part.data === "object" && "partId" in part.data @@ -503,12 +307,12 @@ function getPartMetadataId(part: UIMessage["parts"][number]) { return typeof partId === "string" ? partId : null; } if (part.type === "dynamic-tool") { - const metadata = part.callProviderMetadata?.opencode; + const metadata = part.callProviderMetadata?.ipollowork; if (!metadata || typeof metadata !== "object") return null; return "partId" in metadata ? (metadata as { partId?: string }).partId ?? null : null; } if (part.type !== "text" && part.type !== "reasoning" && part.type !== "file" && part.type !== "source-url" && part.type !== "source-document") return null; - const metadata = part.providerMetadata?.opencode; + const metadata = part.providerMetadata?.ipollowork; if (!metadata || typeof metadata !== "object") return null; return "partId" in metadata ? (metadata as { partId?: string }).partId ?? null : null; } @@ -602,13 +406,13 @@ function appendDelta(messages: UIMessage[], messageId: string, partId: string, d type: "reasoning", text: delta, state: "streaming" as const, - providerMetadata: { opencode: { partId } }, + providerMetadata: { ipollowork: { partId } }, } : { type: "text", text: delta, state: "streaming" as const, - providerMetadata: { opencode: { partId } }, + providerMetadata: { ipollowork: { partId } }, }; nextParts = target.parts.slice(); nextParts.push(newPart); @@ -656,342 +460,209 @@ export function coalescePendingDeltas(items: PendingDelta[]) { return ordered; } -function applyEvent(entry: SyncEntry, workspaceId: string, event: OpencodeEvent) { +function applyEvent(entry: SyncEntry, workspaceId: string, event: ConversationEvent) { const queryClient = getReactQueryClient(); const input = entry.input; if (event.type === "session.updated") { - const update = getSessionUpdatedInfo(event); - if (!update) return; - if (!isTrackedSession(entry, update.sessionId)) return; - // Keep the cached snapshot's revert cursor in sync with the server. The - // renderer derives the visible transcript from this cursor, so a revert - // (or its cleanup on the next prompt) must reach the snapshot cache or - // the transcript stays frozen on stale history. - queryClient.setQueryData( - snapshotKey(workspaceId, update.sessionId), - (current) => { - if (!current) return current; - const revert = (update.info as { revert?: iPolloWorkSessionSnapshot["session"]["revert"] }).revert; - return { ...current, session: { ...current.session, revert } }; - }, + if (!isTrackedSession(entry, event.sessionId)) return; + queryClient.setQueryData( + snapshotKey(workspaceId, event.sessionId), + (current) => current + ? { ...current, session: { ...current.session, revertMessageId: event.info.revertMessageId } } + : current, ); - for (const listener of entry.sessionUpdatedListeners) listener(update); + for (const listener of entry.sessionUpdatedListeners) listener({ sessionId: event.sessionId, info: event.info }); return; } if (event.type === "session.deleted") { - const props = (event.properties ?? {}) as { sessionID?: string; info?: { id?: string } }; - const sessionId = props.sessionID ?? props.info?.id ?? ""; - if (sessionId) useSessionActivityStore.getState().removeSession(workspaceId, sessionId); + useSessionActivityStore.getState().removeSession(workspaceId, event.sessionId); return; } if (event.type === "session.error") { - const sessionId = sessionIdFromProperties(event.properties); - if (sessionId) { - const errorText = describeOpencodeSessionError(sessionErrorFromProperties(event.properties)); - const runStartedAt = takeTaskRunStart(sessionId); - if (runStartedAt !== null) { - captureAnalyticsEvent("task_run_errored", { - duration_ms: Date.now() - runStartedAt, - }); - trackTaskFailed(sessionId, Date.now() - runStartedAt); - } - notifyDesktopEvent({ type: "task.failed", sessionId, errorText }); - useSessionActivityStore.getState().setError(workspaceId, sessionId, errorText); - if (isTrackedSession(entry, sessionId)) { - queryClient.setQueryData(transcriptKey(workspaceId, sessionId), (current = []) => { - // Key the error to the latest assistant turn so it lands beside the - // turn that failed and a later turn's error becomes its own message - // instead of overwriting this one. Falls back to the session id when - // no assistant turn exists yet (e.g. error before any output). - const turnKey = latestAssistantMessageId(current) ?? sessionId; - // Note: turnKey matches the snapshot's per-turn key (the errored - // assistant message id) so a reload reconciles instead of - // duplicating; the sessionId fallback only applies when the run - // errored before any assistant message existed. - return upsertMessage(current, createSessionErrorUIMessage(turnKey, errorText)); - }); - } + const runStartedAt = takeTaskRunStart(event.sessionId); + if (runStartedAt !== null) { + captureAnalyticsEvent("task_run_errored", { duration_ms: Date.now() - runStartedAt }); + trackTaskFailed(event.sessionId, Date.now() - runStartedAt); + } + notifyDesktopEvent({ type: "task.failed", sessionId: event.sessionId, errorText: event.errorText }); + useSessionActivityStore.getState().setError(workspaceId, event.sessionId, event.errorText); + if (isTrackedSession(entry, event.sessionId)) { + queryClient.setQueryData(transcriptKey(workspaceId, event.sessionId), (current = []) => { + const turnKey = latestAssistantMessageId(current) ?? event.sessionId; + return upsertMessage(current, createSessionErrorUIMessage(turnKey, event.errorText)); + }); } return; } - if (event.type === "session.next.compaction.started") { - const sessionId = sessionIdFromProperties(event.properties); - if (sessionId) useSessionActivityStore.getState().setCompacting(workspaceId, sessionId, true); - return; - } - - if (event.type === "session.next.compaction.ended" || event.type === "session.compacted") { - const sessionId = sessionIdFromProperties(event.properties); - if (sessionId) useSessionActivityStore.getState().setCompacting(workspaceId, sessionId, false); + if (event.type === "session.compaction") { + useSessionActivityStore.getState().setCompacting(workspaceId, event.sessionId, event.running); return; } if (event.type === "session.status") { - const props = (event.properties ?? {}) as { sessionID?: string; status?: SessionStatus }; - if (!props.sessionID || !props.status) return; - useSessionActivityStore.getState().setRunStatus(workspaceId, props.sessionID, props.status); - const tracked = isTrackedSession(entry, props.sessionID); - if (tracked) queryClient.setQueryData(statusKey(workspaceId, props.sessionID), props.status); - for (const listener of entry.sessionStatusListeners) listener({ sessionId: props.sessionID, status: props.status }); - if (input && tracked && !isLiveStatus(props.status)) releaseRetainedSessionSoon(input, entry, props.sessionID); + useSessionActivityStore.getState().setRunStatus(workspaceId, event.sessionId, event.status); + const tracked = isTrackedSession(entry, event.sessionId); + if (tracked) queryClient.setQueryData(statusKey(workspaceId, event.sessionId), event.status); + for (const listener of entry.sessionStatusListeners) listener({ sessionId: event.sessionId, status: event.status }); + if (tracked && !isLiveStatus(event.status)) releaseRetainedSessionSoon(input, entry, event.sessionId); return; } if (event.type === "todo.updated") { - const props = (event.properties ?? {}) as { sessionID?: string; todos?: Todo[] }; - if (!props.sessionID || !props.todos) return; - if (!isTrackedSession(entry, props.sessionID)) return; - queryClient.setQueryData(todoKey(workspaceId, props.sessionID), props.todos); + if (isTrackedSession(entry, event.sessionId)) { + queryClient.setQueryData(todoKey(workspaceId, event.sessionId), event.todos); + } return; } if (event.type === "permission.asked") { - const permission = event.properties as PermissionRequest; - if (!permission?.id || !permission.sessionID) return; - notifyDesktopEvent({ - type: "permission.asked", - sessionId: permission.sessionID, - detail: permissionNotificationDetail(permission), - }); - useSessionActivityStore.getState().setWaitingRequest(workspaceId, permission.sessionID, "permission", permission.id, true); - if (!isTrackedSession(entry, permission.sessionID)) return; - const receivedAt = Date.now(); - queryClient.setQueryData(permissionKey(workspaceId, permission.sessionID), (current = []) => { - const existing = current.find((item) => item.id === permission.id); - const next = permissionWithReceivedAt(permission, existing?.receivedAt ?? receivedAt); - if (existing) { - return current.map((item) => (item.id === permission.id ? next : item)).sort(sortPermissions); - } - return [...current, next].sort(sortPermissions); - }); - return; - } - - if (event.type === "permission.v2.asked") { - const permission = event.properties as PermissionV2Request; - if (!permission?.id || !permission.sessionID) return; + const permission = event.permission; notifyDesktopEvent({ type: "permission.asked", - sessionId: permission.sessionID, + sessionId: permission.sessionId, detail: permissionNotificationDetail(permission), }); - useSessionActivityStore.getState().setWaitingRequest(workspaceId, permission.sessionID, "permission", permission.id, true); - if (!isTrackedSession(entry, permission.sessionID)) return; - const receivedAt = Date.now(); - queryClient.setQueryData(permissionKey(workspaceId, permission.sessionID), (current = []) => { + useSessionActivityStore.getState().setWaitingRequest(workspaceId, permission.sessionId, "permission", permission.id, true); + if (!isTrackedSession(entry, permission.sessionId)) return; + queryClient.setQueryData(permissionKey(workspaceId, permission.sessionId), (current = []) => { const existing = current.find((item) => item.id === permission.id); - const next = permissionWithReceivedAt(permission, existing?.receivedAt ?? receivedAt); - if (existing) { - return current.map((item) => (item.id === permission.id ? next : item)).sort(sortPermissions); - } - return [...current, next].sort(sortPermissions); + const next = { ...permission, receivedAt: existing?.receivedAt ?? permission.receivedAt }; + return existing + ? current.map((item) => item.id === permission.id ? next : item).sort(sortPermissions) + : [...current, next].sort(sortPermissions); }); return; } - if (event.type === "permission.replied" || event.type === "permission.v2.replied") { - const props = (event.properties ?? {}) as { sessionID?: string; requestID?: string }; - if (!props.sessionID || !props.requestID) return; - useSessionActivityStore.getState().setWaitingRequest(workspaceId, props.sessionID, "permission", props.requestID, false); - if (!isTrackedSession(entry, props.sessionID)) return; - queryClient.setQueryData(permissionKey(workspaceId, props.sessionID), (current = []) => - current.filter((permission) => permission.id !== props.requestID), + if (event.type === "permission.replied") { + useSessionActivityStore.getState().setWaitingRequest(workspaceId, event.sessionId, "permission", event.requestId, false); + if (!isTrackedSession(entry, event.sessionId)) return; + queryClient.setQueryData(permissionKey(workspaceId, event.sessionId), (current = []) => + current.filter((permission) => permission.id !== event.requestId), ); return; } if (event.type === "question.asked") { - const question = event.properties as QuestionRequest; - if (!question?.id || !question.sessionID) return; + const question = event.question; notifyDesktopEvent({ type: "question.asked", - sessionId: question.sessionID, + sessionId: question.sessionId, question: questionNotificationText(question), }); - useSessionActivityStore.getState().setWaitingRequest(workspaceId, question.sessionID, "question", question.id, true); - if (!isTrackedSession(entry, question.sessionID)) return; - const receivedAt = Date.now(); - queryClient.setQueryData(questionKey(workspaceId, question.sessionID), (current = []) => { + useSessionActivityStore.getState().setWaitingRequest(workspaceId, question.sessionId, "question", question.id, true); + if (!isTrackedSession(entry, question.sessionId)) return; + queryClient.setQueryData(questionKey(workspaceId, question.sessionId), (current = []) => { const existing = current.find((item) => item.id === question.id); - const next = questionWithReceivedAt(question, existing?.receivedAt ?? receivedAt); - if (existing) { - return current.map((item) => (item.id === question.id ? next : item)).sort(sortQuestions); - } - return [...current, next].sort(sortQuestions); + const next = { ...question, receivedAt: existing?.receivedAt ?? question.receivedAt }; + return existing + ? current.map((item) => item.id === question.id ? next : item).sort(sortQuestions) + : [...current, next].sort(sortQuestions); }); return; } - if (event.type === "question.replied" || event.type === "question.rejected") { - const props = (event.properties ?? {}) as { sessionID?: string; requestID?: string }; - if (!props.sessionID || !props.requestID) return; - useSessionActivityStore.getState().setWaitingRequest(workspaceId, props.sessionID, "question", props.requestID, false); - if (!isTrackedSession(entry, props.sessionID)) return; - queryClient.setQueryData(questionKey(workspaceId, props.sessionID), (current = []) => - current.filter((question) => question.id !== props.requestID), + if (event.type === "question.replied") { + useSessionActivityStore.getState().setWaitingRequest(workspaceId, event.sessionId, "question", event.requestId, false); + if (!isTrackedSession(entry, event.sessionId)) return; + queryClient.setQueryData(questionKey(workspaceId, event.sessionId), (current = []) => + current.filter((question) => question.id !== event.requestId), ); return; } - if (event.type === "message.updated") { - const props = (event.properties ?? {}) as { - info?: { id?: string; role?: UIMessage["role"] | string; sessionID?: string; time?: { created?: number; completed?: number } }; - }; - const info = props.info; - if (!info?.id || !info.sessionID || (info.role !== "user" && info.role !== "assistant" && info.role !== "system")) { - return; - } - useSessionActivityStore.getState().markMessageRole(workspaceId, info.sessionID, info.id, info.role); - if (!isTrackedSession(entry, info.sessionID)) return; - const created = info.time?.created; - const completed = info.time?.completed; - const next = { - id: info.id, - role: info.role, - ...(typeof created === "number" || typeof completed === "number" - ? { metadata: { opencode: { - ...(typeof created === "number" ? { created } : {}), - ...(typeof completed === "number" ? { completed } : {}), - } } } - : {}), - parts: [], - } satisfies UIMessage; - queryClient.setQueryData(transcriptKey(workspaceId, info.sessionID), (current = []) => - upsertMessage(current, next), + if (event.type === "message.upsert") { + useSessionActivityStore.getState().markMessageRole(workspaceId, event.sessionId, event.message.id, event.message.role); + if (!isTrackedSession(entry, event.sessionId)) return; + queryClient.setQueryData(transcriptKey(workspaceId, event.sessionId), (current = []) => + upsertMessage(current, event.message), ); return; } if (event.type === "message.removed") { - // Revert cleanup (and explicit message deletion) removes messages - // server-side; drop them from both the live transcript cache and the - // cached snapshot so they can't be resurrected by later merges. - const props = (event.properties ?? {}) as { sessionID?: string; messageID?: string }; - if (!props.sessionID || !props.messageID) return; - if (!isTrackedSession(entry, props.sessionID)) return; - queryClient.setQueryData(transcriptKey(workspaceId, props.sessionID), (current = []) => - current.filter((message) => message.id !== props.messageID), + if (!isTrackedSession(entry, event.sessionId)) return; + queryClient.setQueryData(transcriptKey(workspaceId, event.sessionId), (current = []) => + current.filter((message) => message.id !== event.messageId), ); - queryClient.setQueryData( - snapshotKey(workspaceId, props.sessionID), - (current) => { - if (!current) return current; - return { ...current, messages: current.messages.filter((message) => message.info.id !== props.messageID) }; - }, + queryClient.setQueryData( + snapshotKey(workspaceId, event.sessionId), + (current) => current + ? { ...current, messages: current.messages.filter((message) => message.id !== event.messageId) } + : current, ); return; } - if (event.type === "message.part.updated") { - const props = (event.properties ?? {}) as { part?: Part }; - const part = props.part; - if (!part?.sessionID || !part.messageID) return; - if (partHasVisibleAssistantOutput(part)) { - useSessionActivityStore.getState().markAssistantOutput(workspaceId, part.sessionID, part.messageID); + if (event.type === "message.parts") { + if (event.visibleAssistantOutput) { + useSessionActivityStore.getState().markAssistantOutput(workspaceId, event.sessionId, event.messageId); } - if (!isTrackedSession(entry, part.sessionID)) return; - const [mapped, ...attachments] = toUIParts(part); + if (!isTrackedSession(entry, event.sessionId)) return; + const [mapped, ...attachments] = event.parts; if (!mapped) return; - const pending = entry.pendingDeltas.get(part.id); - // Seed the new part with any deltas that arrived before this - // declaration. We deliberately ignore `pending.reasoning` — it - // can't be trusted because opencode emits `field: "text"` for - // both text and reasoning streams. The part's actual kind - // (`mapped.type`) is the source of truth. - // - // Both `pending.text` and `mapped.text` are cumulative views of the - // same stream, so we keep whichever is longer instead of - // concatenating (concatenation double-counts the bytes that landed - // in both). Without this, reasoning text shows up duplicated in the - // streaming UI. - const seededPart = - pending && (mapped.type === "text" || mapped.type === "reasoning") - ? { - ...mapped, - text: pending.text.length > mapped.text.length ? pending.text : mapped.text, - state: "streaming" as const, - } - : mapped; - // Drop any deltas for this partID still queued in the rAF flush - // buffer — they've already been incorporated into `mapped.text`. - // Without this, the rAF flush would re-append them on top of the - // cumulative text we just wrote, duplicating bytes mid-stream. + const pending = entry.pendingDeltas.get(event.partId); + const seededPart = pending && (mapped.type === "text" || mapped.type === "reasoning") + ? { + ...mapped, + text: pending.text.length > mapped.text.length ? pending.text : mapped.text, + state: "streaming" as const, + } + : mapped; if (entry.deltaFlushBuffer.length > 0) { - entry.deltaFlushBuffer = entry.deltaFlushBuffer.filter( - (item) => item.partId !== part.id, - ); + entry.deltaFlushBuffer = entry.deltaFlushBuffer.filter((item) => item.partId !== event.partId); } - queryClient.setQueryData(transcriptKey(workspaceId, part.sessionID), (current = []) => { - // If we already have this message, keep its role; otherwise infer - // from the alternation pattern. Only the newly-stubbed case needs - // the inference — upsertMessage preserves existing role when the - // stub's role matches what we'd write anyway, and any subsequent - // message.updated will overwrite both. - const existing = current.find((m) => m.id === part.messageID); + queryClient.setQueryData(transcriptKey(workspaceId, event.sessionId), (current = []) => { + const existing = current.find((message) => message.id === event.messageId); const role = existing?.role ?? inferStubRole(current); - const withMessage = upsertMessage(current, { id: part.messageID, role, parts: [] }); - const seededPartId = getPartMetadataId(seededPart) ?? part.id; - let next = upsertPart(withMessage, part.messageID, seededPartId, seededPart); + const withMessage = upsertMessage(current, { id: event.messageId, role, parts: [] }); + const seededPartId = getPartMetadataId(seededPart) ?? event.partId; + let next = upsertPart(withMessage, event.messageId, seededPartId, seededPart); for (const attachment of attachments) { const attachmentId = getPartMetadataId(attachment); - if (attachmentId) next = upsertPart(next, part.messageID, attachmentId, attachment); + if (attachmentId) next = upsertPart(next, event.messageId, attachmentId, attachment); } return next; }); - if (pending) entry.pendingDeltas.delete(part.id); + if (pending) entry.pendingDeltas.delete(event.partId); return; } - if (event.type === "message.part.delta") { - const props = (event.properties ?? {}) as { - sessionID?: string; - messageID?: string; - partID?: string; - field?: string; - delta?: string; - }; - if (!props.sessionID || !props.messageID || !props.partID || !props.delta) return; - useSessionActivityStore.getState().markAssistantOutput(workspaceId, props.sessionID, props.messageID, { allowUnknownMessageRole: true }); - if (!isTrackedSession(entry, props.sessionID)) return; - // Note: we do NOT trust `props.field` to disambiguate reasoning vs - // text. Opencode emits `field: "text"` for both kinds; the actual - // distinction lives on the part's `type`, which we only see via - // `message.part.updated`. The flusher resolves the kind at apply - // time, falling back to `pendingDeltas` if the part hasn't been - // declared yet. + if (event.type === "message.chunk") { + useSessionActivityStore.getState().markAssistantOutput( + workspaceId, + event.sessionId, + event.messageId, + { allowUnknownMessageRole: true }, + ); + if (!isTrackedSession(entry, event.sessionId)) return; entry.deltaFlushBuffer.push({ - sessionId: props.sessionID!, - messageId: props.messageID!, - partId: props.partID!, - reasoning: false, - delta: props.delta!, + sessionId: event.sessionId, + messageId: event.messageId, + partId: event.chunk.id, + reasoning: event.chunk.type === "reasoning-delta", + delta: event.chunk.delta, }); scheduleDeltaFlush(entry, workspaceId); return; } if (event.type === "session.idle") { - const props = (event.properties ?? {}) as { sessionID?: string }; - if (!props.sessionID) return; - // Only emits for runs this client instrumented (markTaskRunStart in the - // send path); also dedupes idle events from multiple workspace syncs. - const runStartedAt = takeTaskRunStart(props.sessionID); + const runStartedAt = takeTaskRunStart(event.sessionId); if (runStartedAt !== null) { - captureAnalyticsEvent("task_run_completed", { - duration_ms: Date.now() - runStartedAt, - }); - trackTaskCompleted(props.sessionID, Date.now() - runStartedAt); - notifyDesktopEvent({ type: "task.completed", sessionId: props.sessionID }); + captureAnalyticsEvent("task_run_completed", { duration_ms: Date.now() - runStartedAt }); + trackTaskCompleted(event.sessionId, Date.now() - runStartedAt); + notifyDesktopEvent({ type: "task.completed", sessionId: event.sessionId }); } - useSessionActivityStore.getState().setRunStatus(workspaceId, props.sessionID, idleStatus); - const tracked = isTrackedSession(entry, props.sessionID); - if (tracked) queryClient.setQueryData(statusKey(workspaceId, props.sessionID), idleStatus); - for (const listener of entry.sessionStatusListeners) listener({ sessionId: props.sessionID, status: idleStatus }); - if (input && tracked) releaseRetainedSessionSoon(input, entry, props.sessionID); + useSessionActivityStore.getState().setRunStatus(workspaceId, event.sessionId, idleStatus); + const tracked = isTrackedSession(entry, event.sessionId); + if (tracked) queryClient.setQueryData(statusKey(workspaceId, event.sessionId), idleStatus); + for (const listener of entry.sessionStatusListeners) listener({ sessionId: event.sessionId, status: idleStatus }); + if (tracked) releaseRetainedSessionSoon(input, entry, event.sessionId); } } @@ -1052,15 +723,9 @@ function flushDeltas(entry: SyncEntry, workspaceId: string) { nextById.set(item.messageId, ensuredMessage); ensuredMessageIds.add(item.messageId); } - // Resolve the part kind from the transcript instead of trusting - // the inbound delta event (opencode emits `field: "text"` for - // both text and reasoning parts). If the part hasn't been - // declared yet via `message.part.updated`, defer the delta into - // `entry.pendingDeltas` so the part can be created with the - // correct kind later. Without this, every delta lands as a text - // part — and reasoning content leaks into the response markdown - // until the next reload reconstructs the transcript from the - // snapshot. + // Resolve the final part kind from the declared transcript part. + // Engines may stream a chunk before its part declaration, so hold + // early chunks until the matching part exists. const ownerMessage = nextById.get(item.messageId); const ownerPartsById = new Map( (ownerMessage?.parts ?? []).flatMap((part) => { @@ -1091,7 +756,6 @@ function flushDeltas(entry: SyncEntry, workspaceId: string) { } function startSync(input: SyncOptions) { - const client = createClient(input.baseUrl, undefined, { token: input.ipolloworkToken, mode: "ipollowork" }); const controller = new AbortController(); const entry = syncs.get(syncKey(input)); let disposed = false; @@ -1116,17 +780,16 @@ function startSync(input: SyncOptions) { const connectionController = new AbortController(); activeConnectionController = connectionController; try { - const sub = await client.event.subscribe(undefined, { signal: connectionController.signal }); retryDelayMs = 1_000; lastEventAt = Date.now(); - for await (const raw of sub.stream) { - if (controller.signal.aborted || connectionController.signal.aborted) return; - lastEventAt = Date.now(); - const event = normalizeEvent(raw); - if (!event) continue; - if (!entry) continue; - applyEvent(entry, input.workspaceId, event); - } + await input.connection.subscribe({ + signal: connectionController.signal, + onEvent: (event) => { + if (controller.signal.aborted || connectionController.signal.aborted || !entry) return; + lastEventAt = Date.now(); + applyEvent(entry, input.workspaceId, event); + }, + }); if (!controller.signal.aborted && activeConnectionController === connectionController) scheduleRetry(); } catch (error) { if ( @@ -1193,7 +856,7 @@ export function ensureWorkspaceSessionSync(input: SyncOptions) { return () => releaseWorkspaceSessionSync(input); } -function releaseWorkspaceSessionSync(input: SyncOptions) { +function releaseWorkspaceSessionSync(input: SyncScope & Pick) { const key = syncKey(input); const existing = syncs.get(key); if (!existing) return; @@ -1206,10 +869,10 @@ function releaseWorkspaceSessionSync(input: SyncOptions) { } } -export function seedSessionState(workspaceId: string, snapshot: iPolloWorkSessionSnapshot) { +export function seedSessionState(workspaceId: string, snapshot: ConversationSnapshot) { const queryClient = getReactQueryClient(); const key = transcriptKey(workspaceId, snapshot.session.id); - const incoming = snapshotToUIMessages(snapshot); + const incoming = snapshot.messages; const existing = queryClient.getQueryData(key); useSessionActivityStore.getState().seedSessionRun( @@ -1228,7 +891,7 @@ export function seedSessionState(workspaceId: string, snapshot: iPolloWorkSessio snapshotMessages: incoming, reason: "snapshot", }), - snapshot.session.revert?.messageID ?? null, + snapshot.session.revertMessageId ?? null, )); queryClient.setQueryData(statusKey(workspaceId, snapshot.session.id), snapshot.status); @@ -1238,19 +901,21 @@ export function seedSessionState(workspaceId: string, snapshot: iPolloWorkSessio /** * Apply a server-confirmed revert to the local session caches. * - * `session.revert` only reaches the renderer through the snapshot cache, so - * after a successful `session.revert` call this stamps the returned revert + * The revert cursor only reaches the renderer through the snapshot cache, so + * after a successful revert this stamps the returned cursor * cursor into the cached snapshot, truncates the live transcript cache, and * refetches the snapshot to pick up the server's post-revert truth. Without * this the UI keeps rendering the old transcript until a full reload. */ -export function applySessionRevert(workspaceId: string, session: Session) { +export function applySessionRevert(workspaceId: string, session: ConversationSession) { const queryClient = getReactQueryClient(); - const revertMessageId = session.revert?.messageID ?? null; + const revertMessageId = session.revertMessageId ?? null; - queryClient.setQueryData( + queryClient.setQueryData( snapshotKey(workspaceId, session.id), - (current) => (current ? { ...current, session: { ...current.session, revert: session.revert } } : current), + (current) => (current + ? { ...current, session: { ...current.session, revertMessageId: session.revertMessageId } } + : current), ); queryClient.setQueryData( transcriptKey(workspaceId, session.id), @@ -1259,7 +924,7 @@ export function applySessionRevert(workspaceId: string, session: Session) { void queryClient.invalidateQueries({ queryKey: snapshotKey(workspaceId, session.id) }); } -export function trackWorkspaceSessionSync(input: SyncOptions, sessionId: string | null | undefined) { +export function trackWorkspaceSessionSync(input: SyncScope, sessionId: string | null | undefined) { const normalizedSessionId = sessionId?.trim() ?? ""; if (!normalizedSessionId) return () => {}; @@ -1288,7 +953,7 @@ export function trackWorkspaceSessionSync(input: SyncOptions, sessionId: string }; } -export function trackWorkspaceSessionsSync(input: SyncOptions, sessionIds: Array) { +export function trackWorkspaceSessionsSync(input: SyncScope, sessionIds: Array) { const seen = new Set(); const releases = sessionIds.flatMap((sessionId) => { const id = sessionId?.trim() ?? ""; @@ -1301,7 +966,7 @@ export function trackWorkspaceSessionsSync(input: SyncOptions, sessionIds: Array }; } -export function __createWorkspaceSessionSyncForTest(input: SyncOptions) { +export function __createWorkspaceSessionSyncForTest(input: SyncScope) { const key = syncKey(input); syncs.set(key, { input, @@ -1325,11 +990,11 @@ export function __createWorkspaceSessionSyncForTest(input: SyncOptions) { }; } -export function __hasWorkspaceSessionSyncForTest(input: SyncOptions) { +export function __hasWorkspaceSessionSyncForTest(input: SyncScope) { return syncs.has(syncKey(input)); } -export function __disposeWorkspaceSessionSyncForTest(input: SyncOptions) { +export function __disposeWorkspaceSessionSyncForTest(input: SyncScope) { const key = syncKey(input); const entry = syncs.get(key); if (!entry) return; @@ -1337,7 +1002,7 @@ export function __disposeWorkspaceSessionSyncForTest(input: SyncOptions) { disposeWorkspaceSync(key, entry); } -export function __applySessionSyncEventForTest(input: SyncOptions, event: OpencodeEvent) { +export function __applySessionSyncEventForTest(input: SyncScope, event: ConversationEvent) { const entry = syncs.get(syncKey(input)); if (!entry) return; applyEvent(entry, input.workspaceId, event); diff --git a/apps/app/src/react-app/domains/session/sync/transcript-reconcile.ts b/apps/app/src/react-app/domains/session/sync/transcript-reconcile.ts index 1c8554e99..0fdcff121 100644 --- a/apps/app/src/react-app/domains/session/sync/transcript-reconcile.ts +++ b/apps/app/src/react-app/domains/session/sync/transcript-reconcile.ts @@ -1,6 +1,5 @@ import type { UIMessage } from "ai"; -import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "../../../../app/types"; import { mergeSnapshotIntoCachedMessages } from "./message-merge"; export type TranscriptReconcileReason = "snapshot" | "revert"; @@ -17,7 +16,7 @@ export type ReconcileTranscriptInput = { /** * Reconcile a server snapshot into the canonical transcript cache. * - * Snapshot reads can lag behind the OpenCode event stream during prompt + * Snapshot reads can lag behind the engine event stream during prompt * submission. This helper centralizes the invariant that ordinary snapshots * may fill/update the cache, but must not make the visible transcript move * backwards. Explicit history operations such as revert can opt into their own @@ -34,11 +33,11 @@ export function reconcileTranscriptMessages(input: ReconcileTranscriptInput): UI } /** - * Hide messages at and after OpenCode's revert cursor. Revert is an explicit + * Hide messages at and after the engine's normalized revert cursor. Revert is an explicit * history mutation, so it is the one place the rendered transcript is allowed * to move backwards. * - * OpenCode treats `session.revert.messageID` as the FIRST reverted message + * The conversation contract treats the cursor as the FIRST reverted message * (every message with `id >= revert.messageID` is reverted), so the cursor * message itself must be hidden too. */ @@ -48,27 +47,3 @@ export function applyRevertCursor(messages: UIMessage[], revertMessageId: string if (idx < 0) return messages; return messages.slice(0, idx); } - -function isSyntheticMessageId(id: string) { - return id.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX); -} - -/** - * Resolve the message id to pass to OpenCode's `session.fork` so the branch - * INCLUDES the message the user branched at. - * - * OpenCode copies messages strictly BEFORE the given id, so branching "at" a - * message means forking at the next real message after it. Synthetic - * client-side messages (e.g. `session-error:*`) are skipped because their ids - * do not exist server-side and would corrupt the fork boundary. Returns null - * when the branch point is the last message, meaning "fork the full session". - */ -export function resolveForkBoundaryId(messages: UIMessage[], messageId: string): string | null { - const idx = messages.findIndex((message) => message.id === messageId); - if (idx < 0) return null; - for (let index = idx + 1; index < messages.length; index += 1) { - const candidate = messages[index]; - if (candidate && !isSyntheticMessageId(candidate.id)) return candidate.id; - } - return null; -} diff --git a/apps/app/src/react-app/domains/session/sync/use-session-interactions.ts b/apps/app/src/react-app/domains/session/sync/use-session-interactions.ts index f8b0bd443..234b19baa 100644 --- a/apps/app/src/react-app/domains/session/sync/use-session-interactions.ts +++ b/apps/app/src/react-app/domains/session/sync/use-session-interactions.ts @@ -1,13 +1,8 @@ -// Pending permissions, questions, and todos for the selected session: -// query-cache subscriptions, snapshot seeding, and reply handlers. -// Extracted verbatim from session-route.tsx (cluster had no readers of its -// internals besides the JSX). import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import type { iPolloWorkServerClient } from "@/app/lib/ipollowork-server"; -import { unwrap } from "@/app/lib/opencode"; -import type { Client, PendingPermission, PendingQuestion, TodoItem } from "@/app/types"; +import type { TodoItem } from "@/app/types"; import { t } from "@/i18n"; import { getReactQueryClient } from "@/react-app/infra/query-client"; import { useQueryCacheState } from "@/react-app/infra/query-cache-state"; @@ -19,9 +14,14 @@ import { seedQuestionState, todoKey, } from "./session-sync"; +import type { + ConversationEngineConnection, + ConversationPermission, + ConversationQuestion, +} from "../engine/conversation-engine"; -const emptyPendingPermissions: PendingPermission[] = []; -const emptyPendingQuestions: PendingQuestion[] = []; +const emptyPermissions: ConversationPermission[] = []; +const emptyQuestions: ConversationQuestion[] = []; const emptyTodos: TodoItem[] = []; function nonEmptyStringList(value: unknown): string[] { @@ -33,25 +33,16 @@ function nonEmptyStringList(value: unknown): string[] { }); } -/** - * OpenCode separates the resource used by the current tool call from the - * broader pattern that an "always" reply is meant to remember. Persist the - * latter so sibling files under the same approved directory do not prompt - * again. Older protocol versions may omit that scope, so retain the current - * resource as a compatibility fallback. - */ -export function persistentPermissionPatterns(permission: PendingPermission): string[] { - const savedPatterns = nonEmptyStringList( - permission.protocol === "v2" ? permission.v2?.save : permission.always, - ); +export function persistentPermissionPatterns(permission: ConversationPermission): string[] { + const savedPatterns = nonEmptyStringList(permission.remember); const patterns = savedPatterns.length > 0 ? savedPatterns - : nonEmptyStringList(permission.patterns); + : nonEmptyStringList(permission.resources); return [...new Set(patterns)]; } export type UseSessionInteractionsInput = { - client: Client | null; + connection: ConversationEngineConnection | null; workspaceId: string; sessionId: string | null; workspaceRoot: string; @@ -61,7 +52,7 @@ export type UseSessionInteractionsInput = { export function useSessionInteractions(input: UseSessionInteractionsInput) { const { - client, + connection, workspaceId, sessionId, workspaceRoot, @@ -78,17 +69,17 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { () => (workspaceId && sessionId ? permissionKey(workspaceId, sessionId) : null), [sessionId, workspaceId], ); - const pendingPermissions = useQueryCacheState( + const pendingPermissions = useQueryCacheState( permissionQueryKey, - emptyPendingPermissions, + emptyPermissions, ); const questionQueryKey = useMemo( () => (workspaceId && sessionId ? questionKey(workspaceId, sessionId) : null), [sessionId, workspaceId], ); - const pendingQuestions = useQueryCacheState( + const pendingQuestions = useQueryCacheState( questionQueryKey, - emptyPendingQuestions, + emptyQuestions, ); const todoQueryKey = useMemo( () => (workspaceId && sessionId ? todoKey(workspaceId, sessionId) : null), @@ -97,27 +88,13 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { const todos = useQueryCacheState(todoQueryKey, emptyTodos); useEffect(() => { - if (!client || !workspaceId || !sessionId) return; + if (!connection || !workspaceId || !sessionId) return; let cancelled = false; const directory = workspaceRoot || undefined; void (async () => { const snapshotStartedAt = Date.now(); try { - const list: Parameters[2] = []; - let readSucceeded = false; - try { - list.push(...unwrap(await client.permission.list({ directory }))); - readSucceeded = true; - } catch { - // Older/newer OpenCode permission APIs can fail independently. - } - try { - list.push(...unwrap(await client.v2.session.permission.list({ sessionID: sessionId })).data); - readSucceeded = true; - } catch { - // Keep the legacy snapshot if the v2 endpoint is unavailable. - } - if (!readSucceeded) return; + const list = await connection.listPermissions({ sessionId, directory }); if (!cancelled) { seedPermissionState(workspaceId, sessionId, list, { snapshotStartedAt }); } @@ -129,16 +106,16 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { return () => { cancelled = true; }; - }, [client, sessionId, workspaceId, workspaceRoot]); + }, [connection, sessionId, workspaceId, workspaceRoot]); useEffect(() => { - if (!client || !workspaceId || !sessionId) return; + if (!connection || !workspaceId || !sessionId) return; let cancelled = false; const directory = workspaceRoot || undefined; void (async () => { const snapshotStartedAt = Date.now(); try { - const list = unwrap(await client.question.list({ directory })); + const list = await connection.listQuestions({ sessionId, directory }); if (!cancelled) { seedQuestionState(workspaceId, sessionId, list, { snapshotStartedAt }); } @@ -150,34 +127,24 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { return () => { cancelled = true; }; - }, [client, sessionId, workspaceId, workspaceRoot]); + }, [connection, sessionId, workspaceId, workspaceRoot]); const activePermission = pendingPermissions[0] ?? null; const respondPermission = useCallback( async (requestID: string, reply: "once" | "always" | "reject") => { - if (!client || !workspaceId || !sessionId) return; + if (!connection || !workspaceId || !sessionId) return; if (permissionReplyBusyRef.current) return; permissionReplyBusyRef.current = true; setPermissionReplyBusy(true); try { const pendingPermission = pendingPermissions.find((permission) => permission.id === requestID); - if (pendingPermission?.protocol === "v2") { - const result = await client.v2.session.permission.reply({ - sessionID: pendingPermission.sessionID, - requestID, - reply, - }); - if (result.error !== undefined) unwrap(result); - } else { - unwrap( - await client.permission.reply({ - requestID, - reply, - directory: workspaceRoot || undefined, - }), - ); - } - getReactQueryClient().setQueryData( + if (!pendingPermission) return; + await connection.replyPermission({ + permission: pendingPermission, + reply, + directory: workspaceRoot || undefined, + }); + getReactQueryClient().setQueryData( permissionKey(workspaceId, sessionId), (current = []) => current.filter((permission) => permission.id !== requestID), ); @@ -187,7 +154,7 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { // scope as a best-effort cross-session authorization. if ( reply === "always" && - pendingPermission?.permission === "external_directory" && + pendingPermission.kind === "external_directory" && ipolloworkServerClient && runtimeWorkspaceId ) { @@ -216,7 +183,7 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { } }, [ - client, + connection, ipolloworkServerClient, pendingPermissions, runtimeWorkspaceId, @@ -229,19 +196,19 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { const activeQuestion = pendingQuestions[0] ?? null; const respondQuestion = useCallback( async (requestID: string, answers: string[][]) => { - if (!client || !workspaceId || !sessionId) return; + if (!connection || !workspaceId || !sessionId) return; if (questionReplyBusyRef.current) return; questionReplyBusyRef.current = true; setQuestionReplyBusy(true); try { - unwrap( - await client.question.reply({ - requestID, - answers, - directory: workspaceRoot || undefined, - }), - ); - getReactQueryClient().setQueryData( + const pendingQuestion = pendingQuestions.find((question) => question.id === requestID); + if (!pendingQuestion) return; + await connection.replyQuestion({ + question: pendingQuestion, + answers, + directory: workspaceRoot || undefined, + }); + getReactQueryClient().setQueryData( questionKey(workspaceId, sessionId), (current = []) => current.filter((question) => question.id !== requestID), ); @@ -254,7 +221,7 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { setQuestionReplyBusy(false); } }, - [client, sessionId, workspaceId, workspaceRoot], + [connection, pendingQuestions, sessionId, workspaceId, workspaceRoot], ); return { diff --git a/apps/app/src/react-app/shell/command-palette.tsx b/apps/app/src/react-app/shell/command-palette.tsx index cbcfd5880..1e8eb9e3f 100644 --- a/apps/app/src/react-app/shell/command-palette.tsx +++ b/apps/app/src/react-app/shell/command-palette.tsx @@ -6,9 +6,9 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; -import type { Agent } from "@opencode-ai/sdk/v2/client"; import { t } from "@/i18n"; +import type { ConversationAgent } from "@/react-app/domains/session/engine/conversation-engine"; import { Command, CommandDialog, @@ -99,7 +99,7 @@ export type CommandPaletteProps = { sessions: SessionOption[]; extraItems?: PaletteItem[]; /** Optional: agent picker submode (Switch agent). */ - listAgents?: () => Promise; + listAgents?: () => Promise; selectedAgent?: string | null; onSelectAgent?: (agent: string | null) => void; }; @@ -112,7 +112,7 @@ export type CommandPaletteProps = { */ export function CommandPalette(props: CommandPaletteProps) { const [mode, setMode] = useState("root"); - const [agents, setAgents] = useState([]); + const [agents, setAgents] = useState([]); useEffect(() => { if (!props.open) { diff --git a/apps/app/src/react-app/shell/route-workspaces.ts b/apps/app/src/react-app/shell/route-workspaces.ts index 680e5eed4..44e54ad28 100644 --- a/apps/app/src/react-app/shell/route-workspaces.ts +++ b/apps/app/src/react-app/shell/route-workspaces.ts @@ -3,8 +3,6 @@ // settings-route was missing the remote-workspace clobber fix in // mergeRouteWorkspaces and used older session-status logic. One copy now. -import type { Session } from "@opencode-ai/sdk/v2/client"; - import type { iPolloWorkWorkspaceInfo } from "@/app/lib/ipollowork-server"; import type { WorkspaceInfo } from "@/app/lib/desktop-types"; import type { ProjectSessionList } from "@/app/types"; @@ -19,6 +17,7 @@ import { isGeneratedSessionTitle, } from "@/app/lib/session-title"; import { t } from "@/i18n"; +import type { ConversationSession } from "@/react-app/domains/session/engine/conversation-engine"; export type RouteWorkspace = iPolloWorkWorkspaceInfo & { displayNameResolved: string; @@ -29,7 +28,7 @@ export type RouteWorkspace = iPolloWorkWorkspaceInfo & { * ipollowork-server's listSessions, optionally enriched with run-status * fields that the sidebar probes defensively via getSessionStatus. */ -export type RouteSession = Session & { +export type RouteSession = ConversationSession & { agent?: string; status?: unknown; state?: unknown; diff --git a/apps/app/src/react-app/shell/session-prompt.ts b/apps/app/src/react-app/shell/session-prompt.ts index b590fb6c4..078b1e680 100644 --- a/apps/app/src/react-app/shell/session-prompt.ts +++ b/apps/app/src/react-app/shell/session-prompt.ts @@ -1,10 +1,5 @@ -import type { - AgentPartInput, - FilePartInput, - TextPartInput, -} from "@opencode-ai/sdk/v2/client"; - import type { ComposerAttachment, ComposerDraft } from "@/app/types"; +import type { ConversationPromptPart } from "@/react-app/domains/session/engine/conversation-engine"; import { designAiSelectionInstruction, type DesignAiSelectionContext, @@ -93,7 +88,7 @@ export function designSelectionContextsForDraft( export async function promptDesignSelectionContexts(input: { contexts: DesignAiSelectionContext[]; workspaceClient: DesignSelectionWorkspaceClient; - prompt: () => Promise<{ error?: unknown }>; + prompt: () => Promise; designSelectionStore?: DesignSelectionStore; }) { const designSelectionStore = input.designSelectionStore ?? useDesignAiSelectionStore; @@ -113,7 +108,7 @@ export async function promptDesignSelectionContexts(input: { designSelectionStore.getState().markRunning(context.id); } const result = await input.prompt(); - if (result.error) throw new Error(serializeSDKError(result.error)); + if (result?.error) throw new Error(serializeSDKError(result.error)); return result; } catch (error) { for (const context of input.contexts) designSelectionStore.getState().fail(context.id); @@ -128,7 +123,7 @@ export async function draftToParts( scope?: DesignSelectionScope, options: DraftToPartsOptions = {}, ) { - const parts: Array = []; + const parts: ConversationPromptPart[] = []; const root = workspaceRoot.trim(); const toAbsolutePath = (path: string) => { diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index a874a9a16..277783c68 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -7,9 +7,10 @@ import { useState, } from "react"; import { useNavigate } from "react-router-dom"; +import type { UIMessage } from "ai"; import { toast } from "@/components/ui/sonner"; -import type { SessionStatus } from "@opencode-ai/sdk/v2/client"; import type { PptxCompatibility, TemplateCategory } from "@ipollowork/types/templates"; +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; import { captureAnalyticsEvent, markTaskRunStart } from "@/app/lib/analytics"; import { @@ -22,8 +23,6 @@ import { import { trackSessionActive, trackTaskStarted } from "@/app/lib/den-telemetry"; import { buildDiagnosticsBundleJson } from "@/app/lib/diagnostics-bundle"; import { downloadTextAsFile } from "@/app/lib/download"; -import { createClient, unwrap } from "@/app/lib/opencode"; -import { abortSessionSafe, forkSession, listCommands, revertSession, setSessionArchived, shellInSession } from "@/app/lib/opencode-session"; import { resolveWorkspaceEndpoint, workspaceServerId, @@ -135,6 +134,8 @@ import { useEngineReload } from "./use-engine-reload"; import { useWorkspaceRouteState } from "./use-workspace-route-state"; import { getReactQueryClient } from "@/react-app/infra/query-client"; import { useSessionControlActions } from "@/react-app/domains/session/control/session-control-actions"; +import type { ConversationStatus } from "@/react-app/domains/session/engine/conversation-engine"; +import { conversationEngineAdapters } from "@/react-app/domains/session/engine/opencode-conversation-engine"; import { workspaceSessionRoute, workspaceSettingsRoute } from "./workspace-routes"; import { WorkspaceProvider } from "./workspace-provider"; import type { OpenTarget } from "@/react-app/domains/session/artifacts/open-target"; @@ -246,6 +247,17 @@ export function SessionRoute() { onServerSettingsChanged: () => setiPolloWorkServerSettingsVersion((value) => value + 1), onHostInfo: setiPolloWorkServerHostInfoState, }); + const conversation = useMemo( + () => opencodeBaseUrl && selectedWorkspaceServerToken && !selectedWorkspaceError + ? conversationEngineAdapters.get(selectedWorkspace?.engineId).connect({ + baseUrl: opencodeBaseUrl, + token: selectedWorkspaceServerToken, + directory: selectedWorkspaceRoot || undefined, + }) + : null, + [opencodeBaseUrl, selectedWorkspace?.engineId, selectedWorkspaceError, selectedWorkspaceRoot, selectedWorkspaceServerToken], + ); + const conversationConnectionKey = `${selectedWorkspace?.engineId?.trim() || DEFAULT_ENGINE_ID}:${opencodeBaseUrl}:${selectedWorkspaceServerToken}`; useSessionMcpMaintenance({ cloudSignedIn: denAuth.isSignedIn && activeWorkContextId === PERSONAL_WORK_CONTEXT_ID, client: selectedWorkspaceEndpoint?.client ?? null, @@ -635,7 +647,7 @@ export function SessionRoute() { respondQuestion, todos, } = useSessionInteractions({ - client: opencodeClient, + connection: conversation, workspaceId: selectedWorkspaceId, sessionId: selectedSessionId, workspaceRoot: selectedWorkspaceRoot, @@ -718,9 +730,9 @@ export function SessionRoute() { // an engine reload, which invalidates the composer's command list cache // and causes it to re-fetch (picking up newly created skills). void engineReloadVersion; - if (!opencodeClient) return []; - return listCommands(opencodeClient, selectedWorkspaceRoot || undefined); - }, [engineReloadVersion, opencodeClient, selectedWorkspaceRoot]); + if (!conversation) return []; + return conversation.listCommands(selectedWorkspaceRoot || undefined); + }, [conversation, engineReloadVersion, selectedWorkspaceRoot]); // Shared by @ mentions and the command palette. Plan and build are product // modes controlled beside the model; hidden and subagent-only entries are @@ -729,15 +741,15 @@ export function SessionRoute() { // Include engineReloadVersion so the composer refetches after newly added // agent files become available, even when the inline picker is hidden. void engineReloadVersion; - if (!opencodeClient) return []; - const list = unwrap(await opencodeClient.app.agents()); + if (!conversation) return []; + const list = await conversation.listAgents(); return list.filter((agent) => !agent.hidden && agent.mode !== "subagent" && agent.name !== "build" && agent.name !== "plan" ); - }, [engineReloadVersion, opencodeClient]); + }, [conversation, engineReloadVersion]); const handleOpenSettings = useCallback((route = "/settings/preferences", workspaceId = sidebarActiveWorkspaceId) => { const sessionId = workspaceId === sidebarActiveWorkspaceId ? selectedSessionId : null; @@ -754,7 +766,7 @@ export function SessionRoute() { navigate("/help", { state: { returnTo } }); }, [navigate, selectedSessionId, sidebarActiveWorkspaceId]); - const handleSessionStatus = useCallback((update: { sessionId: string; status: SessionStatus }) => { + const handleSessionStatus = useCallback((update: { sessionId: string; status: ConversationStatus }) => { if (update.status.type !== "idle" || !selectedWorkspaceEndpoint) return; const { contexts, complete, completeWithoutChange, fail } = useDesignAiSelectionStore.getState(); const runningContexts = Object.values(contexts).filter((context) => ( @@ -784,7 +796,7 @@ export function SessionRoute() { }, [selectedWorkspaceEndpoint]); const surfaceProps = useMemo(() => { - if (!client || !selectedWorkspaceId || !selectedSessionId || !opencodeBaseUrl || !token || !opencodeClient) { + if (!client || !selectedWorkspaceId || !selectedSessionId || !opencodeBaseUrl || !token || !conversation) { return null; } // Transient-safety: when the user switches workspaces the URL-driven @@ -813,6 +825,7 @@ export function SessionRoute() { // local server's, and remote workspaces silently end up calling the // local server with the local `rem_*` id. return { + conversation, workspaceRoot: selectedWorkspaceRoot, developerMode: false, modelLabel, @@ -903,19 +916,16 @@ export function SessionRoute() { trackTaskStarted(targetSessionId, telemetryDimensions); if (draft.mode === "shell") { - await shellInSession(opencodeClient, targetSessionId, text); + await conversation.shell(targetSessionId, text); return true; } if (draft.command) { - const result = await opencodeClient.session.command({ - sessionID: targetSessionId, + await conversation.runCommand({ + sessionId: targetSessionId, command: draft.command.name, arguments: draft.command.arguments, }); - if (result.error) { - throw new Error(serializeSDKError(result.error)); - } return true; } @@ -1063,17 +1073,18 @@ export function SessionRoute() { readWorkspaceFile: async () => { throw new Error("The selected Design element is no longer available in this workspace."); }, writeWorkspaceFile: async () => { throw new Error("The selected Design element is no longer available in this workspace."); }, }, - prompt: () => opencodeClient.session.promptAsync({ - sessionID: targetSessionId, + prompt: () => conversation.sendPrompt({ + sessionId: targetSessionId, parts: promptParts, model: local.prefs.defaultModel ?? undefined, agent: selectedAgent ?? undefined, - ...(local.prefs.defaultModel?.providerID === "tokenstar" && modelVariantValue && tokenStarModelSupportsEffort(local.prefs.defaultModel.modelID) - ? { reasoning_effort: modelVariantValue } - : modelVariantValue - ? { variant: modelVariantValue } - : {}), - ...(systemContext ? { system: systemContext } : {}), + reasoningEffort: local.prefs.defaultModel?.providerID === "tokenstar" && modelVariantValue && tokenStarModelSupportsEffort(local.prefs.defaultModel.modelID) + ? modelVariantValue + : undefined, + variant: local.prefs.defaultModel?.providerID === "tokenstar" && tokenStarModelSupportsEffort(local.prefs.defaultModel.modelID) + ? undefined + : modelVariantValue ?? undefined, + system: systemContext || undefined, }), }); return true; @@ -1096,15 +1107,7 @@ export function SessionRoute() { searchFiles: async (query: string) => { const trimmed = query.trim(); if (!trimmed) return []; - const result = unwrap( - await opencodeClient.find.files({ - query: trimmed, - dirs: "true", - limit: 50, - directory: selectedWorkspaceRoot || undefined, - }), - ); - return result; + return conversation.searchFiles(trimmed, selectedWorkspaceRoot || undefined); }, isRemoteWorkspace: selectedWorkspace?.workspaceType === "remote", isSandboxWorkspace: selectedWorkspace ? isSandboxWorkspace(selectedWorkspace) : false, @@ -1112,9 +1115,8 @@ export function SessionRoute() { const targetSessionId = sessionId.trim() || selectedSessionId; if (!targetSessionId) return false; try { - // Abort any running generation first; OpenCode rejects revert on busy sessions. - await abortSessionSafe(opencodeClient, targetSessionId, selectedWorkspaceRoot || undefined); - const reverted = await revertSession(opencodeClient, targetSessionId, messageId); + await conversation.abort(targetSessionId, selectedWorkspaceRoot || undefined).catch(() => false); + const reverted = await conversation.revert(targetSessionId, messageId); // Stamp the revert cursor into the local caches so the transcript // rewinds immediately instead of waiting for a full reload. applySessionRevert(selectedWorkspaceId, reverted); @@ -1125,12 +1127,12 @@ export function SessionRoute() { return false; } }, - onForkAtMessage: (messageId: string | null, sessionId: string) => { + onForkAtMessage: (messageId: string, sessionId: string, messages: UIMessage[]) => { void (async () => { const targetSessionId = sessionId.trim() || selectedSessionId; if (!targetSessionId) return; try { - const forked = await forkSession(opencodeClient, targetSessionId, messageId ?? undefined); + const forked = await conversation.fork({ sessionId: targetSessionId, messageId, messages }); writeLastSessionFor(selectedWorkspaceId, forked.id); rememberPendingCreatedSession(selectedWorkspaceId, forked.id); setSessionsByWorkspaceId((current) => ({ @@ -1161,6 +1163,7 @@ export function SessionRoute() { }; }, [ client, + conversation, modelPicker.compactOpen, handleOpenSettings, hasUsableModel, @@ -1175,7 +1178,6 @@ export function SessionRoute() { modelVariantValue, navigate, opencodeBaseUrl, - opencodeClient, providerConnectedIds, selectedAgent, selectedSessionId, @@ -1198,8 +1200,7 @@ export function SessionRoute() { const previousSessionScopeRef = useRef<{ workspaceId: string; sessionId: string; - baseUrl: string; - ipolloworkToken: string; + connectionKey: string; } | null>(null); useEffect(() => { const previous = previousSessionScopeRef.current; @@ -1207,8 +1208,7 @@ export function SessionRoute() { ? { workspaceId: selectedWorkspaceEndpoint.workspaceId, sessionId: selectedSessionId, - baseUrl: opencodeBaseUrl, - ipolloworkToken: selectedWorkspaceServerToken, + connectionKey: conversationConnectionKey, } : null; @@ -1217,14 +1217,14 @@ export function SessionRoute() { (!current || previous.workspaceId !== current.workspaceId || previous.sessionId !== current.sessionId || - previous.baseUrl !== current.baseUrl || - previous.ipolloworkToken !== current.ipolloworkToken) + previous.connectionKey !== current.connectionKey) ) { destroyWorkspaceSessionResources(previous, previous.sessionId); } previousSessionScopeRef.current = current; }, [ opencodeBaseUrl, + conversationConnectionKey, selectedSessionId, selectedWorkspaceEndpoint, selectedWorkspaceServerToken, @@ -1248,19 +1248,19 @@ export function SessionRoute() { if (!endpoint || !endpoint.token) { return null; } - const workspaceClient = createClient( - endpoint.opencodeBaseUrl, - workspace.path?.trim() || undefined, - { token: endpoint.token, mode: "ipollowork" }, - ); + const workspaceConversation = conversationEngineAdapters + .get(workspace.engineId) + .connect({ + baseUrl: endpoint.opencodeBaseUrl, + token: endpoint.token, + directory: workspace.path?.trim() || undefined, + }); let createdSessionId: string | null = null; let projectInitializationFailed = false; try { setErrorsByWorkspaceId((current) => ({ ...current, [workspaceId]: null })); setRouteError(null); - const session = unwrap( - await workspaceClient.session.create({ directory: workspace.path?.trim() || undefined }), - ); + const session = await workspaceConversation.create(workspace.path?.trim() || undefined); createdSessionId = session.id; let sessionType = type; if (templateId) { @@ -1482,7 +1482,7 @@ export function SessionRoute() { selectedSessionId, canCreateTask, ipolloworkClient: client, - opencodeClient, + conversation, navigateToSession: navigateToSessionForControl, navigateToSessionRoot: navigateToSessionRootForControl, createTaskInWorkspace: handleCreateTaskInWorkspace, @@ -1670,10 +1670,9 @@ export function SessionRoute() { const handleArchiveSession = useCallback( async (sessionId: string, archived: boolean) => { - if (!opencodeClient) return; + if (!conversation) return; try { - await setSessionArchived( - opencodeClient, + await conversation.setArchived( sessionId, archived, selectedWorkspaceRoot || undefined, @@ -1689,7 +1688,7 @@ export function SessionRoute() { ); } }, - [opencodeClient, refreshRouteState, selectedWorkspaceRoot], + [conversation, refreshRouteState, selectedWorkspaceRoot], ); return ( @@ -1699,7 +1698,7 @@ export function SessionRoute() { opencodeBaseUrl={opencodeBaseUrl} selectedWorkspaceRoot={selectedWorkspaceRoot} > - {opencodeClient && selectedWorkspaceEndpoint && opencodeBaseUrl && selectedWorkspaceServerToken ? ( + {conversation && selectedWorkspaceEndpoint && opencodeBaseUrl && selectedWorkspaceServerToken ? ( @@ -1828,15 +1827,15 @@ export function SessionRoute() { if (!workspace) return; const endpoint = resolveWorkspaceEndpoint(workspace, { baseUrl, token }); if (!endpoint?.token) return; - const workspaceClient = createClient( - endpoint.opencodeBaseUrl, - workspace.path?.trim() || undefined, - { token: endpoint.token, mode: "ipollowork" }, - ); + const workspaceConversation = conversationEngineAdapters + .get(workspace.engineId) + .connect({ + baseUrl: endpoint.opencodeBaseUrl, + token: endpoint.token, + directory: workspace.path?.trim() || undefined, + }); try { - const session = unwrap( - await workspaceClient.session.create({ directory: workspace.path?.trim() || undefined }), - ); + const session = await workspaceConversation.create(workspace.path?.trim() || undefined); saveSessionDraft(workspaceId, session.id, { text: prompt, mode: "prompt" }); writeActiveWorkspaceId(workspaceId || null); writeLastSessionFor(workspaceId, session.id); @@ -1876,15 +1875,11 @@ export function SessionRoute() { respondQuestion={respondQuestion} safeStringify={safeStringify} onRenameSession={ - opencodeClient + conversation ? async (sessionId, nextTitle) => { const trimmed = nextTitle.trim(); if (!trimmed) return; - await opencodeClient.session.update({ - sessionID: sessionId, - title: trimmed, - directory: selectedWorkspaceRoot || undefined, - }); + await conversation.rename(sessionId, trimmed, selectedWorkspaceRoot || undefined); await refreshRouteState(); } : undefined @@ -1904,7 +1899,7 @@ export function SessionRoute() { } : undefined } - onArchiveSession={opencodeClient ? handleArchiveSession : undefined} + onArchiveSession={conversation ? handleArchiveSession : undefined} notFoundMessage={routeNotFoundMessage} onAccessibleTargetsChange={setPaletteAccessibleTargets} /> diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 95a0fc8ab..177f29808 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -130,7 +130,7 @@ import type { ModelRef } from "@/app/types"; import { recordInspectorEvent } from "../../app/lib/app-inspector"; import { ensureDesktopLocaliPolloWorkConnection } from "./desktop-local-ipollowork"; import { resolveiPolloWorkConnection } from "./ipollowork-connection"; -import { abortSessionSafe } from "@/app/lib/opencode-session"; +import { conversationEngineAdapters } from "@/react-app/domains/session/engine/opencode-conversation-engine"; import { notifyAlert } from "./notifications"; import { useReloadCoordinator } from "./reload-coordinator"; import { buildFeedbackUrl } from "@/app/lib/feedback"; @@ -762,6 +762,16 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { }, ); }, [selectedWorkspaceEndpoint, selectedWorkspaceRoot]); + const conversation = useMemo( + () => opencodeBaseUrl && selectedWorkspaceEndpoint?.token + ? conversationEngineAdapters.get(selectedWorkspace?.engineId).connect({ + baseUrl: opencodeBaseUrl, + token: selectedWorkspaceEndpoint.token, + directory: selectedWorkspaceRoot || undefined, + }) + : null, + [opencodeBaseUrl, selectedWorkspace?.engineId, selectedWorkspaceEndpoint?.token, selectedWorkspaceRoot], + ); useEffect(() => { setActiveClient(opencodeClient); @@ -1206,13 +1216,13 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { reloadWorkspaceEngine: reloadWorkspaceEngineFromUi, activeSessions: () => activeReloadBlockingSessions, stopSession: async (sessionId) => { - if (!activeClient) return; - await abortSessionSafe(activeClient, sessionId); + if (!conversation) return; + await conversation.abort(sessionId).catch(() => false); }, }); }, [ - activeClient, activeReloadBlockingSessions, + conversation, ipolloworkClient, reloadCoordinator, reloadWorkspaceEngineFromUi, @@ -2011,8 +2021,8 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { activeSessions={activeReloadBlockingSessions} isRemoteWorkspace={selectedWorkspace?.workspaceType === "remote"} onForceStopSession={async (sessionId) => { - if (!activeClient) return; - await abortSessionSafe(activeClient, sessionId); + if (!conversation) return; + await conversation.abort(sessionId).catch(() => false); }} onReloadEngine={reloadCoordinator.reloadWorkspaceEngine} modalState={{ diff --git a/apps/app/tests/composer-queue-behavior.test.ts b/apps/app/tests/composer-queue-behavior.test.ts index b7434cabe..07acf526e 100644 --- a/apps/app/tests/composer-queue-behavior.test.ts +++ b/apps/app/tests/composer-queue-behavior.test.ts @@ -59,7 +59,7 @@ describe("composer queue behavior", () => { ); expect(abortHandler).not.toContain("clearQueuedDrafts"); - expect(abortHandler).toContain("await abortSessionSafe("); + expect(abortHandler).toContain("await props.conversation.abort("); expect(sessionSurfaceSource).toContain('if (chatStreaming || liveStatus.type !== "idle") return;'); }); diff --git a/apps/app/tests/conversation-engine-adapter.test.ts b/apps/app/tests/conversation-engine-adapter.test.ts new file mode 100644 index 000000000..4bafc48a9 --- /dev/null +++ b/apps/app/tests/conversation-engine-adapter.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import { + ConversationEngineAdapterRegistry, + type ConversationEngineAdapter, +} from "../src/react-app/domains/session/engine/conversation-engine"; +import { + openCodeConversationEngineAdapter, +} from "../src/react-app/domains/session/engine/opencode-conversation-engine"; +import { + mapOpenCodeConversationEvent, + mapOpenCodeConversationSnapshot, +} from "../src/react-app/domains/session/engine/opencode-conversation-mapper"; + +describe("conversation engine adapters", () => { + test("keeps OpenCode as the default and rejects unknown engines", () => { + const registry = new ConversationEngineAdapterRegistry( + DEFAULT_ENGINE_ID, + [openCodeConversationEngineAdapter], + ); + + expect(registry.ids()).toEqual([DEFAULT_ENGINE_ID]); + expect(registry.get()).toBe(openCodeConversationEngineAdapter); + expect(() => registry.get("deepseek-harness")).toThrow( + "Conversation engine is not registered: deepseek-harness", + ); + }); + + test("rejects duplicate adapter registrations", () => { + const duplicate = { ...openCodeConversationEngineAdapter } satisfies ConversationEngineAdapter; + expect(() => new ConversationEngineAdapterRegistry(DEFAULT_ENGINE_ID, [ + openCodeConversationEngineAdapter, + duplicate, + ])).toThrow(`Duplicate conversation engine adapter: ${DEFAULT_ENGINE_ID}`); + }); + + test("maps every existing OpenCode session event into the shared protocol", () => { + const rawEvents = [ + { type: "session.updated", properties: { info: { id: "ses", title: "Title", time: {} } } }, + { type: "session.deleted", properties: { info: { id: "ses" } } }, + { type: "session.error", properties: { sessionID: "ses", error: "failed" } }, + { type: "session.next.compaction.started", properties: { sessionID: "ses" } }, + { type: "session.next.compaction.ended", properties: { sessionID: "ses" } }, + { type: "session.compacted", properties: { sessionID: "ses" } }, + { type: "session.status", properties: { sessionID: "ses", status: { type: "busy" } } }, + { type: "session.idle", properties: { sessionID: "ses" } }, + { + type: "todo.updated", + properties: { + sessionID: "ses", + todos: [{ content: "Ship", status: "pending", priority: "high" }], + }, + }, + { + type: "permission.asked", + properties: { + id: "perm-legacy", + sessionID: "ses", + permission: "bash", + patterns: ["echo ok"], + metadata: {}, + always: ["echo *"], + }, + }, + { + type: "permission.v2.asked", + properties: { + id: "perm-v2", + sessionID: "ses", + action: "file.read", + resources: ["/tmp/a"], + metadata: {}, + save: ["/tmp/*"], + }, + }, + { type: "permission.replied", properties: { sessionID: "ses", requestID: "perm-legacy" } }, + { type: "permission.v2.replied", properties: { sessionID: "ses", requestID: "perm-v2" } }, + { + type: "question.asked", + properties: { + id: "question", + sessionID: "ses", + questions: [{ question: "Continue?", options: [{ label: "Yes" }] }], + }, + }, + { type: "question.replied", properties: { sessionID: "ses", requestID: "question" } }, + { type: "question.rejected", properties: { sessionID: "ses", requestID: "question" } }, + { + type: "message.updated", + properties: { info: { id: "msg", sessionID: "ses", role: "assistant" } }, + }, + { type: "message.removed", properties: { sessionID: "ses", messageID: "msg" } }, + { + type: "message.part.updated", + properties: { + part: { id: "part", sessionID: "ses", messageID: "msg", type: "text", text: "Hello" }, + }, + }, + { + type: "message.part.delta", + properties: { sessionID: "ses", messageID: "msg", partID: "part", delta: "Hello" }, + }, + ]; + + expect(rawEvents.map((event) => mapOpenCodeConversationEvent(event)?.type)).toEqual([ + "session.updated", + "session.deleted", + "session.error", + "session.compaction", + "session.compaction", + "session.compaction", + "session.status", + "session.idle", + "todo.updated", + "permission.asked", + "permission.asked", + "permission.replied", + "permission.replied", + "question.asked", + "question.replied", + "question.replied", + "message.upsert", + "message.removed", + "message.parts", + "message.chunk", + ]); + + expect(mapOpenCodeConversationEvent(rawEvents[10])).toMatchObject({ + type: "permission.asked", + permission: { + kind: "read", + resources: ["/tmp/a"], + remember: ["/tmp/*"], + }, + }); + expect(mapOpenCodeConversationEvent(rawEvents[19])).toMatchObject({ + type: "message.chunk", + chunk: { type: "text-delta", id: "part", delta: "Hello" }, + }); + }); + + test("maps snapshots directly into AI SDK UI messages", () => { + const snapshot = mapOpenCodeConversationSnapshot({ + session: { id: "ses", title: "Title", time: { created: 1, updated: 2 } }, + messages: [{ + info: { id: "msg", role: "assistant", sessionID: "ses", time: { created: 1 } }, + parts: [{ id: "part", type: "text", text: "Hello", sessionID: "ses", messageID: "msg" }], + }], + todos: [{ content: "Ship", status: "pending", priority: "high" }], + status: { type: "idle" }, + }); + + expect(snapshot.messages).toEqual([expect.objectContaining({ + id: "msg", + role: "assistant", + parts: [expect.objectContaining({ type: "text", text: "Hello" })], + })]); + expect(snapshot.todos).toEqual([expect.objectContaining({ content: "Ship" })]); + }); +}); diff --git a/apps/app/tests/design-ai-message-stream.test.ts b/apps/app/tests/design-ai-message-stream.test.ts index b9640c9e2..de8b5b2ee 100644 --- a/apps/app/tests/design-ai-message-stream.test.ts +++ b/apps/app/tests/design-ai-message-stream.test.ts @@ -4,7 +4,7 @@ import type { iPolloWorkSessionSnapshot } from "../src/app/lib/ipollowork-server import { designAiSelectionInstruction, type DesignAiSelectionContext } from "@ipollowork/design-studio"; import { hyperframesAnimationDisplayMetadata } from "../src/app/lib/hyperframes-effect-params"; import { videoVoiceDisplayMetadata } from "../src/react-app/domains/session/video/video-voice"; -import { snapshotToUIMessages } from "../src/react-app/domains/session/sync/usechat-adapter"; +import { snapshotToUIMessages } from "../src/react-app/domains/session/engine/opencode-message-adapter"; const context: DesignAiSelectionContext = { id: "design-ai-message", diff --git a/apps/app/tests/permission-approval-modal.test.ts b/apps/app/tests/permission-approval-modal.test.ts index c2c1f578b..996dbb5b6 100644 --- a/apps/app/tests/permission-approval-modal.test.ts +++ b/apps/app/tests/permission-approval-modal.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import type { PendingPermission } from "../src/app/types"; +import type { ConversationPermission } from "../src/react-app/domains/session/engine/conversation-engine"; import { PermissionApprovalPanel, @@ -13,19 +13,16 @@ const permissionPanelUrl = new URL( import.meta.url, ); -function pendingPermission(overrides: Partial = {}): PendingPermission { +function pendingPermission(overrides: Partial = {}): ConversationPermission { return { id: "permission-1", - sessionID: "session-1", - permission: "bash", - patterns: ["rm -rf dist"], + sessionId: "session-1", + kind: "bash", + resources: ["rm -rf dist"], + remember: [], metadata: {}, - always: { - session: false, - project: false, - }, receivedAt: 1, - protocol: "legacy", + native: null, ...overrides, }; } @@ -95,7 +92,7 @@ describe("permission approval modal helpers", () => { test("uses readable labels for generic permission titles", () => { const html = renderToStaticMarkup( React.createElement(PermissionApprovalPanel, { - permission: pendingPermission({ permission: "todowrite" }), + permission: pendingPermission({ kind: "todowrite" }), respondPermission: () => {}, }), ); diff --git a/apps/app/tests/session-sync-permissions.test.ts b/apps/app/tests/session-sync-permissions.test.ts index f8de63f6b..c9a9014f8 100644 --- a/apps/app/tests/session-sync-permissions.test.ts +++ b/apps/app/tests/session-sync-permissions.test.ts @@ -1,10 +1,15 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { UIMessage } from "ai"; -import type { PermissionRequest, PermissionV2Request, QuestionRequest } from "@opencode-ai/sdk/v2/client"; +import type { PermissionV2Request, QuestionRequest } from "@opencode-ai/sdk/v2/client"; -import type { iPolloWorkSessionSnapshot } from "../src/app/lib/ipollowork-server"; -import type { PendingPermission } from "../src/app/types"; import { getReactQueryClient } from "../src/react-app/infra/query-client"; +import type { + ConversationEngineConnection, + ConversationPermission, + ConversationQuestion, + ConversationSnapshot, +} from "../src/react-app/domains/session/engine/conversation-engine"; +import { mapOpenCodeConversationEvent } from "../src/react-app/domains/session/engine/opencode-conversation-mapper"; import { persistentPermissionPatterns } from "../src/react-app/domains/session/sync/use-session-interactions"; import { __applySessionSyncEventForTest, @@ -26,18 +31,7 @@ import { transcriptKey, } from "../src/react-app/domains/session/sync/session-sync"; -function permission(id: string, sessionID: string): PermissionRequest { - return { - id, - sessionID, - permission: "bash", - patterns: ["echo ok"], - metadata: {}, - always: [], - }; -} - -function v2Permission(id: string, sessionID: string): PermissionV2Request { +function nativeV2Permission(id: string, sessionID: string): PermissionV2Request { return { id, sessionID, @@ -48,7 +42,7 @@ function v2Permission(id: string, sessionID: string): PermissionV2Request { }; } -function question(id: string, sessionID: string): QuestionRequest { +function nativeQuestion(id: string, sessionID: string): QuestionRequest { return { id, sessionID, @@ -62,6 +56,38 @@ function question(id: string, sessionID: string): QuestionRequest { }; } +function permission( + id: string, + sessionId: string, + overrides: Partial = {}, +): ConversationPermission { + return { + id, + sessionId, + kind: "bash", + resources: ["echo ok"], + remember: [], + metadata: {}, + receivedAt: 1, + native: null, + ...overrides, + }; +} + +function question(id: string, sessionId: string): ConversationQuestion { + return { + id, + sessionId, + questions: [{ + header: "Choice", + question: "Pick one", + options: [{ label: "Yes", description: "Proceed" }], + }], + receivedAt: 1, + native: null, + }; +} + function uiMessage(id: string, role: "user" | "assistant", text: string): UIMessage { return { id, @@ -73,36 +99,29 @@ function uiMessage(id: string, role: "user" | "assistant", text: string): UIMess function snapshotWithMessages( messages: Array<{ id: string; role: "user" | "assistant"; text: string }>, sessionId = "session-a", -): iPolloWorkSessionSnapshot { +): ConversationSnapshot { return { session: { id: sessionId, - parentID: undefined, title: "Test session", time: { created: 1, updated: 2 }, - share: undefined, - version: "0", }, - messages: messages.map((message, index) => ({ - info: { - id: message.id, - role: message.role, - sessionID: sessionId, - time: { created: index + 1 }, - }, - parts: [ - { - id: `part_${message.id}`, - type: "text", - text: message.text, - sessionID: sessionId, - messageID: message.id, - }, - ], - })), + messages: messages.map((message) => uiMessage(message.id, message.role, message.text)), todos: [], status: { type: "idle" }, - } as unknown as iPolloWorkSessionSnapshot; + }; +} + +const syncInput = { workspaceId: "workspace-a", connectionKey: "test" }; +const testConnection = { + subscribe: ({ signal }: { signal: AbortSignal }) => new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }), +} as ConversationEngineConnection; + +function applyOpenCodeEvent(input: typeof syncInput, event: unknown) { + const mapped = mapOpenCodeConversationEvent(event); + if (mapped) __applySessionSyncEventForTest(input, mapped); } afterEach(() => { @@ -113,36 +132,23 @@ describe("session permission sync", () => { test("persists the broader legacy always scope instead of the current resource", () => { expect(persistentPermissionPatterns({ ...permission("perm-legacy", "session-a"), - permission: "external_directory", - patterns: ["C:\\Users\\demo\\.agents\\skills\\hyperframes-core\\references\\*"], - always: ["C:\\Users\\demo\\.agents\\skills\\hyperframes-core\\*"], - receivedAt: 1, - protocol: "legacy", + kind: "external_directory", + resources: ["C:\\Users\\demo\\.agents\\skills\\hyperframes-core\\references\\*"], + remember: ["C:\\Users\\demo\\.agents\\skills\\hyperframes-core\\*"], })).toEqual(["C:\\Users\\demo\\.agents\\skills\\hyperframes-core\\*"]); }); test("persists the v2 save scope and falls back for older requests", () => { - const normalized = { - id: "perm-v2", - sessionID: "session-a", - permission: "external_directory", - patterns: ["C:/Users/demo/outside/current.txt"], - metadata: {}, - always: ["C:/Users/demo/outside/*"], - receivedAt: 1, - protocol: "v2" as const, - v2: { - action: "external_directory", - resources: ["C:/Users/demo/outside/current.txt"], - save: ["C:/Users/demo/outside/*", "C:/Users/demo/outside/*"], - }, - } satisfies PendingPermission; + const normalized = permission("perm-v2", "session-a", { + kind: "external_directory", + resources: ["C:/Users/demo/outside/current.txt"], + remember: ["C:/Users/demo/outside/*", "C:/Users/demo/outside/*"], + }); expect(persistentPermissionPatterns(normalized)).toEqual(["C:/Users/demo/outside/*"]); expect(persistentPermissionPatterns({ ...normalized, - always: [], - v2: { ...normalized.v2, save: undefined }, + remember: [], })).toEqual(["C:/Users/demo/outside/current.txt"]); }); @@ -153,7 +159,7 @@ describe("session permission sync", () => { ]); expect(getReactQueryClient().getQueryData(permissionKey("workspace-a", "session-a"))).toMatchObject([ - { id: "perm-a", sessionID: "session-a", permission: "bash" }, + { id: "perm-a", sessionId: "session-a", kind: "bash" }, ]); }); @@ -182,7 +188,7 @@ describe("session permission sync", () => { seedPermissionState("workspace-a", "session-a", [], { snapshotStartedAt: 100 }); expect(getReactQueryClient().getQueryData(permissionKey("workspace-a", "session-a"))).toMatchObject([ - { id: "perm-live", sessionID: "session-a", permission: "bash" }, + { id: "perm-live", sessionId: "session-a", kind: "bash" }, ]); }); @@ -201,37 +207,43 @@ describe("session permission sync", () => { test("seeds v2 permissions for the selected session", () => { seedPermissionState("workspace-a", "session-a", [ - v2Permission("perm-v2-a", "session-a"), - v2Permission("perm-v2-b", "session-b"), + permission("perm-v2-a", "session-a", { + kind: "read", + resources: ["/outside/project/secrets.txt"], + remember: ["/outside/project/*"], + }), + permission("perm-v2-b", "session-b", { + kind: "read", + resources: ["/outside/project/secrets.txt"], + remember: ["/outside/project/*"], + }), ]); expect(getReactQueryClient().getQueryData(permissionKey("workspace-a", "session-a"))).toMatchObject([ { id: "perm-v2-a", - sessionID: "session-a", - permission: "read", - patterns: ["/outside/project/secrets.txt"], - protocol: "v2", + sessionId: "session-a", + kind: "read", + resources: ["/outside/project/secrets.txt"], }, ]); }); test("adds and removes live v2 permission events", () => { - const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", ipolloworkToken: "token" }; const cleanup = __createWorkspaceSessionSyncForTest(syncInput); const releaseSession = trackWorkspaceSessionSync(syncInput, "session-a"); try { - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "permission.v2.asked", - properties: v2Permission("perm-v2-live", "session-a"), + properties: nativeV2Permission("perm-v2-live", "session-a"), }); expect(getReactQueryClient().getQueryData(permissionKey("workspace-a", "session-a"))).toMatchObject([ - { id: "perm-v2-live", sessionID: "session-a", permission: "read", protocol: "v2" }, + { id: "perm-v2-live", sessionId: "session-a", kind: "read" }, ]); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "permission.v2.replied", properties: { sessionID: "session-a", requestID: "perm-v2-live", reply: "once" }, }); @@ -252,26 +264,25 @@ describe("session question sync", () => { ]); expect(getReactQueryClient().getQueryData(questionKey("workspace-a", "session-a"))).toMatchObject([ - { id: "question-a", sessionID: "session-a" }, + { id: "question-a", sessionId: "session-a" }, ]); }); test("adds and removes live question events", () => { - const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", ipolloworkToken: "token" }; const cleanup = __createWorkspaceSessionSyncForTest(syncInput); const releaseSession = trackWorkspaceSessionSync(syncInput, "session-a"); try { - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "question.asked", - properties: question("question-live", "session-a"), + properties: nativeQuestion("question-live", "session-a"), } as any); expect(getReactQueryClient().getQueryData(questionKey("workspace-a", "session-a"))).toMatchObject([ - { id: "question-live", sessionID: "session-a" }, + { id: "question-live", sessionId: "session-a" }, ]); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "question.replied", properties: { sessionID: "session-a", requestID: "question-live", answers: [["Yes"]] }, } as any); @@ -330,7 +341,6 @@ describe("session transcript sync", () => { }); test("continues accepting stream deltas for a recently unselected session", async () => { - const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", ipolloworkToken: "token" }; const cleanup = __createWorkspaceSessionSyncForTest(syncInput); try { @@ -338,11 +348,11 @@ describe("session transcript sync", () => { releaseSessionA(); const releaseSessionB = trackWorkspaceSessionSync(syncInput, "session-b"); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.updated", properties: { info: { id: "msg-assistant", role: "assistant", sessionID: "session-a" } }, } as any); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.part.updated", properties: { part: { @@ -354,7 +364,7 @@ describe("session transcript sync", () => { }, }, } as any); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.part.delta", properties: { sessionID: "session-a", @@ -376,7 +386,6 @@ describe("session transcript sync", () => { }); test("destroys an explicitly switched-away session and ignores later events", () => { - const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", ipolloworkToken: "token" }; const cleanup = __createWorkspaceSessionSyncForTest(syncInput); try { @@ -399,7 +408,7 @@ describe("session transcript sync", () => { expect(getReactQueryClient().getQueryData(queryKey)).toBeUndefined(); } - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.updated", properties: { info: { id: "msg-late", role: "assistant", sessionID: "session-a" } }, } as any); @@ -410,21 +419,21 @@ describe("session transcript sync", () => { }); test("keeps workspace stream alive while retained sessions remain after route unmount", async () => { - const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", ipolloworkToken: "token" }; - const releaseWorkspace = ensureWorkspaceSessionSync(syncInput); - const releaseSessionA = trackWorkspaceSessionSync(syncInput, "session-a"); + const liveSyncInput = { ...syncInput, connection: testConnection }; + const releaseWorkspace = ensureWorkspaceSessionSync(liveSyncInput); + const releaseSessionA = trackWorkspaceSessionSync(liveSyncInput, "session-a"); releaseSessionA(); releaseWorkspace(); try { - expect(__hasWorkspaceSessionSyncForTest(syncInput)).toBe(true); + expect(__hasWorkspaceSessionSyncForTest(liveSyncInput)).toBe(true); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(liveSyncInput, { type: "message.updated", properties: { info: { id: "msg-route-leave", role: "assistant", sessionID: "session-a" } }, } as any); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(liveSyncInput, { type: "message.part.updated", properties: { part: { @@ -436,7 +445,7 @@ describe("session transcript sync", () => { }, }, } as any); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(liveSyncInput, { type: "message.part.delta", properties: { sessionID: "session-a", @@ -451,7 +460,7 @@ describe("session transcript sync", () => { const transcript = getReactQueryClient().getQueryData(transcriptKey("workspace-a", "session-a")); expect(transcript?.[0]?.parts[0]).toMatchObject({ text: "stream survived settings route" }); } finally { - __disposeWorkspaceSessionSyncForTest(syncInput); + __disposeWorkspaceSessionSyncForTest(liveSyncInput); } }); }); diff --git a/apps/app/tests/session-sync-tool-parts.test.ts b/apps/app/tests/session-sync-tool-parts.test.ts index 4d2309ed8..63c5fe842 100644 --- a/apps/app/tests/session-sync-tool-parts.test.ts +++ b/apps/app/tests/session-sync-tool-parts.test.ts @@ -10,11 +10,20 @@ import { trackWorkspaceSessionSync, transcriptKey, } from "../src/react-app/domains/session/sync/session-sync"; -import { describeOpencodeSessionError } from "../src/react-app/domains/session/sync/usechat-adapter"; +import { mapOpenCodeConversationEvent } from "../src/react-app/domains/session/engine/opencode-conversation-mapper"; + +function applyOpenCodeEvent( + input: { workspaceId: string; connectionKey: string }, + event: unknown, +) { + const mapped = mapOpenCodeConversationEvent(event); + if (mapped) __applySessionSyncEventForTest(input, mapped); +} +import { describeOpencodeSessionError } from "../src/react-app/domains/session/engine/opencode-message-adapter"; import { parseDynamicToolUIPart, parseStructuredOutputUIPart, -} from "../src/react-app/domains/session/sync/parse-tool-parts"; +} from "../src/react-app/domains/session/engine/opencode-tool-parts"; import { videoVoiceDisplayMetadata } from "../src/react-app/domains/session/video/video-voice"; afterEach(() => { @@ -146,16 +155,16 @@ describe("tool part mapper", () => { }); test("session sync defers empty in-progress write tools until input arrives", () => { - const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", ipolloworkToken: "token" }; + const syncInput = { workspaceId: "workspace-a", connectionKey: "test" }; const cleanup = __createWorkspaceSessionSyncForTest(syncInput); const release = trackWorkspaceSessionSync(syncInput, "session-a"); try { - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.updated", properties: { info: { id: "msg-a", role: "assistant", sessionID: "session-a" } }, } as any); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.part.updated", properties: { part: writeToolPart("pending", {}) }, } as any); @@ -163,7 +172,7 @@ describe("tool part mapper", () => { let transcript = getReactQueryClient().getQueryData(transcriptKey("workspace-a", "session-a")); expect(transcript?.[0]?.parts ?? []).toEqual([]); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.part.updated", properties: { part: writeToolPart("running", { content: "hello", filePath: "src/main.ts" }), @@ -184,16 +193,16 @@ describe("tool part mapper", () => { }); test("session sync preserves every reference tag from one synthetic part", () => { - const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", ipolloworkToken: "token" }; + const syncInput = { workspaceId: "workspace-a", connectionKey: "test" }; const cleanup = __createWorkspaceSessionSyncForTest(syncInput); const release = trackWorkspaceSessionSync(syncInput, "session-a"); try { - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.updated", properties: { info: { id: "msg-a", role: "user", sessionID: "session-a" } }, } as any); - __applySessionSyncEventForTest(syncInput, { + applyOpenCodeEvent(syncInput, { type: "message.part.updated", properties: { part: {