summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/e2e/session/session-composer-dock.spec.ts298
-rw-r--r--packages/app/src/pages/directory-layout.tsx11
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.test.ts83
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.ts22
-rw-r--r--packages/app/src/pages/session/composer/session-request-tree.ts45
5 files changed, 389 insertions, 70 deletions
diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts
index 6bf7714a6..e9cfc03e4 100644
--- a/packages/app/e2e/session/session-composer-dock.spec.ts
+++ b/packages/app/e2e/session/session-composer-dock.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
-import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
+import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
@@ -11,11 +11,23 @@ import {
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
-
-async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
- const session = await sdk.session.create({ title }).then((r) => r.data)
+type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
+
+async function withDockSession<T>(
+ sdk: Sdk,
+ title: string,
+ fn: (session: { id: string; title: string }) => Promise<T>,
+ opts?: { permission?: PermissionRule[] },
+) {
+ const session = await sdk.session
+ .create(opts?.permission ? { title, permission: opts.permission } : { title })
+ .then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
- return fn(session)
+ try {
+ return await fn(session)
+ } finally {
+ await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
+ }
}
test.setTimeout(120_000)
@@ -28,6 +40,85 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
}
}
+async function clearPermissionDock(page: any, label: RegExp) {
+ const dock = page.locator(permissionDockSelector)
+ for (let i = 0; i < 3; i++) {
+ const count = await dock.count()
+ if (count === 0) return
+ await dock.getByRole("button", { name: label }).click()
+ await page.waitForTimeout(150)
+ }
+}
+
+async function withMockPermission<T>(
+ page: any,
+ request: {
+ id: string
+ sessionID: string
+ permission: string
+ patterns: string[]
+ metadata?: Record<string, unknown>
+ always?: string[]
+ },
+ opts: { child?: any } | undefined,
+ fn: () => Promise<T>,
+) {
+ let pending = [
+ {
+ ...request,
+ always: request.always ?? ["*"],
+ metadata: request.metadata ?? {},
+ },
+ ]
+
+ const list = async (route: any) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(pending),
+ })
+ }
+
+ const reply = async (route: any) => {
+ const url = new URL(route.request().url())
+ const id = url.pathname.split("/").pop()
+ pending = pending.filter((item) => item.id !== id)
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(true),
+ })
+ }
+
+ await page.route("**/permission", list)
+ await page.route("**/session/*/permissions/*", reply)
+
+ const sessionList = opts?.child
+ ? async (route: any) => {
+ const res = await route.fetch()
+ const json = await res.json()
+ const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
+ if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
+ await route.fulfill({
+ status: res.status(),
+ headers: res.headers(),
+ contentType: "application/json",
+ body: JSON.stringify(json),
+ })
+ }
+ : undefined
+
+ if (sessionList) await page.route("**/session?*", sessionList)
+
+ try {
+ return await fn()
+ } finally {
+ await page.unroute("**/permission", list)
+ await page.unroute("**/session/*/permissions/*", reply)
+ if (sessionList) await page.unroute("**/session?*", sessionList)
+ }
+}
+
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
@@ -76,72 +167,175 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
- await withDockSeed(sdk, session.id, async () => {
- await gotoSession(session.id)
-
- await seedSessionPermission(sdk, {
+ await gotoSession(session.id)
+ await withMockPermission(
+ page,
+ {
+ id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
- patterns: ["README.md"],
- description: "Need permission for command",
- })
-
- await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
- await expect(page.locator(promptSelector)).toHaveCount(0)
-
- await page
- .locator(permissionDockSelector)
- .getByRole("button", { name: /allow once/i })
- .click()
- await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
- await expect(page.locator(promptSelector)).toBeVisible()
- })
+ patterns: ["/tmp/opencode-e2e-perm-once"],
+ metadata: { description: "Need permission for command" },
+ },
+ undefined,
+ async () => {
+ await page.goto(page.url())
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await clearPermissionDock(page, /allow once/i)
+ await page.goto(page.url())
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
+ },
+ )
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
- await withDockSeed(sdk, session.id, async () => {
- await gotoSession(session.id)
-
- await seedSessionPermission(sdk, {
+ await gotoSession(session.id)
+ await withMockPermission(
+ page,
+ {
+ id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
- patterns: ["REJECT.md"],
- })
-
- await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
- await expect(page.locator(promptSelector)).toHaveCount(0)
-
- await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
- await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
- await expect(page.locator(promptSelector)).toBeVisible()
- })
+ patterns: ["/tmp/opencode-e2e-perm-reject"],
+ },
+ undefined,
+ async () => {
+ await page.goto(page.url())
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await clearPermissionDock(page, /deny/i)
+ await page.goto(page.url())
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
+ },
+ )
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
- await withDockSeed(sdk, session.id, async () => {
- await gotoSession(session.id)
-
- await seedSessionPermission(sdk, {
+ await gotoSession(session.id)
+ await withMockPermission(
+ page,
+ {
+ id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
- patterns: ["README.md"],
- description: "Need permission for command",
+ patterns: ["/tmp/opencode-e2e-perm-always"],
+ metadata: { description: "Need permission for command" },
+ },
+ undefined,
+ async () => {
+ await page.goto(page.url())
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await clearPermissionDock(page, /allow always/i)
+ await page.goto(page.url())
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
+ },
+ )
+ })
+})
+
+test("child session question request blocks parent dock and unblocks after submit", async ({
+ page,
+ sdk,
+ gotoSession,
+}) => {
+ await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
+ await gotoSession(session.id)
+
+ const child = await sdk.session
+ .create({
+ title: "e2e composer dock child question",
+ parentID: session.id,
+ })
+ .then((r) => r.data)
+ if (!child?.id) throw new Error("Child session create did not return an id")
+
+ try {
+ await withDockSeed(sdk, child.id, async () => {
+ await seedSessionQuestion(sdk, {
+ sessionID: child.id,
+ questions: [
+ {
+ header: "Child input",
+ question: "Pick one child option",
+ options: [
+ { label: "Continue", description: "Continue child" },
+ { label: "Stop", description: "Stop child" },
+ ],
+ },
+ ],
+ })
+
+ const dock = page.locator(questionDockSelector)
+ await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await dock.locator('[data-slot="question-option"]').first().click()
+ await dock.getByRole("button", { name: /submit/i }).click()
+
+ await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
})
+ } finally {
+ await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
+ }
+ })
+})
- await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
- await expect(page.locator(promptSelector)).toHaveCount(0)
+test("child session permission request blocks parent dock and supports allow once", async ({
+ page,
+ sdk,
+ gotoSession,
+}) => {
+ await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
+ await gotoSession(session.id)
- await page
- .locator(permissionDockSelector)
- .getByRole("button", { name: /allow always/i })
- .click()
- await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
- await expect(page.locator(promptSelector)).toBeVisible()
- })
+ const child = await sdk.session
+ .create({
+ title: "e2e composer dock child permission",
+ parentID: session.id,
+ })
+ .then((r) => r.data)
+ if (!child?.id) throw new Error("Child session create did not return an id")
+
+ try {
+ await withMockPermission(
+ page,
+ {
+ id: "per_e2e_child",
+ sessionID: child.id,
+ permission: "bash",
+ patterns: ["/tmp/opencode-e2e-perm-child"],
+ metadata: { description: "Need child permission" },
+ },
+ { child },
+ async () => {
+ await page.goto(page.url())
+ const dock = page.locator(permissionDockSelector)
+ await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+
+ await clearPermissionDock(page, /allow once/i)
+ await page.goto(page.url())
+
+ await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+ await expect(page.locator(promptSelector)).toBeVisible()
+ },
+ )
+ } finally {
+ await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
+ }
})
})
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index 4f1d93ab2..71b52180f 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -1,12 +1,11 @@
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
-import { SDKProvider, useSDK } from "@/context/sdk"
+import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { DataProvider } from "@opencode-ai/ui/context"
-import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
@@ -15,19 +14,11 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
- const sdk = useSDK()
return (
<DataProvider
data={sync.data}
directory={props.directory}
- onPermissionRespond={(input: {
- sessionID: string
- permissionID: string
- response: "once" | "always" | "reject"
- }) => sdk.client.permission.respond(input)}
- onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)}
- onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
>
diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts
new file mode 100644
index 000000000..7b6029eb3
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, test } from "bun:test"
+import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
+
+const session = (input: { id: string; parentID?: string }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ }) as Session
+
+const permission = (id: string, sessionID: string) =>
+ ({
+ id,
+ sessionID,
+ }) as PermissionRequest
+
+const question = (id: string, sessionID: string) =>
+ ({
+ id,
+ sessionID,
+ questions: [],
+ }) as QuestionRequest
+
+describe("sessionPermissionRequest", () => {
+ test("prefers the current session permission", () => {
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
+ const permissions = {
+ root: [permission("perm-root", "root")],
+ child: [permission("perm-child", "child")],
+ }
+
+ expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-root")
+ })
+
+ test("returns a nested child permission", () => {
+ const sessions = [
+ session({ id: "root" }),
+ session({ id: "child", parentID: "root" }),
+ session({ id: "grand", parentID: "child" }),
+ session({ id: "other" }),
+ ]
+ const permissions = {
+ grand: [permission("perm-grand", "grand")],
+ other: [permission("perm-other", "other")],
+ }
+
+ expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand")
+ })
+
+ test("returns undefined without a matching tree permission", () => {
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
+ const permissions = {
+ other: [permission("perm-other", "other")],
+ }
+
+ expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined()
+ })
+})
+
+describe("sessionQuestionRequest", () => {
+ test("prefers the current session question", () => {
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
+ const questions = {
+ root: [question("q-root", "root")],
+ child: [question("q-child", "child")],
+ }
+
+ expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root")
+ })
+
+ test("returns a nested child question", () => {
+ const sessions = [
+ session({ id: "root" }),
+ session({ id: "child", parentID: "root" }),
+ session({ id: "grand", parentID: "child" }),
+ ]
+ const questions = {
+ grand: [question("q-grand", "grand")],
+ }
+
+ expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
+ })
+})
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts
index 04c6f7e69..ed65867ef 100644
--- a/packages/app/src/pages/session/composer/session-composer-state.ts
+++ b/packages/app/src/pages/session/composer/session-composer-state.ts
@@ -7,14 +7,20 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
+import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export function createSessionComposerBlocked() {
const params = useParams()
const sync = useSync()
+ const permissionRequest = createMemo(() =>
+ sessionPermissionRequest(sync.data.session, sync.data.permission, params.id),
+ )
+ const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id))
+
return createMemo(() => {
const id = params.id
if (!id) return false
- return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
+ return !!permissionRequest() || !!questionRequest()
})
}
@@ -26,18 +32,18 @@ export function createSessionComposerState() {
const language = useLanguage()
const questionRequest = createMemo((): QuestionRequest | undefined => {
- const id = params.id
- if (!id) return
- return sync.data.question[id]?.[0]
+ return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
- const id = params.id
- if (!id) return
- return sync.data.permission[id]?.[0]
+ return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id)
})
- const blocked = createSessionComposerBlocked()
+ const blocked = createMemo(() => {
+ const id = params.id
+ if (!id) return false
+ return !!permissionRequest() || !!questionRequest()
+ })
const todos = createMemo((): Todo[] => {
const id = params.id
diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts
new file mode 100644
index 000000000..f9673e254
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-request-tree.ts
@@ -0,0 +1,45 @@
+import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
+
+function sessionTreeRequest<T>(session: Session[], request: Record<string, T[] | undefined>, sessionID?: string) {
+ if (!sessionID) return
+
+ const map = session.reduce((acc, item) => {
+ if (!item.parentID) return acc
+ const list = acc.get(item.parentID)
+ if (list) list.push(item.id)
+ if (!list) acc.set(item.parentID, [item.id])
+ return acc
+ }, new Map<string, string[]>())
+
+ const seen = new Set([sessionID])
+ const ids = [sessionID]
+ for (const id of ids) {
+ const list = map.get(id)
+ if (!list) continue
+ for (const child of list) {
+ if (seen.has(child)) continue
+ seen.add(child)
+ ids.push(child)
+ }
+ }
+
+ const id = ids.find((id) => !!request[id]?.[0])
+ if (!id) return
+ return request[id]?.[0]
+}
+
+export function sessionPermissionRequest(
+ session: Session[],
+ request: Record<string, PermissionRequest[] | undefined>,
+ sessionID?: string,
+) {
+ return sessionTreeRequest(session, request, sessionID)
+}
+
+export function sessionQuestionRequest(
+ session: Session[],
+ request: Record<string, QuestionRequest[] | undefined>,
+ sessionID?: string,
+) {
+ return sessionTreeRequest(session, request, sessionID)
+}