diff --git a/.changeset/real-core-test-seams.md b/.changeset/real-core-test-seams.md
new file mode 100644
index 000000000..69ff1e3bc
--- /dev/null
+++ b/.changeset/real-core-test-seams.md
@@ -0,0 +1,5 @@
+---
+'@conciv/core': patch
+---
+
+makeApp accepts an injectable engine-staleness probe, shared by /health, the rpc meta.engine procedure and the mcp server instructions
diff --git a/.changeset/terminal-launch-opener-seam.md b/.changeset/terminal-launch-opener-seam.md
new file mode 100644
index 000000000..2cf0ddb09
--- /dev/null
+++ b/.changeset/terminal-launch-opener-seam.md
@@ -0,0 +1,5 @@
+---
+'@conciv/extension-terminal': patch
+---
+
+the terminal extension is created through `createTerminalExtension({openTerminal})`, so the host that spawns the terminal window is injectable; the default export is unchanged
diff --git a/.fallowrc.json b/.fallowrc.json
index d4382b29d..64a3af315 100644
--- a/.fallowrc.json
+++ b/.fallowrc.json
@@ -149,6 +149,7 @@
"packages/extensions/tanstack/test/host/conciv/extensions/connect-probe.tsx",
"packages/extensions/try-it/test/fixture/connect-pane-fixture.tsx",
"e2e/harnesses/vite.*.config.ts",
- "apps/conciv/test/helpers/fake-core-router.ts"
+ "apps/conciv/test/commands/core-control.ts",
+ "apps/conciv/test/commands/core-testkit.ts"
]
}
diff --git a/apps/conciv/package.json b/apps/conciv/package.json
index 178e4aeee..be66b8b4c 100644
--- a/apps/conciv/package.json
+++ b/apps/conciv/package.json
@@ -58,7 +58,6 @@
"@conciv/storage-history": "workspace:^",
"@conciv/uno-preset": "workspace:*",
"@conciv/vitest-config": "workspace:*",
- "@orpc/server": "catalog:",
"@solidjs/testing-library": "^0.8.10",
"@tanstack/ai": "catalog:",
"@tanstack/query-core": "^5.80.2",
diff --git a/apps/conciv/test/chat-pane.browser.test.tsx b/apps/conciv/test/chat-pane.browser.test.tsx
index a4dc0700a..bb9ee5b32 100644
--- a/apps/conciv/test/chat-pane.browser.test.tsx
+++ b/apps/conciv/test/chat-pane.browser.test.tsx
@@ -1,127 +1,147 @@
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page, userEvent} from 'vitest/browser'
+import type {RpcClient} from '@conciv/contract'
import {ChatPane} from '../src/pane/chat-pane.js'
-import {installFakeCore, sessionRow, type FakeCore, type FakeCoreConfig} from './helpers/fake-core.js'
-import {mountPane, PANE_SESSION, type PaneMount} from './helpers/pane-harness.js'
-
-let core: FakeCore | null = null
-
-afterEach(() => {
- core?.restore()
- core = null
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession, openTranscriptStream, runTurn, seedDraft, sendTurn} from './helpers/core-session.js'
+import {mountPane, type PaneMount} from './helpers/pane-harness.js'
+import {trackedFaults} from './helpers/tracked-faults.js'
+
+const SEND_PATH = ['chat', 'send']
+const SUBSCRIBE_PATH = ['chat', 'subscribe']
+
+const core = {base: ''}
+const active: {pane: PaneMount | null} = {pane: null}
+const faults = trackedFaults()
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'chat-pane', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
+
+afterEach(async () => {
+ await coreControl.releaseTurn()
+ active.pane?.dispose()
+ active.pane = null
})
-function mountChatPane(config: FakeCoreConfig = {}): PaneMount {
- core = installFakeCore({sessions: [sessionRow({id: PANE_SESSION})], ...config})
- return mountPane(() => )
+async function newSession(): Promise<{rpc: RpcClient; sessionId: string}> {
+ const rpc = coreRpc(core.base)
+ return {rpc, sessionId: await createSession(rpc)}
}
-const input = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
-const removeGrab = () => page.getByRole('button', {name: 'Remove grabbed element'})
-
-function draftWithGrab(text: string): FakeCoreConfig['draft'] {
- return {
- sessionId: PANE_SESSION,
- text: '',
- selectionStart: 0,
- selectionEnd: 0,
- grabs: [text],
- updatedAt: 1,
- }
+function mountChatPane(sessionId: string): PaneMount {
+ const mount = mountPane({base: core.base, sessionId}, () => )
+ active.pane = mount
+ return mount
}
-async function sendWithStagedGrab(config: FakeCoreConfig): Promise {
- mountChatPane({...config, draft: draftWithGrab('the grabbed hero section')})
+const input = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
+const removeGrab = () => page.getByRole('button', {name: 'Remove grabbed element'})
+const notifications = () => page.getByRole('region', {name: /Notifications/})
+const stopButton = () => page.getByRole('button', {name: 'Stop generating'})
+const skeleton = () => page.getByRole('status', {name: 'Loading conversation'})
+
+async function sendWithStagedGrab(): Promise {
+ const {rpc, sessionId} = await newSession()
+ await seedDraft(rpc, sessionId, {grabs: ['the grabbed hero section']})
+ mountChatPane(sessionId)
await expect.element(removeGrab()).toBeVisible()
await input().fill('explain the section I grabbed')
await userEvent.keyboard('{Enter}')
}
async function startStreamingRun(): Promise {
- mountChatPane({holdRun: true})
+ const {sessionId} = await newSession()
+ await coreControl.holdTurn()
+ mountChatPane(sessionId)
await input().fill('first turn')
await userEvent.keyboard('{Enter}')
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
+ await expect.element(stopButton()).toBeVisible()
}
test('restores the server-side draft text and staged grabs when the pane mounts', async () => {
- mountChatPane({
- draft: {
- sessionId: PANE_SESSION,
- text: 'kept across the reload',
- selectionStart: 22,
- selectionEnd: 22,
- grabs: ['a grabbed heading'],
- updatedAt: 1,
- },
- })
+ const {rpc, sessionId} = await newSession()
+ await seedDraft(rpc, sessionId, {text: 'kept across the reload', grabs: ['a grabbed heading']})
+
+ mountChatPane(sessionId)
await expect.element(input()).toHaveTextContent('kept across the reload')
await expect.element(page.getByText('a grabbed heading')).toBeVisible()
})
test('a rejected send keeps the draft in the composer and tells the user why', async () => {
- mountChatPane({rejectSend: true})
+ const {sessionId} = await newSession()
+ await faults.install({kind: 'fail', path: SEND_PATH, status: 500})
+ mountChatPane(sessionId)
await expect.element(input()).toBeVisible()
await input().fill('a message the server refuses')
await userEvent.keyboard('{Enter}')
- await expect
- .element(page.getByRole('region', {name: /Notifications/}))
- .toHaveTextContent(/Internal Server Error|could not be sent/)
+ await expect.element(notifications()).toHaveTextContent(/Internal Server Error|could not be sent/)
await expect.element(input()).toHaveTextContent('a message the server refuses')
})
test('sending drops the staged grab card at once, while the turn is still streaming', async () => {
- await sendWithStagedGrab({holdRun: true})
+ await coreControl.holdTurn()
+ await sendWithStagedGrab()
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
+ await expect.element(stopButton()).toBeVisible()
await expect.element(removeGrab()).not.toBeInTheDocument()
})
test('a send the server refuses puts the staged grab card back', async () => {
- await sendWithStagedGrab({rejectSend: true})
+ await faults.install({kind: 'fail', path: SEND_PATH, status: 500})
+ await sendWithStagedGrab()
- await expect
- .element(page.getByRole('region', {name: /Notifications/}))
- .toHaveTextContent(/Internal Server Error|could not be sent/)
+ await expect.element(notifications()).toHaveTextContent(/Internal Server Error|could not be sent/)
await expect.element(removeGrab()).toBeVisible()
- await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
+ await expect.element(page.getByText('the grabbed hero section', {exact: true})).toBeVisible()
})
test('a send that throws at the transport puts the staged grab card back', async () => {
- await sendWithStagedGrab({throwSend: true})
+ await faults.install({kind: 'abort', path: SEND_PATH})
+ await sendWithStagedGrab()
- await expect.element(page.getByRole('region', {name: /Notifications/})).toHaveTextContent(/could not be sent|fetch/)
+ await expect.element(notifications()).toHaveTextContent(/could not be sent|fetch/)
await expect.element(removeGrab()).toBeVisible()
- await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
+ await expect.element(page.getByText('the grabbed hero section', {exact: true})).toBeVisible()
})
test('a queued second send cannot cross-restore the grabs of the turn that failed', async () => {
- const mount = mountChatPane({holdRun: true, draft: draftWithGrab('the grabbed hero section')})
+ const {rpc, sessionId} = await newSession()
+ await seedDraft(rpc, sessionId, {grabs: ['the grabbed hero section']})
+ await coreControl.holdTurn()
+ const mount = mountChatPane(sessionId)
- await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
+ await expect.element(page.getByText('the grabbed hero section', {exact: true})).toBeVisible()
await input().fill('turn A')
await userEvent.keyboard('{Enter}')
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
+ await expect.element(stopButton()).toBeVisible()
mount.pane.grabStore.stageAll([{text: 'the grabbed pricing table'}])
- await expect.element(page.getByText('the grabbed pricing table')).toBeVisible()
+ await expect.element(page.getByText('the grabbed pricing table', {exact: true})).toBeVisible()
await input().fill('turn B')
await userEvent.keyboard('{Enter}')
await expect.element(page.getByRole('button', {name: 'Remove from queue'})).toBeVisible()
- await expect.element(page.getByText('the grabbed pricing table')).not.toBeInTheDocument()
+ await expect.element(page.getByText('the grabbed pricing table', {exact: true})).not.toBeInTheDocument()
- core?.push({type: 'RUN_ERROR', threadId: 'conciv_1', runId: 'conciv_run_1', message: 'turn A failed'})
+ await coreControl.scriptError('turn A failed')
+ await coreControl.releaseTurn()
- await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
- await expect.element(page.getByText('the grabbed pricing table')).not.toBeInTheDocument()
+ await expect.element(page.getByText('the grabbed hero section', {exact: true})).toBeVisible()
+ await expect.element(page.getByText('the grabbed pricing table', {exact: true})).not.toBeInTheDocument()
})
test('sending announces thinking and then the reply through the live region', async () => {
- mountChatPane()
+ const {sessionId} = await newSession()
+ mountChatPane(sessionId)
await expect.element(input()).toBeVisible()
await input().fill('rename the widget package')
@@ -132,57 +152,67 @@ test('sending announces thinking and then the reply through the live region', as
})
test('the refresh affordance re-subscribes and shows the transcript the server re-leads', async () => {
- mountChatPane({
- snapshotFor: (subscribeIndex) =>
- subscribeIndex < 2
- ? []
- : [{id: 'a1', role: 'assistant', parts: [{type: 'text', content: 'the refreshed transcript'}]}],
- })
+ const {rpc, sessionId} = await newSession()
+ mountChatPane(sessionId)
+ await expect.element(page.getByText('How can I help you today?')).toBeVisible()
- await expect.element(input()).toBeVisible()
+ const stream = await openTranscriptStream(rpc, sessionId)
+ const gate = await faults.install({kind: 'gate', path: SUBSCRIBE_PATH})
await page.getByRole('button', {name: 'Refresh the conversation'}).click()
+ await coreControl.scriptTurn({toolCalls: [], text: 'the refreshed transcript'})
+ await sendTurn(rpc, sessionId, 'lead the transcript from the server')
+ await stream.awaitTurnEnd()
+ stream.close()
+ await coreControl.releaseFault(gate)
+
await expect.element(page.getByText('the refreshed transcript')).toBeVisible()
})
test('the refresh affordance is disabled while the run streams', async () => {
- mountChatPane({holdRun: true})
+ const {sessionId} = await newSession()
+ await coreControl.holdTurn()
+ mountChatPane(sessionId)
await expect.element(input()).toBeVisible()
await input().fill('start a run')
await userEvent.keyboard('{Enter}')
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
+ await expect.element(stopButton()).toBeVisible()
await expect.element(page.getByRole('button', {name: 'Refresh the conversation'})).toBeDisabled()
})
test('the initial load shows a conversation skeleton until the snapshot arrives', async () => {
- mountChatPane({holdSnapshot: true})
+ const {sessionId} = await newSession()
+ const gate = await faults.install({kind: 'gate', path: SUBSCRIBE_PATH})
+ mountChatPane(sessionId)
- await expect.element(page.getByRole('status', {name: 'Loading conversation'})).toBeVisible()
+ await expect.element(skeleton()).toBeVisible()
- core?.releaseSnapshot()
+ await coreControl.releaseFault(gate)
await expect.element(page.getByText('How can I help you today?')).toBeVisible()
- await expect.element(page.getByRole('status', {name: 'Loading conversation'})).not.toBeInTheDocument()
+ await expect.element(skeleton()).not.toBeInTheDocument()
})
test('the trailing control morphs from send to a single stop button while streaming, and back once the run stops', async () => {
- mountChatPane({holdRun: true})
+ const {sessionId} = await newSession()
+ await coreControl.holdTurn()
+ mountChatPane(sessionId)
await expect.element(page.getByRole('button', {name: 'Send message'})).toBeVisible()
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).not.toBeInTheDocument()
+ await expect.element(stopButton()).not.toBeInTheDocument()
await input().fill('start a run')
await userEvent.keyboard('{Enter}')
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
+ await expect.element(stopButton()).toBeVisible()
await expect.element(page.getByRole('button', {name: 'Send message'})).not.toBeInTheDocument()
- await page.getByRole('button', {name: 'Stop generating'}).click()
+ await stopButton().click()
await expect.element(page.getByRole('button', {name: 'Send message'})).toBeVisible()
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).not.toBeInTheDocument()
+ await expect.element(stopButton()).not.toBeInTheDocument()
})
test('Enter while streaming queues the draft instead of sending or stopping', async () => {
@@ -192,7 +222,7 @@ test('Enter while streaming queues the draft instead of sending or stopping', as
await userEvent.keyboard('{Enter}')
await expect.element(page.getByText('a queued follow-up')).toBeVisible()
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
+ await expect.element(stopButton()).toBeVisible()
await expect.element(input()).toHaveTextContent('')
})
@@ -208,7 +238,7 @@ test('clicking stop with a queue interrupts the run and flushes every queued mes
await expect.element(page.getByText('bravo step')).toBeVisible()
await expect.element(page.getByRole('button', {name: 'Remove from queue'}).first()).toBeVisible()
- await page.getByRole('button', {name: 'Stop generating'}).click()
+ await stopButton().click()
await expect.element(page.getByRole('button', {name: 'Remove from queue'})).not.toBeInTheDocument()
await expect.element(page.getByText(/alpha step[\s\S]*bravo step/)).toBeVisible()
@@ -217,7 +247,7 @@ test('clicking stop with a queue interrupts the run and flushes every queued mes
test('clicking stop with an empty queue just stops the run', async () => {
await startStreamingRun()
- await page.getByRole('button', {name: 'Stop generating'}).click()
+ await stopButton().click()
await expect.element(page.getByRole('button', {name: 'Send message'})).toBeVisible()
})
@@ -234,7 +264,7 @@ test('Escape in the focused composer does what the stop button does and leaves t
await expect.element(page.getByRole('button', {name: 'Remove from queue'})).not.toBeInTheDocument()
await expect.element(page.getByText('queued while running')).toBeVisible()
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
+ await expect.element(stopButton()).toBeVisible()
await expect.element(input()).toHaveTextContent('draft kept in place')
})
@@ -244,26 +274,23 @@ test('Escape outside the composer does not stop the run', async () => {
await userEvent.click(page.getByRole('log', {name: 'Announcements'}))
await userEvent.keyboard('{Escape}')
- await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
+ await expect.element(stopButton()).toBeVisible()
})
test('a new-session divider does not flash before the transcript snapshot hydrates', async () => {
- mountChatPane({
- holdSnapshot: true,
- markers: [{id: 'marker-1', sessionId: PANE_SESSION, afterTurn: 0, kind: 'new'}],
- snapshotFor: () => [
- {id: 'u1', role: 'user', parts: [{type: 'text', content: 'restart with a clean slate'}]},
- {id: 'a1', role: 'assistant', parts: [{type: 'text', content: 'starting a fresh session'}]},
- ],
- })
-
- await expect.element(page.getByRole('status', {name: 'Loading conversation'})).toBeVisible()
- await core?.idle()
+ const {rpc, sessionId} = await newSession()
+ await coreControl.scriptTurn({toolCalls: [], text: 'starting a fresh session'})
+ await runTurn(rpc, sessionId, 'restart with a clean slate')
+ const gate = await faults.install({kind: 'gate', path: SUBSCRIBE_PATH})
+ const mount = mountChatPane(sessionId)
+
+ await expect.element(skeleton()).toBeVisible()
+ await mount.queryClient.ensureQueryData(mount.data.utils.markers.list.queryOptions({input: {sessionId}}))
await expect.element(page.getByRole('separator', {name: 'New session'})).not.toBeInTheDocument()
- core?.releaseSnapshot()
+ await coreControl.releaseFault(gate)
await expect.element(page.getByText('starting a fresh session')).toBeVisible()
await expect.element(page.getByRole('separator', {name: 'New session'})).toBeVisible()
- await expect.element(page.getByRole('status', {name: 'Loading conversation'})).not.toBeInTheDocument()
+ await expect.element(skeleton()).not.toBeInTheDocument()
})
diff --git a/apps/conciv/test/commands/core-control.ts b/apps/conciv/test/commands/core-control.ts
new file mode 100644
index 000000000..677a1b716
--- /dev/null
+++ b/apps/conciv/test/commands/core-control.ts
@@ -0,0 +1,252 @@
+import {join} from 'node:path'
+import type {BrowserCommand, BrowserCommandContext} from 'vitest/node'
+import type {EngineStaleness} from '@conciv/contract'
+import type {HarnessSessionMeta} from '@conciv/protocol/harness-types'
+import type {ScriptedTurn} from '@conciv/harness-testkit'
+import type {CoreKit} from './core-testkit.js'
+import type {RpcObserver} from '@conciv/extension-testkit/rpc-observer'
+
+export type BootCoreInput = {
+ id: string
+ text?: string
+ resume?: boolean
+ displayName?: string
+ connect?: boolean
+ terminal?: boolean
+ history?: HarnessSessionMeta[]
+ allowedOrigins?: string[]
+}
+
+export type BootCoreResult = {base: string; wsBase: string; bootMs: number}
+
+export type FaultSpec =
+ | {kind: 'fail'; path: string[]; status?: number}
+ | {kind: 'abort'; path?: string[]}
+ | {kind: 'gate'; path?: string[]}
+
+type Fault = {
+ pending: () => number
+ awaitCaptured: (count: number) => Promise
+ release: () => Promise
+ dispose: () => Promise
+}
+
+type CoreTestkit = typeof import('./core-testkit.js')
+
+type TerminalState = {launches: number; succeeds: boolean}
+
+type FileState = {
+ kit: CoreKit | null
+ staleness: {value: EngineStaleness}
+ faults: Map
+ handles: {count: number}
+ terminal: TerminalState
+ observer: RpcObserver | null
+}
+
+const FRESH: EngineStaleness = {
+ stale: false,
+ changed: [],
+ tracked: ['@conciv/core'],
+ bootedAt: 0,
+ fingerprint: 'testkit0000',
+}
+
+const AWAIT_RPC_TIMEOUT_MS = 15_000
+
+const files = new Map()
+
+function testkitOf(ctx: BrowserCommandContext): Promise {
+ const nodeProject = ctx.project.vitest.getRootProject()
+ return nodeProject.import(join(ctx.project.config.root, 'test/commands/core-testkit.ts'))
+}
+
+function stateOf(ctx: BrowserCommandContext): FileState {
+ const key = ctx.testPath ?? 'shared'
+ const existing = files.get(key)
+ if (existing) return existing
+ const created: FileState = {
+ kit: null,
+ staleness: {value: FRESH},
+ faults: new Map(),
+ handles: {count: 0},
+ terminal: {launches: 0, succeeds: true},
+ observer: null,
+ }
+ files.set(key, created)
+ return created
+}
+
+function kitOf(ctx: BrowserCommandContext): CoreKit {
+ const kit = stateOf(ctx).kit
+ if (!kit) throw new Error('no core is booted for this test file; call bootCore first')
+ return kit
+}
+
+function observerOf(ctx: BrowserCommandContext): RpcObserver {
+ const observer = stateOf(ctx).observer
+ if (!observer) throw new Error('no core is booted for this test file; call bootCore first')
+ return observer
+}
+
+function faultOf(ctx: BrowserCommandContext, handle: string): Fault {
+ const fault = stateOf(ctx).faults.get(handle)
+ if (!fault) throw new Error(`no fault is installed under the handle "${handle}"`)
+ return fault
+}
+
+const bootCore: BrowserCommand<[BootCoreInput]> = async (ctx, input): Promise => {
+ const state = stateOf(ctx)
+ if (state.kit) throw new Error('a core is already booted for this test file; call closeCore first')
+ const startedAt = Date.now()
+ const openedTerminal = (): Promise => {
+ state.terminal.launches += 1
+ return Promise.resolve(state.terminal.succeeds)
+ }
+ const {bootCoreKit, createTerminalExtension, observeRpc} = await testkitOf(ctx)
+ const kit = await bootCoreKit({
+ id: input.id,
+ text: input.text,
+ resume: input.resume,
+ displayName: input.displayName,
+ history: input.history,
+ allowedOrigins: input.allowedOrigins,
+ staleness: () => state.staleness.value,
+ ...(input.terminal ? {extensions: [createTerminalExtension({openTerminal: openedTerminal})]} : {}),
+ ...(input.connect
+ ? {
+ connect: {
+ plan: (context) => ({
+ argv: ['claude', '--resume', context.harnessSessionId ?? 'new'],
+ env: {},
+ files: [],
+ }),
+ },
+ }
+ : {}),
+ })
+ state.kit = kit
+ state.observer = observeRpc(ctx.page)
+ return {base: kit.base, wsBase: kit.wsBase, bootMs: Date.now() - startedAt}
+}
+
+const closeCore: BrowserCommand<[]> = async (ctx): Promise => {
+ const state = stateOf(ctx)
+ for (const fault of state.faults.values()) await fault.dispose()
+ state.faults.clear()
+ state.staleness.value = FRESH
+ state.terminal.launches = 0
+ state.terminal.succeeds = true
+ state.observer?.dispose()
+ state.observer = null
+ const kit = state.kit
+ state.kit = null
+ if (!kit) return
+ kit.harness.script.release()
+ await kit.cleanup()
+}
+
+const setStaleness: BrowserCommand<[EngineStaleness]> = (ctx, value): void => {
+ stateOf(ctx).staleness.value = value
+}
+
+const holdTurn: BrowserCommand<[]> = (ctx): void => {
+ kitOf(ctx).harness.script.hold()
+}
+
+const releaseTurn: BrowserCommand<[]> = (ctx): void => {
+ kitOf(ctx).harness.script.release()
+}
+
+const scriptError: BrowserCommand<[string]> = (ctx, message): void => {
+ kitOf(ctx).harness.script.scriptError(message)
+}
+
+const scriptTurn: BrowserCommand<[ScriptedTurn]> = (ctx, turn): string[] => kitOf(ctx).harness.script.scriptTurn(turn)
+
+const setTerminalLaunch: BrowserCommand<[boolean]> = (ctx, succeeds): void => {
+ stateOf(ctx).terminal.succeeds = succeeds
+}
+
+const terminalLaunches: BrowserCommand<[]> = (ctx): number => stateOf(ctx).terminal.launches
+
+const rpcCallCount: BrowserCommand<[string[]]> = (ctx, path): number => observerOf(ctx).completedCount({path})
+
+const rpcMark: BrowserCommand<[]> = (ctx): number => observerOf(ctx).mark()
+
+const awaitRpcCall: BrowserCommand<[string[], number]> = async (ctx, path, since): Promise => {
+ const record = await observerOf(ctx).completed({path, since, timeout: AWAIT_RPC_TIMEOUT_MS})
+ return record.status
+}
+
+const installFault: BrowserCommand<[FaultSpec]> = async (ctx, spec): Promise => {
+ const state = stateOf(ctx)
+ const {abortRpcCalls, failRpcCalls, gateRpcCalls} = await testkitOf(ctx)
+ state.handles.count += 1
+ const handle = `fault-${state.handles.count}`
+ if (spec.kind === 'gate') {
+ state.faults.set(handle, await gateRpcCalls(ctx.page, spec.path ? {path: spec.path} : {}))
+ return handle
+ }
+ const injector =
+ spec.kind === 'abort'
+ ? await abortRpcCalls(ctx.page, spec.path ? {path: spec.path} : {})
+ : await failRpcCalls(ctx.page, {path: spec.path, ...(spec.status ? {status: spec.status} : {})})
+ state.faults.set(handle, {
+ pending: () => 0,
+ awaitCaptured: async () => {
+ throw new Error(`the fault "${handle}" is a ${spec.kind} fault, which never captures pending requests`)
+ },
+ release: () => {
+ injector.repair()
+ return Promise.resolve()
+ },
+ dispose: injector.dispose,
+ })
+ return handle
+}
+
+const releaseFault: BrowserCommand<[string]> = async (ctx, handle): Promise => {
+ await faultOf(ctx, handle).release()
+}
+
+const faultPending: BrowserCommand<[string]> = (ctx, handle): number => faultOf(ctx, handle).pending()
+
+const awaitFaultPending: BrowserCommand<[string, number]> = async (ctx, handle, count): Promise => {
+ const timer: {value: ReturnType | null} = {value: null}
+ const deadline = new Promise((_, reject) => {
+ timer.value = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `the fault "${handle}" captured ${faultOf(ctx, handle).pending()} of ${count} pending requests within ${AWAIT_RPC_TIMEOUT_MS}ms`,
+ ),
+ ),
+ AWAIT_RPC_TIMEOUT_MS,
+ )
+ })
+ try {
+ await Promise.race([faultOf(ctx, handle).awaitCaptured(count), deadline])
+ } finally {
+ if (timer.value) clearTimeout(timer.value)
+ }
+}
+
+export const coreCommands = {
+ bootCore,
+ closeCore,
+ setStaleness,
+ holdTurn,
+ releaseTurn,
+ scriptError,
+ scriptTurn,
+ setTerminalLaunch,
+ terminalLaunches,
+ rpcCallCount,
+ rpcMark,
+ awaitRpcCall,
+ installFault,
+ releaseFault,
+ faultPending,
+ awaitFaultPending,
+}
diff --git a/apps/conciv/test/commands/core-testkit.ts b/apps/conciv/test/commands/core-testkit.ts
new file mode 100644
index 000000000..f445aa6a3
--- /dev/null
+++ b/apps/conciv/test/commands/core-testkit.ts
@@ -0,0 +1,4 @@
+export {bootCoreKit, type CoreKit} from '@conciv/extension-testkit/core-kit'
+export {abortRpcCalls, failRpcCalls, gateRpcCalls} from '@conciv/extension-testkit/rpc-fault'
+export {observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer'
+export {createTerminalExtension} from '@conciv/extension-terminal/server'
diff --git a/apps/conciv/test/core-request-gate.browser.test.ts b/apps/conciv/test/core-request-gate.browser.test.ts
new file mode 100644
index 000000000..6ab5f5fe2
--- /dev/null
+++ b/apps/conciv/test/core-request-gate.browser.test.ts
@@ -0,0 +1,79 @@
+import {afterAll, beforeAll, describe, expect, it} from 'vitest'
+import {EventType} from '@tanstack/ai'
+import {makeRpcClient, type RpcClient} from '@conciv/contract'
+import {coreControl} from './helpers/core-control.js'
+import {trackedFaults} from './helpers/tracked-faults.js'
+
+const core: {rpc: RpcClient | null} = {rpc: null}
+const faults = trackedFaults()
+
+function statusOf(value: unknown): number | null {
+ if (typeof value !== 'object' || value === null || !('status' in value)) return null
+ return typeof value.status === 'number' ? value.status : null
+}
+
+function chunkType(value: unknown): unknown {
+ if (typeof value !== 'object' || value === null || !('type' in value)) return null
+ return value.type
+}
+
+function rpc(): RpcClient {
+ if (!core.rpc) throw new Error('the core kit did not boot')
+ return core.rpc
+}
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'request-gate', allowedOrigins: [window.location.origin]})
+ core.rpc = makeRpcClient(booted.base)
+}, 60_000)
+
+afterAll(async () => {
+ core.rpc = null
+ await coreControl.closeCore()
+}, 30_000)
+
+describe('gated rpc requests against a real core', () => {
+ it('holds the subscribe request until release, then streams the real snapshot', async () => {
+ const {sessionId} = await rpc().sessions.create()
+ const gate = await faults.install({kind: 'gate', path: ['chat', 'subscribe']})
+ const arrived = {value: false}
+ const subscription = rpc()
+ .chat.subscribe({sessionId})
+ .then((iterator) => {
+ arrived.value = true
+ return iterator
+ })
+
+ await coreControl.awaitFaultPending(gate, 1)
+ expect(arrived.value).toBe(false)
+
+ await coreControl.releaseFault(gate)
+ const iterator = await subscription
+ const first = await iterator.next()
+
+ expect(chunkType(first.value)).toBe(EventType.MESSAGES_SNAPSHOT)
+ expect(await coreControl.faultPending(gate)).toBe(0)
+
+ await iterator.return?.()
+ }, 30_000)
+
+ it('keeps a cross-origin injected failure an rpc rejection rather than a transport error', async () => {
+ const {sessionId} = await rpc().sessions.create()
+ const fault = await faults.install({kind: 'fail', path: ['sessions', 'rename'], status: 500})
+
+ const rejection = await rpc()
+ .sessions.rename({sessionId, title: 'renamed by the fault test'})
+ .then(
+ () => null,
+ (reason: unknown) => reason,
+ )
+
+ expect(rejection).not.toBeInstanceOf(TypeError)
+ expect(String(rejection)).not.toMatch(/failed to fetch/i)
+ expect(statusOf(rejection)).toBe(500)
+
+ await coreControl.releaseFault(fault)
+ const renamed = await rpc().sessions.rename({sessionId, title: 'renamed by the fault test'})
+ expect(renamed.title).toBe('renamed by the fault test')
+ }, 30_000)
+})
diff --git a/apps/conciv/test/draft-storage.test.ts b/apps/conciv/test/draft-storage.test.ts
index 394ec8b1c..64a3d3711 100644
--- a/apps/conciv/test/draft-storage.test.ts
+++ b/apps/conciv/test/draft-storage.test.ts
@@ -1,65 +1,55 @@
-import {afterEach, expect, test, vi} from 'vitest'
-import {makeRpcClient, type DraftRow} from '@conciv/contract'
-import {appendDraft, makeDraftStorage, type PaneDraftStorage} from '../src/pane/draft-storage.js'
-
-const BASE = 'http://conciv.test'
-const SESSION = 'conciv_1'
-
-type Server = {row: DraftRow | null; writes: unknown[]; failReads: boolean}
-
-const realFetch = globalThis.fetch
-
-afterEach(() => {
- globalThis.fetch = realFetch
- vi.useRealTimers()
+import {expect, test as baseTest} from 'vitest'
+import {makeRpcClient, type RpcClient} from '@conciv/contract'
+import {bootCoreKit, type CoreKit} from '@conciv/extension-testkit/core-kit'
+import {appendDraft, makeDraftStorage} from '../src/pane/draft-storage.js'
+import {createSession, seedDraft} from './helpers/core-session.js'
+import {proxyTo, type ProxyCore} from './helpers/proxy.js'
+
+const DRAFTS_GET_PATH = '/rpc/drafts/get'
+const DRAFTS_SET_PATH = '/rpc/drafts/set'
+
+type StoredDraft = {sessionId: string; text: string; selectionStart: number; selectionEnd: number; grabs: string[]}
+
+const test = baseTest.extend<{kit: CoreKit; core: ProxyCore; sessionId: string}>({
+ kit: [
+ // oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring
+ async ({}, use) => {
+ const kit = await bootCoreKit({id: 'draft-storage'})
+ await use(kit)
+ await kit.cleanup()
+ },
+ {scope: 'file'},
+ ],
+ core: async ({kit}, use) => {
+ const core = await proxyTo(kit.base)
+ await use(core)
+ await core.close()
+ },
+ sessionId: async ({kit}, use) => {
+ await use(await createSession(kit.rpc))
+ },
})
-function reply(value: unknown): Response {
- return new Response(JSON.stringify({json: value, meta: []}), {
- status: 200,
- headers: {'content-type': 'application/json'},
- })
+function composerDraft(text: string, grabs: string[] = []): string {
+ return JSON.stringify({text, quote: null, grabs, attachments: []})
}
-function installServer(server: Server): void {
- globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => {
- const request = input instanceof Request ? input : new Request(input, init)
- const path = new URL(request.url).pathname
- if (path === '/rpc/drafts/get') {
- if (server.failReads) return new Response('down', {status: 500})
- return reply(server.row)
- }
- if (path === '/rpc/drafts/set') {
- const parsed: unknown = await request.json()
- if (typeof parsed === 'object' && parsed !== null && 'json' in parsed) server.writes.push(parsed.json)
- return reply({ok: true})
- }
- throw new Error(`the fake core has no route for ${path}`)
+async function storedDraft(rpc: RpcClient, sessionId: string): Promise {
+ const row = await rpc.drafts.get({sessionId})
+ if (!row) return null
+ return {
+ sessionId: row.sessionId,
+ text: row.text,
+ selectionStart: row.selectionStart,
+ selectionEnd: row.selectionEnd,
+ grabs: row.grabs,
}
}
-function draftRow(text: string, grabs: string[]): DraftRow {
- return {sessionId: SESSION, text, selectionStart: text.length, selectionEnd: text.length, grabs, updatedAt: 1}
-}
-
-async function settleWrites(): Promise {
- await vi.advanceTimersByTimeAsync(350)
- await Promise.resolve()
-}
-
-async function bootWritableStorage(): Promise<{server: Server; draftStorage: PaneDraftStorage}> {
- const server: Server = {row: null, writes: [], failReads: false}
- installServer(server)
- const draftStorage = await makeDraftStorage(makeRpcClient(BASE), SESSION)
- vi.useFakeTimers()
- return {server, draftStorage}
-}
-
-test('seeds the cache from the server draft row in the composer draft shape', async () => {
- const server: Server = {row: draftRow('kept across the reload', ['a grabbed heading']), writes: [], failReads: false}
- installServer(server)
+test('seeds the cache from the server draft row in the composer draft shape', async ({kit, core, sessionId}) => {
+ await seedDraft(kit.rpc, sessionId, {text: 'kept across the reload', grabs: ['a grabbed heading']})
- const draftStorage = await makeDraftStorage(makeRpcClient(BASE), SESSION)
+ const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
expect(JSON.parse(draftStorage.storage.getItem('any') ?? '')).toEqual({
text: 'kept across the reload',
@@ -69,104 +59,134 @@ test('seeds the cache from the server draft row in the composer draft shape', as
})
})
-test('starts empty when the server has no draft', async () => {
- const server: Server = {row: null, writes: [], failReads: false}
- installServer(server)
-
- const draftStorage = await makeDraftStorage(makeRpcClient(BASE), SESSION)
+test('starts empty when the server has no draft', async ({core, sessionId}) => {
+ const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
expect(draftStorage.storage.getItem('any')).toBeNull()
})
-test('writes the composer draft back to the server with the caret at the end', async () => {
- const {server, draftStorage} = await bootWritableStorage()
+test('writes the composer draft back to the server with the caret at the end', async ({kit, core, sessionId}) => {
+ const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
+
+ draftStorage.storage.setItem('any', composerDraft('a fresh draft', ['a heading']))
- draftStorage.storage.setItem(
- 'any',
- JSON.stringify({text: 'a fresh draft', quote: null, grabs: ['a heading'], attachments: []}),
- )
- await settleWrites()
+ await core.awaitRequest(DRAFTS_SET_PATH)
- expect(server.writes).toEqual([
- {sessionId: SESSION, text: 'a fresh draft', selectionStart: 13, selectionEnd: 13, grabs: ['a heading']},
- ])
+ expect(await storedDraft(kit.rpc, sessionId)).toEqual({
+ sessionId,
+ text: 'a fresh draft',
+ selectionStart: 13,
+ selectionEnd: 13,
+ grabs: ['a heading'],
+ })
})
-test('collapses a burst of writes into the last draft', async () => {
- const {server, draftStorage} = await bootWritableStorage()
+test('collapses a burst of writes into the last draft', async ({kit, core, sessionId}) => {
+ const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
+
+ draftStorage.storage.setItem('any', composerDraft('a'))
+ draftStorage.storage.setItem('any', composerDraft('ab'))
+ draftStorage.storage.setItem('any', composerDraft('abc'))
- draftStorage.storage.setItem('any', JSON.stringify({text: 'a', quote: null, grabs: [], attachments: []}))
- draftStorage.storage.setItem('any', JSON.stringify({text: 'ab', quote: null, grabs: [], attachments: []}))
- draftStorage.storage.setItem('any', JSON.stringify({text: 'abc', quote: null, grabs: [], attachments: []}))
- await settleWrites()
+ await core.awaitRequest(DRAFTS_SET_PATH)
- expect(server.writes).toEqual([{sessionId: SESSION, text: 'abc', selectionStart: 3, selectionEnd: 3, grabs: []}])
+ expect(await storedDraft(kit.rpc, sessionId)).toEqual({
+ sessionId,
+ text: 'abc',
+ selectionStart: 3,
+ selectionEnd: 3,
+ grabs: [],
+ })
+ expect(core.requestCount(DRAFTS_SET_PATH)).toBe(1)
})
-test('keeps the latest value readable even when the payload cannot be persisted', async () => {
- const {server, draftStorage} = await bootWritableStorage()
+test('keeps the latest value readable even when the payload cannot be persisted', async ({kit, core, sessionId}) => {
+ const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
draftStorage.storage.setItem('any', 'not json at all')
- await settleWrites()
-
expect(draftStorage.storage.getItem('any')).toBe('not json at all')
- expect(server.writes).toEqual([])
+
+ draftStorage.storage.setItem('any', composerDraft('a draft that parses'))
+
+ await core.awaitRequest(DRAFTS_SET_PATH)
+
+ expect(await storedDraft(kit.rpc, sessionId)).toEqual({
+ sessionId,
+ text: 'a draft that parses',
+ selectionStart: 19,
+ selectionEnd: 19,
+ grabs: [],
+ })
+ expect(core.requestCount(DRAFTS_SET_PATH)).toBe(1)
})
-test('persists the noted caret offsets with the draft text', async () => {
- const {server, draftStorage} = await bootWritableStorage()
+test('persists the noted caret offsets with the draft text', async ({kit, core, sessionId}) => {
+ const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
- draftStorage.storage.setItem('any', JSON.stringify({text: 'say hello!', quote: null, grabs: [], attachments: []}))
+ draftStorage.storage.setItem('any', composerDraft('say hello!'))
draftStorage.noteSelection({start: 4, end: 4})
- await settleWrites()
- expect(server.writes).toEqual([
- {sessionId: SESSION, text: 'say hello!', selectionStart: 4, selectionEnd: 4, grabs: []},
- ])
+ await core.awaitRequest(DRAFTS_SET_PATH)
+
+ expect(await storedDraft(kit.rpc, sessionId)).toEqual({
+ sessionId,
+ text: 'say hello!',
+ selectionStart: 4,
+ selectionEnd: 4,
+ grabs: [],
+ })
})
-test('clamps noted offsets that fall beyond the persisted text', async () => {
- const {server, draftStorage} = await bootWritableStorage()
+test('clamps noted offsets that fall beyond the persisted text', async ({kit, core, sessionId}) => {
+ const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
draftStorage.noteSelection({start: 40, end: 44})
- draftStorage.storage.setItem('any', JSON.stringify({text: 'short', quote: null, grabs: [], attachments: []}))
- await settleWrites()
+ draftStorage.storage.setItem('any', composerDraft('short'))
- expect(server.writes).toEqual([{sessionId: SESSION, text: 'short', selectionStart: 5, selectionEnd: 5, grabs: []}])
+ await core.awaitRequest(DRAFTS_SET_PATH)
+
+ expect(await storedDraft(kit.rpc, sessionId)).toEqual({
+ sessionId,
+ text: 'short',
+ selectionStart: 5,
+ selectionEnd: 5,
+ grabs: [],
+ })
})
-test('appends to the stored draft on a new line with the caret at the end', async () => {
- const server: Server = {row: draftRow('a first line', ['a heading']), writes: [], failReads: false}
- installServer(server)
+test('appends to the stored draft on a new line with the caret at the end', async ({kit, core, sessionId}) => {
+ await seedDraft(kit.rpc, sessionId, {text: 'a first line', grabs: ['a heading']})
- await appendDraft(makeRpcClient(BASE), SESSION, 'a second line')
+ await appendDraft(makeRpcClient(core.base), sessionId, 'a second line')
- expect(server.writes).toEqual([
- {
- sessionId: SESSION,
- text: 'a first line\na second line',
- selectionStart: 26,
- selectionEnd: 26,
- grabs: ['a heading'],
- },
- ])
+ expect(await storedDraft(kit.rpc, sessionId)).toEqual({
+ sessionId,
+ text: 'a first line\na second line',
+ selectionStart: 26,
+ selectionEnd: 26,
+ grabs: ['a heading'],
+ })
})
-test('survives a failed initial read and still accepts writes', async () => {
- const server: Server = {row: null, writes: [], failReads: true}
- installServer(server)
+test('survives a failed initial read and still accepts writes', async ({kit, core, sessionId}) => {
+ await seedDraft(kit.rpc, sessionId, {text: 'unreachable at boot'})
+ core.fail(DRAFTS_GET_PATH)
- const draftStorage = await makeDraftStorage(makeRpcClient(BASE), SESSION)
- server.failReads = false
- vi.useFakeTimers()
- draftStorage.storage.setItem(
- 'any',
- JSON.stringify({text: 'after the outage', quote: null, grabs: [], attachments: []}),
- )
- await settleWrites()
+ const draftStorage = await makeDraftStorage(makeRpcClient(core.base), sessionId)
+
+ expect(draftStorage.storage.getItem('any')).toBeNull()
+
+ core.repair()
+ draftStorage.storage.setItem('any', composerDraft('after the outage'))
expect(draftStorage.storage.getItem('any')).toContain('after the outage')
- expect(server.writes).toEqual([
- {sessionId: SESSION, text: 'after the outage', selectionStart: 16, selectionEnd: 16, grabs: []},
- ])
+ await core.awaitRequest(DRAFTS_SET_PATH)
+
+ expect(await storedDraft(kit.rpc, sessionId)).toEqual({
+ sessionId,
+ text: 'after the outage',
+ selectionStart: 16,
+ selectionEnd: 16,
+ grabs: [],
+ })
})
diff --git a/apps/conciv/test/engine-staleness.browser.test.tsx b/apps/conciv/test/engine-staleness.browser.test.tsx
index c348bb74d..f5890abae 100644
--- a/apps/conciv/test/engine-staleness.browser.test.tsx
+++ b/apps/conciv/test/engine-staleness.browser.test.tsx
@@ -1,26 +1,56 @@
import '@conciv/ui-kit-system/tokens.css'
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
+import type {EngineStaleness} from '@conciv/contract'
import {EngineStaleNotice} from '../src/shell/engine-notice.js'
-import {installFakeCore, sessionRow, type FakeCore} from './helpers/fake-core.js'
-import {mountPane, PANE_SESSION} from './helpers/pane-harness.js'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession} from './helpers/core-session.js'
+import {mountPane, type PaneMount} from './helpers/pane-harness.js'
+
+const core = {base: ''}
+const mounted: {pane: PaneMount | null} = {pane: null}
+
+const TRACKED = ['@conciv/core', '@conciv/tools']
+
+function staleness(stale: boolean, fingerprint: string): EngineStaleness {
+ return {
+ stale,
+ changed: stale ? ['@conciv/tools'] : [],
+ tracked: TRACKED,
+ bootedAt: 0,
+ fingerprint,
+ }
+}
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'engine-staleness', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
-let core: FakeCore | null = null
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
afterEach(() => {
- core?.restore()
- core = null
+ mounted.pane?.dispose()
+ mounted.pane = null
})
-function mountNotice(config: Parameters[0] = {}): {refetch: () => Promise} {
- core = installFakeCore({sessions: [sessionRow({id: PANE_SESSION})], ...config})
- const mounted = mountPane(() => )
- return {refetch: mounted.refetch}
+async function mountNotice(engine: EngineStaleness): Promise {
+ await coreControl.setStaleness(engine)
+ const sessionId = await createSession(coreRpc(core.base))
+ const pane = mountPane({base: core.base, sessionId}, () => )
+ mounted.pane = pane
+ return pane
+}
+
+async function settleEngine(pane: PaneMount): Promise {
+ await pane.queryClient.ensureQueryData(pane.data.utils.meta.engine.queryOptions())
}
test('an engine running outdated code says so, and says it is the server code that moved', async () => {
- mountNotice({engineStale: true})
+ await mountNotice(staleness(true, 'stamp-boot'))
await expect
.element(page.getByRole('alert'))
@@ -29,15 +59,15 @@ test('an engine running outdated code says so, and says it is the server code th
})
test('an engine that matches the code on disk raises nothing at all', async () => {
- mountNotice({engineStale: false})
+ const pane = await mountNotice(staleness(false, 'stamp-boot'))
- await core?.idle()
+ await settleEngine(pane)
await expect.element(page.getByRole('alert')).not.toBeInTheDocument()
})
test('the outdated-engine notice stands until it is dismissed by hand', async () => {
- mountNotice({engineStale: true})
+ await mountNotice(staleness(true, 'stamp-boot'))
await expect.element(page.getByRole('alert')).toBeVisible()
await page.getByRole('button', {name: 'Dismiss'}).click()
@@ -46,33 +76,33 @@ test('the outdated-engine notice stands until it is dismissed by hand', async ()
})
test('restarting the engine takes the notice down without anyone dismissing it', async () => {
- const mounted = mountNotice({engineStale: true})
+ const pane = await mountNotice(staleness(true, 'stamp-boot'))
await expect.element(page.getByRole('alert')).toBeVisible()
- core?.setEngine({stale: false, fingerprint: 'stamp-restarted'})
- await mounted.refetch()
+ await coreControl.setStaleness(staleness(false, 'stamp-restarted'))
+ await pane.refetch()
await expect.element(page.getByRole('alert')).not.toBeInTheDocument()
})
test('a dismissed notice stays down while the engine is stale in the very same way', async () => {
- const mounted = mountNotice({engineStale: true})
+ const pane = await mountNotice(staleness(true, 'stamp-boot'))
await page.getByRole('button', {name: 'Dismiss'}).click()
await expect.element(page.getByRole('alert')).not.toBeInTheDocument()
- await mounted.refetch()
- await core?.idle()
+ await pane.refetch()
+ await settleEngine(pane)
await expect.element(page.getByRole('alert')).not.toBeInTheDocument()
})
test('a further rebuild speaks up again even after the earlier notice was dismissed', async () => {
- const mounted = mountNotice({engineStale: true})
+ const pane = await mountNotice(staleness(true, 'stamp-boot'))
await page.getByRole('button', {name: 'Dismiss'}).click()
await expect.element(page.getByRole('alert')).not.toBeInTheDocument()
- core?.setEngine({stale: true, fingerprint: 'stamp-rebuilt-again'})
- await mounted.refetch()
+ await coreControl.setStaleness(staleness(true, 'stamp-rebuilt-again'))
+ await pane.refetch()
await expect.element(page.getByRole('alert')).toBeVisible()
})
diff --git a/apps/conciv/test/fake-core-socket.browser.test.ts b/apps/conciv/test/fake-core-socket.browser.test.ts
deleted file mode 100644
index 9248dc814..000000000
--- a/apps/conciv/test/fake-core-socket.browser.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {describe, expect, it} from 'vitest'
-import {z} from 'zod'
-import type {RpcClient} from '@conciv/contract'
-import {rpcOverWebsocket} from '@conciv/harness-testkit/rpc-websocket-client'
-
-const AddressSchema = z.object({base: z.string(), wsUrl: z.string()})
-
-async function openFakeCore(): Promise<{client: RpcClient; socket: WebSocket}> {
- const payload: unknown = await fetch('/__fake-core').then((response) => response.json())
- const socket = new WebSocket(AddressSchema.parse(payload).wsUrl)
- return {client: rpcOverWebsocket(socket), socket}
-}
-
-describe('the apps/conciv fake core answers the production rpc client over a real websocket', () => {
- it('serves the canned session list to a client that never touches http', async () => {
- const {client, socket} = await openFakeCore()
- const sessions = await client.sessions.list(undefined)
- expect(sessions[0]?.id).toBe('conciv_1')
- socket.close()
- })
-
- it('streams the transcript snapshot and the run chunks a send produces', async () => {
- const {client, socket} = await openFakeCore()
- const abort = new AbortController()
- const stream = await client.chat.subscribe({sessionId: 'conciv_1'}, {signal: abort.signal})
- const seen: string[] = []
- async function collect(): Promise {
- for await (const chunk of stream) {
- const type = typeof chunk === 'object' && chunk !== null && 'type' in chunk ? String(chunk.type) : ''
- seen.push(type)
- if (type === 'RUN_FINISHED') return
- }
- }
- const collected = collect()
- await client.chat.send({sessionId: 'conciv_1', runId: 'conciv_run_1', text: 'hello'})
- await collected
- abort.abort()
- expect(seen[0]).toBe('MESSAGES_SNAPSHOT')
- expect(seen).toContain('RUN_STARTED')
- expect(seen.at(-1)).toBe('RUN_FINISHED')
- socket.close()
- })
-})
diff --git a/apps/conciv/test/helpers/core-control.ts b/apps/conciv/test/helpers/core-control.ts
new file mode 100644
index 000000000..0f805a36f
--- /dev/null
+++ b/apps/conciv/test/helpers/core-control.ts
@@ -0,0 +1,27 @@
+import {commands} from 'vitest/browser'
+import type {EngineStaleness} from '@conciv/contract'
+import type {ScriptedTurn} from '@conciv/harness-testkit'
+import type {BootCoreInput, BootCoreResult, FaultSpec} from '../commands/core-control.js'
+
+declare module 'vitest/internal/browser' {
+ interface BrowserCommands {
+ bootCore: (input: BootCoreInput) => Promise
+ closeCore: () => Promise
+ setStaleness: (value: EngineStaleness) => Promise
+ holdTurn: () => Promise
+ releaseTurn: () => Promise
+ scriptError: (message: string) => Promise
+ scriptTurn: (turn: ScriptedTurn) => Promise
+ setTerminalLaunch: (succeeds: boolean) => Promise
+ terminalLaunches: () => Promise
+ rpcCallCount: (path: string[]) => Promise
+ rpcMark: () => Promise
+ awaitRpcCall: (path: string[], since: number) => Promise
+ installFault: (spec: FaultSpec) => Promise
+ releaseFault: (handle: string) => Promise
+ faultPending: (handle: string) => Promise
+ awaitFaultPending: (handle: string, count: number) => Promise
+ }
+}
+
+export const coreControl = commands
diff --git a/apps/conciv/test/helpers/core-session.ts b/apps/conciv/test/helpers/core-session.ts
new file mode 100644
index 000000000..4df9d492b
--- /dev/null
+++ b/apps/conciv/test/helpers/core-session.ts
@@ -0,0 +1,58 @@
+import {EventType} from '@tanstack/ai'
+import {makeRpcClient, type RpcClient} from '@conciv/contract'
+
+export type SeededDraft = {text?: string; grabs?: string[]}
+
+export type TranscriptStream = {awaitTurnEnd: () => Promise; close: () => void}
+
+export function coreRpc(base: string): RpcClient {
+ return makeRpcClient(base)
+}
+
+export async function createSession(rpc: RpcClient): Promise {
+ const {sessionId} = await rpc.sessions.create()
+ return sessionId
+}
+
+export async function seedDraft(rpc: RpcClient, sessionId: string, draft: SeededDraft): Promise {
+ const text = draft.text ?? ''
+ await rpc.drafts.set({
+ sessionId,
+ text,
+ selectionStart: text.length,
+ selectionEnd: text.length,
+ grabs: draft.grabs ?? [],
+ })
+}
+
+function isFinal(chunk: unknown): boolean {
+ if (typeof chunk !== 'object' || chunk === null || !('type' in chunk)) return false
+ return chunk.type === EventType.RUN_FINISHED || chunk.type === EventType.RUN_ERROR
+}
+
+export async function openTranscriptStream(rpc: RpcClient, sessionId: string): Promise {
+ const controller = new AbortController()
+ const iterator = await rpc.chat.subscribe({sessionId}, {signal: controller.signal})
+ return {
+ awaitTurnEnd: async () => {
+ for await (const chunk of iterator) {
+ if (isFinal(chunk)) return
+ }
+ },
+ close: () => controller.abort(),
+ }
+}
+
+export async function sendTurn(rpc: RpcClient, sessionId: string, text: string): Promise {
+ await rpc.chat.send({sessionId, runId: crypto.randomUUID(), text})
+}
+
+export async function runTurn(rpc: RpcClient, sessionId: string, text: string): Promise {
+ const stream = await openTranscriptStream(rpc, sessionId)
+ try {
+ await sendTurn(rpc, sessionId, text)
+ await stream.awaitTurnEnd()
+ } finally {
+ stream.close()
+ }
+}
diff --git a/apps/conciv/test/helpers/fake-core-router.ts b/apps/conciv/test/helpers/fake-core-router.ts
deleted file mode 100644
index a2b37988d..000000000
--- a/apps/conciv/test/helpers/fake-core-router.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import {implement} from '@orpc/server'
-import {EventType, type StreamChunk} from '@tanstack/ai'
-import {contract} from '@conciv/contract'
-import type {RpcContext} from '@conciv/protocol/rpc-types'
-import {sessionRow} from './fake-core.js'
-
-const os = implement(contract).$context()
-
-const RUN_ID = 'conciv_run_1'
-
-export type FakeCoreStream = {
- push: (chunk: StreamChunk) => void
- subscribeCount: () => number
-}
-
-function makeStream(): {
- stream: FakeCoreStream
- subscribe: (signal: AbortSignal) => AsyncGenerator
-} {
- const listeners = new Set<(chunk: StreamChunk) => void>()
- const opened = {count: 0}
- return {
- stream: {
- push: (chunk) => {
- for (const listener of listeners) listener(chunk)
- },
- subscribeCount: () => opened.count,
- },
- subscribe: async function* (signal: AbortSignal): AsyncGenerator {
- opened.count += 1
- const queue: StreamChunk[] = [{type: EventType.MESSAGES_SNAPSHOT, messages: []}]
- const waiter = {wake: () => {}}
- const listener = (chunk: StreamChunk): void => {
- queue.push(chunk)
- waiter.wake()
- }
- listeners.add(listener)
- try {
- while (!signal.aborted) {
- const next = queue.shift()
- if (next !== undefined) {
- yield next
- continue
- }
- await new Promise((resolve) => {
- waiter.wake = resolve
- signal.addEventListener('abort', () => resolve(), {once: true})
- })
- }
- } finally {
- listeners.delete(listener)
- }
- },
- }
-}
-
-export function makeFakeCoreRouter(): {router: ReturnType; stream: FakeCoreStream} {
- const {stream, subscribe} = makeStream()
- return {router: buildRouter(subscribe, stream), stream}
-}
-
-function buildRouter(subscribe: (signal: AbortSignal) => AsyncGenerator, stream: FakeCoreStream) {
- return {
- sessions: {
- list: os.sessions.list.handler(() => [sessionRow({id: 'conciv_1'})]),
- create: os.sessions.create.handler(() => ({sessionId: 'conciv_2'})),
- compact: os.sessions.compact.handler((): {ok: true} => ({ok: true})),
- },
- drafts: {
- get: os.drafts.get.handler(() => null),
- set: os.drafts.set.handler((): {ok: true} => ({ok: true})),
- },
- markers: {
- list: os.markers.list.handler(() => []),
- },
- captures: {
- list: os.captures.list.handler(() => ({captures: [], cssBundles: {}})),
- },
- meta: {
- models: os.meta.models.handler(() => ({
- models: [{id: 'model-1', name: 'Fable'}],
- defaultModel: 'model-1',
- harness: {id: 'claude', name: 'Claude', canLaunch: true, imageInput: false},
- })),
- commands: os.meta.commands.handler(() => ({commands: []})),
- tools: os.meta.tools.handler(() => ({tools: []})),
- engine: os.meta.engine.handler(() => ({
- stale: false,
- changed: [],
- tracked: ['@conciv/core'],
- bootedAt: 0,
- fingerprint: 'stamp-boot',
- })),
- },
- chat: {
- subscribe: os.chat.subscribe.handler(({signal}) => subscribe(signal ?? new AbortController().signal)),
- stop: os.chat.stop.handler((): {ok: true} => ({ok: true})),
- send: os.chat.send.handler((): {ok: true; runId: string} => {
- queueMicrotask(() => {
- stream.push({type: EventType.RUN_STARTED, threadId: 'conciv_1', runId: RUN_ID})
- stream.push({type: EventType.RUN_FINISHED, threadId: 'conciv_1', runId: RUN_ID})
- })
- return {ok: true, runId: RUN_ID}
- }),
- },
- }
-}
diff --git a/apps/conciv/test/helpers/fake-core.ts b/apps/conciv/test/helpers/fake-core.ts
deleted file mode 100644
index 3e15ba7f1..000000000
--- a/apps/conciv/test/helpers/fake-core.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-import {onlineManager} from '@tanstack/query-core'
-import {
- browserRpcConnection,
- closeBrowserRpcConnection,
- type DraftRow,
- type MarkerRow,
- type SessionMeta,
-} from '@conciv/contract'
-import '../../src/lib/api-base.js'
-
-export const CORE_BASE = 'http://conciv.test'
-
-export type CoreCall = {path: string; body: unknown}
-
-export type FakeCore = {
- calls: CoreCall[]
- push: (chunk: unknown) => void
- subscribeCount: () => number
- releaseSnapshot: () => void
- idle: () => Promise
- restore: () => void
- setEngine: (next: {stale: boolean; fingerprint?: string}) => void
- setNetworkFail: (fail: boolean) => void
- setResolveRejects: (fail: boolean) => void
- setRejectEngineProbe: (fail: boolean) => void
- setResolveTransportFails: (fail: boolean) => void
-}
-
-const QUIET_MS = 60
-
-export type FakeCoreConfig = {
- draft?: DraftRow | null
- delays?: Record
- sessions?: SessionMeta[]
- rejectSend?: boolean
- throwSend?: boolean
- snapshotFor?: (subscribeIndex: number) => unknown[]
- holdSnapshot?: boolean
- markers?: MarkerRow[]
- holdRun?: boolean
- launchOk?: boolean
- launchRejects?: boolean
- engineStale?: boolean
- networkFail?: boolean
- resolveRejects?: boolean
- rejectEngineProbe?: boolean
- resolveTransportFails?: boolean
-}
-
-export function sessionRow(overrides: Partial & {id: string}): SessionMeta {
- return {
- title: 'rename the widget package',
- updatedAt: Date.now(),
- messageCount: 3,
- running: false,
- origin: 'conciv',
- usage: null,
- model: null,
- hidden: false,
- native: null,
- ...overrides,
- }
-}
-
-function reply(value: unknown): Response {
- return new Response(JSON.stringify({json: value, meta: []}), {
- status: 200,
- headers: {'content-type': 'application/json'},
- })
-}
-
-function frame(chunk: unknown): Uint8Array {
- return new TextEncoder().encode(`event: message\ndata: ${JSON.stringify({json: chunk, meta: []})}\n\n`)
-}
-
-async function bodyOf(request: Request): Promise {
- try {
- const parsed: unknown = await request.clone().json()
- if (typeof parsed === 'object' && parsed !== null && 'json' in parsed) return parsed.json
- return parsed
- } catch {
- return null
- }
-}
-
-const RUN_ID = 'conciv_run_1'
-
-function delayFor(schedule: number | number[] | undefined, callIndex: number): number {
- if (schedule === undefined) return 0
- if (typeof schedule === 'number') return schedule
- return schedule[Math.min(callIndex, schedule.length - 1)] ?? 0
-}
-
-export function installFakeCore(config: FakeCoreConfig = {}): FakeCore {
- const realFetch = globalThis.fetch
- const engine = {stale: config.engineStale ?? false, fingerprint: 'stamp-boot'}
- const calls: CoreCall[] = []
- let subscribes = 0
- let snapshotReleased = false
- let networkFail = config.networkFail ?? false
- let resolveRejects = config.resolveRejects ?? false
- let rejectEngineProbe = config.rejectEngineProbe ?? false
- let resolveTransportFails = config.resolveTransportFails ?? false
- if (typeof window !== 'undefined') window.__CONCIV_API_BASE__ = CORE_BASE
- let inFlight = 0
- let quietTimer: ReturnType | undefined
- const waitingForIdle: (() => void)[] = []
- const scheduleIdle = () => {
- if (quietTimer !== undefined) clearTimeout(quietTimer)
- if (inFlight > 0) return
- quietTimer = setTimeout(() => {
- for (const resolve of waitingForIdle.splice(0)) resolve()
- }, QUIET_MS)
- }
- const core: FakeCore = {
- calls,
- push: () => {},
- subscribeCount: () => subscribes,
- releaseSnapshot: () => {
- snapshotReleased = true
- },
- idle: () =>
- new Promise((resolve) => {
- waitingForIdle.push(resolve)
- scheduleIdle()
- }),
- restore: () => {
- globalThis.fetch = realFetch
- closeBrowserRpcConnection(CORE_BASE)
- onlineManager.setOnline(true)
- if (typeof window !== 'undefined') delete window.__CONCIV_API_BASE__
- },
- setEngine: (next) => {
- engine.stale = next.stale
- if (next.fingerprint !== undefined) engine.fingerprint = next.fingerprint
- },
- setNetworkFail: (fail) => {
- networkFail = fail
- },
- setResolveRejects: (fail) => {
- resolveRejects = fail
- },
- setRejectEngineProbe: (fail) => {
- rejectEngineProbe = fail
- },
- setResolveTransportFails: (fail) => {
- resolveTransportFails = fail
- },
- }
-
- const liveStream = (signal: AbortSignal): Response => {
- subscribes += 1
- const messages = config.snapshotFor?.(subscribes) ?? []
- const stream = new ReadableStream({
- start: (controller) => {
- const sendSnapshot = () => controller.enqueue(frame({type: 'MESSAGES_SNAPSHOT', messages}))
- const held = config.holdSnapshot === true && !snapshotReleased
- if (held) core.releaseSnapshot = sendSnapshot
- if (!held) sendSnapshot()
- core.push = (chunk) => controller.enqueue(frame(chunk))
- signal.addEventListener('abort', () => {
- core.push = () => {}
- controller.error(new DOMException('aborted', 'AbortError'))
- })
- },
- })
- return new Response(stream, {status: 200, headers: {'content-type': 'text/event-stream'}})
- }
-
- const routes: Record Response> = {
- '/rpc/sessions/list': () => reply(config.sessions ?? [sessionRow({id: 'conciv_1'})]),
- '/rpc/sessions/resolve': () => {
- if (resolveTransportFails) throw new TypeError('Failed to fetch')
- return resolveRejects
- ? new Response('resolve refused', {status: 500})
- : reply({sessionId: config.sessions?.[0]?.id ?? 'conciv_1'})
- },
- '/rpc/sessions/create': () => reply({sessionId: 'conciv_2'}),
- '/rpc/sessions/compact': () => reply({ok: true}),
- '/rpc/drafts/get': () => reply(config.draft ?? null),
- '/rpc/drafts/set': () => reply({ok: true}),
- '/rpc/markers/list': () => reply(config.markers ?? []),
- '/rpc/captures/list': () => reply({captures: [], cssBundles: {}}),
- '/rpc/meta/models': () =>
- reply({
- models: [{id: 'model-1', name: 'Fable'}],
- defaultModel: 'model-1',
- harness: {id: 'claude', name: 'Claude', canLaunch: true, imageInput: false},
- }),
- '/rpc/meta/commands': () => reply({commands: []}),
- '/rpc/meta/engine': () =>
- rejectEngineProbe
- ? new Response('engine probe refused', {status: 500})
- : reply({
- stale: engine.stale,
- changed: engine.stale ? ['@conciv/tools'] : [],
- tracked: ['@conciv/core', '@conciv/tools'],
- bootedAt: 0,
- fingerprint: engine.fingerprint,
- }),
- '/rpc/meta/tools': () => reply({tools: []}),
- '/rpc/registry/catalog': () => reply([]),
- '/rpc/chat/subscribe': (_body, signal) => liveStream(signal),
- '/rpc/chat/stop': () => reply({ok: true}),
- '/rpc/chat/send': () => {
- if (config.throwSend) throw new TypeError('Failed to fetch')
- if (config.rejectSend) return new Response('send refused', {status: 500})
- queueMicrotask(() => {
- core.push({type: 'RUN_STARTED', threadId: 'conciv_1', runId: RUN_ID})
- if (config.holdRun) return
- core.push({type: 'RUN_FINISHED', threadId: 'conciv_1', runId: RUN_ID, finishReason: 'stop'})
- })
- return reply({ok: true, runId: RUN_ID})
- },
- '/rpc/ext/terminal/launch': () => {
- if (config.launchRejects) return new Response('no terminal', {status: 500})
- return reply({ok: config.launchOk ?? true})
- },
- '/rpc/ext/terminal/connectCommand': () => reply({command: 'claude --resume fake-session'}),
- }
-
- globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => {
- const request = input instanceof Request ? input : new Request(input, init)
- const url = new URL(request.url)
- if (url.origin !== CORE_BASE) return realFetch(input, init)
- if (networkFail) throw new TypeError('Failed to fetch')
- const route = routes[url.pathname]
- if (!route) throw new Error(`the fake core has no route for ${url.pathname}`)
- inFlight += 1
- scheduleIdle()
- const body = await bodyOf(request)
- const priorCalls = calls.filter((call) => call.path === url.pathname).length
- calls.push({path: url.pathname, body})
- const delay = delayFor(config.delays?.[url.pathname], priorCalls)
- if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
- const response = route(body, request.signal)
- inFlight -= 1
- scheduleIdle()
- return response
- }
- browserRpcConnection(CORE_BASE, 'fetch')
- return core
-}
diff --git a/apps/conciv/test/helpers/pane-harness.tsx b/apps/conciv/test/helpers/pane-harness.tsx
index 8fc2b3d89..bc8ffd8a1 100644
--- a/apps/conciv/test/helpers/pane-harness.tsx
+++ b/apps/conciv/test/helpers/pane-harness.tsx
@@ -1,7 +1,8 @@
import {createRoot, createSignal, type JSX} from 'solid-js'
import {render} from '@solidjs/testing-library'
import {QueryClient, QueryClientProvider} from '@tanstack/solid-query'
-import {makeRpcClient} from '@conciv/contract'
+import {browserRpcConnection, closeBrowserRpcConnection, makeRpcClient} from '@conciv/contract'
+import '../../src/lib/api-base.js'
import {HostApiProvider} from '@conciv/extension/host'
import {AppContext, type AppContextValue} from '../../src/app/context.js'
import {EngineReachabilityContext, makeEngineReachability} from '../../src/app/reachability.js'
@@ -11,18 +12,19 @@ import {
makePendingAttachmentQueue,
type PaneContextValue,
} from '../../src/app/pane-context.js'
-import {makeAppData} from '../../src/data/app-data.js'
+import {makeAppData, type AppData} from '../../src/data/app-data.js'
import {parseConcivSettings} from '../../src/data/settings.js'
import {makeLayerStack} from '../../src/shell/dialogs.js'
import {NoticeContextProvider, NoticeSurface} from '../../src/shell/notice-context.js'
-import {CORE_BASE} from './fake-core.js'
-export const PANE_SESSION = 'conciv_1'
+export type PaneMountOptions = {base: string; sessionId: string}
export type PaneMount = {
dispose: () => void
announced: () => string[]
pane: PaneContextValue
+ data: AppData
+ queryClient: QueryClient
refetch: () => Promise
}
@@ -34,8 +36,10 @@ function AnnounceLog(props: {entries: () => string[]}): JSX.Element {
)
}
-export function mountPane(view: (pane: PaneContextValue) => JSX.Element): PaneMount {
- const rpc = makeRpcClient(CORE_BASE)
+export function mountPane(options: PaneMountOptions, view: (pane: PaneContextValue) => JSX.Element): PaneMount {
+ const rpc = makeRpcClient(options.base)
+ window.__CONCIV_API_BASE__ = options.base
+ browserRpcConnection(options.base, 'fetch')
const queryClient = new QueryClient()
const data = makeAppData(rpc, queryClient)
const [announced, setAnnounced] = createSignal([])
@@ -55,10 +59,10 @@ export function mountPane(view: (pane: PaneContextValue) => JSX.Element): PaneMo
connectBind: async () => '',
connectMode: false,
connectionGeneration: () => 0,
- apiBase: () => CORE_BASE,
+ apiBase: () => options.base,
}
const pane: PaneContextValue = {
- sessionId: () => PANE_SESSION,
+ sessionId: () => options.sessionId,
running: () => false,
viewLocked: () => false,
setLockedFor: () => () => {},
@@ -96,9 +100,13 @@ export function mountPane(view: (pane: PaneContextValue) => JSX.Element): PaneMo
dispose: () => {
mounted.unmount()
reachabilityRoot.dispose()
+ closeBrowserRpcConnection(options.base)
+ delete window.__CONCIV_API_BASE__
},
announced,
pane,
+ data,
+ queryClient,
refetch: () => queryClient.invalidateQueries({queryKey: data.utils.meta.engine.key()}),
}
}
diff --git a/apps/conciv/test/helpers/proxy.ts b/apps/conciv/test/helpers/proxy.ts
index 88e5ac37c..20c8f884f 100644
--- a/apps/conciv/test/helpers/proxy.ts
+++ b/apps/conciv/test/helpers/proxy.ts
@@ -2,14 +2,26 @@ import {createServer, request as httpRequest, type IncomingMessage, type Server}
import type {Duplex} from 'node:stream'
import {listenLocal} from './listen-local.js'
+const AWAIT_REQUEST_TIMEOUT_MS = 4000
+
+export type AwaitRequestOptions = {since?: number; timeout?: number}
+
export type ProxyCore = {
base: string
port: number
- requestCount: () => number
+ requestCount: (pathname?: string) => number
wsConnectionCount: () => number
+ mark: () => number
+ awaitRequest: (pathname: string, options?: AwaitRequestOptions) => Promise
+ fail: (pathname: string) => void
+ repair: () => void
close: () => Promise
}
+type Completion = {pathname: string; at: number}
+
+type Waiter = {pathname: string; since: number; deliver: () => void}
+
function handshakeResponse(upstream: IncomingMessage): string {
const statusLine = `HTTP/1.1 ${upstream.statusCode ?? 101} ${upstream.statusMessage ?? 'Switching Protocols'}`
const headers = upstream.rawHeaders.reduce((lines, value, index) => {
@@ -21,11 +33,33 @@ function handshakeResponse(upstream: IncomingMessage): string {
export async function proxyTo(targetBase: string, opts: {blockUpgrades?: boolean} = {}): Promise {
const target = new URL(targetBase)
- let count = 0
+ const seen: string[] = []
+ const refused = new Set()
let upgrades = 0
const piped = new Set()
+ const completions: Completion[] = []
+ const waiters = new Set()
+ const sequence = {next: 0}
+
+ const settle = (pathname: string): void => {
+ const at = (sequence.next += 1)
+ completions.push({pathname, at})
+ for (const waiter of waiters) {
+ if (waiter.pathname !== pathname || at <= waiter.since) continue
+ waiters.delete(waiter)
+ waiter.deliver()
+ }
+ }
+
const server: Server = createServer((req, res) => {
- count += 1
+ const pathname = new URL(req.url ?? '/', target).pathname
+ seen.push(pathname)
+ res.on('finish', () => settle(pathname))
+ if (refused.has(pathname)) {
+ res.writeHead(500, {'content-type': 'text/plain'})
+ res.end('the proxied core refused the call')
+ return
+ }
const proxyReq = httpRequest(
{
hostname: target.hostname,
@@ -78,8 +112,38 @@ export async function proxyTo(targetBase: string, opts: {blockUpgrades?: boolean
return {
base,
port,
- requestCount: () => count,
+ requestCount: (pathname) =>
+ pathname === undefined ? seen.length : seen.filter((entry) => entry === pathname).length,
wsConnectionCount: () => upgrades,
+ mark: () => sequence.next,
+ awaitRequest: (pathname, options = {}) => {
+ const since = options.since ?? sequence.next
+ if (completions.some((entry) => entry.pathname === pathname && entry.at > since)) return Promise.resolve()
+ const timeoutMs = options.timeout ?? AWAIT_REQUEST_TIMEOUT_MS
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ waiters.delete(waiter)
+ reject(
+ new Error(
+ `no proxied request to ${pathname} completed within ${timeoutMs}ms (completed paths: ${completions.map((entry) => entry.pathname).join(', ')})`,
+ ),
+ )
+ }, timeoutMs)
+ const waiter: Waiter = {
+ pathname,
+ since,
+ deliver: () => {
+ clearTimeout(timer)
+ resolve()
+ },
+ }
+ waiters.add(waiter)
+ })
+ },
+ fail: (pathname) => {
+ refused.add(pathname)
+ },
+ repair: () => refused.clear(),
close: async () => {
for (const socket of piped) socket.destroy()
piped.clear()
diff --git a/apps/conciv/test/helpers/retry-recovery.ts b/apps/conciv/test/helpers/retry-recovery.ts
index c9a0b9ec7..5500dd9b0 100644
--- a/apps/conciv/test/helpers/retry-recovery.ts
+++ b/apps/conciv/test/helpers/retry-recovery.ts
@@ -4,11 +4,11 @@ import {page} from 'vitest/browser'
type Locator = ReturnType
export async function expectRetryRecovers(
- clearFailure: () => void,
+ clearFailure: () => Promise,
editor: () => Locator,
failureLocator: () => Locator,
): Promise {
- clearFailure()
+ await clearFailure()
await page.getByRole('button', {name: 'Retry'}).click()
await expect.element(editor(), {timeout: 8000}).toBeVisible()
await expect.element(failureLocator()).not.toBeInTheDocument()
diff --git a/apps/conciv/test/helpers/shell-harness.tsx b/apps/conciv/test/helpers/shell-harness.tsx
index b65a57347..9b158f283 100644
--- a/apps/conciv/test/helpers/shell-harness.tsx
+++ b/apps/conciv/test/helpers/shell-harness.tsx
@@ -1,41 +1,42 @@
+import {onlineManager} from '@tanstack/query-core'
import {render} from '@solidjs/testing-library'
import {RouterProvider, createMemoryHistory} from '@tanstack/solid-router'
-import {makeBrowserRpcClient} from '@conciv/contract'
+import {closeBrowserRpcConnection, makeBrowserRpcClient} from '@conciv/contract'
import type {AnyExtension} from '@conciv/extension'
+import '../../src/lib/api-base.js'
import {parseConcivSettings} from '../../src/data/settings.js'
import {createConcivRouter, disposeConcivRouter} from '../../src/router.js'
-import {CORE_BASE, installFakeCore, sessionRow, type FakeCore, type FakeCoreConfig} from './fake-core.js'
export type ShellHarness = {
- mountShell: (entry: string, config?: FakeCoreConfig, extensions?: AnyExtension[]) => void
- core: () => FakeCore | null
+ mountShell: (entry: string, extensions?: AnyExtension[]) => void
dispose: () => void
}
-export function createShellHarness(sessionId: string): ShellHarness {
- let core: FakeCore | null = null
- let mountedRouter: ReturnType | null = null
+export function createShellHarness(base: () => string): ShellHarness {
+ const mounted: {router: ReturnType | null} = {router: null}
- const mountShell = (entry: string, config: FakeCoreConfig = {}, extensions: AnyExtension[] = []): void => {
- core = installFakeCore({sessions: [sessionRow({id: sessionId})], ...config})
+ const mountShell = (entry: string, extensions: AnyExtension[] = []): void => {
+ const apiBase = base()
+ window.__CONCIV_API_BASE__ = apiBase
const router = createConcivRouter({
- rpc: makeBrowserRpcClient(CORE_BASE, {transport: 'fetch'}).rpc,
+ rpc: makeBrowserRpcClient(apiBase, {transport: 'fetch'}).rpc,
history: createMemoryHistory({initialEntries: [entry]}),
environment: {rootNode: document, document},
settings: parseConcivSettings(''),
- apiBase: () => CORE_BASE,
+ apiBase: () => apiBase,
extensions,
})
- mountedRouter = router
+ mounted.router = router
render(() => )
}
const dispose = (): void => {
- if (mountedRouter) disposeConcivRouter(mountedRouter)
- mountedRouter = null
- core?.restore()
- core = null
+ if (mounted.router) disposeConcivRouter(mounted.router)
+ mounted.router = null
+ closeBrowserRpcConnection(base())
+ onlineManager.setOnline(true)
+ delete window.__CONCIV_API_BASE__
}
- return {mountShell, core: () => core, dispose}
+ return {mountShell, dispose}
}
diff --git a/apps/conciv/test/helpers/tracked-faults.ts b/apps/conciv/test/helpers/tracked-faults.ts
new file mode 100644
index 000000000..0dc872c0c
--- /dev/null
+++ b/apps/conciv/test/helpers/tracked-faults.ts
@@ -0,0 +1,22 @@
+import {afterEach} from 'vitest'
+import type {FaultSpec} from '../commands/core-control.js'
+import {coreControl} from './core-control.js'
+
+export type TrackedFaults = {
+ install: (spec: FaultSpec) => Promise
+ releaseAll: () => Promise
+}
+
+export function trackedFaults(): TrackedFaults {
+ const handles: string[] = []
+ const install = async (spec: FaultSpec): Promise => {
+ const handle = await coreControl.installFault(spec)
+ handles.push(handle)
+ return handle
+ }
+ const releaseAll = async (): Promise => {
+ for (const handle of handles.splice(0)) await coreControl.releaseFault(handle)
+ }
+ afterEach(releaseAll)
+ return {install, releaseAll}
+}
diff --git a/apps/conciv/test/launch-actions.browser.test.tsx b/apps/conciv/test/launch-actions.browser.test.tsx
index 8b4c4dfda..6e9141a9d 100644
--- a/apps/conciv/test/launch-actions.browser.test.tsx
+++ b/apps/conciv/test/launch-actions.browser.test.tsx
@@ -1,17 +1,19 @@
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
import type {GrabApi} from '@conciv/grab'
import {HostApiProvider} from '@conciv/extension/host'
import {ComposerActions} from '../src/composer/actions.js'
-import {installFakeCore, type FakeCore} from './helpers/fake-core.js'
-import {mountPane, PANE_SESSION} from './helpers/pane-harness.js'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession} from './helpers/core-session.js'
+import {mountPane, type PaneMount} from './helpers/pane-harness.js'
+import {trackedFaults} from './helpers/tracked-faults.js'
-let core: FakeCore | null = null
+const CONNECT_COMMAND_PATH = ['ext', 'terminal', 'connectCommand']
+const LAUNCH_PATH = ['ext', 'terminal', 'launch']
-afterEach(() => {
- core?.restore()
- core = null
-})
+const core = {base: ''}
+const mounted: {pane: PaneMount | null} = {pane: null}
+const faults = trackedFaults()
const grabApi: GrabApi = {
pick: async () => null,
@@ -23,13 +25,34 @@ const grabApi: GrabApi = {
clear: () => {},
}
-function mountActions(config: Parameters[0] = {}): FakeCore {
- const fake = installFakeCore(config)
- core = fake
- mountPane(() => (
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({
+ id: 'launch-actions',
+ displayName: 'Claude',
+ connect: true,
+ terminal: true,
+ resume: true,
+ allowedOrigins: [window.location.origin],
+ })
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
+
+afterEach(async () => {
+ mounted.pane?.dispose()
+ mounted.pane = null
+ await coreControl.setTerminalLaunch(true)
+})
+
+async function mountActions(): Promise {
+ const sessionId = await createSession(coreRpc(core.base))
+ mounted.pane = mountPane({base: core.base, sessionId}, () => (
{}}>
{}}
onNewSession={() => {}}
@@ -37,7 +60,6 @@ function mountActions(config: Parameters[0] = {}): FakeC
/>
))
- return fake
}
async function openMenu(): Promise {
@@ -45,7 +67,7 @@ async function openMenu(): Promise {
}
test('the terminal menu offers launch and copy, and the connect candidates are gone', async () => {
- mountActions()
+ await mountActions()
await openMenu()
@@ -55,44 +77,47 @@ test('the terminal menu offers launch and copy, and the connect candidates are g
})
test('opening externally launches through the terminal extension', async () => {
- const fake = mountActions({launchOk: true})
+ await mountActions()
await openMenu()
await page.getByText('Open in Claude').click()
await expect.element(page.getByText('Opened in Claude.')).toBeVisible()
- expect(fake.calls.filter((call) => call.path === '/rpc/ext/terminal/launch')).toHaveLength(1)
+ expect(await coreControl.terminalLaunches()).toBe(1)
+ expect(await coreControl.rpcCallCount(LAUNCH_PATH)).toBe(1)
})
test('when the terminal cannot open, the connect command is offered instead', async () => {
- const fake = mountActions({launchOk: false})
+ await coreControl.setTerminalLaunch(false)
+ await mountActions()
+ const before = await coreControl.rpcCallCount(CONNECT_COMMAND_PATH)
await openMenu()
await page.getByText('Open in Claude').click()
- await expect
- .element(page.getByText(/Command copied|Run in your terminal: claude --resume fake-session/))
- .toBeVisible()
- expect(fake.calls.filter((call) => call.path === '/rpc/ext/terminal/connectCommand')).toHaveLength(1)
+ await expect.element(page.getByText(/Command copied|Run in your terminal: cd .*'claude' '--resume'/)).toBeVisible()
+ expect((await coreControl.rpcCallCount(CONNECT_COMMAND_PATH)) - before).toBe(1)
})
test('copy command asks the terminal extension for the command', async () => {
- const fake = mountActions()
+ await mountActions()
+ const before = await coreControl.rpcCallCount(CONNECT_COMMAND_PATH)
await openMenu()
await page.getByText('Copy command').click()
- await expect
- .element(page.getByText(/Command copied|Run in your terminal: claude --resume fake-session/))
- .toBeVisible()
- expect(fake.calls.filter((call) => call.path === '/rpc/ext/terminal/connectCommand')).toHaveLength(1)
+ await expect.element(page.getByText(/Command copied|Run in your terminal: cd .*'claude' '--resume'/)).toBeVisible()
+ expect((await coreControl.rpcCallCount(CONNECT_COMMAND_PATH)) - before).toBe(1)
})
test('a launch failure is surfaced instead of swallowed', async () => {
- mountActions({launchRejects: true})
+ const fault = await faults.install({kind: 'fail', path: LAUNCH_PATH, status: 500})
+ await mountActions()
await openMenu()
await page.getByText('Open in Claude').click()
await expect.element(page.getByText('Couldn’t open Claude.')).toBeVisible()
+
+ await coreControl.releaseFault(fault)
})
diff --git a/apps/conciv/test/page-session-card.browser.test.tsx b/apps/conciv/test/page-session-card.browser.test.tsx
index 6bbec862d..701734099 100644
--- a/apps/conciv/test/page-session-card.browser.test.tsx
+++ b/apps/conciv/test/page-session-card.browser.test.tsx
@@ -1,46 +1,45 @@
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
import {ChatPane} from '../src/pane/chat-pane.js'
-import {installFakeCore, sessionRow, type FakeCore} from './helpers/fake-core.js'
-import {mountPane, PANE_SESSION} from './helpers/pane-harness.js'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession, runTurn} from './helpers/core-session.js'
+import {mountPane, type PaneMount} from './helpers/pane-harness.js'
-let core: FakeCore | null = null
+const core = {base: ''}
+const mounted: {pane: PaneMount | null} = {pane: null}
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'page-session-card', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
afterEach(() => {
- core?.restore()
- core = null
+ mounted.pane?.dispose()
+ mounted.pane = null
})
-const HISTORY_TRANSCRIPT = [
- {
- id: 'a1',
- role: 'assistant',
- parts: [
- {
- type: 'tool-call',
- id: 'f1',
- name: 'page.fill',
- arguments: '{"selector":"#name","value":"Ada"}',
- state: 'complete',
- },
- {type: 'tool-result', toolCallId: 'f1', content: '{"ok":true,"value":"Ada"}', state: 'complete'},
+test('a reloaded transcript of page acts renders one aggregated session card with the reply', async () => {
+ const rpc = coreRpc(core.base)
+ const sessionId = await createSession(rpc)
+ await coreControl.scriptTurn({
+ toolCalls: [
+ {name: 'page.fill', input: {selector: '#name', value: 'Ada'}, result: {ok: true, value: 'Ada'}},
{
- type: 'tool-call',
- id: 'f2',
name: 'page.fill',
- arguments: '{"selector":"#email","value":"ada@example.com"}',
- state: 'complete',
+ input: {selector: '#email', value: 'ada@example.com'},
+ result: {ok: true, value: 'ada@example.com'},
},
- {type: 'tool-result', toolCallId: 'f2', content: '{"ok":true,"value":"ada@example.com"}', state: 'complete'},
- {type: 'text', content: 'The profile form is filled in.'},
],
- },
-]
+ text: 'The profile form is filled in.',
+ })
+ await runTurn(rpc, sessionId, 'fill in the profile form')
-test('a reloaded transcript of page acts renders one aggregated session card with the reply', async () => {
- core = installFakeCore({sessions: [sessionRow({id: PANE_SESSION})], snapshotFor: () => HISTORY_TRANSCRIPT})
- mountPane(() => )
+ mounted.pane = mountPane({base: core.base, sessionId}, () => )
await expect.element(page.getByText('Edited the page'), {timeout: 5000}).toBeVisible()
await expect.element(page.getByText('The profile form is filled in.')).toBeVisible()
diff --git a/apps/conciv/test/panel-connect.browser.test.tsx b/apps/conciv/test/panel-connect.browser.test.tsx
index 6d576c52b..99528d46e 100644
--- a/apps/conciv/test/panel-connect.browser.test.tsx
+++ b/apps/conciv/test/panel-connect.browser.test.tsx
@@ -1,15 +1,29 @@
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
import {defineExtension, getExtensionApi, type RegisterExtension} from '@conciv/extension'
import {Show, type JSX} from 'solid-js'
+import {coreControl} from './helpers/core-control.js'
import {createShellHarness} from './helpers/shell-harness.js'
+import {trackedFaults} from './helpers/tracked-faults.js'
import {expectRetryRecovers} from './helpers/retry-recovery.js'
-const PANEL_SESSION = 'conciv_1'
const FOUND_API_BASE = 'http://found.test'
const CONNECT_PROBE_NAME = 'connect-probe'
-const harness = createShellHarness(PANEL_SESSION)
+const RESOLVE_PATH = ['sessions', 'resolve']
+
+const core = {base: ''}
+const harness = createShellHarness(() => core.base)
+const faults = trackedFaults()
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'panel-connect', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
afterEach(harness.dispose)
@@ -35,8 +49,7 @@ declare module '@conciv/protocol/config-types' {
interface ExtensionRegistry extends RegisterExtension {}
}
-const mountShell = (config: Parameters[1] = {}): void =>
- harness.mountShell('/panel/connect?open=true', config, [connectProbe])
+const mountShell = (): void => harness.mountShell('/panel/connect?open=true', [connectProbe])
const simulateFound = () => page.getByRole('button', {name: 'Simulate found'})
const bindFailure = () => page.getByText(/conciv couldn.t connect to that workspace/)
@@ -44,18 +57,20 @@ const serverError = () => page.getByText('Internal Server Error')
const editor = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
test('a bind that fails with a transport failure shows the connect failure screen, and retrying against a healthy engine hands off to the panel', async () => {
- mountShell({resolveTransportFails: true})
+ const unreachable = await faults.install({kind: 'abort', path: RESOLVE_PATH})
+ mountShell()
await simulateFound().click()
await expect.element(bindFailure(), {timeout: 8000}).toBeVisible()
await expect.element(page.getByRole('button', {name: 'Retry'})).toBeVisible()
- await expectRetryRecovers(() => harness.core()?.setResolveTransportFails(false), editor, bindFailure)
-})
+ await expectRetryRecovers(() => coreControl.releaseFault(unreachable), editor, bindFailure)
+}, 30_000)
test('a bind that fails with a server error shows the actual error, not the workspace-unreachable message', async () => {
- mountShell({resolveRejects: true})
+ const refused = await faults.install({kind: 'fail', path: RESOLVE_PATH, status: 500})
+ mountShell()
await expect.element(simulateFound(), {timeout: 8000}).toBeVisible()
await simulateFound().click()
@@ -63,8 +78,8 @@ test('a bind that fails with a server error shows the actual error, not the work
await expect.element(serverError(), {timeout: 8000}).toBeVisible()
await expect.element(bindFailure()).not.toBeInTheDocument()
- await expectRetryRecovers(() => harness.core()?.setResolveRejects(false), editor, serverError)
-})
+ await expectRetryRecovers(() => coreControl.releaseFault(refused), editor, serverError)
+}, 30_000)
test('a bind that succeeds on the first try never shows the failure screen', async () => {
mountShell()
@@ -73,4 +88,4 @@ test('a bind that succeeds on the first try never shows the failure screen', asy
await expect.element(editor(), {timeout: 8000}).toBeVisible()
await expect.element(bindFailure()).not.toBeInTheDocument()
-})
+}, 30_000)
diff --git a/apps/conciv/test/panel-focus-stability.browser.test.tsx b/apps/conciv/test/panel-focus-stability.browser.test.tsx
index d579d7c76..cd3f017f8 100644
--- a/apps/conciv/test/panel-focus-stability.browser.test.tsx
+++ b/apps/conciv/test/panel-focus-stability.browser.test.tsx
@@ -1,61 +1,73 @@
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
-import {render} from '@solidjs/testing-library'
-import {RouterProvider, createMemoryHistory} from '@tanstack/solid-router'
-import {makeRpcClient} from '@conciv/contract'
-import {parseConcivSettings} from '../src/data/settings.js'
-import {createConcivRouter} from '../src/router.js'
-import {CORE_BASE, installFakeCore, sessionRow, type FakeCore} from './helpers/fake-core.js'
-
-const PANEL_SESSION = 'conciv_1'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession} from './helpers/core-session.js'
+import {createShellHarness} from './helpers/shell-harness.js'
+import {trackedFaults} from './helpers/tracked-faults.js'
+
const SETTLED = {timeout: 1500}
-let core: FakeCore | null = null
-
-afterEach(() => {
- core?.restore()
- core = null
-})
-
-function openPanel(config: Parameters[0] = {}): void {
- core = installFakeCore({sessions: [sessionRow({id: PANEL_SESSION})], ...config})
- const router = createConcivRouter({
- rpc: makeRpcClient(CORE_BASE),
- history: createMemoryHistory({initialEntries: [`/panel/${PANEL_SESSION}?open=true`]}),
- environment: {rootNode: document, document},
- settings: parseConcivSettings(''),
- })
- render(() => )
-}
+
+const core = {base: ''}
+const harness = createShellHarness(() => core.base)
+const faults = trackedFaults()
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'panel-focus-stability', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
+
+afterEach(harness.dispose)
const editor = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
-test('a composer that mounts after a slow draft load keeps the focus the panel gave it', async () => {
- openPanel({delays: {'/rpc/drafts/get': 250}})
+async function openPanel(): Promise {
+ const sessionId = await createSession(coreRpc(core.base))
+ harness.mountShell(`/panel/${sessionId}?open=true`)
+}
+
+async function expectFocusSurvivesPendingRequest(path: string[]): Promise {
+ const since = await coreControl.rpcMark()
+ const held = await faults.install({kind: 'gate', path})
+ await openPanel()
+ await coreControl.awaitFaultPending(held, 1)
await expect.element(editor(), SETTLED).toBeVisible()
const mountedNode = editor().element()
- await core?.idle()
await expect.element(editor(), SETTLED).toHaveFocus()
- expect(editor().element().isSameNode(mountedNode)).toBe(true)
-})
+ expect(await coreControl.faultPending(held)).toBe(1)
-test('a composer that mounts while the harness metadata is still loading takes the focus the panel gave it', async () => {
- openPanel({delays: {'/rpc/meta/models': 400}})
+ await coreControl.releaseFault(held)
+ await coreControl.awaitRpcCall(path, since)
- await expect.element(editor(), SETTLED).toBeVisible()
- const mountedNode = editor().element()
- await core?.idle()
await expect.element(editor(), SETTLED).toHaveFocus()
expect(editor().element().isSameNode(mountedNode)).toBe(true)
-})
+}
-test('a composer that mounts while the transcript is still loading takes the focus the panel gave it', async () => {
- openPanel({delays: {'/rpc/markers/list': 400}})
+test('a composer that mounts after a slow draft load keeps the focus the panel gave it', async () => {
+ const path = ['drafts', 'get']
+ const since = await coreControl.rpcMark()
+ const held = await faults.install({kind: 'gate', path})
+ await openPanel()
+ await coreControl.awaitFaultPending(held, 1)
+
+ await coreControl.releaseFault(held)
+ await coreControl.awaitRpcCall(path, since)
await expect.element(editor(), SETTLED).toBeVisible()
const mountedNode = editor().element()
- await core?.idle()
await expect.element(editor(), SETTLED).toHaveFocus()
expect(editor().element().isSameNode(mountedNode)).toBe(true)
-})
+}, 30_000)
+
+test('a composer that mounts while the harness metadata is still loading takes the focus the panel gave it', async () => {
+ await expectFocusSurvivesPendingRequest(['meta', 'models'])
+}, 30_000)
+
+test('a composer that mounts while the transcript is still loading takes the focus the panel gave it', async () => {
+ await expectFocusSurvivesPendingRequest(['markers', 'list'])
+}, 30_000)
diff --git a/apps/conciv/test/panel-min-height.browser.test.tsx b/apps/conciv/test/panel-min-height.browser.test.tsx
index d4b85fbdd..605975e01 100644
--- a/apps/conciv/test/panel-min-height.browser.test.tsx
+++ b/apps/conciv/test/panel-min-height.browser.test.tsx
@@ -1,48 +1,38 @@
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
-import {render} from '@solidjs/testing-library'
-import {RouterProvider, createMemoryHistory} from '@tanstack/solid-router'
-import {makeRpcClient} from '@conciv/contract'
-import {parseConcivSettings} from '../src/data/settings.js'
-import {createConcivRouter} from '../src/router.js'
-import {CORE_BASE, installFakeCore, sessionRow, type FakeCore} from './helpers/fake-core.js'
-
-const PANEL_SESSION = 'conciv_1'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession, seedDraft} from './helpers/core-session.js'
+import {createShellHarness} from './helpers/shell-harness.js'
+
const STAGED = ['the grabbed hero section', 'the grabbed price row', 'the grabbed footer', 'the grabbed nav']
-let core: FakeCore | null = null
-
-afterEach(() => {
- core?.restore()
- core = null
-})
-
-function openPanel(): void {
- core = installFakeCore({
- sessions: [sessionRow({id: PANEL_SESSION})],
- draft: {
- sessionId: PANEL_SESSION,
- text: '',
- selectionStart: 0,
- selectionEnd: 0,
- grabs: STAGED,
- updatedAt: 1,
- },
- })
- const router = createConcivRouter({
- rpc: makeRpcClient(CORE_BASE),
- history: createMemoryHistory({initialEntries: [`/panel/${PANEL_SESSION}?open=true`]}),
- environment: {rootNode: document, document},
- settings: parseConcivSettings(''),
- })
- render(() => )
+
+const core = {base: ''}
+const harness = createShellHarness(() => core.base)
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'panel-min-height', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
+
+afterEach(harness.dispose)
+
+async function openPanel(): Promise {
+ const rpc = coreRpc(core.base)
+ const sessionId = await createSession(rpc)
+ await seedDraft(rpc, sessionId, {grabs: STAGED})
+ harness.mountShell(`/panel/${sessionId}?open=true`)
}
const input = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
test('the composer stays reachable when the viewport clamps the panel below its minimum height', async () => {
await page.viewport(1000, 400)
- openPanel()
+ await openPanel()
await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
@@ -51,22 +41,22 @@ test('the composer stays reachable when the viewport clamps the panel below its
await page.getByRole('button', {name: 'Send message'}).click()
await expect.element(page.getByText('still typeable at the smallest panel')).toBeVisible()
-})
+}, 30_000)
test('the grabs strip resizes through the shared separator handle', async () => {
await page.viewport(1000, 900)
- openPanel()
+ await openPanel()
const handle = page.getByRole('separator', {name: 'Resize grabs height'})
await expect.element(handle).toBeVisible()
await expect.element(handle).toHaveAttribute('aria-valuenow', '288')
await expect.element(page.getByText('the grabbed hero section')).toBeVisible()
await expect.element(page.getByText('the grabbed nav')).toBeVisible()
-})
+}, 30_000)
test('the staged grabs come back into the flow once the panel has room again', async () => {
await page.viewport(1000, 400)
- openPanel()
+ await openPanel()
await expect.element(input()).toBeVisible()
@@ -80,4 +70,4 @@ test('the staged grabs come back into the flow once the panel has room again', a
await input().fill('room to breathe')
await page.getByRole('button', {name: 'Send message'}).click()
await expect.element(page.getByText('room to breathe')).toBeVisible()
-})
+}, 30_000)
diff --git a/apps/conciv/test/quick-add-pane.browser.test.tsx b/apps/conciv/test/quick-add-pane.browser.test.tsx
index edb3d6e1a..6d54c3cdc 100644
--- a/apps/conciv/test/quick-add-pane.browser.test.tsx
+++ b/apps/conciv/test/quick-add-pane.browser.test.tsx
@@ -1,48 +1,65 @@
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession} from './helpers/core-session.js'
import {createShellHarness} from './helpers/shell-harness.js'
-import {sessionRow} from './helpers/fake-core.js'
+import {trackedFaults} from './helpers/tracked-faults.js'
import {expectRetryRecovers} from './helpers/retry-recovery.js'
-const PANEL_SESSION = 'conciv_1'
-const harness = createShellHarness(PANEL_SESSION)
-const mountShell = (config: Parameters[1] = {}): void => harness.mountShell('/quick', config)
+const RESOLVE_PATH = ['sessions', 'resolve']
+
+const core = {base: ''}
+const harness = createShellHarness(() => core.base)
+const faults = trackedFaults()
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'quick-add-pane', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
afterEach(harness.dispose)
const startFailure = () => page.getByText(/conciv could not start a pane/)
const editor = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
+const closePane = () => page.getByRole('button', {name: 'Close pane'})
test('a quick terminal pane that fails to start shows a retry action', async () => {
- mountShell({resolveRejects: true})
+ const refused = await faults.install({kind: 'fail', path: RESOLVE_PATH, status: 500})
+ harness.mountShell('/quick')
await expect.element(startFailure(), {timeout: 8000}).toBeVisible()
await expect.element(page.getByRole('button', {name: 'Retry'})).toBeVisible()
-})
+
+ await coreControl.releaseFault(refused)
+}, 30_000)
test('retrying a failed quick terminal pane against a healthy engine starts it', async () => {
- mountShell({resolveRejects: true})
+ const refused = await faults.install({kind: 'fail', path: RESOLVE_PATH, status: 500})
+ harness.mountShell('/quick')
await expect.element(startFailure(), {timeout: 8000}).toBeVisible()
- await expectRetryRecovers(() => harness.core()?.setResolveRejects(false), editor, startFailure)
-})
+ await expectRetryRecovers(() => coreControl.releaseFault(refused), editor, startFailure)
+}, 30_000)
test('rapid double-trigger creates exactly one pane', async () => {
- harness.mountShell('/quick?panes=conciv_1&focus=0', {
- sessions: [sessionRow({id: 'conciv_1'})],
- delays: {'/rpc/sessions/resolve': 300},
- })
+ const sessionId = await createSession(coreRpc(core.base))
+ harness.mountShell(`/quick?panes=${sessionId}&focus=0`)
await expect.element(editor(), {timeout: 8000}).toBeVisible()
+ const held = await faults.install({kind: 'gate', path: RESOLVE_PATH})
const splitButton = page.getByRole('button', {name: 'Split pane (Mod+D)'})
const firstClick = splitButton.click()
const secondClick = splitButton.click()
await Promise.all([firstClick, secondClick])
+ await coreControl.awaitFaultPending(held, 1)
+
+ await coreControl.releaseFault(held)
- await harness.core()?.idle()
- const addPaneCalls = harness
- .core()
- ?.calls.filter((call) => call.path === '/rpc/sessions/resolve' && Object.keys(call.body ?? {}).length === 0).length
- expect(addPaneCalls).toBe(1)
-})
+ await expect.element(closePane().nth(1), {timeout: 8000}).toBeVisible()
+ await expect.element(closePane().nth(2)).not.toBeInTheDocument()
+}, 30_000)
diff --git a/apps/conciv/test/reachability-flows.browser.test.tsx b/apps/conciv/test/reachability-flows.browser.test.tsx
index 343a5d802..734bdf4e5 100644
--- a/apps/conciv/test/reachability-flows.browser.test.tsx
+++ b/apps/conciv/test/reachability-flows.browser.test.tsx
@@ -1,12 +1,24 @@
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page, userEvent} from 'vitest/browser'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession} from './helpers/core-session.js'
import {createShellHarness} from './helpers/shell-harness.js'
+import {trackedFaults} from './helpers/tracked-faults.js'
import {expectRetryRecovers} from './helpers/retry-recovery.js'
-const PANEL_SESSION = 'conciv_1'
-const harness = createShellHarness(PANEL_SESSION)
-const mountShell = harness.mountShell
+const core = {base: ''}
+const harness = createShellHarness(() => core.base)
+const faults = trackedFaults()
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'reachability-flows', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
afterEach(harness.dispose)
@@ -16,13 +28,20 @@ const genericBoundary = () => page.getByText('Something went wrong!')
const engineUnreachableNotice = () => page.getByText('conciv lost connection to the engine.')
const serverError = () => page.getByText('Internal Server Error')
+async function openSessionPanel(): Promise {
+ const sessionId = await createSession(coreRpc(core.base))
+ harness.mountShell(`/panel/${sessionId}?open=true`)
+ await expect.element(editor(), {timeout: 8000}).toBeVisible()
+}
+
test('a dead engine at boot shows our error screen, not the generic boundary, and Retry recovers it', async () => {
- mountShell('/panel/latest?open=true', {networkFail: true})
+ const outage = await faults.install({kind: 'abort'})
+ harness.mountShell('/panel/latest?open=true')
await expect.element(errorScreen(), {timeout: 8000}).toBeVisible()
await expect.element(genericBoundary()).not.toBeInTheDocument()
- harness.core()?.setNetworkFail(false)
+ await coreControl.releaseFault(outage)
await page
.getByRole('alert')
.filter({hasText: /couldn.t reach the engine/})
@@ -30,40 +49,42 @@ test('a dead engine at boot shows our error screen, not the generic boundary, an
.click()
await expect.element(editor(), {timeout: 8000}).toBeVisible()
-})
+}, 30_000)
test('a server error while resolving /panel/latest shows the actual error, not the unreachable message', async () => {
- mountShell('/panel/latest?open=true', {resolveRejects: true})
+ const refused = await faults.install({kind: 'fail', path: ['sessions', 'resolve'], status: 500})
+ harness.mountShell('/panel/latest?open=true')
await expect.element(serverError(), {timeout: 8000}).toBeVisible()
await expect.element(errorScreen()).not.toBeInTheDocument()
await expect.element(genericBoundary()).not.toBeInTheDocument()
- await expectRetryRecovers(() => harness.core()?.setResolveRejects(false), editor, serverError)
-})
+ await expectRetryRecovers(() => coreControl.releaseFault(refused), editor, serverError)
+}, 30_000)
test('a healthy engine resolves /panel/latest straight to the warm session', async () => {
- mountShell('/panel/latest?open=true')
+ harness.mountShell('/panel/latest?open=true')
await expect.element(editor(), {timeout: 8000}).toBeVisible()
await expect.element(errorScreen()).not.toBeInTheDocument()
-})
+}, 30_000)
test('a sustained outage raises exactly one standing notice, and it clears once the engine returns', async () => {
- mountShell(`/panel/${PANEL_SESSION}?open=true`)
- await expect.element(editor(), {timeout: 8000}).toBeVisible()
+ await openSessionPanel()
+
+ const outage = await faults.install({kind: 'abort'})
+ await editor().fill('rename the widget package')
- harness.core()?.setNetworkFail(true)
await expect.element(engineUnreachableNotice(), {timeout: 8000}).toBeVisible()
await expect.element(page.getByRole('button', {name: 'Retry'})).toBeVisible()
- harness.core()?.setNetworkFail(false)
+ await coreControl.releaseFault(outage)
await expect.element(engineUnreachableNotice(), {timeout: 8000}).not.toBeInTheDocument()
-})
+}, 30_000)
test('a 500 from an otherwise healthy engine never raises the unreachable notice', async () => {
- mountShell(`/panel/${PANEL_SESSION}?open=true`, {rejectSend: true})
- await expect.element(editor(), {timeout: 8000}).toBeVisible()
+ const refused = await faults.install({kind: 'fail', path: ['chat', 'send'], status: 500})
+ await openSessionPanel()
await editor().fill('rename the widget package')
await userEvent.keyboard('{Enter}')
@@ -72,25 +93,31 @@ test('a 500 from an otherwise healthy engine never raises the unreachable notice
.element(page.getByRole('region', {name: /Notifications/}))
.toHaveTextContent(/Internal Server Error|could not be sent/)
await expect.element(engineUnreachableNotice()).not.toBeInTheDocument()
-})
+
+ await coreControl.releaseFault(refused)
+}, 30_000)
test('a failing engine-info probe on an otherwise healthy connection never raises the unreachable notice', async () => {
- mountShell(`/panel/${PANEL_SESSION}?open=true`, {rejectEngineProbe: true})
+ const refused = await faults.install({kind: 'fail', path: ['meta', 'engine'], status: 500})
+ const since = await coreControl.rpcMark()
+ await openSessionPanel()
- await expect.element(editor(), {timeout: 8000}).toBeVisible()
- await harness.core()?.idle()
+ expect(await coreControl.awaitRpcCall(['meta', 'engine'], since)).toBe(500)
await expect.element(engineUnreachableNotice()).not.toBeInTheDocument()
-})
+
+ await coreControl.releaseFault(refused)
+}, 30_000)
test('the composer disables sending with a distinct message once the engine is unreachable', async () => {
- mountShell(`/panel/${PANEL_SESSION}?open=true`)
- await expect.element(editor(), {timeout: 8000}).toBeVisible()
- await editor().fill('rename the widget package')
+ await openSessionPanel()
- harness.core()?.setNetworkFail(true)
+ const outage = await faults.install({kind: 'abort'})
+ await editor().fill('rename the widget package')
await expect
.element(page.getByRole('button', {name: 'conciv lost connection to the engine'}), {timeout: 8000})
.toBeVisible()
await expect.element(page.getByRole('button', {name: 'conciv lost connection to the engine'})).toBeDisabled()
-})
+
+ await coreControl.releaseFault(outage)
+}, 30_000)
diff --git a/apps/conciv/test/route-boundary.browser.test.tsx b/apps/conciv/test/route-boundary.browser.test.tsx
index 0803c7e4b..75ee54e21 100644
--- a/apps/conciv/test/route-boundary.browser.test.tsx
+++ b/apps/conciv/test/route-boundary.browser.test.tsx
@@ -1,67 +1,72 @@
import './helpers/utilities.css'
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
-import {render} from '@solidjs/testing-library'
-import {RouterProvider, createMemoryHistory} from '@tanstack/solid-router'
-import {makeRpcClient} from '@conciv/contract'
-import {parseConcivSettings} from '../src/data/settings.js'
-import {createConcivRouter} from '../src/router.js'
-import {CORE_BASE, installFakeCore, sessionRow, type FakeCore} from './helpers/fake-core.js'
-
-const PANEL_SESSION = 'conciv_1'
-const HELD_ROUTE_MS = 1500
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc, createSession} from './helpers/core-session.js'
+import {createShellHarness} from './helpers/shell-harness.js'
+import {trackedFaults} from './helpers/tracked-faults.js'
+
const WHILE_HELD = {timeout: 700}
-let core: FakeCore | null = null
-
-afterEach(() => {
- core?.restore()
- core = null
-})
-
-const PANEL_ENTRY = `/panel/${PANEL_SESSION}?open=true`
-const CLOSED_ENTRY = '/'
-
-function mountShell(entry: string, config: Parameters[0] = {}): void {
- core = installFakeCore({sessions: [sessionRow({id: PANEL_SESSION})], ...config})
- const router = createConcivRouter({
- rpc: makeRpcClient(CORE_BASE),
- history: createMemoryHistory({initialEntries: [entry]}),
- environment: {rootNode: document, document},
- settings: parseConcivSettings(''),
- })
- render(() => )
-}
+
+const core = {base: ''}
+const harness = createShellHarness(() => core.base)
+const faults = trackedFaults()
+
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({id: 'route-boundary', allowedOrigins: [window.location.origin]})
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
+
+afterEach(harness.dispose)
const editor = () => page.getByRole('textbox', {name: 'Message the conciv agent'})
const launcher = () => page.getByRole('button', {name: 'Open conciv chat'})
const routePending = () => page.getByRole('progressbar', {name: 'Loading conciv'})
+async function openPanel(): Promise {
+ const sessionId = await createSession(coreRpc(core.base))
+ harness.mountShell(`/panel/${sessionId}?open=true`)
+}
+
test('the pane paints its composer while the element captures query is still in flight', async () => {
- mountShell(PANEL_ENTRY, {delays: {'/rpc/captures/list': HELD_ROUTE_MS}})
+ const held = await faults.install({kind: 'gate', path: ['captures', 'list']})
+ await openPanel()
await expect.element(editor(), WHILE_HELD).toBeVisible()
await expect.element(routePending(), WHILE_HELD).not.toBeInTheDocument()
-})
+
+ await coreControl.releaseFault(held)
+}, 30_000)
test('the shell keeps its launcher while the session list query is still in flight', async () => {
- mountShell(CLOSED_ENTRY, {delays: {'/rpc/sessions/list': HELD_ROUTE_MS}})
+ const held = await faults.install({kind: 'gate', path: ['sessions', 'list']})
+ harness.mountShell('/')
await expect.element(launcher(), WHILE_HELD).toBeVisible()
await expect.element(routePending(), WHILE_HELD).not.toBeInTheDocument()
-})
+
+ await coreControl.releaseFault(held)
+}, 30_000)
test('a pane whose queries all answer immediately never shows the route pending loader', async () => {
- mountShell(PANEL_ENTRY)
+ await openPanel()
await expect.element(editor(), WHILE_HELD).toBeVisible()
- await core?.idle()
await expect.element(routePending(), WHILE_HELD).not.toBeInTheDocument()
-})
+}, 30_000)
test('a slow beforeLoad reveals the route pending loader, then hands off to the pane', async () => {
- mountShell('/panel/latest?open=true', {delays: {'/rpc/sessions/resolve': 900}})
+ const held = await faults.install({kind: 'gate', path: ['sessions', 'resolve']})
+ harness.mountShell('/panel/latest?open=true')
await expect.element(routePending()).toBeVisible()
- await expect.element(editor(), {timeout: 2000}).toBeVisible()
+
+ await coreControl.releaseFault(held)
+
+ await expect.element(editor(), {timeout: 8000}).toBeVisible()
await expect.element(routePending()).not.toBeInTheDocument()
-})
+}, 30_000)
diff --git a/apps/conciv/test/session-selector.browser.test.tsx b/apps/conciv/test/session-selector.browser.test.tsx
index 7f27410f6..675ea4d0b 100644
--- a/apps/conciv/test/session-selector.browser.test.tsx
+++ b/apps/conciv/test/session-selector.browser.test.tsx
@@ -1,30 +1,58 @@
-import {afterEach, expect, test} from 'vitest'
+import {afterAll, afterEach, beforeAll, expect, test} from 'vitest'
import {page} from 'vitest/browser'
+import type {RpcClient} from '@conciv/contract'
import {SessionSelector} from '../src/composer/session-selector.js'
-import {installFakeCore, sessionRow, type FakeCore} from './helpers/fake-core.js'
-import {mountPane} from './helpers/pane-harness.js'
+import {coreControl} from './helpers/core-control.js'
+import {coreRpc} from './helpers/core-session.js'
+import {mountPane, type PaneMount} from './helpers/pane-harness.js'
-let core: FakeCore | null = null
+const EXTERNAL_ID = 'external-mid-run'
-afterEach(() => {
- core?.restore()
- core = null
-})
+const core = {base: ''}
+const mounted: {pane: PaneMount | null} = {pane: null}
-function mountSelector(): void {
- core = installFakeCore({
- sessions: [
- sessionRow({id: 'conciv_1', title: 'the active session'}),
- sessionRow({id: 'conciv_2', title: 'a session mid-run', running: true, origin: 'external'}),
- ],
+beforeAll(async () => {
+ const booted = await coreControl.bootCore({
+ id: 'session-selector',
+ resume: true,
+ allowedOrigins: [window.location.origin],
+ history: [{id: EXTERNAL_ID, derivedTitle: 'a session mid-run', updatedAt: Date.now(), messageCount: 3}],
})
- mountPane(() => (
- 'conciv_1'} onActivate={() => {}} onNewSession={() => {}} />
+ core.base = booted.base
+}, 60_000)
+
+afterAll(async () => {
+ await coreControl.closeCore()
+}, 30_000)
+
+afterEach(async () => {
+ mounted.pane?.dispose()
+ mounted.pane = null
+ await coreControl.releaseTurn()
+})
+
+async function adoptExternalSession(rpc: RpcClient): Promise {
+ const listed = await rpc.sessions.list()
+ const external = listed.find((meta) => meta.native?.nativeId === EXTERNAL_ID)
+ if (!external?.native) throw new Error('the harness history fixture never reached the session list')
+ const {sessionId} = await rpc.sessions.open(external.native)
+ return sessionId
+}
+
+async function mountSelector(): Promise {
+ const rpc = coreRpc(core.base)
+ const activeId = (await rpc.sessions.create()).sessionId
+ await rpc.sessions.rename({sessionId: activeId, title: 'the active session'})
+ const externalId = await adoptExternalSession(rpc)
+ await coreControl.holdTurn()
+ await rpc.chat.send({sessionId: externalId, runId: crypto.randomUUID(), text: 'keep this session busy'})
+ mounted.pane = mountPane({base: core.base, sessionId: activeId}, () => (
+ activeId} onActivate={() => {}} onNewSession={() => {}} />
))
}
test('a session with a live core run carries the running dot, an idle one does not', async () => {
- mountSelector()
+ await mountSelector()
await page.getByRole('button', {name: 'Session: the active session'}).click()
@@ -33,7 +61,7 @@ test('a session with a live core run carries the running dot, an idle one does n
})
test('the selector keeps freshness, message count and origin in the row description', async () => {
- mountSelector()
+ await mountSelector()
await page.getByRole('button', {name: 'Session: the active session'}).click()
diff --git a/apps/conciv/vitest.config.ts b/apps/conciv/vitest.config.ts
index 3fca0ceab..e3ba3b903 100644
--- a/apps/conciv/vitest.config.ts
+++ b/apps/conciv/vitest.config.ts
@@ -1,14 +1,10 @@
-import type {Plugin} from 'vite'
import {defineConfig} from 'vitest/config'
import {BaseSequencer, type TestSpecification} from 'vitest/node'
-import {serveRpcRouter} from '@conciv/harness-testkit/rpc-mounts'
-import {makeFakeCoreRouter} from './test/helpers/fake-core-router.js'
+import {coreCommands} from './test/commands/core-control.js'
import {playwright} from '@vitest/browser-playwright'
import solidPlugin from 'vite-plugin-solid'
import {browserOptimizeDeps, ciTest, ciTestSolidBrowser} from '@conciv/vitest-config'
-const FAKE_CORE_ADDRESS_PATH = '/__fake-core'
-
const SESSION_KILLER_SUITE = 'router-restore.browser.test.ts'
class RunSessionKillerLastSequencer extends BaseSequencer {
@@ -20,21 +16,6 @@ class RunSessionKillerLastSequencer extends BaseSequencer {
}
}
-const fakeCoreSocket: Plugin = {
- name: 'fake-core-socket',
- async configureServer(server) {
- const {router} = makeFakeCoreRouter()
- const served = await serveRpcRouter({router})
- server.middlewares.use((req, res, next) => {
- if (req.url !== FAKE_CORE_ADDRESS_PATH) return next()
- res.setHeader('content-type', 'application/json')
- res.end(JSON.stringify({base: served.base, wsUrl: served.wsUrl}))
- })
- served.unref()
- server.httpServer?.on('close', () => void served.close())
- },
-}
-
export default defineConfig({
test: {
...ciTest(),
@@ -51,7 +32,7 @@ export default defineConfig({
},
},
{
- plugins: [solidPlugin(), fakeCoreSocket],
+ plugins: [solidPlugin()],
optimizeDeps: browserOptimizeDeps(),
test: {
...ciTestSolidBrowser(),
@@ -63,6 +44,7 @@ export default defineConfig({
headless: true,
provider: playwright({}),
instances: [{browser: 'chromium'}],
+ commands: coreCommands,
},
},
},
diff --git a/packages/client/test/helpers/boot.ts b/packages/client/test/helpers/boot.ts
index cfb5149b7..2da4bda77 100644
--- a/packages/client/test/helpers/boot.ts
+++ b/packages/client/test/helpers/boot.ts
@@ -1,10 +1,4 @@
-import {
- createFakeHarness,
- createRecordingTerminalOpener,
- createTestkit,
- type FakeHarness,
- type Kit,
-} from '@conciv/harness-testkit'
+import {createFakeHarness, createTestkit, type FakeHarness, type Kit} from '@conciv/harness-testkit'
import type {AnyExtension} from '@conciv/extension'
import {makeApp} from '@conciv/core/app'
@@ -26,7 +20,6 @@ export async function bootClientKit(opts: {extensions?: AnyExtension[]} = {}): P
},
cwd: env.cwd,
openInEditor: () => {},
- openTerminal: createRecordingTerminalOpener().open,
harness: env.harness,
extensions: opts.extensions,
})
diff --git a/packages/core/src/api/rpc/mount.ts b/packages/core/src/api/rpc/mount.ts
index 5b88a320b..ecf56ef3d 100644
--- a/packages/core/src/api/rpc/mount.ts
+++ b/packages/core/src/api/rpc/mount.ts
@@ -2,6 +2,7 @@ import {implement} from '@orpc/server'
import {contract} from '@conciv/contract'
import type {RpcContext} from '@conciv/protocol/rpc-types'
import type {ChatTool} from '@conciv/protocol/chat-types'
+import type {EngineStaleness} from '@conciv/contract'
import type {CompositeRpcRouter as CompositeRouterOf} from '@conciv/extension/rpc-mount'
import type {ChatDeps} from '../../chat/runtime.js'
import type {Compactor, Send} from '../../chat/run.js'
@@ -18,6 +19,7 @@ export type RpcDeps = {
openFromFrames: (frames: OpenSourceFrames) => Promise
page: PageEnv
registry: ToolRegistry
+ staleness: () => EngineStaleness
askTimeoutMs?: number
}
diff --git a/packages/core/src/api/rpc/router.ts b/packages/core/src/api/rpc/router.ts
index 8148c5ea3..f055afa9b 100644
--- a/packages/core/src/api/rpc/router.ts
+++ b/packages/core/src/api/rpc/router.ts
@@ -14,7 +14,6 @@ import {makeAskGate, requiresApproval} from '../../chat/gate.js'
import {rowById} from '../../chat/session-rows.js'
import {pageQueryStream} from '../../page-bus.js'
import {symbolicateFrames} from '../../editor/symbolicate.js'
-import {engineStaleness} from '../../lib/engine-stamp.js'
import {chatRouter} from './chat.js'
import {harnessMetaOf, sessionsRouter} from './sessions.js'
import {os, type RpcDeps} from './mount.js'
@@ -240,7 +239,7 @@ export function makeRpcRouter(deps: RpcDeps) {
listCommands(chat, {sessionId: input.sessionId, origin: context.origin}),
),
tools: os.meta.tools.handler(() => ({tools: deps.tools})),
- engine: os.meta.engine.handler(() => engineStaleness()),
+ engine: os.meta.engine.handler(() => deps.staleness()),
},
})
}
diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts
index 9545b62b8..b30b8675a 100644
--- a/packages/core/src/app.ts
+++ b/packages/core/src/app.ts
@@ -1,7 +1,7 @@
import {existsSync} from 'node:fs'
import {Hono} from 'hono'
import {z} from 'zod'
-import {EngineStalenessSchema} from '@conciv/contract'
+import {EngineStalenessSchema, type EngineStaleness} from '@conciv/contract'
import {upgradeWebSocket} from '@conciv/serve'
import {HTTPException} from 'hono/http-exception'
import type {HarnessAdapter} from '@conciv/protocol/harness-types'
@@ -94,6 +94,8 @@ export type MakeAppOpts = {
nativePageDir?: string
nativeUrl?: () => string | undefined
+
+ staleness?: () => EngineStaleness
}
export function slug(name: string): string {
@@ -211,7 +213,11 @@ export const HealthSchema = z.object({
export type CoreVars = CorsVars & {chat: ChatDeps} & McpVars
-function composeRoutes(vars: CoreVars, rpc: CompositeRpcRouter, onShutdown?: () => void) {
+function composeRoutes(
+ vars: CoreVars,
+ rpc: CompositeRpcRouter,
+ deps: {staleness: () => EngineStaleness; onShutdown?: () => void},
+) {
return new Hono<{Variables: CoreVars}>()
.onError((error, c) => {
if (error instanceof HTTPException) return c.json({message: error.message}, error.status)
@@ -226,11 +232,11 @@ function composeRoutes(vars: CoreVars, rpc: CompositeRpcRouter, onShutdown?: ()
})
.use(corsMiddleware())
.get('/health', (c) =>
- c.json(HealthSchema.parse({ok: true, harness: vars.chat.harness.id, engine: engineStaleness()})),
+ c.json(HealthSchema.parse({ok: true, harness: vars.chat.harness.id, engine: deps.staleness()})),
)
.post('/api/shutdown', (c) => {
- if (!onShutdown) return c.json({message: 'shutdown not supported'}, 404)
- setTimeout(onShutdown, 50)
+ if (!deps.onShutdown) return c.json({message: 'shutdown not supported'}, 404)
+ setTimeout(deps.onShutdown, 50)
return c.json({ok: true})
})
.get(
@@ -270,6 +276,7 @@ export async function makeApp(opts: MakeAppOpts): Promise {
assertUniqueExtensionSlugs(extensions)
const harness = opts.harness ?? requireHarness(opts.cfg.harness)
+ const staleness = opts.staleness ?? engineStaleness
const db = openDb(opts.cfg.stateRoot)
await recoverInterruptedRuns({db, harness, claudeHome: opts.claudeHome})
const asks = createAskRegistry()
@@ -465,6 +472,7 @@ export async function makeApp(opts: MakeAppOpts): Promise {
openFromFrames: (frames) => openSourceFromFrames(frames, opts.cwd, opts.openInEditor),
page: pageEnv,
registry,
+ staleness,
...(opts.askTimeoutMs === undefined ? {} : {askTimeoutMs: opts.askTimeoutMs}),
})
@@ -493,11 +501,11 @@ export async function makeApp(opts: MakeAppOpts): Promise {
sessionModel,
sessionForNativeId: async (nativeId) => (await rowByNativeId(db, nativeId))?.id ?? null,
noteToolCall: (sessionId, toolCallId, toolName) => asks.noteToolCall(sessionId, toolCallId, toolName),
- staleness: engineStaleness,
+ staleness,
},
},
compositeRpc,
- opts.onShutdown,
+ {staleness, onShutdown: opts.onShutdown},
)
if (opts.nativePageDir) app.route(NATIVE_PAGE_PATH, makeNativePageApp(opts.nativePageDir))
diff --git a/packages/core/test/api/engine-staleness-di.it.test.ts b/packages/core/test/api/engine-staleness-di.it.test.ts
new file mode 100644
index 000000000..c4992dc8c
--- /dev/null
+++ b/packages/core/test/api/engine-staleness-di.it.test.ts
@@ -0,0 +1,84 @@
+import {mkdtempSync, rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {afterEach, describe, expect, it} from 'vitest'
+import type {EngineStaleness} from '@conciv/contract'
+import {makeRpcClient, serveApp, type ServedApp} from '@conciv/harness-testkit'
+import {HealthSchema, makeApp} from '../../src/app.js'
+import {resolveConfig} from '../../src/config.js'
+
+const dirs: string[] = []
+const state = {served: undefined as ServedApp | undefined, dispose: undefined as (() => Promise) | undefined}
+
+const INJECTED: EngineStaleness = {
+ stale: true,
+ changed: ['@conciv/injected-probe'],
+ tracked: ['@conciv/injected-probe', '@conciv/core'],
+ bootedAt: 1_700_000_000_000,
+ fingerprint: 'injected1234',
+}
+
+const INITIALIZE = JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'initialize',
+ params: {protocolVersion: '2025-06-18', capabilities: {}, clientInfo: {name: 'staleness-di-test', version: '0'}},
+})
+
+afterEach(async () => {
+ await state.served?.close()
+ await state.dispose?.()
+ state.served = undefined
+ state.dispose = undefined
+ for (const dir of dirs.splice(0)) rmSync(dir, {recursive: true, force: true})
+})
+
+async function serveWithProbe(staleness: () => EngineStaleness): Promise {
+ const root = mkdtempSync(join(tmpdir(), 'conciv-staleness-di-'))
+ dirs.push(root)
+ const {app, dispose} = await makeApp({cfg: resolveConfig({}, root), cwd: root, openInEditor: () => {}, staleness})
+ state.dispose = dispose
+ const served = await serveApp(app.fetch)
+ state.served = served
+ return served
+}
+
+describe('engine staleness dependency injection (IT, real http)', () => {
+ it('reports the injected probe consistently on /health, rpc meta.engine and the mcp instructions', async () => {
+ const calls = {count: 0}
+ const served = await serveWithProbe(() => {
+ calls.count += 1
+ return INJECTED
+ })
+
+ const health = HealthSchema.parse(await (await fetch(`${served.base}/health`)).json())
+ const engine = await makeRpcClient(served.base).meta.engine()
+ const mcp = await fetch(`${served.base}/api/mcp`, {
+ method: 'POST',
+ headers: {'content-type': 'application/json', accept: 'application/json, text/event-stream'},
+ body: INITIALIZE,
+ })
+ const instructions = await mcp.text()
+
+ expect(health.engine).toEqual(INJECTED)
+ expect(engine).toEqual(INJECTED)
+ expect(instructions).toContain('@conciv/injected-probe')
+ expect(calls.count).toBeGreaterThanOrEqual(3)
+ }, 20_000)
+
+ it('defaults to the real engine stamp when no probe is injected', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'conciv-staleness-default-'))
+ dirs.push(root)
+ const {app, dispose} = await makeApp({cfg: resolveConfig({}, root), cwd: root, openInEditor: () => {}})
+ state.dispose = dispose
+ const served = await serveApp(app.fetch)
+ state.served = served
+
+ const health = HealthSchema.parse(await (await fetch(`${served.base}/health`)).json())
+ const engine = await makeRpcClient(served.base).meta.engine()
+
+ expect(health.engine.tracked).toContain('@conciv/core')
+ expect(engine.fingerprint).toBe(health.engine.fingerprint)
+ expect(engine.stale).toBe(false)
+ }, 20_000)
+})
diff --git a/packages/embed/tests/e2e/model-selector.it.test.ts b/packages/embed/tests/e2e/model-selector.it.test.ts
index fcee71003..7cd1be3e2 100644
--- a/packages/embed/tests/e2e/model-selector.it.test.ts
+++ b/packages/embed/tests/e2e/model-selector.it.test.ts
@@ -15,7 +15,7 @@ test.describe('model selector error path', () => {
test('offers a retry when meta.models fails and recovers on retry', async ({page}) => {
test.setTimeout(180_000)
- const models = await failRpcCalls(page, {path: ['meta', 'models']})
+ const models = await failRpcCalls(page, {path: ['meta', 'models'], websocket: true})
await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'})
await openPanel(page)
diff --git a/packages/embed/tests/e2e/rpc-fault.it.test.ts b/packages/embed/tests/e2e/rpc-fault.it.test.ts
index 060bf7784..e7b0faf17 100644
--- a/packages/embed/tests/e2e/rpc-fault.it.test.ts
+++ b/packages/embed/tests/e2e/rpc-fault.it.test.ts
@@ -6,7 +6,7 @@ const suite = setupWsProbeSuite()
test.describe('rpc fault injection reaches calls that ride the websocket', () => {
test('fails only the targeted procedure and lets it recover after repair', async ({page}) => {
- const models = await failRpcCalls(page, {path: ['meta', 'models']})
+ const models = await failRpcCalls(page, {path: ['meta', 'models'], websocket: true})
await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'})
await page.evaluate((wsUrl) => window.__CONCIV_WS_PROBE__.connect(wsUrl), suite.socketUrl())
diff --git a/packages/extension-testkit/src/core-kit.ts b/packages/extension-testkit/src/core-kit.ts
index 90119be7f..619529d26 100644
--- a/packages/extension-testkit/src/core-kit.ts
+++ b/packages/extension-testkit/src/core-kit.ts
@@ -2,23 +2,34 @@ import {createFakeHarness, createTestkit, type FakeHarness, type Kit} from '@con
import {makeApp} from '@conciv/core/app'
import type {AnyExtension} from '@conciv/extension'
import type {ToolRegistry} from '@conciv/extension/registry'
-import type {HarnessCommand, HarnessModel} from '@conciv/protocol/harness-types'
+import type {HarnessCommand, HarnessConnect, HarnessModel, HarnessSessionMeta} from '@conciv/protocol/harness-types'
+import type {EngineStaleness} from '@conciv/contract'
export type CoreKit = Kit & {harness: FakeHarness; registry: ToolRegistry}
export async function bootCoreKit(opts: {
id: string
text?: string
+ resume?: boolean
+ displayName?: string
+ connect?: HarnessConnect
extensions?: AnyExtension[]
models?: HarnessModel[]
commands?: HarnessCommand[]
+ history?: HarnessSessionMeta[]
+ allowedOrigins?: string[]
+ staleness?: () => EngineStaleness
nativePageDir?: string
}): Promise {
const harness = createFakeHarness({
id: opts.id,
text: opts.text ?? 'Hello from conciv',
+ ...(opts.resume ? {resume: true} : {}),
+ ...(opts.displayName ? {displayName: opts.displayName} : {}),
+ ...(opts.connect ? {connect: opts.connect} : {}),
models: opts.models,
commands: opts.commands,
+ history: opts.history,
})
const captured: {registry?: ToolRegistry} = {}
const kit = await createTestkit(harness, async (env) => {
@@ -37,6 +48,8 @@ export async function bootCoreKit(opts: {
openInEditor: () => {},
harness: env.harness,
extensions: opts.extensions,
+ allowedOrigins: opts.allowedOrigins,
+ staleness: opts.staleness,
nativePageDir: opts.nativePageDir,
})
captured.registry = registry
diff --git a/packages/extension-testkit/src/rpc-fault.ts b/packages/extension-testkit/src/rpc-fault.ts
index b2aede17e..f4801d283 100644
--- a/packages/extension-testkit/src/rpc-fault.ts
+++ b/packages/extension-testkit/src/rpc-fault.ts
@@ -1,27 +1,40 @@
-import type {Page, WebSocketRoute} from 'playwright'
+import type {Page, Route, WebSocketRoute} from 'playwright'
import {toHttpPath} from '@orpc/client/standard'
import {encodeResponseMessage, MessageType} from '@orpc/standard-server-peer'
import {decodeRpcFrame} from './rpc-frames.js'
const FAILURE_BODY = {json: {}, meta: []}
-export type RpcFaultInjector = {repair: () => void}
+const RPC_PREFIX = '/rpc'
+
+type UrlMatcher = (url: URL) => boolean
+type RouteHandler = (route: Route) => Promise
+
+export type RpcFaultInjector = {repair: () => void; dispose: () => Promise}
+
+function pathMatcher(path: readonly string[] | undefined): UrlMatcher {
+ if (!path) return (url) => url.pathname === RPC_PREFIX || url.pathname.startsWith(`${RPC_PREFIX}/`)
+ const httpPath = toHttpPath(path)
+ return (url) => url.pathname.endsWith(httpPath)
+}
+
+function isPreflight(route: Route): boolean {
+ return route.request().method() === 'OPTIONS'
+}
export async function failRpcCalls(
page: Page,
- options: {path: readonly string[]; status?: number},
+ options: {path: readonly string[]; status?: number; websocket?: boolean},
): Promise {
const status = options.status ?? 500
- const httpPath = toHttpPath(options.path)
const broken = {value: true}
+ const matcher = pathMatcher(options.path)
+ const handler: RouteHandler = async (route) => {
+ if (isPreflight(route) || !broken.value) return route.continue()
+ await route.fulfill({status, contentType: 'application/json', body: JSON.stringify(FAILURE_BODY)})
+ }
- await page.route(
- (url) => url.pathname.endsWith(httpPath),
- async (route) => {
- if (!broken.value) return route.continue()
- await route.fulfill({status, contentType: 'application/json', body: JSON.stringify(FAILURE_BODY)})
- },
- )
+ await page.route(matcher, handler)
const holdSocket = (socket: WebSocketRoute): void => {
const server = socket.connectToServer()
@@ -45,12 +58,37 @@ export async function failRpcCalls(
server.onMessage((message) => socket.send(message))
}
- await page.routeWebSocket((url) => url.pathname.endsWith('/rpc-ws'), holdSocket)
+ if (options.websocket) await page.routeWebSocket((url) => url.pathname.endsWith('/rpc-ws'), holdSocket)
+
+ return {
+ repair: () => {
+ broken.value = false
+ },
+ dispose: async () => {
+ broken.value = false
+ await page.unroute(matcher, handler)
+ },
+ }
+}
+
+export async function abortRpcCalls(page: Page, options: {path?: readonly string[]} = {}): Promise {
+ const broken = {value: true}
+ const matcher = pathMatcher(options.path)
+ const handler: RouteHandler = async (route) => {
+ if (isPreflight(route) || !broken.value) return route.continue()
+ await route.abort('connectionrefused')
+ }
+
+ await page.route(matcher, handler)
return {
repair: () => {
broken.value = false
},
+ dispose: async () => {
+ broken.value = false
+ await page.unroute(matcher, handler)
+ },
}
}
@@ -61,9 +99,9 @@ export async function holdRpcCalls(page: Page): Promise {
const sockets = new Set()
await page.route(
- (url) => url.pathname.startsWith('/rpc/') || url.pathname === '/rpc',
+ (url) => url.pathname.startsWith('/rpc/') || url.pathname === RPC_PREFIX,
async (route) => {
- if (!held.value) return route.continue()
+ if (isPreflight(route) || !held.value) return route.continue()
await route.abort('connectionrefused')
},
)
@@ -94,3 +132,54 @@ export async function holdRpcCalls(page: Page): Promise {
},
}
}
+
+export type RpcGate = {
+ pending: () => number
+ awaitCaptured: (count: number) => Promise
+ release: () => Promise
+ dispose: () => Promise
+}
+
+type CaptureWaiter = {count: number; deliver: () => void}
+
+export async function gateRpcCalls(page: Page, options: {path?: readonly string[]} = {}): Promise {
+ const matcher = pathMatcher(options.path)
+ const open = {value: false}
+ const captured: Route[] = []
+ const waiters = new Set()
+ const handler: RouteHandler = async (route) => {
+ if (isPreflight(route) || open.value) return route.continue()
+ captured.push(route)
+ for (const waiter of waiters) {
+ if (captured.length < waiter.count) continue
+ waiters.delete(waiter)
+ waiter.deliver()
+ }
+ }
+
+ await page.route(matcher, handler)
+
+ const settle = async (): Promise => {
+ const pending = captured.splice(0)
+ await Promise.all(pending.map((route) => route.continue().catch(() => {})))
+ }
+
+ return {
+ pending: () => captured.length,
+ awaitCaptured: (count) => {
+ if (captured.length >= count) return Promise.resolve()
+ return new Promise((resolve) => {
+ waiters.add({count, deliver: resolve})
+ })
+ },
+ release: async () => {
+ open.value = true
+ await settle()
+ },
+ dispose: async () => {
+ open.value = true
+ await settle()
+ await page.unroute(matcher, handler)
+ },
+ }
+}
diff --git a/packages/extension-testkit/test/core-kit-seams.it.test.ts b/packages/extension-testkit/test/core-kit-seams.it.test.ts
new file mode 100644
index 000000000..9297a9bbe
--- /dev/null
+++ b/packages/extension-testkit/test/core-kit-seams.it.test.ts
@@ -0,0 +1,66 @@
+import {afterEach, describe, expect, it} from 'vitest'
+import type {EngineStaleness} from '@conciv/contract'
+import {bootCoreKit, type CoreKit} from '../src/core-kit.js'
+
+const STALENESS: EngineStaleness = {
+ stale: true,
+ changed: ['@conciv/kit-probe'],
+ tracked: ['@conciv/kit-probe'],
+ bootedAt: 1_700_000_000_000,
+ fingerprint: 'kitprobe123',
+}
+
+const ORIGIN = 'https://widget.example.test'
+
+const state = {kit: undefined as CoreKit | undefined}
+
+afterEach(async () => {
+ await state.kit?.cleanup()
+ state.kit = undefined
+})
+
+describe('bootCoreKit seams', () => {
+ it('adopts the harness transcript rows as external sessions with their message counts', async () => {
+ const kit = await bootCoreKit({
+ id: 'seams-history',
+ history: [{id: 'native-1', derivedTitle: 'A native session', updatedAt: 1_700, messageCount: 7}],
+ })
+ state.kit = kit
+
+ const sessions = await kit.rpc.sessions.list({})
+ const adopted = sessions.find((meta) => meta.native?.nativeId === 'native-1')
+
+ expect(adopted?.origin).toBe('external')
+ expect(adopted?.messageCount).toBe(7)
+ expect(adopted?.title).toBe('A native session')
+ expect(adopted?.running).toBe(false)
+ }, 30_000)
+
+ it('serves the injected staleness probe to the widget rpc surface', async () => {
+ const kit = await bootCoreKit({id: 'seams-staleness', staleness: () => STALENESS})
+ state.kit = kit
+
+ expect(await kit.rpc.meta.engine()).toEqual(STALENESS)
+ }, 30_000)
+
+ it('lets a non-loopback origin through only when the kit declares it allowed', async () => {
+ const closed = await bootCoreKit({id: 'seams-origins-closed'})
+ state.kit = closed
+ const rejected = await fetch(`${closed.base}/rpc/meta/engine`, {
+ method: 'OPTIONS',
+ headers: {origin: ORIGIN, 'access-control-request-method': 'POST'},
+ })
+ await closed.cleanup()
+ state.kit = undefined
+
+ const open = await bootCoreKit({id: 'seams-origins-open', allowedOrigins: [ORIGIN]})
+ state.kit = open
+ const accepted = await fetch(`${open.base}/rpc/meta/engine`, {
+ method: 'OPTIONS',
+ headers: {origin: ORIGIN, 'access-control-request-method': 'POST'},
+ })
+
+ expect(rejected.status).toBe(403)
+ expect(accepted.headers.get('access-control-allow-origin')).toBe(ORIGIN)
+ }, 30_000)
+})
diff --git a/packages/extensions/terminal/src/server.ts b/packages/extensions/terminal/src/server.ts
index dcd35e5f0..3d90d971e 100644
--- a/packages/extensions/terminal/src/server.ts
+++ b/packages/extensions/terminal/src/server.ts
@@ -5,7 +5,7 @@ import {os} from '@orpc/server'
import {z} from 'zod'
import {defineExtension, type ServerApi} from '@conciv/extension'
import {SessionId} from '@conciv/protocol/chat-types'
-import type {HarnessConnectContext, HarnessConnectPlan} from '@conciv/protocol/harness-types'
+import type {HarnessConnectContext, HarnessConnectPlan, TerminalOpener} from '@conciv/protocol/harness-types'
import {TtyClientControlSchema, type TtyClientControl} from '@conciv/protocol/terminal-types'
import type {RpcContext} from '@conciv/protocol/rpc-types'
import {createTtySessions, type TtySession, type TtySink} from './server/pty-sessions.js'
@@ -23,6 +23,7 @@ const ESCAPE_KEY = String.fromCharCode(27)
type TerminalRuntime = {
server: ServerApi>
tty: ReturnType
+ openTerminal?: TerminalOpener
}
type TerminalEnv = {Variables: {terminal: TerminalRuntime}}
@@ -166,7 +167,13 @@ function makeTerminalRouter(runtime: TerminalRuntime) {
const plan = await connectPlanFor(runtime, input.sessionId, input.model ?? null, apiBase(runtime, context))
if (!plan) throw errors.NO_CONNECT()
const {server} = runtime
- return {ok: await launchConnectPlan(plan, {cwd: server.cwd, stateDir: server.stateDir})}
+ return {
+ ok: await launchConnectPlan(plan, {
+ cwd: server.cwd,
+ stateDir: server.stateDir,
+ ...(runtime.openTerminal ? {openTerminal: runtime.openTerminal} : {}),
+ }),
+ }
}),
connectCommand: terminalOs
.errors(noConnect)
@@ -221,21 +228,25 @@ const app = new Hono().get(
export type TerminalAppType = typeof app
-export default defineExtension({name: TERMINAL_NAME}).server((server) => {
- const tty = createTtySessions()
- const runtime: TerminalRuntime = {server, tty}
- return {
- context: {},
- router: makeTerminalRouter(runtime),
- app: new Hono()
- .use(async (c, next) => {
- c.set('terminal', runtime)
- await next()
- })
- .route('/', app),
- dispose: () => tty.shutdown(),
- }
-})
+export function createTerminalExtension(opts: {openTerminal?: TerminalOpener} = {}) {
+ return defineExtension({name: TERMINAL_NAME}).server((server) => {
+ const tty = createTtySessions()
+ const runtime: TerminalRuntime = {server, tty, ...(opts.openTerminal ? {openTerminal: opts.openTerminal} : {})}
+ return {
+ context: {},
+ router: makeTerminalRouter(runtime),
+ app: new Hono()
+ .use(async (c, next) => {
+ c.set('terminal', runtime)
+ await next()
+ })
+ .route('/', app),
+ dispose: () => tty.shutdown(),
+ }
+ })
+}
+
+export default createTerminalExtension()
function parseControl(text: string): TtyClientControl | null {
if (!text.startsWith('{')) return null
diff --git a/packages/harness-testkit/src/create-fake-harness.ts b/packages/harness-testkit/src/create-fake-harness.ts
index ac06fe1a3..15b707981 100644
--- a/packages/harness-testkit/src/create-fake-harness.ts
+++ b/packages/harness-testkit/src/create-fake-harness.ts
@@ -3,8 +3,11 @@ import {
defineHarness,
type HarnessAdapter,
type HarnessCommand,
+ type HarnessConnect,
type HarnessConnectContext,
+ type HarnessHistory,
type HarnessModel,
+ type HarnessSessionMeta,
} from '@conciv/protocol/harness-types'
import {makeTextAdapter} from '@conciv/harness'
import {makeScriptedRun, type ScriptedRun} from './scripted-run.js'
@@ -16,7 +19,6 @@ export type FakeHarness = HarnessAdapter & {
const BASE_CAPABILITIES = {
resume: false,
permissionGate: 'none',
- transcriptHistory: false,
compaction: false,
systemPrompt: 'none',
mcp: 'none',
@@ -24,36 +26,94 @@ const BASE_CAPABILITIES = {
init: 'none',
} as const
+type SharedFields = {
+ id: string
+ binName: string
+ displayName?: string
+ connect?: HarnessConnect
+ chatConfig: HarnessAdapter['chatConfig']
+ models: HarnessModel[] | undefined
+ tty: {command(ctx: HarnessConnectContext): TtyCommand} | undefined
+}
+
+function fixtureHistory(rows: HarnessSessionMeta[]): HarnessHistory {
+ return {
+ list: () => Promise.resolve(rows),
+ messages: () => Promise.resolve([]),
+ meta: (_cwd, sessionId) => Promise.resolve(rows.find((row) => row.id === sessionId) ?? null),
+ observe: () => ({
+ revision: () => Promise.resolve({ok: false as const, reason: 'missing' as const, detail: 'fixture history'}),
+ read: () => Promise.resolve({ok: false as const, reason: 'missing' as const, detail: 'fixture history'}),
+ close: () => {},
+ }),
+ }
+}
+
+function buildAdapter(
+ shared: SharedFields,
+ base: Omit & {resume: boolean},
+ history: HarnessHistory | undefined,
+ commands: HarnessCommand[] | undefined,
+): HarnessAdapter {
+ const listCommands = commands ? () => Promise.resolve(commands) : undefined
+ if (history && listCommands) {
+ return defineHarness({
+ ...shared,
+ capabilities: {...base, transcriptHistory: true, slashCommands: 'live'},
+ history,
+ commands: listCommands,
+ })
+ }
+ if (history) {
+ return defineHarness({
+ ...shared,
+ capabilities: {...base, transcriptHistory: true, slashCommands: 'none'},
+ history,
+ })
+ }
+ if (listCommands) {
+ return defineHarness({
+ ...shared,
+ capabilities: {...base, transcriptHistory: false, slashCommands: 'live'},
+ commands: listCommands,
+ })
+ }
+ return defineHarness({
+ ...shared,
+ capabilities: {...base, transcriptHistory: false, slashCommands: 'none'},
+ })
+}
+
export function createFakeHarness(
opts: {
id?: string
text?: string
+ resume?: boolean
+ displayName?: string
+ connect?: HarnessConnect
models?: HarnessModel[]
commands?: HarnessCommand[]
+ history?: HarnessSessionMeta[]
tty?: {command(ctx: HarnessConnectContext): TtyCommand}
} = {},
): FakeHarness {
const id = opts.id ?? 'fake-harness'
const scripted = makeScriptedRun({text: opts.text})
- const commands = opts.commands
- const shared = {
+ const shared: SharedFields = {
id,
binName: 'true',
- chatConfig: (deps: Parameters[0]) => ({
- adapter: makeTextAdapter(id, () => scripted.chatStream(deps)),
- }),
+ ...(opts.displayName ? {displayName: opts.displayName} : {}),
+ ...(opts.connect ? {connect: opts.connect} : {}),
+ chatConfig: (deps) => ({adapter: makeTextAdapter(id, () => scripted.chatStream(deps))}),
models: opts.models,
tty: opts.tty,
}
- const adapter = commands
- ? defineHarness({
- ...shared,
- capabilities: {...BASE_CAPABILITIES, slashCommands: 'live'},
- commands: () => Promise.resolve(commands),
- })
- : defineHarness({
- ...shared,
- capabilities: {...BASE_CAPABILITIES, slashCommands: 'none'},
- })
+ const capabilities = {...BASE_CAPABILITIES, resume: opts.resume ?? false}
+ const adapter = buildAdapter(
+ shared,
+ capabilities,
+ opts.history ? fixtureHistory(opts.history) : undefined,
+ opts.commands,
+ )
return Object.assign(adapter, {script: scripted})
}
diff --git a/packages/harness-testkit/src/scripted-run.ts b/packages/harness-testkit/src/scripted-run.ts
index 6d8e863cf..de6ad6777 100644
--- a/packages/harness-testkit/src/scripted-run.ts
+++ b/packages/harness-testkit/src/scripted-run.ts
@@ -1,20 +1,70 @@
import {EventType, type StreamChunk} from '@tanstack/ai'
import type {HarnessChatDeps} from '@conciv/protocol/harness-types'
+export type ScriptedTurnToolCall = {name: string; input: unknown; result?: unknown}
+
+export type ScriptedTurn = {toolCalls: ScriptedTurnToolCall[]; text?: string}
+
export type ScriptedRun = {
chatStream: (deps: HarnessChatDeps) => AsyncGenerator
hold: () => void
release: () => void
scriptToolCall: (name: string, input: unknown, opts?: {blocking?: boolean}) => string
+ scriptTurn: (turn: ScriptedTurn) => string[]
scriptCustomEvent: (name: string, value: unknown) => void
scriptError: (message: string) => void
}
+type QueuedToolCall = {id: string; name: string; input: unknown; result: unknown}
+
+type QueuedTurn = {toolCalls: QueuedToolCall[]; text?: string; blocking: boolean}
+
+const THREAD = {threadId: 'scripted', runId: 'scripted'} as const
+
+function scriptedResult(call: ScriptedTurnToolCall): unknown {
+ if (call.result === undefined) return {ok: true}
+ return call.result
+}
+
+function* requestChunks(call: {id: string; name: string; input: unknown}): Generator {
+ const toolCallId = call.id
+ yield {type: EventType.TOOL_CALL_START, toolCallId, toolCallName: call.name, toolName: call.name}
+ yield {type: EventType.TOOL_CALL_ARGS, toolCallId, delta: JSON.stringify(call.input)}
+ yield {type: EventType.TOOL_CALL_END, toolCallId}
+}
+
+function resultChunk(toolCallId: string, result: unknown): StreamChunk {
+ return {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: `${toolCallId}-result`,
+ toolCallId,
+ content: JSON.stringify(result),
+ state: 'output-available',
+ }
+}
+
+function* turnChunks(turn: QueuedTurn): Generator {
+ for (const call of turn.toolCalls) {
+ yield* requestChunks(call)
+ if (!turn.blocking) yield resultChunk(call.id, call.result)
+ }
+}
+
+function sessionIdChunk(deps: HarnessChatDeps): StreamChunk {
+ const sessionId = deps.resumeSessionId ?? `fake-${deps.sessionId}`
+ return {type: EventType.CUSTOM, name: 'fake.session-id', value: {sessionId}, ...THREAD}
+}
+
+function* customEventChunks(events: {name: string; value: unknown}[]): Generator {
+ for (const event of events) yield {type: EventType.CUSTOM, name: event.name, value: event.value, ...THREAD}
+}
+
export function makeScriptedRun(opts: {text?: string} = {}): ScriptedRun {
+ const defaultText = opts.text ?? 'ok'
const gate = {held: false, waiting: new Set<() => void>()}
const turns = {count: 0}
const toolCalls = {count: 0}
- const queuedToolCalls: Array<{id: string; name: string; input: unknown; blocking: boolean}> = []
+ const queuedTurns: QueuedTurn[] = []
const queuedCustomEvents: Array<{name: string; value: unknown}> = []
const queuedErrors: string[] = []
const hold = () => {
@@ -29,9 +79,18 @@ export function makeScriptedRun(opts: {text?: string} = {}): ScriptedRun {
const scriptToolCall = (name: string, input: unknown, toolOpts: {blocking?: boolean} = {}) => {
toolCalls.count += 1
const toolCallId = `tc-${toolCalls.count}`
- queuedToolCalls.push({id: toolCallId, name, input, blocking: toolOpts.blocking ?? true})
+ const blocking = toolOpts.blocking ?? true
+ queuedTurns.push({toolCalls: [{id: toolCallId, name, input, result: {ok: true}}], blocking})
return toolCallId
}
+ const scriptTurn = (turn: ScriptedTurn) => {
+ const calls = turn.toolCalls.map((call) => {
+ toolCalls.count += 1
+ return {id: `tc-${toolCalls.count}`, name: call.name, input: call.input, result: scriptedResult(call)}
+ })
+ queuedTurns.push({toolCalls: calls, text: turn.text, blocking: false})
+ return calls.map((call) => call.id)
+ }
const scriptCustomEvent = (name: string, value: unknown) => {
queuedCustomEvents.push({name, value})
}
@@ -41,40 +100,20 @@ export function makeScriptedRun(opts: {text?: string} = {}): ScriptedRun {
const chatStream = async function* (deps: HarnessChatDeps): AsyncGenerator {
turns.count += 1
const messageId = `scripted-${turns.count}`
- yield {type: EventType.RUN_STARTED, threadId: 'scripted', runId: 'scripted'}
- yield {
- type: EventType.CUSTOM,
- name: 'fake.session-id',
- value: {sessionId: `fake-${deps.sessionId}`},
- threadId: 'scripted',
- runId: 'scripted',
- }
- const toolCall = queuedToolCalls.shift()
- if (toolCall) {
- const toolCallId = toolCall.id
- yield {type: EventType.TOOL_CALL_START, toolCallId, toolCallName: toolCall.name, toolName: toolCall.name}
- yield {type: EventType.TOOL_CALL_ARGS, toolCallId, delta: JSON.stringify(toolCall.input)}
- yield {type: EventType.TOOL_CALL_END, toolCallId}
- if (toolCall.blocking) {
- yield {type: EventType.RUN_FINISHED, threadId: 'scripted', runId: 'scripted', finishReason: 'tool_calls'}
- return
- }
- yield {
- type: EventType.TOOL_CALL_RESULT,
- messageId: `${toolCallId}-result`,
- toolCallId,
- content: JSON.stringify({ok: true}),
- state: 'output-available',
- }
- }
- for (const event of queuedCustomEvents.splice(0)) {
- yield {type: EventType.CUSTOM, name: event.name, value: event.value, threadId: 'scripted', runId: 'scripted'}
+ yield {type: EventType.RUN_STARTED, ...THREAD}
+ yield sessionIdChunk(deps)
+ const scriptedTurn = queuedTurns.shift()
+ if (scriptedTurn) yield* turnChunks(scriptedTurn)
+ if (scriptedTurn?.blocking) {
+ yield {type: EventType.RUN_FINISHED, ...THREAD, finishReason: 'tool_calls'}
+ return
}
- yield {type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: opts.text ?? 'ok'}
+ yield* customEventChunks(queuedCustomEvents.splice(0))
+ yield {type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: scriptedTurn?.text ?? defaultText}
if (gate.held) await new Promise((resolve) => gate.waiting.add(resolve))
const failure = queuedErrors.shift()
if (failure) throw new Error(failure)
- yield {type: EventType.RUN_FINISHED, threadId: 'scripted', runId: 'scripted'}
+ yield {type: EventType.RUN_FINISHED, ...THREAD}
}
- return {chatStream, hold, release, scriptToolCall, scriptCustomEvent, scriptError}
+ return {chatStream, hold, release, scriptToolCall, scriptTurn, scriptCustomEvent, scriptError}
}
diff --git a/packages/harness-testkit/src/terminal-opener.ts b/packages/harness-testkit/src/terminal-opener.ts
deleted file mode 100644
index 35526c728..000000000
--- a/packages/harness-testkit/src/terminal-opener.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type {TerminalOpener, TerminalOpenRequest} from '@conciv/protocol/harness-types'
-
-export type RecordingTerminalOpener = {open: TerminalOpener; opened: TerminalOpenRequest[]}
-
-export function createRecordingTerminalOpener(): RecordingTerminalOpener {
- const opened: TerminalOpenRequest[] = []
- return {
- opened,
- open: (request) => {
- opened.push({bin: request.bin, args: [...request.args]})
- return Promise.resolve(true)
- },
- }
-}
diff --git a/packages/harness-testkit/src/testkit.ts b/packages/harness-testkit/src/testkit.ts
index d726d05b4..9edd5aad9 100644
--- a/packages/harness-testkit/src/testkit.ts
+++ b/packages/harness-testkit/src/testkit.ts
@@ -11,6 +11,7 @@ export {
} from './create-testkit.js'
export {createTestHarness, type TestHarness} from './create-test-harness.js'
export {createFakeHarness, type FakeHarness} from './create-fake-harness.js'
+export type {ScriptedRun, ScriptedTurn, ScriptedTurnToolCall} from './scripted-run.js'
export {harnessAvailable} from './harness-available.js'
export {
makeApprovingCallTool,
@@ -25,6 +26,5 @@ export {
export {approvalIds} from './run-events.js'
export {makeRpcClient, resolveSession, type RpcClient} from './session.js'
export {harnessModes, type HarnessMode} from './harness-modes.js'
-export {createRecordingTerminalOpener, type RecordingTerminalOpener} from './terminal-opener.js'
export type {RunStream} from './run-stream.js'
export type {RunEvents, SeenToolCall} from './run-events.js'
diff --git a/packages/harness-testkit/test/create-fake-harness.test.ts b/packages/harness-testkit/test/create-fake-harness.test.ts
index c8f7d16a0..2326b644c 100644
--- a/packages/harness-testkit/test/create-fake-harness.test.ts
+++ b/packages/harness-testkit/test/create-fake-harness.test.ts
@@ -15,6 +15,26 @@ const context: HarnessConnectContext = {
hookUrl: null,
}
+describe('createFakeHarness transcript history', () => {
+ it('declares no transcript history by default', () => {
+ const harness = createFakeHarness()
+ expect(harness.capabilities.transcriptHistory).toBe(false)
+ expect(harness.history).toBeUndefined()
+ })
+
+ it('serves the injected rows as the harness transcript list', async () => {
+ const rows = [
+ {id: 'external-1', derivedTitle: 'An external session', updatedAt: 1_700, messageCount: 7},
+ {id: 'external-2', derivedTitle: 'Another one', updatedAt: 1_800, messageCount: 2},
+ ]
+ const harness = createFakeHarness({history: rows})
+
+ expect(harness.capabilities.transcriptHistory).toBe(true)
+ expect(await harness.history?.list('/project')).toEqual(rows)
+ expect(await harness.history?.messages('/project', 'external-1')).toEqual([])
+ })
+})
+
describe('createFakeHarness tty', () => {
it('has no tty by default', () => {
expect(createFakeHarness().tty).toBeUndefined()
diff --git a/packages/harness-testkit/test/scripted-run.test.ts b/packages/harness-testkit/test/scripted-run.test.ts
index 455bc04ee..0026d2c55 100644
--- a/packages/harness-testkit/test/scripted-run.test.ts
+++ b/packages/harness-testkit/test/scripted-run.test.ts
@@ -40,6 +40,77 @@ describe('makeScriptedRun', () => {
expect(secondEmittedId).toBe(secondScriptedId)
})
+ it('keeps two independently queued tool calls in two separate turns', async () => {
+ const scripted = makeScriptedRun()
+ scripted.scriptToolCall('first_tool', {a: 1})
+ scripted.scriptToolCall('second_tool', {b: 2})
+ const drainTurn = async (): Promise => {
+ const chunks: StreamChunk[] = []
+ for await (const chunk of scripted.chatStream(deps())) chunks.push(chunk)
+ return chunks
+ }
+ const first = await drainTurn()
+ const second = await drainTurn()
+ const startsIn = (chunks: StreamChunk[]): string[] =>
+ chunks.flatMap((chunk) => (chunk.type === EventType.TOOL_CALL_START ? [chunk.toolCallName] : []))
+ expect(startsIn(first)).toEqual(['first_tool'])
+ expect(startsIn(second)).toEqual(['second_tool'])
+ })
+
+ it('emits every tool call of a scripted turn, with its result, inside one turn', async () => {
+ const scripted = makeScriptedRun()
+ const ids = scripted.scriptTurn({
+ toolCalls: [
+ {name: 'first_tool', input: {a: 1}, result: {ok: 'one'}},
+ {name: 'second_tool', input: {b: 2}, result: {ok: 'two'}},
+ ],
+ text: 'both tools ran',
+ })
+ const chunks: StreamChunk[] = []
+ for await (const chunk of scripted.chatStream(deps())) chunks.push(chunk)
+
+ const starts = chunks.flatMap((chunk) => (chunk.type === EventType.TOOL_CALL_START ? [chunk.toolCallId] : []))
+ const results = chunks.flatMap((chunk) =>
+ chunk.type === EventType.TOOL_CALL_RESULT ? [{id: chunk.toolCallId, content: chunk.content}] : [],
+ )
+ const text = chunks.flatMap((chunk) => (chunk.type === EventType.TEXT_MESSAGE_CONTENT ? [chunk.delta] : []))
+ expect(starts).toEqual(ids)
+ expect(results).toEqual([
+ {id: ids[0], content: JSON.stringify({ok: 'one'})},
+ {id: ids[1], content: JSON.stringify({ok: 'two'})},
+ ])
+ expect(text).toEqual(['both tools ran'])
+ expect(chunks.at(-1)?.type).toBe(EventType.RUN_FINISHED)
+ })
+
+ it('keeps a scripted null tool result as null instead of substituting the default', async () => {
+ const scripted = makeScriptedRun()
+ const ids = scripted.scriptTurn({toolCalls: [{name: 'nullish_tool', input: {a: 1}, result: null}]})
+ const chunks: StreamChunk[] = []
+ for await (const chunk of scripted.chatStream(deps())) chunks.push(chunk)
+ const results = chunks.flatMap((chunk) =>
+ chunk.type === EventType.TOOL_CALL_RESULT ? [{id: chunk.toolCallId, content: chunk.content}] : [],
+ )
+ expect(results).toEqual([{id: ids[0], content: 'null'}])
+ })
+
+ it('streams a queued tool call and a queued turn in the order they were scripted', async () => {
+ const scripted = makeScriptedRun()
+ scripted.scriptToolCall('first_tool', {a: 1})
+ scripted.scriptTurn({toolCalls: [{name: 'second_tool', input: {b: 2}}], text: 'turn ran'})
+ const drainTurn = async (): Promise => {
+ const chunks: StreamChunk[] = []
+ for await (const chunk of scripted.chatStream(deps())) chunks.push(chunk)
+ return chunks
+ }
+ const first = await drainTurn()
+ const second = await drainTurn()
+ const startsIn = (chunks: StreamChunk[]): string[] =>
+ chunks.flatMap((chunk) => (chunk.type === EventType.TOOL_CALL_START ? [chunk.toolCallName] : []))
+ expect(startsIn(first)).toEqual(['first_tool'])
+ expect(startsIn(second)).toEqual(['second_tool'])
+ })
+
it('holds the turn open until release()', async () => {
const scripted = makeScriptedRun()
scripted.hold()
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 250043693..ea676707d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -242,9 +242,6 @@ importers:
'@conciv/vitest-config':
specifier: workspace:*
version: link:../../packages/vitest-config
- '@orpc/server':
- specifier: 'catalog:'
- version: 1.14.7(@opentelemetry/api@1.9.1)(crossws@0.4.10(srvx@0.11.22))(ws@8.21.0)
'@solidjs/testing-library':
specifier: ^0.8.10
version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.14))(solid-js@1.9.14)