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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/app/src/context/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/utils/server-scope"

let getPromptSessionCacheKey: typeof import("./prompt").getPromptSessionCacheKey
let isPromptSessionReady: typeof import("./prompt").isPromptSessionReady
let createPromptReady: typeof import("./prompt").createPromptReady
let createPromptRouteScope: typeof import("./prompt").createPromptRouteScope

beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useLocation: () => ({}),
useNavigate: () => () => undefined,
useParams: () => ({}),
useSearchParams: () => [{}],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./prompt")
getPromptSessionCacheKey = mod.getPromptSessionCacheKey
isPromptSessionReady = mod.isPromptSessionReady
createPromptReady = mod.createPromptReady
createPromptRouteScope = mod.createPromptRouteScope
})

describe("getPromptSessionCacheKey", () => {
test("separates prompt sessions by server scope", () => {
const local = getPromptSessionCacheKey(ServerScope.local, { dir: "/repo", id: "ses_123" })
const remote = getPromptSessionCacheKey("ssh:debian" as ServerScope, { dir: "/repo", id: "ses_123" })

expect(String(local)).toBe("local\u0000/repo\u0000ses_123")
expect(String(remote)).toBe("ssh:debian\u0000/repo\u0000ses_123")
expect(remote).not.toBe(local)
})

test("separates workspace prompt sessions by server scope", () => {
expect(String(getPromptSessionCacheKey(ServerScope.local, { dir: "/repo", id: undefined }))).toBe(
"local\u0000/repo\u0000__workspace__",
)
})

test("keeps explicit draft sessions keyed by draft id", () => {
expect(getPromptSessionCacheKey(ServerScope.local, { draftID: "draft_123" })).toBe("draft:draft_123")
})
})

describe("isPromptSessionReady", () => {
test("returns the readiness accessor value instead of the accessor function", () => {
expect(isPromptSessionReady({ ready: () => false })).toBe(false)
expect(isPromptSessionReady({ ready: () => true })).toBe(true)
})
})

describe("createPromptReady", () => {
test("exposes a lazy promise property without reading the session during construction", () => {
let calls = 0
const promise = Promise.resolve(true)
const ready = createPromptReady(() => {
calls++
return { ready: Object.assign(() => true, { promise }) }
})

expect(calls).toBe(0)
expect(ready()).toBe(true)
expect(calls).toBe(1)
expect(ready.promise).toBe(promise)
expect(calls).toBe(2)
})
})

describe("createPromptRouteScope", () => {
test("returns undefined until a draft or directory scope is available", () => {
expect(createPromptRouteScope({})).toBeUndefined()
})

test("prefers draft scope over route directory scope", () => {
expect(createPromptRouteScope({ draftId: "draft-1", dir: "/tmp/project", id: "ses_1" })).toEqual({
draftID: "draft-1",
})
})

test("uses route directory scope when available", () => {
expect(createPromptRouteScope({ dir: "/tmp/project", id: "ses_1" })).toEqual({
dir: "/tmp/project",
id: "ses_1",
})
})
})
61 changes: 42 additions & 19 deletions packages/app/src/context/prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { useServer } from "./server"
Expand Down Expand Up @@ -167,18 +167,39 @@ type PromptStore = {
}
}

type Scope = { draftID: string } | { dir: string; id?: string }

function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
export type Scope = { draftID: string } | { dir: string; id?: string }

type PromptCacheEntry = {
value: PromptSession
dispose: VoidFunction
}

type PromptReady = (() => boolean) & { promise: Promise<unknown> | undefined }

export const getPromptSessionCacheKey = (serverScope: ServerScope, scope: Scope) => {
if ("draftID" in scope) return `draft:${scope.draftID}`
return ScopedKey.from(serverScope, scope.dir, scope.id ?? WORKSPACE_KEY)
}

export const isPromptSessionReady = (session: { ready: () => boolean }) => session.ready()

export function createPromptRouteScope(input: { draftId?: string; dir?: string; id?: string }) {
if (input.draftId) return { draftID: input.draftId }
if (input.dir) return { dir: input.dir, id: input.id }
return undefined
}

export function createPromptReady(input: () => { ready: PromptReady }): PromptReady {
const ready = (() => isPromptSessionReady(input())) as PromptReady
Object.defineProperty(ready, "promise", {
enumerable: true,
get() {
return input().ready.promise
},
})
return ready
}

function promptTarget(serverScope: ServerScope, scope: Scope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
Expand All @@ -194,12 +215,6 @@ export function createPromptSession(serverScope: ServerScope, scope: Scope) {
return { ready, ...createPromptStateValue(store, setStore) }
}

export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}

function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
Expand Down Expand Up @@ -255,7 +270,7 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P

export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) as PromptReady
return {
ready,
...createPromptStateValue(store, setStore),
Expand Down Expand Up @@ -320,7 +335,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}

const key = scopeKey(scope)
const key = getPromptSessionCacheKey(serverSDK().scope, scope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
Expand All @@ -341,9 +356,17 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value
}

const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
)
const fallback = createPromptState()
const session = createMemo(() => {
const directory = sdk().directory
const scope = createPromptRouteScope({
draftId: search.draftId,
dir: directory ? base64Encode(directory) : undefined,
id: params.id,
})
if (scope) return load(scope)
return fallback
})
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)

Expand Down
Loading