diff --git a/.github/workflows/activation-e2e.yml b/.github/workflows/activation-e2e.yml index 1aa88b9..a1eb2bc 100644 --- a/.github/workflows/activation-e2e.yml +++ b/.github/workflows/activation-e2e.yml @@ -5,6 +5,12 @@ name: Activation E2E (Maestro) # receive reply), including the connect-time-401 negative case tied to the # 0%-7-day-retention / GitHub issue #76 investigation. # +# Also covers newer surfaces merged after the initial activation suite: +# DirectoryBrowserSheet's server-folder picker, the directory-less +# "all sessions across all projects" list (+ the #46/#48 open-across-project +# regression), and VariantPicker's reasoning-effort chip. See +# .maestro/flows/directory-picker.yaml, all-sessions.yaml, variant-picker.yaml. +# # Runs against tests/fixtures/mock-opencode-server.ts (a small dependency-free # HTTP+SSE stub matching the REAL client protocol read from src/lib/sdk.ts — # NOT a live opencode server, NOT a WebSocket), so the suite is fast and fully diff --git a/.maestro/flows/all-sessions.yaml b/.maestro/flows/all-sessions.yaml new file mode 100644 index 0000000..ec023cb --- /dev/null +++ b/.maestro/flows/all-sessions.yaml @@ -0,0 +1,84 @@ +appId: cc.agentlabs.opencode +name: All sessions across directories - list + open-across-project (regression #46/#48) +--- +# New-feature regression coverage for the "all sessions across all projects" +# list: src/stores/sessions.ts loadSessions() intentionally uses +# clientForDirectory(undefined) (a directory-less client) so GET /session +# returns sessions from every directory, not just the active connection's. +# +# Also locks in the fix for GitHub issues #46/#48: tapping a session that +# belongs to a directory OTHER than the active connection's must actually +# open it (src/stores/sessions.ts selectSession uses clientForDirectory(dir) +# when the session's directory differs from the active one), not silently +# fail or open the wrong session. +# +# The CI job starts `node tests/fixtures/mock-opencode-server.ts --port 4098 +# --seed-sessions` on the runner host BEFORE this flow runs. --seed-sessions +# pre-populates two fixed-ID sessions before any connection is made: +# seed-default -> directory /mock/project (matches the active +# connection's default project — GET /project/current) +# seed-other -> directory /mock/project/other-dir (a DIFFERENT directory) +# +# Why this can actually FAIL if #46/#48 regresses: the mock ENFORCES +# per-directory scoping — GET /session/seed-other and +# GET /session/seed-other/message return 404 unless the request carries +# x-opencode-directory: /mock/project/other-dir. With the fix present, the +# app threads the session's directory through selectSession -> +# clientForDirectory, sends the right header, and the session screen renders. +# Without it, the request goes out with the wrong (or no) directory header, +# the mock 404s, and the session-screen assertions below (title, directory +# badge, empty state) fail. + +- launchApp: + clearState: true +- assertVisible: + id: "telemetry-consent-card" +- tapOn: + id: "telemetry-decline-button" + +- assertVisible: + text: "No Connection" +- tapOn: + id: "add-connection-button" +- tapOn: + id: "connect-ip-input" +- inputText: "127.0.0.1:4098" +- hideKeyboard +- tapOn: + id: "connect-submit-button" + +# 40s margin: see activation-positive.yaml — the connect handshake's own +# fetches are individually capped at 30s (src/lib/sdk.ts REQUEST_TIMEOUT_MS). +- extendedWaitUntil: + visible: + id: "connection-status-dot" + timeout: 40000 +- takeScreenshot: allsessions-S1_connected + +# Both seeded sessions must appear, even though only one belongs to the +# active connection's own directory. +- extendedWaitUntil: + visible: + text: "Default Project Session" + timeout: 15000 +- assertVisible: + text: "Cross-Project Session" +- takeScreenshot: allsessions-S2_both_directories_listed + +# Open the session from the OTHER directory (#46/#48 regression). +- tapOn: + id: "session-item-seed-other" +- extendedWaitUntil: + visible: + id: "chat-message-input" + timeout: 15000 +- assertVisible: + text: "Cross-Project Session" +- assertVisible: + text: "other-dir" +# Confirms GET /session/seed-other/message actually resolved (empty state, +# not stuck loading / errored) — i.e. the cross-directory session genuinely +# opened, not just navigated to a dead screen. +- assertVisible: + text: "Start a conversation" +- takeScreenshot: allsessions-S3_cross_directory_session_opened diff --git a/.maestro/flows/directory-picker.yaml b/.maestro/flows/directory-picker.yaml new file mode 100644 index 0000000..aa18bc0 --- /dev/null +++ b/.maestro/flows/directory-picker.yaml @@ -0,0 +1,117 @@ +appId: cc.agentlabs.opencode +name: Directory picker - browse server folders and create a session in one +--- +# New-feature regression coverage for DirectoryBrowserSheet +# (src/components/chat/DirectoryBrowserSheet.tsx): New Session -> Browse +# Folders -> the sheet lists the mock server's fake directory tree -> tapping +# a folder navigates into it -> Up navigation returns to the parent -> tapping +# "Use this folder" creates a session scoped to the chosen directory. +# +# The CI job starts `node tests/fixtures/mock-opencode-server.ts --port 4099` +# (normal mode, no --fail-auth / --seed-sessions) on the runner host BEFORE +# this flow runs. Its fake filesystem (FAKE_FILE_TREE in +# tests/fixtures/mock-opencode-server.ts) is: +# /mock/project -> frontend/ (dir), backend/ (dir), README.md (file) +# /mock/project/frontend -> (empty) +# /mock/project/backend -> (empty) +# and GET /project (FAKE_SERVER_PROJECTS) additionally lists a "docs-project" +# server-known project, exercised as a secondary assertion below. + +- launchApp: + clearState: true +- assertVisible: + id: "telemetry-consent-card" +- tapOn: + id: "telemetry-decline-button" + +- assertVisible: + text: "No Connection" +- tapOn: + id: "add-connection-button" +- tapOn: + id: "connect-ip-input" +- inputText: "127.0.0.1:4099" +- hideKeyboard +- tapOn: + id: "connect-submit-button" + +# 40s margin: see activation-positive.yaml — the connect handshake's own +# fetches are individually capped at 30s (src/lib/sdk.ts REQUEST_TIMEOUT_MS). +- extendedWaitUntil: + visible: + id: "connection-status-dot" + timeout: 40000 +- takeScreenshot: dirpicker-S1_connected + +# Open the New Session modal (long-press the FAB for the full options sheet) +- longPressOn: + id: "new-session-fab" +- assertVisible: + text: "New Session" +# GET /project surfaces "docs-project" under "Server Projects" (excludes the +# current project, mock-project) — confirms the project-list endpoint wired up. +# The modal fetches it async on open, so wait rather than assert immediately. +- extendedWaitUntil: + visible: + text: "docs-project" + timeout: 10000 +- takeScreenshot: dirpicker-S2_new_session_modal + +# Open the folder browser +- tapOn: + id: "browse-folders-button" +- assertVisible: + text: "Browse Folders" +- extendedWaitUntil: + visible: + id: "directory-row-frontend" + timeout: 10000 +- assertVisible: + id: "directory-row-backend" +# README.md is a FILE, not a directory — DirectoryBrowserSheet filters entries +# to type === "directory", so it must never appear as a row. +- assertNotVisible: + text: "README.md" +- takeScreenshot: dirpicker-S3_root_listing + +# Navigate into "frontend" +- tapOn: + id: "directory-row-frontend" +- extendedWaitUntil: + visible: + text: "No subfolders here" + timeout: 10000 +- takeScreenshot: dirpicker-S4_inside_frontend + +# Up navigation must return to the root listing +- tapOn: + id: "directory-up-button" +- extendedWaitUntil: + visible: + id: "directory-row-frontend" + timeout: 10000 +- assertVisible: + id: "directory-row-backend" +- takeScreenshot: dirpicker-S5_up_navigation_back_to_root + +# Navigate into "backend" and select it +- tapOn: + id: "directory-row-backend" +- extendedWaitUntil: + visible: + text: "No subfolders here" + timeout: 10000 +- takeScreenshot: dirpicker-S6_inside_backend +- tapOn: + id: "directory-select-button" + +# The app creates a session scoped to /mock/project/backend (mock POST +# /session reads x-opencode-directory) and navigates straight to it — the +# native header's directory badge must show "backend". +- extendedWaitUntil: + visible: + id: "chat-message-input" + timeout: 15000 +- assertVisible: + text: "backend" +- takeScreenshot: dirpicker-S7_session_created_in_backend diff --git a/.maestro/flows/variant-picker.yaml b/.maestro/flows/variant-picker.yaml new file mode 100644 index 0000000..bc8b6d5 --- /dev/null +++ b/.maestro/flows/variant-picker.yaml @@ -0,0 +1,102 @@ +appId: cc.agentlabs.opencode +name: Variant picker - reasoning-effort chip renders, selects, and still sends +--- +# New-feature regression coverage for VariantPicker +# (src/components/chat/VariantPicker.tsx): the reasoning-effort chip only +# renders when the selected model's provider entry carries `variants` +# (src/stores/catalog.ts / GET /provider), selecting an option updates the +# chip label, and sending a message afterwards still works end-to-end +# (variant flows into sendMessage -> client.session.prompt's `variant` field +# — see src/stores/sessions.ts sendMessage). +# +# The CI job starts `node tests/fixtures/mock-opencode-server.ts --port 4099` +# on the runner host BEFORE this flow runs (same fresh, non-seeded normal-mode +# server used by directory-picker.yaml — GET /provider's mock-model carries +# low/medium/high variants; see tests/fixtures/mock-opencode-server.ts). + +- launchApp: + clearState: true +- assertVisible: + id: "telemetry-consent-card" +- tapOn: + id: "telemetry-decline-button" + +- assertVisible: + text: "No Connection" +- tapOn: + id: "add-connection-button" +- tapOn: + id: "connect-ip-input" +- inputText: "127.0.0.1:4099" +- hideKeyboard +- tapOn: + id: "connect-submit-button" + +# 40s margin: see activation-positive.yaml — the connect handshake's own +# fetches are individually capped at 30s (src/lib/sdk.ts REQUEST_TIMEOUT_MS). +- extendedWaitUntil: + visible: + id: "connection-status-dot" + timeout: 40000 +- takeScreenshot: variant-S1_connected + +- tapOn: + id: "new-session-fab" +- extendedWaitUntil: + visible: + id: "chat-message-input" + timeout: 15000 + +# The chip only appears once catalog.load() (triggered on connect, see +# app/_layout.tsx) has resolved GET /provider and found variants for the +# selected model — wait rather than assert immediately. +- extendedWaitUntil: + visible: + id: "variant-chip" + timeout: 15000 +- assertVisible: + text: "Auto" +- takeScreenshot: variant-S2_chip_default_auto + +- tapOn: + id: "variant-chip" +- assertVisible: + text: "Reasoning Effort" +- assertVisible: + id: "variant-option-low" +- assertVisible: + id: "variant-option-medium" +- assertVisible: + id: "variant-option-high" +- takeScreenshot: variant-S3_picker_options + +- tapOn: + id: "variant-option-high" +# Sheet closes and the chip label reflects the new selection. +- assertVisible: + text: "High" +- takeScreenshot: variant-S4_chip_shows_high + +# Sending a message must still work with a variant selected (regression: the +# variant chip must not break the send path). +- tapOn: + id: "chat-message-input" +- inputText: "Message with reasoning effort set to high" +- hideKeyboard +- takeScreenshot: variant-S5_message_typed +- tapOn: + id: "chat-send-button" + +- extendedWaitUntil: + visible: + text: "Hello from the mock opencode server" + timeout: 20000 +- assertVisible: + id: "chat-bubble-assistant" +- assertVisible: + text: "Message with reasoning effort set to high" +# The chip must still read "High" after the round trip (selection persists +# across a send, it isn't reset by the reply landing). +- assertVisible: + text: "High" +- takeScreenshot: variant-S6_reply_received_variant_still_high diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 5b31f5f..1a41f73 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -74,6 +74,7 @@ function SessionItem({ style={[styles.sessionItem, isDark && styles.sessionItemDark]} onPress={onPress} onLongPress={onLongPress} + testID={`session-item-${session.id}`} > @@ -656,6 +657,7 @@ export default function SessionsScreen() { openBrowser(currentProject?.path?.absolute || activeConnection?.directory || null, "create") } disabled={isCreating} + testID="browse-folders-button" > diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 80d07d4..08cdeef 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -722,6 +722,7 @@ export default function SessionScreen() { variantSheetRef.current?.expand()} + testID="variant-chip" > diff --git a/scripts/run-e2e-flows.sh b/scripts/run-e2e-flows.sh index 5a2238f..a296359 100755 --- a/scripts/run-e2e-flows.sh +++ b/scripts/run-e2e-flows.sh @@ -11,9 +11,7 @@ set -uo pipefail ROOT="$(pwd)" # capture BEFORE any cd, so diag paths are absolute APK="android/app/build/outputs/apk/release/app-release.apk" -# Only the flows that exist on main. directory-picker/all-sessions/variant-picker -# land with the test/e2e-new-features PR. -FLOWS=(activation-positive activation-negative-401) +FLOWS=(activation-positive activation-negative-401 directory-picker all-sessions variant-picker) mkdir -p "$ROOT/artifacts/screenshots" "$ROOT/artifacts/diag" echo "== installing APK ==" diff --git a/src/components/chat/DirectoryBrowserSheet.tsx b/src/components/chat/DirectoryBrowserSheet.tsx index 5f9df9a..6c53648 100644 --- a/src/components/chat/DirectoryBrowserSheet.tsx +++ b/src/components/chat/DirectoryBrowserSheet.tsx @@ -140,7 +140,7 @@ export function DirectoryBrowserSheet({ Browse Folders - + {jumpPath.trim() && ( @@ -176,7 +177,11 @@ export function DirectoryBrowserSheet({ data={entries} keyExtractor={(item: FileEntry) => item.absolute} renderItem={({ item }: { item: FileEntry }) => ( - enter(item.absolute)}> + enter(item.absolute)} + testID={`directory-row-${item.name}`} + > diff --git a/src/components/chat/VariantPicker.tsx b/src/components/chat/VariantPicker.tsx index 9e416ae..e832d24 100644 --- a/src/components/chat/VariantPicker.tsx +++ b/src/components/chat/VariantPicker.tsx @@ -67,6 +67,7 @@ export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef } handleSelect(item.id)} + testID={`variant-option-${item.id ?? "auto"}`} > {item.label} diff --git a/tests/fixtures/mock-opencode-server.ts b/tests/fixtures/mock-opencode-server.ts index 5004d2d..d07ced0 100644 --- a/tests/fixtures/mock-opencode-server.ts +++ b/tests/fixtures/mock-opencode-server.ts @@ -25,6 +25,37 @@ // message.part.updated) before the canned assistant reply, and returns // it from GET /session/:id/message. // +// Also implements the surface the newer flows need (DirectoryBrowserSheet, +// all-sessions across directories, VariantPicker — see +// .maestro/flows/directory-picker.yaml / all-sessions.yaml / variant-picker.yaml): +// - GET /file (directory-scoped via the x-opencode-directory header, NOT the +// literal ?path= query — see src/lib/headers.ts) -> FAKE_FILE_TREE below. +// - GET /project -> FAKE_SERVER_PROJECTS, for the "Server Projects" section. +// - POST /session honors x-opencode-directory so sessions can be created in +// a browsed/picked folder. +// - GET /session/:id, needed to open a session from the directory-less +// all-sessions list (src/stores/sessions.ts loadSessions/selectSession), +// including sessions the client never itself created. +// - Per-directory workspace scoping is ENFORCED (like the real server): +// GET /session/:id and GET /session/:id/message 404 unless the request's +// x-opencode-directory (or DEFAULT_DIRECTORY when absent) matches the +// session's own directory, and GET /session without ?roots=true only +// lists the request directory's sessions. This is what gives +// all-sessions.yaml teeth as a #46/#48 regression test — an app that +// stops threading the session's directory gets 404s, not silent passes. +// - GET /provider's mock-model carries `variants` (low/medium/high) so +// VariantPicker has options to render. +// +// Shared-state note: in CI (.github/workflows/activation-e2e.yml) the +// instance on port 4099 is shared by directory-picker.yaml and then +// variant-picker.yaml (run sequentially in the same emulator session). +// State persists across flows — e.g. the session directory-picker creates in +// /mock/project/backend still exists when variant-picker runs. That is +// harmless today (variant-picker creates its own quick session and never +// asserts on list contents), but keep it in mind when adding assertions +// about "how many sessions exist" to either flow — or give a new flow its +// own port instead. +// // Two modes: // - Normal mode: implements the endpoints above so the app can connect, // open a session, send a message, and render a canned assistant reply. @@ -36,6 +67,7 @@ // Usage: // node tests/fixtures/mock-opencode-server.ts --port 4096 // node tests/fixtures/mock-opencode-server.ts --port 4097 --fail-auth +// node tests/fixtures/mock-opencode-server.ts --port 4098 --seed-sessions import http from "node:http" import { randomUUID } from "node:crypto" @@ -48,6 +80,14 @@ export interface MockServerOptions { replyText?: string /** Delay before the canned reply is pushed over SSE, in ms. */ replyDelayMs?: number + /** + * Pre-populate two sessions in two different directories at startup + * (used by .maestro/flows/all-sessions.yaml to test the directory-less + * "all sessions across all projects" list — see src/stores/sessions.ts + * loadSessions()'s clientForDirectory(undefined) — and the cross-project + * open regression for GitHub issues #46/#48). + */ + seedSessions?: boolean } interface StoredSession { @@ -84,13 +124,81 @@ interface StoredMessage { export const DEFAULT_REPLY_TEXT = "Hello from the mock opencode server — activation e2e canned reply." +// The directory a request is scoped to when the client sends no +// x-opencode-directory header (i.e. a connection added without an explicit +// directory). Mirrors the real server's notion of a default/current workspace. +export const DEFAULT_DIRECTORY = "/mock/project" + +// Resolve the workspace directory a request is scoped to. Directory-scoped +// clients (src/stores/connections.ts clientForDirectory(dir)) send the +// x-opencode-directory header (src/lib/headers.ts); directory-less clients +// send none and fall back to DEFAULT_DIRECTORY. +function requestDirectory(req: http.IncomingMessage): string { + return (req.headers["x-opencode-directory"] as string | undefined) || DEFAULT_DIRECTORY +} + +// Fake server-side filesystem tree for DirectoryBrowserSheet +// (src/components/chat/DirectoryBrowserSheet.tsx -> client.file.list({path: "."}) +// -> GET /file). The client always requests path=".", scoping to a directory +// entirely via the x-opencode-directory header (src/lib/headers.ts) — so this +// map is keyed by absolute directory, not by the literal query string. +// Root has two subdirectories (for the picker + Up-navigation flow) plus one +// regular file (to exercise DirectoryBrowserSheet's type === "directory" filter). +export const FAKE_FILE_TREE: Record> = { + "/mock/project": [ + { name: "frontend", path: "frontend", absolute: "/mock/project/frontend", type: "directory", ignored: false }, + { name: "backend", path: "backend", absolute: "/mock/project/backend", type: "directory", ignored: false }, + { name: "README.md", path: "README.md", absolute: "/mock/project/README.md", type: "file", ignored: false }, + ], + "/mock/project/frontend": [], + "/mock/project/backend": [], +} + +// Fake server-known projects (GET /project), consumed by the "Server Projects" +// section of the New Session modal (app/(tabs)/index.tsx). "mock-project" +// matches GET /project/current so the UI filters it out of this list. +export const FAKE_SERVER_PROJECTS = [ + { id: "mock-project", name: "mock-project", path: { cwd: "/mock/project", root: "/mock/project", absolute: "/mock/project" } }, + { + id: "mock-project-docs", + name: "docs-project", + path: { cwd: "/mock/docs-project", root: "/mock/docs-project", absolute: "/mock/docs-project" }, + }, +] + export function createMockOpencodeServer(opts: MockServerOptions) { - const { port, failAuth = false, replyText = DEFAULT_REPLY_TEXT, replyDelayMs = 300 } = opts + const { port, failAuth = false, replyText = DEFAULT_REPLY_TEXT, replyDelayMs = 300, seedSessions = false } = opts const sessions = new Map() const messagesBySession = new Map() const sseClients = new Set() + if (seedSessions) { + const now = Date.now() + const seedDefault: StoredSession = { + id: "seed-default", + slug: "seed-def", + projectID: "mock-project", + directory: "/mock/project", + title: "Default Project Session", + version: "0.0.0-mock", + time: { created: now - 120_000, updated: now - 120_000 }, + } + const seedOther: StoredSession = { + id: "seed-other", + slug: "seed-oth", + projectID: "mock-project-other", + directory: "/mock/project/other-dir", + title: "Cross-Project Session", + version: "0.0.0-mock", + time: { created: now - 60_000, updated: now - 60_000 }, + } + sessions.set(seedDefault.id, seedDefault) + messagesBySession.set(seedDefault.id, []) + sessions.set(seedOther.id, seedOther) + messagesBySession.set(seedOther.id, []) + } + function broadcast(type: string, properties: Record) { const line = `data: ${JSON.stringify({ type, properties })}\n\n` for (const res of sseClients) { @@ -207,7 +315,7 @@ export function createMockOpencodeServer(opts: MockServerOptions) { }) } if (method === "GET" && path === "/project") { - return json(res, 200, []) + return json(res, 200, FAKE_SERVER_PROJECTS) } if (method === "GET" && path === "/path") { return json(res, 200, { @@ -240,6 +348,13 @@ export function createMockOpencodeServer(opts: MockServerOptions) { tool_call: false, limit: { context: 8000, output: 2000 }, status: "active", + // Reasoning-effort variants for VariantPicker + // (src/components/chat/VariantPicker.tsx reads Object.keys(variants)). + variants: { + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, + }, }, }, }, @@ -248,6 +363,14 @@ export function createMockOpencodeServer(opts: MockServerOptions) { connected: ["mock"], }) } + + // Server-side filesystem browsing for DirectoryBrowserSheet. The client + // always requests path="." (see src/lib/sdk.ts file.list) and scopes to a + // directory via the x-opencode-directory header (src/lib/headers.ts). + if (method === "GET" && path === "/file") { + const dir = requestDirectory(req) + return json(res, 200, FAKE_FILE_TREE[dir] || []) + } if (method === "GET" && path === "/permission") { return json(res, 200, []) } @@ -271,11 +394,15 @@ export function createMockOpencodeServer(opts: MockServerOptions) { if (method === "POST" && path === "/session") { const id = randomUUID() const now = Date.now() + // Directory-scoped clients (connections store clientForDirectory()) send + // the target directory via this header — used by the "create session in + // a browsed/picked folder" flow (DirectoryBrowserSheet -> onCreateInDirectory). + const directory = requestDirectory(req) const session: StoredSession = { id, slug: id.slice(0, 8), projectID: "mock-project", - directory: "/mock/project", + directory, title: "Mock Session", version: "0.0.0-mock", time: { created: now, updated: now }, @@ -285,12 +412,53 @@ export function createMockOpencodeServer(opts: MockServerOptions) { return json(res, 200, session) } if (method === "GET" && path === "/session") { - return json(res, 200, Array.from(sessions.values())) + // Directory-less "all sessions across all projects" list: the app's + // loadSessions() (src/stores/sessions.ts) uses clientForDirectory(undefined) + // — no x-opencode-directory header — and passes ?roots=true (src/lib/sdk.ts + // session.list). Only that combination returns sessions from every + // directory; otherwise the list is scoped to the request's directory, so + // directory-scoped and directory-less clients are actually distinguishable. + if (url.searchParams.get("roots") === "true") { + return json(res, 200, Array.from(sessions.values())) + } + const dir = requestDirectory(req) + return json( + res, + 200, + Array.from(sessions.values()).filter((s) => s.directory === dir), + ) + } + + // Single-session fetch (src/lib/sdk.ts session.get -> src/stores/sessions.ts + // selectSession), used whenever a session from the all-sessions list (which + // may belong to any directory) is opened — including sessions the client + // never created itself (e.g. the seeded ones below). + // + // Directory ownership is ENFORCED, mirroring the real server's per-directory + // workspace scoping: a session is only visible to a request scoped to the + // session's own directory. This is what makes all-sessions.yaml real + // regression coverage for #46/#48 — if the app stopped threading the + // session's directory into clientFor()/clientForDirectory(), the request + // would carry the wrong (or no) x-opencode-directory header and get a 404 + // here, and the flow's session-screen assertions would fail. + const sessionGetMatch = path.match(/^\/session\/([^/]+)$/) + if (method === "GET" && sessionGetMatch) { + const sid = sessionGetMatch[1] + const session = sessions.get(sid) + if (!session || session.directory !== requestDirectory(req)) { + return json(res, 404, { error: `unknown session ${sid}` }) + } + return json(res, 200, session) } const sessionMessageMatch = path.match(/^\/session\/([^/]+)\/message$/) if (method === "GET" && sessionMessageMatch) { const sid = sessionMessageMatch[1] + const session = sessions.get(sid) + // Same per-directory enforcement as GET /session/:id above. + if (!session || session.directory !== requestDirectory(req)) { + return json(res, 404, { error: `unknown session ${sid}` }) + } return json(res, 200, messagesBySession.get(sid) || []) } @@ -347,11 +515,12 @@ export function createMockOpencodeServer(opts: MockServerOptions) { } } -function parseArgs(argv: string[]): { port: number; failAuth: boolean } { - const opts = { port: 4096, failAuth: false } +function parseArgs(argv: string[]): { port: number; failAuth: boolean; seedSessions: boolean } { + const opts = { port: 4096, failAuth: false, seedSessions: false } for (let i = 0; i < argv.length; i++) { if (argv[i] === "--port") opts.port = Number(argv[++i]) else if (argv[i] === "--fail-auth") opts.failAuth = true + else if (argv[i] === "--seed-sessions") opts.seedSessions = true } return opts } @@ -363,7 +532,9 @@ if (invokedDirectly) { const opts = parseArgs(process.argv.slice(2)) const mock = createMockOpencodeServer(opts) mock.listen().then(() => { - console.log(`[mock-opencode-server] listening on ${mock.url} (failAuth=${opts.failAuth})`) + console.log( + `[mock-opencode-server] listening on ${mock.url} (failAuth=${opts.failAuth}, seedSessions=${opts.seedSessions})`, + ) }) const shutdown = () => { mock.close().then(() => process.exit(0))