From 4d6b7bfb0a2e32fc2438607382cc7a6113be5a17 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:37:54 +0200 Subject: [PATCH 1/2] docs: audit runtime costs and complexity --- .gitattributes | 10 ++ docs/README.md | 1 + docs/audits/2026-09-16-runtime.md | 182 +++++++++++++++++++++++++++++ packages/core/bin/audit-runtime.ts | 104 +++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 docs/audits/2026-09-16-runtime.md create mode 100644 packages/core/bin/audit-runtime.ts diff --git a/.gitattributes b/.gitattributes index c2c7c4a..bf7f649 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,13 @@ *.icns binary *.woff2 binary docker/boite-server text eol=lf + +# Keep language statistics focused on TypeScript, Svelte and Rust. +# Supporting styles, entry points and scripts remain visible in diffs. +*.css -linguist-detectable +*.html -linguist-detectable +*.js -linguist-detectable +*.ps1 -linguist-detectable +*.sh -linguist-detectable +Dockerfile -linguist-detectable +docker/boite-server -linguist-detectable diff --git a/docs/README.md b/docs/README.md index 0835541..052171c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,4 +22,5 @@ - [CI](ci.md): checks, caching, nightly builds and image publication. - [Releasing](releasing.md): installers, channels and versioning. - [Release reports](releases/2.0.0-beta.1.md): the first beta's scope and measurements. +- [Runtime audit](audits/2026-09-16-runtime.md): reproduced runtime issues and complexity priorities. - [Repository rules](../AGENTS.md): boundaries a contributor must preserve. diff --git a/docs/audits/2026-09-16-runtime.md b/docs/audits/2026-09-16-runtime.md new file mode 100644 index 0000000..e1be084 --- /dev/null +++ b/docs/audits/2026-09-16-runtime.md @@ -0,0 +1,182 @@ +# Runtime, infrastructure and complexity audit + +Reviewed revision: `81fc7db`. Findings describe that revision, not completed fixes. +The accompanying change to `.gitattributes` only filters language statistics. + +## Reproduce + +Run `bun packages/core/bin/audit-runtime.ts` from the repository root. It creates +a disposable data directory, uses scripted echo drivers, closes its servers and +removes its journal. It does not call a live provider. Its JSON output records +observations, not passing regression assertions. Timings are synthetic readings, +not application latency or a comparison with an earlier release. + +## Findings in correction order + +### 1. A quoted completion marker completes an unfinished goal + +Priority: high. Owner: `ActivityStore.finished`, +[activity.ts](../../packages/core/src/activity.ts), line 124. + +The completion regex scans every assistant text part without distinguishing prose +from fenced examples. A reply saying it is not finished, followed by a fenced +`[BOITE_GOAL_COMPLETE]` example, changes the goal to `complete`. +Probe: `quoted-marker`, observed `goalStatus: complete`. + +Use an explicit completion signal where the protocol supports it. For the text +fallback, define and test a terminal marker outside code blocks. Include the +blocked marker in the same parser and test quoted examples of both. + +### 2. Goal completion reads a UI page rather than the whole completed turn + +Priority: medium. Same owner as finding 1. + +`finished` calls `threads.get`, which returns only the last 120 messages. A turn +with a completion marker followed by 120 assistant messages remains active. +Probe: `marker-before-page`, observed `goalStatus: active`. The goal can schedule +another paid turn even though its own completion marker was emitted. + +Read the completed turn's messages by turn ID, independently of UI pagination. +This is an edge case involving many messages, not simply a long text response. + +### 3. Every turn decodes the complete conversation to find one user message + +Priority: medium. Owner: `ThreadStore.lastUserInput`, +[threads.ts](../../packages/core/src/threads.ts), line 1122. + +`lastUserInput` calls `journal.listMessages(threadId)`, deserializes all message +parts, then searches backwards for its turn. This includes old tool output and +image data. It runs synchronously while constructing the driver context, so the +cost blocks other work on the core's event loop. + +The scripted turn with 5,000 historical messages decoded 5,001 messages. In one +run, medians of seven reads with 2,048 text characters per historical message were: + +| Historical messages | Full read median | +| --- | --- | +| 100 | 0.19 ms | +| 1,000 | 5.25 ms | +| 5,000 | 57.43 ms | + +Repeated runs put the 5,000-message median between 21 and 81 ms. The deterministic +finding is the 5,001 decoded messages, not a promised latency improvement. +Query the user message by thread and turn, with a suitable index. Also review +`failStuckTurn` and `retitle`, which independently read whole histories. + +### 4. The core client timeout stops at WebSocket open + +Priority: medium. Owner: `connect`, +[client.ts](../../packages/core/src/client.ts), lines 53 and 109. + +The open event clears the timeout before `send('hello')`. A peer that accepts the +socket but never answers leaves `connect` pending indefinitely. Later RPC calls +also have no response deadline. This affects the core client used by the pairing +CLI and automation; the UI has a separate client and is not proven affected. + +Probe: `hello-timeout`, `timeoutMs: 20`, `settledAfter100Ms: false` against a local +silent WebSocket peer. The probe closes that peer to release the pending call. +Keep a deadline through the handshake, close on failure, and give RPC callers a +bounded or explicitly cancellable wait. + +### 5. The Docker HTTPS proxy instructions omit the required origin setting + +Priority: medium. Owners: [server.md](../server.md), remote access section; +`isAllowedOrigin` in [server.ts](../../packages/core/src/server.ts), line 153. + +Preserving `Host` and `Origin`, as documented, does not admit an external HTTPS +origin automatically. The default check compares against the core's listen port +and local host names. A browser using a public HTTPS name receives 403 until that +exact origin is in `browserOrigins`. + +Probe: `proxy-origin` sends the same request before and after configuring the +allowlist: 403 before, 400 after. The latter is the expected missing-upgrade error +from this HTTP probe and proves only that the origin gate accepted it. +Document how to set `browserOrigins` through an authenticated owner connection +before switching to the proxy. Keep the strict gate. No real proxy was deployed. + +### 6. Inline code is parsed again as Markdown + +Priority: medium. Owner: `inline`, +[markdown.ts](../../packages/ui/src/lib/markdown.ts), line 17. + +The replacement pipeline first creates a code tag, then applies emphasis and +link replacements inside it. Input containing backticks around `**literal**` +produces `literal` rather than literal asterisks. +Probe: `markdown-inline-code`. This is especially visible in coding answers. + +Tokenize code spans before formatting surrounding text. Add cases for emphasis, +links, escaped characters and multiple code spans. Both phone and shell share +this renderer; the probe verifies HTML output, not browser pixels. + +### 7. Unchanged activity state appends journal events on startup and shutdown + +Priority: low. Owner: constructor, `pauseAll` and `close` in +[activity.ts](../../packages/core/src/activity.ts). + +Every saved activity is loaded and saved again, even when already paused or +complete. Shutdown similarly saves every tracked activity. With two paused goals, +the probes recorded two new `thread.activity` events on close and another two +when constructing the startup loader. Each event duplicates the complete task list. + +Only persist a startup or shutdown transition when its state changes. Keep +notifications for actual transitions. The measured result is event amplification; +no disk-size or startup-latency claim was measured. + +## Complexity shortlist + +The audit scanned 2,247 functions across `packages` and `apps`: 63 scored at least +15 cyclomatic complexity. After adding the probe it scanned 2,252 functions, +with the same 63 above the threshold. Scores count decision paths, not defects. +No cognitive scores were available in this run. No production refactor was made. + +| Function | CCN before / after | Assessment | +| --- | --- | --- | +| `renderMarkdown` | 52 / 52 | Separate block consumption and list nesting; pair the work with parser regression cases. | +| `ModelPicker.onkeydown` | 47 / 47 | Four navigation contexts share one handler: legacy menu, search results, account chips and general rows. Model those contexts explicitly. | +| `Claude.handleStream` | 40 / 40 | Text and thinking duplicate block lookup, creation, writes and accumulated content. Extract that shared operation; preserve tool JSON handling. | +| `Workspace.add` / `boot` | 34 / 34 and 31 / 31 | URL validation, connection, identity deduplication, persistence and lifecycle checks are interleaved. Separate those responsibilities with cancellation tests. | +| `ActivityStore.observeTool` | 31 / 31 | Separate protocol-specific task extraction from the state update. Preserve full-list versus incremental updates. | +| `FakeClient.dispatch` | 216 / 216 | Not an immediate runtime optimization. Many branches are the RPC contract; group substantial handlers by domain if fake/core behavior diverges. | +| `Codex.onNotification` | 36 / 36 | Mainly protocol dispatch. Keep the switch; extract substantial payload normalization only. | +| `runCommand` | 32 / 32 | The switch represents commands. Modified CCN is 15; do not introduce a class hierarchy to lower this score. | + +The first two production targets are the Markdown parser, which has a reproduced +formatting defect, and the goal completion logic, which affects execution. +Do not start by rewriting the highest-scoring fake RPC dispatcher. + +## Coverage and limits + +Core tests: 344 passed, 12 skipped, zero failures before building the UI. UI tests: +259 passed across 34 files. Core and contracts type checks passed using the local +TypeScript entry points. Svelte check reported zero errors and zero warnings. +Production UI build passed. The local installation lacked command shims, so the +package entry points were invoked directly. Svelte analysis found no issues in +`Workspace`; its ModelPicker suggestions were not treated as confirmed defects. + +`bun test tests/e2e`: 56 passed, 16 skipped, one prerequisite failure because +`boite-shell.exe` was not built in this checkout. The browser/core checks ran; +the native shell checks did not. This is not a fully passing end-to-end run. + +Commands used for the available checks: + +```sh +bun packages/core/node_modules/typescript/bin/tsc -p packages/contracts/tsconfig.json +bun packages/core/node_modules/typescript/bin/tsc -p packages/core/tsconfig.json +bun run --cwd packages/core test +cd packages/ui +bun node_modules/svelte-check/bin/svelte-check --tsconfig ./tsconfig.json --tsgo +bun node_modules/vitest/vitest.mjs run --maxWorkers=8 +bun node_modules/vite/bin/vite.js build +cd ../.. +bun test tests/e2e +bun scripts/check-docs.ts +git diff --check +``` + +The review covered shared core paths, all five driver families through their +scripted tests, UI and fake transport, CI selection, Docker startup and health, +and release workflows. Goal/history findings apply independently of the selected +driver. Docker and live providers were not run. No fleet infrastructure was +inspected or changed. RSS, throughput under load, Linux process termination and +native shell behavior need separate measurements. This is a prioritized audit, +not a claim that every defect has been found. diff --git a/packages/core/bin/audit-runtime.ts b/packages/core/bin/audit-runtime.ts new file mode 100644 index 0000000..398ca77 --- /dev/null +++ b/packages/core/bin/audit-runtime.ts @@ -0,0 +1,104 @@ +/** Runtime audit probes against a disposable journal and scripted drivers. */ +import { ActivityStore } from '../src/activity.ts'; +import { connect } from '../src/client.ts'; +import { setDriver } from '../src/drivers/index.ts'; +import { echoThread, startTestCore, waitFor } from '../test/harness.ts'; +import { renderMarkdown } from '../../ui/src/lib/markdown.ts'; + +const h = await startTestCore(); +let restore: (() => void) | undefined; +try { + const client = await h.connect(); + const proxyOrigin = 'https://boite.example.test'; + const rejected = await fetch(`${h.url}/rpc`, { headers: { origin: proxyOrigin } }); + h.core.settings.set({ browserOrigins: [proxyOrigin] }); + const allowed = await fetch(`${h.url}/rpc`, { headers: { origin: proxyOrigin } }); + console.log(JSON.stringify({ probe: 'proxy-origin', beforeAllowlist: rejected.status, afterAllowlist: allowed.status })); + const { threadId } = await echoThread(h, client); + const journal = h.core.journal; + let written = 0; + for (const count of [100, 1000, 5000]) { + journal.db.transaction(() => { + for (; written < count; written++) journal.putMessage({ + id: `audit-${written}`, threadId, turnId: 'audit-history', role: 'assistant', + parts: [{ type: 'text', text: 'x'.repeat(2048) }], state: 'complete', createdAt: written, + }); + })(); + const times: number[] = []; + for (let run = 0; run < 7; run++) { + const start = performance.now(); + journal.listMessages(threadId); + times.push(performance.now() - start); + } + console.log(JSON.stringify({ probe: 'history-read', messages: count, medianMs: +times.sort((a, b) => a - b)[3]!.toFixed(2) })); + } + let decoded = 0; + const original = journal.listMessages.bind(journal); + journal.listMessages = (id) => { const messages = original(id); decoded += messages.length; return messages; }; + restore = setDriver('echo', { + protocol: 'echo', + startTurn() { return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; }, + }); + const turn = h.core.threads.startTurn(threadId, 'latest prompt'); + await waitFor(() => journal.getTurn(turn.id)?.status === 'done'); + console.log(JSON.stringify({ probe: 'start-turn', historyMessages: 5000, decodedMessages: decoded })); + journal.listMessages = original; + restore(); restore = undefined; + + for (const scenario of ['quoted-marker', 'marker-before-page'] as const) { + const { threadId: goalThread } = await echoThread(h, client, scenario); + restore = setDriver('echo', { + protocol: 'echo', + startTurn(ctx) { + const id = ctx.emit.startMessage('assistant'); + const text = scenario === 'quoted-marker' + ? 'Not finished. This is the marker format:\n```\n[BOITE_GOAL_COMPLETE]\n```' + : '[BOITE_GOAL_COMPLETE]'; + ctx.emit.part(id, 0, { type: 'text', text }); + ctx.emit.complete(id, 'complete'); + if (scenario === 'marker-before-page') for (let i = 0; i < 120; i++) { + const extra = ctx.emit.startMessage('assistant'); + ctx.emit.part(extra, 0, { type: 'text', text: 'Additional output.' }); + ctx.emit.complete(extra, 'complete'); + } + return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; + }, + }); + h.core.activity.set({ threadId: goalThread, goal: { objective: 'Audit completion detection' } }); + await waitFor(() => h.core.activity.get(goalThread).goal!.iterations > 0 && h.core.threads.get(goalThread).status === 'idle'); + console.log(JSON.stringify({ probe: scenario, goalStatus: h.core.activity.get(goalThread).goal?.status })); + h.core.activity.pauseAll(goalThread); + restore(); restore = undefined; + } + + const events = () => (journal.db.query("SELECT COUNT(*) AS n FROM events WHERE type = 'thread.activity'").get() as { n: number }).n; + const before = events(); + h.core.activity.close(); + const afterClose = events(); + console.log(JSON.stringify({ probe: 'inactive-activity-close', addedEvents: afterClose - before })); + // Exercise the startup loader against the saved, already paused activities. + const reloaded = new ActivityStore(h.core); + console.log(JSON.stringify({ probe: 'inactive-activity-boot', addedEvents: events() - afterClose })); + reloaded.close(); + console.log(JSON.stringify({ probe: 'markdown-inline-code', html: renderMarkdown('`**literal**`') })); +} finally { + restore?.(); + await h.stop(); +} + +// An accepted socket whose peer never answers hello must still respect timeoutMs. +const silent = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch(request, server) { return server.upgrade(request) ? undefined : new Response('upgrade required'); }, + websocket: { message() {} }, +}); +let settled = false; +const pending = connect(`http://127.0.0.1:${silent.port}`, 'synthetic-token', { timeoutMs: 20 }) + .then(client => { settled = true; client.close(); }, () => { settled = true; }); +try { + await Bun.sleep(100); + console.log(JSON.stringify({ probe: 'hello-timeout', timeoutMs: 20, settledAfter100Ms: settled })); +} finally { + void silent.stop(true); + await pending; +} From e8d6521ccdc604673df0ebf5a5546c89d2f0f127 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:13:19 +0200 Subject: [PATCH 2/2] fix: address runtime audit findings and simplify control flow --- docs/README.md | 1 - docs/audits/2026-09-16-runtime.md | 182 ----------------- docs/server.md | 12 ++ packages/core/bin/audit-runtime.ts | 104 ---------- packages/core/src/activity.ts | 86 ++++++-- packages/core/src/client.ts | 29 ++- packages/core/src/drivers/claude.ts | 27 ++- packages/core/src/journal.ts | 23 ++- packages/core/src/threads.ts | 6 +- packages/core/test/activity.test.ts | 57 ++++++ packages/core/test/client-timeout.test.ts | 30 +++ packages/core/test/model-switch.test.ts | 4 +- packages/ui/src/components/Composer.test.ts | 27 +++ packages/ui/src/components/ModelPicker.svelte | 103 ++++++---- packages/ui/src/lib/markdown.test.ts | 7 + packages/ui/src/lib/markdown.ts | 181 ++++++++++------- packages/ui/src/lib/workspace.svelte.ts | 184 +++++++++++------- packages/ui/src/lib/workspace.test.ts | 48 +++++ tests/e2e/ui.test.ts | 19 ++ 19 files changed, 624 insertions(+), 506 deletions(-) delete mode 100644 docs/audits/2026-09-16-runtime.md delete mode 100644 packages/core/bin/audit-runtime.ts create mode 100644 packages/core/test/client-timeout.test.ts diff --git a/docs/README.md b/docs/README.md index 052171c..0835541 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,5 +22,4 @@ - [CI](ci.md): checks, caching, nightly builds and image publication. - [Releasing](releasing.md): installers, channels and versioning. - [Release reports](releases/2.0.0-beta.1.md): the first beta's scope and measurements. -- [Runtime audit](audits/2026-09-16-runtime.md): reproduced runtime issues and complexity priorities. - [Repository rules](../AGENTS.md): boundaries a contributor must preserve. diff --git a/docs/audits/2026-09-16-runtime.md b/docs/audits/2026-09-16-runtime.md deleted file mode 100644 index e1be084..0000000 --- a/docs/audits/2026-09-16-runtime.md +++ /dev/null @@ -1,182 +0,0 @@ -# Runtime, infrastructure and complexity audit - -Reviewed revision: `81fc7db`. Findings describe that revision, not completed fixes. -The accompanying change to `.gitattributes` only filters language statistics. - -## Reproduce - -Run `bun packages/core/bin/audit-runtime.ts` from the repository root. It creates -a disposable data directory, uses scripted echo drivers, closes its servers and -removes its journal. It does not call a live provider. Its JSON output records -observations, not passing regression assertions. Timings are synthetic readings, -not application latency or a comparison with an earlier release. - -## Findings in correction order - -### 1. A quoted completion marker completes an unfinished goal - -Priority: high. Owner: `ActivityStore.finished`, -[activity.ts](../../packages/core/src/activity.ts), line 124. - -The completion regex scans every assistant text part without distinguishing prose -from fenced examples. A reply saying it is not finished, followed by a fenced -`[BOITE_GOAL_COMPLETE]` example, changes the goal to `complete`. -Probe: `quoted-marker`, observed `goalStatus: complete`. - -Use an explicit completion signal where the protocol supports it. For the text -fallback, define and test a terminal marker outside code blocks. Include the -blocked marker in the same parser and test quoted examples of both. - -### 2. Goal completion reads a UI page rather than the whole completed turn - -Priority: medium. Same owner as finding 1. - -`finished` calls `threads.get`, which returns only the last 120 messages. A turn -with a completion marker followed by 120 assistant messages remains active. -Probe: `marker-before-page`, observed `goalStatus: active`. The goal can schedule -another paid turn even though its own completion marker was emitted. - -Read the completed turn's messages by turn ID, independently of UI pagination. -This is an edge case involving many messages, not simply a long text response. - -### 3. Every turn decodes the complete conversation to find one user message - -Priority: medium. Owner: `ThreadStore.lastUserInput`, -[threads.ts](../../packages/core/src/threads.ts), line 1122. - -`lastUserInput` calls `journal.listMessages(threadId)`, deserializes all message -parts, then searches backwards for its turn. This includes old tool output and -image data. It runs synchronously while constructing the driver context, so the -cost blocks other work on the core's event loop. - -The scripted turn with 5,000 historical messages decoded 5,001 messages. In one -run, medians of seven reads with 2,048 text characters per historical message were: - -| Historical messages | Full read median | -| --- | --- | -| 100 | 0.19 ms | -| 1,000 | 5.25 ms | -| 5,000 | 57.43 ms | - -Repeated runs put the 5,000-message median between 21 and 81 ms. The deterministic -finding is the 5,001 decoded messages, not a promised latency improvement. -Query the user message by thread and turn, with a suitable index. Also review -`failStuckTurn` and `retitle`, which independently read whole histories. - -### 4. The core client timeout stops at WebSocket open - -Priority: medium. Owner: `connect`, -[client.ts](../../packages/core/src/client.ts), lines 53 and 109. - -The open event clears the timeout before `send('hello')`. A peer that accepts the -socket but never answers leaves `connect` pending indefinitely. Later RPC calls -also have no response deadline. This affects the core client used by the pairing -CLI and automation; the UI has a separate client and is not proven affected. - -Probe: `hello-timeout`, `timeoutMs: 20`, `settledAfter100Ms: false` against a local -silent WebSocket peer. The probe closes that peer to release the pending call. -Keep a deadline through the handshake, close on failure, and give RPC callers a -bounded or explicitly cancellable wait. - -### 5. The Docker HTTPS proxy instructions omit the required origin setting - -Priority: medium. Owners: [server.md](../server.md), remote access section; -`isAllowedOrigin` in [server.ts](../../packages/core/src/server.ts), line 153. - -Preserving `Host` and `Origin`, as documented, does not admit an external HTTPS -origin automatically. The default check compares against the core's listen port -and local host names. A browser using a public HTTPS name receives 403 until that -exact origin is in `browserOrigins`. - -Probe: `proxy-origin` sends the same request before and after configuring the -allowlist: 403 before, 400 after. The latter is the expected missing-upgrade error -from this HTTP probe and proves only that the origin gate accepted it. -Document how to set `browserOrigins` through an authenticated owner connection -before switching to the proxy. Keep the strict gate. No real proxy was deployed. - -### 6. Inline code is parsed again as Markdown - -Priority: medium. Owner: `inline`, -[markdown.ts](../../packages/ui/src/lib/markdown.ts), line 17. - -The replacement pipeline first creates a code tag, then applies emphasis and -link replacements inside it. Input containing backticks around `**literal**` -produces `literal` rather than literal asterisks. -Probe: `markdown-inline-code`. This is especially visible in coding answers. - -Tokenize code spans before formatting surrounding text. Add cases for emphasis, -links, escaped characters and multiple code spans. Both phone and shell share -this renderer; the probe verifies HTML output, not browser pixels. - -### 7. Unchanged activity state appends journal events on startup and shutdown - -Priority: low. Owner: constructor, `pauseAll` and `close` in -[activity.ts](../../packages/core/src/activity.ts). - -Every saved activity is loaded and saved again, even when already paused or -complete. Shutdown similarly saves every tracked activity. With two paused goals, -the probes recorded two new `thread.activity` events on close and another two -when constructing the startup loader. Each event duplicates the complete task list. - -Only persist a startup or shutdown transition when its state changes. Keep -notifications for actual transitions. The measured result is event amplification; -no disk-size or startup-latency claim was measured. - -## Complexity shortlist - -The audit scanned 2,247 functions across `packages` and `apps`: 63 scored at least -15 cyclomatic complexity. After adding the probe it scanned 2,252 functions, -with the same 63 above the threshold. Scores count decision paths, not defects. -No cognitive scores were available in this run. No production refactor was made. - -| Function | CCN before / after | Assessment | -| --- | --- | --- | -| `renderMarkdown` | 52 / 52 | Separate block consumption and list nesting; pair the work with parser regression cases. | -| `ModelPicker.onkeydown` | 47 / 47 | Four navigation contexts share one handler: legacy menu, search results, account chips and general rows. Model those contexts explicitly. | -| `Claude.handleStream` | 40 / 40 | Text and thinking duplicate block lookup, creation, writes and accumulated content. Extract that shared operation; preserve tool JSON handling. | -| `Workspace.add` / `boot` | 34 / 34 and 31 / 31 | URL validation, connection, identity deduplication, persistence and lifecycle checks are interleaved. Separate those responsibilities with cancellation tests. | -| `ActivityStore.observeTool` | 31 / 31 | Separate protocol-specific task extraction from the state update. Preserve full-list versus incremental updates. | -| `FakeClient.dispatch` | 216 / 216 | Not an immediate runtime optimization. Many branches are the RPC contract; group substantial handlers by domain if fake/core behavior diverges. | -| `Codex.onNotification` | 36 / 36 | Mainly protocol dispatch. Keep the switch; extract substantial payload normalization only. | -| `runCommand` | 32 / 32 | The switch represents commands. Modified CCN is 15; do not introduce a class hierarchy to lower this score. | - -The first two production targets are the Markdown parser, which has a reproduced -formatting defect, and the goal completion logic, which affects execution. -Do not start by rewriting the highest-scoring fake RPC dispatcher. - -## Coverage and limits - -Core tests: 344 passed, 12 skipped, zero failures before building the UI. UI tests: -259 passed across 34 files. Core and contracts type checks passed using the local -TypeScript entry points. Svelte check reported zero errors and zero warnings. -Production UI build passed. The local installation lacked command shims, so the -package entry points were invoked directly. Svelte analysis found no issues in -`Workspace`; its ModelPicker suggestions were not treated as confirmed defects. - -`bun test tests/e2e`: 56 passed, 16 skipped, one prerequisite failure because -`boite-shell.exe` was not built in this checkout. The browser/core checks ran; -the native shell checks did not. This is not a fully passing end-to-end run. - -Commands used for the available checks: - -```sh -bun packages/core/node_modules/typescript/bin/tsc -p packages/contracts/tsconfig.json -bun packages/core/node_modules/typescript/bin/tsc -p packages/core/tsconfig.json -bun run --cwd packages/core test -cd packages/ui -bun node_modules/svelte-check/bin/svelte-check --tsconfig ./tsconfig.json --tsgo -bun node_modules/vitest/vitest.mjs run --maxWorkers=8 -bun node_modules/vite/bin/vite.js build -cd ../.. -bun test tests/e2e -bun scripts/check-docs.ts -git diff --check -``` - -The review covered shared core paths, all five driver families through their -scripted tests, UI and fake transport, CI selection, Docker startup and health, -and release workflows. Goal/history findings apply independently of the selected -driver. Docker and live providers were not run. No fleet infrastructure was -inspected or changed. RSS, throughput under load, Linux process termination and -native shell behavior need separate measurements. This is a prioritized audit, -not a claim that every defect has been found. diff --git a/docs/server.md b/docs/server.md index 90c64b2..baf0d16 100644 --- a/docs/server.md +++ b/docs/server.md @@ -95,6 +95,18 @@ The core does not terminate TLS. Public access needs an HTTPS reverse proxy that forwards WebSocket upgrades and preserves `Host` and `Origin`. Serve the UI and `/rpc` from the same origin. Do not expose plain HTTP to the internet. +Before connecting through the proxy, add its exact browser origin, such as +`https://boite.example.com`, to the core's `browserOrigins` setting. Connect the +desktop shell directly as an owner, select that machine, open Machines > Allowed +browser origins, and configure the origins there. Keep any existing origins that are +still needed. Origins contain a scheme, hostname and optional port, with no +path or trailing slash. See [machine connections](machines.md). + +Preserving the proxy headers alone is not enough: an HTTPS origin on port 443 +differs from the core listening on port 7337. An origin that is not allowed gets +HTTP 403 on `/rpc`, even when the pairing token is valid. Keep the allowlist +explicit; do not strip the `Origin` header to bypass this check. + ### Image verification From a Linux checkout with Docker: diff --git a/packages/core/bin/audit-runtime.ts b/packages/core/bin/audit-runtime.ts deleted file mode 100644 index 398ca77..0000000 --- a/packages/core/bin/audit-runtime.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** Runtime audit probes against a disposable journal and scripted drivers. */ -import { ActivityStore } from '../src/activity.ts'; -import { connect } from '../src/client.ts'; -import { setDriver } from '../src/drivers/index.ts'; -import { echoThread, startTestCore, waitFor } from '../test/harness.ts'; -import { renderMarkdown } from '../../ui/src/lib/markdown.ts'; - -const h = await startTestCore(); -let restore: (() => void) | undefined; -try { - const client = await h.connect(); - const proxyOrigin = 'https://boite.example.test'; - const rejected = await fetch(`${h.url}/rpc`, { headers: { origin: proxyOrigin } }); - h.core.settings.set({ browserOrigins: [proxyOrigin] }); - const allowed = await fetch(`${h.url}/rpc`, { headers: { origin: proxyOrigin } }); - console.log(JSON.stringify({ probe: 'proxy-origin', beforeAllowlist: rejected.status, afterAllowlist: allowed.status })); - const { threadId } = await echoThread(h, client); - const journal = h.core.journal; - let written = 0; - for (const count of [100, 1000, 5000]) { - journal.db.transaction(() => { - for (; written < count; written++) journal.putMessage({ - id: `audit-${written}`, threadId, turnId: 'audit-history', role: 'assistant', - parts: [{ type: 'text', text: 'x'.repeat(2048) }], state: 'complete', createdAt: written, - }); - })(); - const times: number[] = []; - for (let run = 0; run < 7; run++) { - const start = performance.now(); - journal.listMessages(threadId); - times.push(performance.now() - start); - } - console.log(JSON.stringify({ probe: 'history-read', messages: count, medianMs: +times.sort((a, b) => a - b)[3]!.toFixed(2) })); - } - let decoded = 0; - const original = journal.listMessages.bind(journal); - journal.listMessages = (id) => { const messages = original(id); decoded += messages.length; return messages; }; - restore = setDriver('echo', { - protocol: 'echo', - startTurn() { return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; }, - }); - const turn = h.core.threads.startTurn(threadId, 'latest prompt'); - await waitFor(() => journal.getTurn(turn.id)?.status === 'done'); - console.log(JSON.stringify({ probe: 'start-turn', historyMessages: 5000, decodedMessages: decoded })); - journal.listMessages = original; - restore(); restore = undefined; - - for (const scenario of ['quoted-marker', 'marker-before-page'] as const) { - const { threadId: goalThread } = await echoThread(h, client, scenario); - restore = setDriver('echo', { - protocol: 'echo', - startTurn(ctx) { - const id = ctx.emit.startMessage('assistant'); - const text = scenario === 'quoted-marker' - ? 'Not finished. This is the marker format:\n```\n[BOITE_GOAL_COMPLETE]\n```' - : '[BOITE_GOAL_COMPLETE]'; - ctx.emit.part(id, 0, { type: 'text', text }); - ctx.emit.complete(id, 'complete'); - if (scenario === 'marker-before-page') for (let i = 0; i < 120; i++) { - const extra = ctx.emit.startMessage('assistant'); - ctx.emit.part(extra, 0, { type: 'text', text: 'Additional output.' }); - ctx.emit.complete(extra, 'complete'); - } - return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; - }, - }); - h.core.activity.set({ threadId: goalThread, goal: { objective: 'Audit completion detection' } }); - await waitFor(() => h.core.activity.get(goalThread).goal!.iterations > 0 && h.core.threads.get(goalThread).status === 'idle'); - console.log(JSON.stringify({ probe: scenario, goalStatus: h.core.activity.get(goalThread).goal?.status })); - h.core.activity.pauseAll(goalThread); - restore(); restore = undefined; - } - - const events = () => (journal.db.query("SELECT COUNT(*) AS n FROM events WHERE type = 'thread.activity'").get() as { n: number }).n; - const before = events(); - h.core.activity.close(); - const afterClose = events(); - console.log(JSON.stringify({ probe: 'inactive-activity-close', addedEvents: afterClose - before })); - // Exercise the startup loader against the saved, already paused activities. - const reloaded = new ActivityStore(h.core); - console.log(JSON.stringify({ probe: 'inactive-activity-boot', addedEvents: events() - afterClose })); - reloaded.close(); - console.log(JSON.stringify({ probe: 'markdown-inline-code', html: renderMarkdown('`**literal**`') })); -} finally { - restore?.(); - await h.stop(); -} - -// An accepted socket whose peer never answers hello must still respect timeoutMs. -const silent = Bun.serve({ - hostname: '127.0.0.1', port: 0, - fetch(request, server) { return server.upgrade(request) ? undefined : new Response('upgrade required'); }, - websocket: { message() {} }, -}); -let settled = false; -const pending = connect(`http://127.0.0.1:${silent.port}`, 'synthetic-token', { timeoutMs: 20 }) - .then(client => { settled = true; client.close(); }, () => { settled = true; }); -try { - await Bun.sleep(100); - console.log(JSON.stringify({ probe: 'hello-timeout', timeoutMs: 20, settledAfter100Ms: settled })); -} finally { - void silent.stop(true); - await pending; -} diff --git a/packages/core/src/activity.ts b/packages/core/src/activity.ts index 7361559..d516b0a 100644 --- a/packages/core/src/activity.ts +++ b/packages/core/src/activity.ts @@ -16,13 +16,9 @@ export class ActivityStore { for (const thread of core.journal.listThreads()) { const saved = core.journal.getSetting(`activity:${thread.id}`) as ThreadActivity | undefined; if (!saved) continue; - for (const kind of ['goal', 'loop'] as const) { - const item = saved[kind]; - if (item?.status === 'active') { item.status = 'paused'; item.error = 'Core restarted. Resume to continue.'; } - } - if (saved.loop) saved.loop.nextRunAt = null; + const changed = pauseActivity(saved, 'Core restarted. Resume to continue.'); this.states.set(thread.id, saved); - this.save(thread.id); + if (changed) this.save(thread.id); } core.bus.onAny((name, payload) => { if (this.closed) return; @@ -103,12 +99,7 @@ export class ActivityStore { const state = this.get(threadId); let createdId: string | null = null; if (part.name.toLowerCase() === 'taskcreate') { - try { - const output = JSON.parse(part.output ?? 'null'); - const id = output?.task?.id ?? output?.taskId ?? output?.id; - if (typeof id === 'string' || typeof id === 'number') createdId = String(id); - } catch { /* Some Claude versions return a human-readable result. */ } - createdId ??= /Task\s+#?(\d+)\s+created/i.exec(part.output ?? '')?.[1] ?? null; + createdId = createdTaskId(part.output); if (!createdId) return; } const id = String(input.taskId ?? createdId); @@ -128,16 +119,28 @@ export class ActivityStore { const state = this.states.get(turn.threadId); if (!state) return; if (owned?.kind === 'goal' && owned.generation === (this.generations.get(turn.threadId) ?? 0) && state.goal?.status === 'active') { - const messages = this.core.threads.get(turn.threadId).messages; - const completed = messages.some((message) => message.turnId === turn.id && message.role === 'assistant' && message.parts.some((part) => part.type === 'text' && /^\s*\[BOITE_GOAL_COMPLETE\]\s*$/m.test(part.text))); - const blocked = messages.some((message) => message.turnId === turn.id && message.role === 'assistant' && message.parts.some((part) => part.type === 'text' && /^\s*\[BOITE_GOAL_BLOCKED\]\s*$/m.test(part.text))); - if (blocked) { state.goal.status = 'paused'; state.goal.error = 'The agent reported a blocker. Read its answer before resuming.'; this.save(turn.threadId); } - else if (completed) { state.goal.status = 'complete'; this.save(turn.threadId); } + const signal = this.goalResult(turn); + if (signal === 'blocked') { state.goal.status = 'paused'; state.goal.error = 'The agent reported a blocker. Read its answer before resuming.'; this.save(turn.threadId); } + else if (signal === 'complete') { state.goal.status = 'complete'; this.save(turn.threadId); } } // Let the scheduler release its running slot and clients submit queued user input first. this.schedule(turn.threadId, 250); } + private goalResult(turn: Turn): 'complete' | 'blocked' | null { + let result: 'complete' | null = null; + for (const message of this.core.journal.walkTurnMessages(turn.threadId, turn.id)) { + if (message.role !== 'assistant') continue; + for (const part of message.parts) { + if (part.type !== 'text') continue; + const signal = goalSignal(part.text); + if (signal === 'blocked') return signal; + if (signal === 'complete') result = signal; + } + } + return result; + } + private run(threadId: string): void { if (this.closed) return; const thread = this.core.journal.getThread(threadId); @@ -150,7 +153,7 @@ export class ActivityStore { return; } const prompt = kind === 'goal' - ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, write [BOITE_GOAL_COMPLETE] alone on its own line. If blocked or waiting for user input, explain what is missing and write [BOITE_GOAL_BLOCKED] alone on its own line.` + ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, end your answer with [BOITE_GOAL_COMPLETE] alone on its own line, outside code blocks. If blocked or waiting for user input, explain what is missing and end with [BOITE_GOAL_BLOCKED] alone on its own line, outside code blocks.` : state.loop!.prompt; try { const turn = this.core.threads.startTurn(threadId, prompt); @@ -177,9 +180,7 @@ export class ActivityStore { const state = this.states.get(threadId); if (!state) return; this.clearTimer(threadId); - for (const kind of ['goal', 'loop'] as const) { const item = state[kind]; if (item?.status === 'active') { item.status = 'paused'; item.error = error; } } - if (state.loop) state.loop.nextRunAt = null; - this.save(threadId); + if (pauseActivity(state, error)) this.save(threadId); } private save(threadId: string): void { @@ -192,6 +193,49 @@ export class ActivityStore { } export function taskStatus(value: unknown): AgentTask['status'] { return value === 'completed' ? 'completed' : value === 'in_progress' || value === 'inProgress' ? 'in_progress' : 'pending'; } + +function createdTaskId(text: string | null): string | null { + try { + const output = JSON.parse(text ?? 'null'); + const id = output?.task?.id ?? output?.taskId ?? output?.id; + if (typeof id === 'string' || typeof id === 'number') return String(id); + } catch { /* Older agents return a human-readable confirmation. */ } + return /Task\s+#?(\d+)\s+created/i.exec(text ?? '')?.[1] ?? null; +} + +function pauseActivity(state: ThreadActivity, error: string | null): boolean { + let changed = false; + for (const item of [state.goal, state.loop]) { + if (item?.status !== 'active') continue; + item.status = 'paused'; + item.error = error; + changed = true; + } + if (state.loop && state.loop.nextRunAt !== null) { + state.loop.nextRunAt = null; + changed = true; + } + return changed; +} + +/** Only a final standalone marker outside fenced or indented code is a signal. */ +export function goalSignal(text: string): 'complete' | 'blocked' | null { + let fence: string | null = null; + let last = ''; + for (const line of text.split(/\r?\n/)) { + const mark = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (fence !== null) { + if (mark && mark[1]![0] === fence[0] && mark[1]!.length >= fence.length && mark[2]!.trim() === '') fence = null; + if (line.trim()) last = ''; + continue; + } + if (mark) { fence = mark[1]!; last = ''; continue; } + if (line.trim()) last = line; + } + if (/^ {0,3}\[BOITE_GOAL_COMPLETE\][ \t]*$/.test(last)) return 'complete'; + if (/^ {0,3}\[BOITE_GOAL_BLOCKED\][ \t]*$/.test(last)) return 'blocked'; + return null; +} export function normalizeTasks(items: unknown[]): AgentTask[] { return items.flatMap((value, index) => { if (!value || typeof value !== 'object') return []; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 7a62c4f..af66570 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -12,6 +12,8 @@ import type { export interface ConnectOptions { client?: { name: string; version: string }; timeoutMs?: number; + /** Response deadline for ordinary RPC calls; the handshake uses timeoutMs. */ + requestTimeoutMs?: number; /** Say hello with a pairing grant instead of the token; `session` then carries what came back. */ grant?: string; } @@ -52,18 +54,28 @@ function socketUrl(url: string): string { export async function connect(url: string, token: string, options: ConnectOptions = {}): Promise { const timeoutMs = options.timeoutMs ?? 5000; + const deadline = Date.now() + timeoutMs; const socket = new WebSocket(socketUrl(url)); const pending = new Map(); const listeners = new Map void>>(); let nextId = 1; let closed = false; - const send = (method: string, params: unknown): Promise => { + const send = (method: string, params: unknown, waitMs = options.requestTimeoutMs ?? 120_000): Promise => { const id = nextId; nextId += 1; return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`timed out waiting for ${method}`)); + }, waitMs); + const waiter: Pending = { + resolve(value) { clearTimeout(timer); resolve(value); }, + reject(error) { clearTimeout(timer); reject(error); }, + }; + pending.set(id, waiter); + try { socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); } + catch (error) { pending.delete(id); waiter.reject(error as Error); } }); }; @@ -75,6 +87,7 @@ export async function connect(url: string, token: string, options: ConnectOption } catch { return; } + if (frame === null || typeof frame !== 'object') return; if (typeof frame.id === 'number') { const waiter = pending.get(frame.id); if (waiter === undefined) return; @@ -95,7 +108,7 @@ export async function connect(url: string, token: string, options: ConnectOption }); await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('the socket did not open')), timeoutMs); + const timer = setTimeout(() => reject(new Error('timed out opening the socket')), timeoutMs); socket.addEventListener('open', () => { clearTimeout(timer); resolve(); @@ -104,13 +117,17 @@ export async function connect(url: string, token: string, options: ConnectOption clearTimeout(timer); reject(new Error('the socket failed to open')); }); - }); + socket.addEventListener('close', () => { + clearTimeout(timer); + reject(new Error('the socket closed before opening')); + }, { once: true }); + }).catch(error => { socket.close(); throw error; }); const hello = (await send('hello', { ...(options.grant === undefined ? { token } : { grant: options.grant }), protocolVersion: PROTOCOL_VERSION, client: options.client ?? { name: 'test', version: '2.0.0-beta.1' }, - })) as { core: CoreInfo; principal: Principal; session?: { id: string; token: string } }; + }, Math.max(1, deadline - Date.now())).catch(error => { socket.close(); throw error; })) as { core: CoreInfo; principal: Principal; session?: { id: string; token: string } }; if (hello.core.protocolVersion !== PROTOCOL_VERSION) { socket.close(); throw new Error(`core protocol version must be ${PROTOCOL_VERSION}`); diff --git a/packages/core/src/drivers/claude.ts b/packages/core/src/drivers/claude.ts index 23c426d..3097bcd 100644 --- a/packages/core/src/drivers/claude.ts +++ b/packages/core/src/drivers/claude.ts @@ -370,13 +370,7 @@ class ClaudeTurn { case 'content_block_delta': { // `signature_delta` signs the thinking block; it is not text. if (event.delta?.type === 'thinking_delta') { - const thinking = event.delta.thinking ?? ''; - if (thinking.length === 0) break; - const seen = event.index === undefined ? undefined : this.thinkingBlocks.get(event.index); - const at = seen ?? this.openThinking(); - if (event.index !== undefined) this.thinkingBlocks.set(event.index, at); - this.write(at, thinking); - this.streamedThinking.set(this.apiMessageId, (this.streamedThinking.get(this.apiMessageId) ?? '') + thinking); + this.streamContent('thinking', event.index, event.delta.thinking ?? ''); break; } if (event.delta?.type === 'input_json_delta') { @@ -390,13 +384,7 @@ class ClaudeTurn { break; } if (event.delta?.type !== 'text_delta') break; - const text = event.delta.text ?? ''; - if (text.length === 0) break; - const known = event.index === undefined ? undefined : this.textBlocks.get(event.index); - const index = known ?? this.openText(); - if (event.index !== undefined) this.textBlocks.set(event.index, index); - this.write(index, text); - this.streamedText.set(this.apiMessageId, (this.streamedText.get(this.apiMessageId) ?? '') + text); + this.streamContent('text', event.index, event.delta.text ?? ''); break; } default: @@ -404,6 +392,17 @@ class ClaudeTurn { } } + private streamContent(kind: 'text' | 'thinking', blockIndex: number | undefined, text: string): void { + if (!text.length) return; + const blocks = kind === 'text' ? this.textBlocks : this.thinkingBlocks; + const accumulated = kind === 'text' ? this.streamedText : this.streamedThinking; + const known = blockIndex === undefined ? undefined : blocks.get(blockIndex); + const index = known ?? (kind === 'text' ? this.openText() : this.openThinking()); + if (blockIndex !== undefined) blocks.set(blockIndex, index); + this.write(index, text); + accumulated.set(this.apiMessageId, (accumulated.get(this.apiMessageId) ?? '') + text); + } + private handleAssistant(message: SDKAssistantMessage): void { if (message.error !== undefined) { this.fail(errorSentence(message.error)); diff --git a/packages/core/src/journal.ts b/packages/core/src/journal.ts index a07d00a..5b117e2 100644 --- a/packages/core/src/journal.ts +++ b/packages/core/src/journal.ts @@ -424,6 +424,7 @@ export class Journal { this.db.exec('PRAGMA busy_timeout = 5000'); this.db.transaction(() => migrate(this.db))(); this.db.exec('CREATE INDEX IF NOT EXISTS turns_by_status ON turns (status)'); + this.db.exec('CREATE INDEX IF NOT EXISTS messages_by_turn ON messages (thread_id, turn_id)'); } append(event: JournalEvent, apply: (db: Database) => T): T { @@ -684,11 +685,27 @@ export class Journal { return rows.map(toMessage); } + lastUserMessage(threadId: string, turnId: string): Message | null { + const row = this.db.query("SELECT * FROM messages WHERE thread_id = ? AND turn_id = ? AND role = 'user' ORDER BY rowid DESC LIMIT 1") + .get(threadId, turnId) as MessageRow | null; + return row === null ? null : toMessage(row); + } + + *walkTurnMessages(threadId: string, turnId: string): Iterable { + const statement = this.db.prepare('SELECT * FROM messages WHERE thread_id = ? AND turn_id = ? ORDER BY rowid'); + try { + for (const row of statement.iterate(threadId, turnId)) yield toMessage(row as MessageRow); + } finally { statement.finalize(); } + } + /** Stream history for a continuation without loading images from every message at once. */ *walkMessages(threadId: string): Iterable { - for (const row of this.db.query('SELECT * FROM messages WHERE thread_id = ? ORDER BY rowid').iterate(threadId)) { - yield toMessage(row as MessageRow); - } + // A caller may stop at its current turn. Do not leave a partially consumed + // cached statement for the next continuation to reuse. + const statement = this.db.prepare('SELECT * FROM messages WHERE thread_id = ? ORDER BY rowid'); + try { + for (const row of statement.iterate(threadId)) yield toMessage(row as MessageRow); + } finally { statement.finalize(); } } /** diff --git a/packages/core/src/threads.ts b/packages/core/src/threads.ts index 34a898f..b4a322d 100644 --- a/packages/core/src/threads.ts +++ b/packages/core/src/threads.ts @@ -1120,10 +1120,8 @@ export class ThreadStore { /** The user message of the turn, read back from the journal: the text and the images it carried. */ private lastUserInput(threadId: ThreadId, turnId: TurnId): { prompt: string; attachments: ImageAttachment[] } { - const messages = this.core.journal.listMessages(threadId); - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message === undefined || message.turnId !== turnId || message.role !== 'user') continue; + const message = this.core.journal.lastUserMessage(threadId, turnId); + if (message !== null) { const attachments: ImageAttachment[] = []; for (const part of message.parts) { if (part.type === 'image') { diff --git a/packages/core/test/activity.test.ts b/packages/core/test/activity.test.ts index 59f4d04..e349ed3 100644 --- a/packages/core/test/activity.test.ts +++ b/packages/core/test/activity.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from 'bun:test'; import { Core } from '../src/core.ts'; +import { ActivityStore } from '../src/activity.ts'; import { connect } from '../src/client.ts'; import { setDriver } from '../src/drivers/index.ts'; import { echoThread, startTestCore, waitFor, type TestCore } from './harness.ts'; @@ -9,6 +10,62 @@ let restore: (() => void) | undefined; beforeEach(async () => { h = await startTestCore(); }); afterEach(async () => { restore?.(); restore = undefined; await h.stop(); }); +test.each([ + ['quoted complete', 'Not finished.\n```\n[BOITE_GOAL_COMPLETE]\n```', 0, 'active'], + ['quoted blocked', 'Continuing.\n~~~text\n[BOITE_GOAL_BLOCKED]\n~~~', 0, 'active'], + ['indented example', 'Example:\n [BOITE_GOAL_COMPLETE]', 0, 'active'], + ['beyond page', '[BOITE_GOAL_COMPLETE]', 120, 'complete'], +] as const)('goal marker: %s', async (_name, text, extraMessages, expected) => { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + restore = setDriver('echo', { + protocol: 'echo', startTurn(ctx) { + for (let i = 0; i <= extraMessages; i++) { + const id = ctx.emit.startMessage('assistant'); + ctx.emit.part(id, 0, { type: 'text', text: i === 0 ? text : 'Additional output.' }); + ctx.emit.complete(id, 'complete'); + } + return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; + }, + }); + h.core.activity.set({ threadId, goal: { objective: 'Check completion' } }); + await waitFor(() => h.core.activity.get(threadId).goal!.iterations > 0 && h.core.threads.get(threadId).status === 'idle'); + expect(h.core.activity.get(threadId).goal?.status).toBe(expected); + h.core.activity.pauseAll(threadId); +}); + +test('paused activity produces no new events when loaded or closed again', async () => { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + h.core.activity.set({ threadId, goal: { objective: 'Wait' } }); + h.core.activity.pauseAll(threadId); + const count = () => (h.core.journal.db.query("SELECT COUNT(*) AS n FROM events WHERE type = 'thread.activity'").get() as { n: number }).n; + const before = count(); + h.core.activity.close(); + expect(count()).toBe(before); + const loaded = new ActivityStore(h.core); + loaded.close(); + expect(count()).toBe(before); +}); + +test('starting a turn does not decode historical messages to read its prompt', async () => { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + h.core.journal.putMessage({ id: 'history', threadId, turnId: 'old', role: 'assistant', state: 'complete', createdAt: 0, parts: [{ type: 'text', text: 'old'.repeat(1000) }] }); + const original = h.core.journal.listMessages.bind(h.core.journal); + h.core.journal.listMessages = () => { throw new Error('unbounded history read'); }; + let prompt: string | undefined; + restore = setDriver('echo', { protocol: 'echo', startTurn(ctx) { + prompt = ctx.prompt; + return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; + } }); + try { + const turn = h.core.threads.startTurn(threadId, 'current prompt'); + await waitFor(() => h.core.journal.getTurn(turn.id)?.finishedAt !== null); + expect(prompt).toBe('current prompt'); + } finally { h.core.journal.listMessages = original; } +}); + test('goal continues across turns, reports tasks and stops only on completion', async () => { const client = await h.connect(); const { threadId } = await echoThread(h, client); diff --git a/packages/core/test/client-timeout.test.ts b/packages/core/test/client-timeout.test.ts new file mode 100644 index 0000000..60828fa --- /dev/null +++ b/packages/core/test/client-timeout.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'bun:test'; +import { PROTOCOL_VERSION } from '@boite/contracts'; +import { connect } from '../src/client.ts'; + +test.each(['hello', 'call'] as const)('client bounds an unanswered %s and closes a failed handshake', async (phase) => { + let closed = false; + const server = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch(request, self) { return self.upgrade(request) ? undefined : new Response('upgrade required'); }, + websocket: { + message(socket, raw) { + const frame = JSON.parse(String(raw)); + if (phase === 'call' && frame.method === 'hello') socket.send(JSON.stringify({ jsonrpc: '2.0', id: frame.id, result: { core: { protocolVersion: PROTOCOL_VERSION }, principal: 'owner' } })); + }, + close() { closed = true; }, + }, + }); + let client: Awaited> | undefined; + const result = (async () => { + client = await connect(`http://127.0.0.1:${server.port}`, 'test', { timeoutMs: 25, requestTimeoutMs: 25 }); + await client.call('settings.get', {}); + })().then(() => 'resolved', error => String(error)); + try { + expect(await Promise.race([result, Bun.sleep(300).then(() => 'still pending')])).toContain('timed out'); + if (phase === 'hello') { + for (let i = 0; i < 20 && !closed; i++) await Bun.sleep(5); + expect(closed).toBe(true); + } + } finally { client?.close(); void server.stop(true); await result; } +}); diff --git a/packages/core/test/model-switch.test.ts b/packages/core/test/model-switch.test.ts index f26017c..61a9763 100644 --- a/packages/core/test/model-switch.test.ts +++ b/packages/core/test/model-switch.test.ts @@ -33,7 +33,9 @@ test('account switching keeps the thread, carries prior exchanges and never reus }); const run = async (prompt: string) => { const turn = await client.call('turns.start', { threadId, prompt }); - await waitFor(() => h.core.journal.getTurn(turn.id)?.status === 'done'); + await waitFor(() => h.core.journal.getTurn(turn.id)?.finishedAt != null); + expect(h.core.journal.getTurn(turn.id)?.error).toBeNull(); + expect(h.core.journal.getTurn(turn.id)?.status).toBe('done'); }; await run('the passwordless project uses blue widgets'); const switched = await client.call('threads.update', { threadId, accountId: 'second-account', model: 'echo' }); diff --git a/packages/ui/src/components/Composer.test.ts b/packages/ui/src/components/Composer.test.ts index a85b528..bcfa405 100644 --- a/packages/ui/src/components/Composer.test.ts +++ b/packages/ui/src/components/Composer.test.ts @@ -313,6 +313,33 @@ test('a model with no reasoning scale gets no chip at all', async () => { expect(query('[data-testid=composer-picker]').textContent).toContain('Claude Haiku 4.5'); }); +test('the picker keeps row, account and legacy keyboard navigation separate', async () => { + await mountOnFake(); + await openDraft(); + query('[data-testid=composer-picker]').click(); + await waitFor(() => document.querySelector('[data-testid=composer-picker-menu]') !== null); + + const claudeTile = query('[data-provider=claude]'); + claudeTile.focus(); + expect(press('ArrowDown')).toBe(false); + expect(document.activeElement).toBe(query('[data-provider=echo]')); + + const firstSeat = query('[data-instance="claude::a-claude-main"]'); + const secondSeat = query('[data-instance="claude::a-claude-side"]'); + firstSeat.focus(); + expect(press('ArrowRight')).toBe(false); + expect(document.activeElement).toBe(secondSeat); + expect(press('ArrowLeft')).toBe(false); + expect(document.activeElement).toBe(firstSeat); + + const legacyRow = query('[data-testid=picker-legacy]'); + legacyRow.focus(); + expect(press('ArrowRight')).toBe(false); + await waitFor(() => document.activeElement?.closest('[data-testid=picker-legacy-menu]') !== null); + expect(press('ArrowLeft')).toBe(false); + expect(document.activeElement).toBe(legacyRow); +}); + test('a refused turn keeps the prompt for Enter and Ctrl+Enter', async () => { await mountOnFake(); await store.open('t-trace'); diff --git a/packages/ui/src/components/ModelPicker.svelte b/packages/ui/src/components/ModelPicker.svelte index 1aecc51..2b2cbf8 100644 --- a/packages/ui/src/components/ModelPicker.svelte +++ b/packages/ui/src/components/ModelPicker.svelte @@ -284,51 +284,70 @@ return root ? Array.from(root.querySelectorAll('.popover .models [data-row]:not(:disabled)')) : []; } - function onkeydown(event: KeyboardEvent) { - if (!popover.open) return; - const active = document.activeElement as HTMLElement | null; - if (event.key === 'ArrowRight' && active?.dataset.testid === 'picker-legacy') { event.preventDefault(); legacy.show(); queueMicrotask(() => root?.querySelector('[data-testid=picker-legacy-menu] [data-model]')?.focus()); return; } - if (event.key === 'ArrowLeft' && active?.closest('[data-testid=picker-legacy-menu]')) { event.preventDefault(); legacy.hide(); root?.querySelector('[data-testid=picker-legacy]')?.focus(); return; } - const searching = searchable && (active === searchBox || (active?.hasAttribute('data-model') ?? false)); - - if (event.key === 'Escape') { - event.stopPropagation(); - if (legacyOpen) { legacy.hide(); root?.querySelector('[data-testid=picker-legacy]')?.focus(); return; } - // The query goes first: closing on it would throw away what was just typed. - if (searchable && modelQuery !== '') { - modelQuery = ''; - searchBox?.focus(); - return; - } - popover.hide(); + function handleEscape(event: KeyboardEvent) { + event.stopPropagation(); + if (legacyOpen) { + legacy.hide(); + root?.querySelector('[data-testid=picker-legacy]')?.focus(); return; } - if (event.key === 'Enter' && searching) { - // A focused row is activated by the browser too; taking the default keeps it to one pick. - event.preventDefault(); - const row = active === searchBox ? modelRows()[0] : active; - row?.click(); + // The query goes first: closing on it would throw away what was just typed. + if (searchable && modelQuery !== '') { + modelQuery = ''; + searchBox?.focus(); return; } - if (searching && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { + popover.hide(); + } + + function handleLegacyNavigation(event: KeyboardEvent, active: HTMLElement | null): boolean { + if (event.key === 'ArrowRight' && active?.dataset.testid === 'picker-legacy') { event.preventDefault(); - const list = modelRows(); - const here = active === searchBox || !active ? -1 : list.indexOf(active); - if (event.key === 'ArrowDown') list[Math.min(here + 1, list.length - 1)]?.focus(); - else if (here <= 0) searchBox?.focus(); - else list[here - 1]?.focus(); - return; + legacy.show(); + queueMicrotask(() => root?.querySelector('[data-testid=picker-legacy-menu] [data-model]')?.focus()); + return true; } - if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - // The account chips are a segmented control: the arrows walk them. - const chips = root ? Array.from(root.querySelectorAll('.popover [data-seat]:not(:disabled)')) : []; - const here = chips.indexOf(document.activeElement as HTMLElement); - if (here === -1) return; + if (event.key === 'ArrowLeft' && active?.closest('[data-testid=picker-legacy-menu]')) { event.preventDefault(); - const step = event.key === 'ArrowRight' ? 1 : -1; - chips[(here + step + chips.length) % chips.length]?.focus(); - return; + legacy.hide(); + root?.querySelector('[data-testid=picker-legacy]')?.focus(); + return true; } + return false; + } + + function handleModelSearch(event: KeyboardEvent, active: HTMLElement | null): boolean { + if (!searchable || (active !== searchBox && !active?.hasAttribute('data-model'))) return false; + if (event.key === 'Enter') { + // A focused row is activated by the browser too; taking the default keeps it to one pick. + event.preventDefault(); + const row = active === searchBox ? modelRows()[0] : active; + row?.click(); + return true; + } + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return false; + event.preventDefault(); + const list = modelRows(); + const here = active === searchBox || !active ? -1 : list.indexOf(active); + if (event.key === 'ArrowDown') list[Math.min(here + 1, list.length - 1)]?.focus(); + else if (here <= 0) searchBox?.focus(); + else list[here - 1]?.focus(); + return true; + } + + function handleAccountNavigation(event: KeyboardEvent, active: HTMLElement | null): boolean { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return false; + // The account chips are a segmented control: the arrows walk them. + const chips = root ? Array.from(root.querySelectorAll('.popover [data-seat]:not(:disabled)')) : []; + const here = active ? chips.indexOf(active) : -1; + if (here === -1) return false; + event.preventDefault(); + const step = event.key === 'ArrowRight' ? 1 : -1; + chips[(here + step + chips.length) % chips.length]?.focus(); + return true; + } + + function handleRowNavigation(event: KeyboardEvent, active: HTMLElement | null) { if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; const list = focusable(); if (list.length === 0) return; @@ -338,6 +357,16 @@ list[next]?.focus(); } + function onkeydown(event: KeyboardEvent) { + if (!popover.open) return; + const active = document.activeElement as HTMLElement | null; + if (event.key === 'Escape') return handleEscape(event); + if (handleLegacyNavigation(event, active)) return; + if (handleModelSearch(event, active)) return; + if (handleAccountNavigation(event, active)) return; + handleRowNavigation(event, active); + } + function onWindowPointerdown(event: PointerEvent) { if (!popover.open) return; if (root && event.target instanceof Node && root.contains(event.target)) return; diff --git a/packages/ui/src/lib/markdown.test.ts b/packages/ui/src/lib/markdown.test.ts index 2efdbd7..0eb04ad 100644 --- a/packages/ui/src/lib/markdown.test.ts +++ b/packages/ui/src/lib/markdown.test.ts @@ -2,6 +2,13 @@ import { describe, expect, test } from 'vitest'; import { renderMarkdown, withCaret } from './markdown'; describe('renderMarkdown', () => { + test('inline code keeps emphasis and links literal', () => { + expect(renderMarkdown('`**literal**` and **bold**')).toBe('

**literal** and bold

'); + expect(renderMarkdown('`[x](https://example.test)`')).toBe('

[x](https://example.test)

'); + expect(renderMarkdown('``a ` b`` and ``')).toBe('

a ` b and <tag>

'); + expect(renderMarkdown('**`code`** and `[x](https://example.test)`')).toBe('

code and [x](https://example.test)

'); + expect(renderMarkdown('\0 `**code**`')).toBe('

\0 **code**

'); + }); test('paragraphs, headings, inline marks and links, everything escaped', () => { expect(renderMarkdown('one\ntwo\n\nthree')).toBe('

one\ntwo

three

'); expect(renderMarkdown('# Title\n## Sub')).toBe('

Title

Sub

'); diff --git a/packages/ui/src/lib/markdown.ts b/packages/ui/src/lib/markdown.ts index cd0093d..e3b818a 100644 --- a/packages/ui/src/lib/markdown.ts +++ b/packages/ui/src/lib/markdown.ts @@ -15,8 +15,19 @@ function escape(text: string): string { } function inline(text: string): string { + const code: string[] = []; + let marker = '\0'; + while (text.includes(marker)) marker += '\0'; + const protectedText = text.replace(/(? { + code.push(`${escape(value)}`); + return `${marker}${code.length - 1}${marker}`; + }); + // Format around opaque spans, then restore them without parsing their contents. + return inlineFormatting(protectedText).split(marker).map((part, index) => index % 2 ? code[Number(part)]! : part).join(''); +} + +function inlineFormatting(text: string): string { let out = escape(text); - out = out.replace(/`([^`\n]+)`/g, '$1'); out = out.replace(/\*\*([^*\n]+)\*\*/g, '$1'); out = out.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1$2'); out = out.replace(/~~([^~\n]+)~~/g, '$1'); @@ -89,9 +100,7 @@ export function renderMarkdown(source: string): string { const lines = source.replaceAll('\r\n', '\n').split('\n'); const html: string[] = []; let paragraph: string[] = []; - let list: ListItem[] | null = null; - /** The items open at each indent, innermost last: where the next line nests. */ - let stack: ListItem[] = []; + const list = new MarkdownList(); const flushParagraph = (): void => { if (paragraph.length === 0) return; @@ -99,10 +108,7 @@ export function renderMarkdown(source: string): string { paragraph = []; }; const flushList = (): void => { - if (!list) return; - html.push(renderItems(list)); - list = null; - stack = []; + html.push(list.flush()); }; const flushAll = (): void => { flushParagraph(); @@ -111,17 +117,11 @@ export function renderMarkdown(source: string): string { for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ''; - const fence = FENCE.exec(line); + const fence = readFence(lines, index); if (fence) { flushAll(); - const code: string[] = []; - index += 1; - while (index < lines.length && !FENCE.test(lines[index] ?? '')) { - code.push(lines[index] ?? ''); - index += 1; - } - const language = fence[1] ? ` data-language="${escape(fence[1])}"` : ''; - html.push(`${escape(code.join('\n'))}`); + html.push(fence.html); + index = fence.end; continue; } @@ -144,70 +144,32 @@ export function renderMarkdown(source: string): string { continue; } - if (QUOTE.test(line)) { + const quote = readQuote(lines, index); + if (quote) { flushAll(); - const quoted: string[] = []; - while (index < lines.length) { - const match = QUOTE.exec(lines[index] ?? ''); - if (!match) break; - quoted.push(match[1] ?? ''); - index += 1; - } - index -= 1; - html.push(`
${renderMarkdown(quoted.join('\n'))}
`); + html.push(quote.html); + index = quote.end; continue; } // A table is a row, a delimiter row, then rows until a blank or a line with no pipe. - const next = lines[index + 1] ?? ''; - if (line.includes('|') && TABLE_DELIMITER.test(next) && cells(next).length >= 1 && !ITEM.test(line)) { + const table = readTable(lines, index); + if (table) { flushAll(); - const body: string[] = []; - let cursor = index + 2; - while (cursor < lines.length) { - const candidate = lines[cursor] ?? ''; - if (candidate.trim() === '' || !candidate.includes('|')) break; - body.push(candidate); - cursor += 1; - } - html.push(renderTable(line, next, body)); - index = cursor - 1; + html.push(table.html); + index = table.end; continue; } - const item = ITEM.exec(line); + const item = parseListItem(line); if (item) { flushParagraph(); - const indent = (item[1] ?? '').length; - const kind: 'ul' | 'ol' = BULLET_MARK.test(line) ? 'ul' : 'ol'; - const task = TASK.exec(item[2] ?? ''); - const entry: ListItem = { - indent, - kind, - text: task ? (task[2] ?? '') : (item[2] ?? ''), - task: task ? ((task[1] ?? ' ') === ' ' ? 'open' : 'done') : null, - children: [] - }; - // Pop every open item at this depth or deeper: the new one is their sibling or an uncle. - while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? 0) >= indent) stack.pop(); - const parent = stack[stack.length - 1]; - if (!parent) { - if (list && list[0]?.kind !== kind && list[0]?.indent === indent) flushList(); - if (!list) list = []; - list.push(entry); - } else { - parent.children.push(entry); - } - stack.push(entry); + html.push(list.add(item)); continue; } // Indented text under an item continues that item. - const open = stack[stack.length - 1]; - if (open && /^\s{2,}\S/.test(line)) { - open.text += ` ${line.trim()}`; - continue; - } + if (list.continue(line)) continue; flushList(); paragraph.push(line); @@ -217,6 +179,93 @@ export function renderMarkdown(source: string): string { return html.join(''); } +interface Block { html: string; end: number } + +function readFence(lines: string[], start: number): Block | null { + const fence = FENCE.exec(lines[start] ?? ''); + if (!fence) return null; + const code: string[] = []; + let end = start + 1; + while (end < lines.length && !FENCE.test(lines[end] ?? '')) code.push(lines[end++] ?? ''); + const language = fence[1] ? ` data-language="${escape(fence[1])}"` : ''; + return { html: `${escape(code.join('\n'))}`, end }; +} + +function readQuote(lines: string[], start: number): Block | null { + const quoted: string[] = []; + let cursor = start; + while (cursor < lines.length) { + const match = QUOTE.exec(lines[cursor] ?? ''); + if (!match) break; + quoted.push(match[1] ?? ''); + cursor++; + } + return quoted.length ? { html: `
${renderMarkdown(quoted.join('\n'))}
`, end: cursor - 1 } : null; +} + +function readTable(lines: string[], start: number): Block | null { + const head = lines[start] ?? ''; + const delimiter = lines[start + 1] ?? ''; + if (!head.includes('|') || !TABLE_DELIMITER.test(delimiter) || ITEM.test(head)) return null; + const body: string[] = []; + let cursor = start + 2; + while (cursor < lines.length) { + const line = lines[cursor] ?? ''; + if (!line.trim() || !line.includes('|')) break; + body.push(line); + cursor++; + } + return { html: renderTable(head, delimiter, body), end: cursor - 1 }; +} + +function parseListItem(line: string): ListItem | null { + const item = ITEM.exec(line); + if (!item) return null; + const text = item[2] ?? ''; + const task = TASK.exec(text); + return { + indent: (item[1] ?? '').length, + kind: BULLET_MARK.test(line) ? 'ul' : 'ol', + text: task ? task[2] ?? '' : text, + task: task ? (task[1] === ' ' ? 'open' : 'done') : null, + children: [] + }; +} + +/** Open ancestors retain the list nesting while blocks consume source lines. */ +class MarkdownList { + private items: ListItem[] = []; + private stack: ListItem[] = []; + + add(entry: ListItem): string { + while (this.stack.length && this.stack[this.stack.length - 1]!.indent >= entry.indent) this.stack.pop(); + const parent = this.stack[this.stack.length - 1]; + let previous = ''; + if (parent) parent.children.push(entry); + else { + const first = this.items[0]; + if (first && first.kind !== entry.kind && first.indent === entry.indent) previous = this.flush(); + this.items.push(entry); + } + this.stack.push(entry); + return previous; + } + + continue(line: string): boolean { + const open = this.stack[this.stack.length - 1]; + if (!open || !/^\s{2,}\S/.test(line)) return false; + open.text += ` ${line.trim()}`; + return true; + } + + flush(): string { + const html = this.items.length ? renderItems(this.items) : ''; + this.items = []; + this.stack = []; + return html; + } +} + /** Where the caret goes when the text ends on a block that closes in several tags. */ const TAILS = ['', '', '', '', '

']; diff --git a/packages/ui/src/lib/workspace.svelte.ts b/packages/ui/src/lib/workspace.svelte.ts index 4b2f341..f9988f2 100644 --- a/packages/ui/src/lib/workspace.svelte.ts +++ b/packages/ui/src/lib/workspace.svelte.ts @@ -7,7 +7,8 @@ import { upsertEnvironment, readStoredEndpoint, fromTauri, - type Endpoint + type Endpoint, + type StoredEnvironment } from './endpoint'; import { strings } from './strings'; @@ -29,6 +30,17 @@ function profiles(): Record { try { return JSON.parse(localStorage.getItem(PROFILE_KEY) ?? '{}') ?? {}; } catch { return {}; } } +/** A connection's URL is also its identity in the workspace and saved list. */ +function endpointIdentity(endpoint: Endpoint): { id: string; host: string } | null { + try { + const url = new URL(endpoint.url); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null; + return { id: url.toString().replace(/\/+$/, ''), host: url.host }; + } catch { + return null; + } +} + /** Each host keeps its own ids, credentials and pending requests. */ export class Workspace { machines = $state([]); @@ -38,6 +50,60 @@ export class Workspace { #generation = 0; #lifecycle = 0; + #current(lifecycle: number): boolean { + return lifecycle === this.#lifecycle; + } + + #primaryMachine(selected: Endpoint | null, remembered: StoredEnvironment[]): Machine { + const machine: Machine = { + id: store.endpointUrl ?? 'local', + label: remembered.find(e => e.url === selected?.url)?.label ?? (store.localCore ? strings.machines.local : store.core?.hostname) ?? strings.machines.local, + store + }; + this.restoreProfile(machine); + return machine; + } + + async #addFakeMachine(lifecycle: number): Promise { + const { FakeClient } = await import('./fake-client'); + if (!this.#current(lifecycle)) return; + const remote = new Store(); + remote.machineId = 'http://builder.test'; + remote.visible = false; + remote.attach(new FakeClient()); + await remote.connect(); + if (!this.#current(lifecycle)) { + remote.client?.close(); + remote.detach(); + return; + } + remote.booted = true; + if (remote.core) remote.core.os = 'linux'; + const machine = { id: remote.machineId, label: 'Builder', store: remote }; + this.restoreProfile(machine); + this.machines = [...this.machines, machine]; + } + + async #connectShellLocal(lifecycle: number): Promise { + if (!window.__TAURI_INTERNALS__ || store.localCore) return; + const local = await fromTauri(); + if (!this.#current(lifecycle)) return; + if (local && local.url !== store.endpointUrl) await this.add(local, strings.machines.local); + } + + async #adoptPopulatedJournal(lifecycle: number): Promise { + // A fresh Dev core may have no threads while an older core on this PC has a journal. + const populated = store.localCore && store.threads.length === 0 && store.core?.hostname + ? this.machines.find(m => m.store !== store && m.store.connection === 'ready' && m.store.threads.length > 0 && m.store.core?.hostname === store.core?.hostname) + : undefined; + if (!populated) return; + await store.switchEnvironment(populated.id); + if (!this.#current(lifecycle) || store.connection !== 'ready') return; + populated.store.client?.close(); + populated.store.detach(); + this.machines = [{ id: populated.id, label: populated.label, icon: populated.icon, store }, ...this.machines.filter(m => m.store !== store && m !== populated)]; + } + async boot(): Promise { const lifecycle = ++this.#lifecycle; store.visible = true; @@ -50,32 +116,18 @@ export class Workspace { const selected = readStoredEndpoint(); const remembered = readEnvironments(); await store.boot(); - if (lifecycle !== this.#lifecycle) return; + if (!this.#current(lifecycle)) return; this.active = store; - this.machines = [ - { id: store.endpointUrl ?? 'local', label: remembered.find(e => e.url === selected?.url)?.label ?? (store.localCore ? strings.machines.local : store.core?.hostname) ?? strings.machines.local, store } - ]; - this.restoreProfile(this.machines[0]!); + this.machines = [this.#primaryMachine(selected, remembered)]; if (import.meta.env.DEV && new URLSearchParams(window.location.search).get('fake') === '1') { if (new URLSearchParams(window.location.search).get('machines') === '1') { - const { FakeClient } = await import('./fake-client'); - const remote = new Store(); - remote.machineId = 'http://builder.test'; - remote.visible = false; - remote.attach(new FakeClient()); - await remote.connect(); - remote.booted = true; - if (remote.core) remote.core.os = 'linux'; - this.machines = [...this.machines, { id: remote.machineId, label: 'Builder', store: remote }]; - this.restoreProfile(this.machines[1]!); + await this.#addFakeMachine(lifecycle); } return; } const primaryEndpoint = readStoredEndpoint(); - if (window.__TAURI_INTERNALS__ && !store.localCore) { - const local = await fromTauri(); - if (local && local.url !== store.endpointUrl) await this.add(local, strings.machines.local); - } + await this.#connectShellLocal(lifecycle); + if (!this.#current(lifecycle)) return; if (!store.localCore && primaryEndpoint?.url === store.endpointUrl && primaryEndpoint.token) { upsertEnvironment({ ...primaryEndpoint, paired: primaryEndpoint.paired ?? false, label: this.machines[0]!.label }); } @@ -84,19 +136,8 @@ export class Workspace { .filter((e) => e.url !== store.endpointUrl) .map((e) => this.add(e, e.label)) ); - // Older workspaces added a fresh Dev core beside the paired core on the same PC. - // Keep that existing journal as the primary connection when the fresh core has no threads. - const populated = store.localCore && store.threads.length === 0 && store.core?.hostname - ? this.machines.find(m => m.store !== store && m.store.connection === 'ready' && m.store.threads.length > 0 && m.store.core?.hostname === store.core?.hostname) - : undefined; - if (populated && lifecycle === this.#lifecycle) { - await store.switchEnvironment(populated.id); - if (store.connection === 'ready') { - populated.store.client?.close(); - populated.store.detach(); - this.machines = [{ id: populated.id, label: populated.label, icon: populated.icon, store }, ...this.machines.filter(m => m.store !== store && m !== populated)]; - } - } + if (!this.#current(lifecycle)) return; + await this.#adoptPopulatedJournal(lifecycle); } restoreProfile(machine: Machine): void { @@ -125,17 +166,45 @@ export class Workspace { } } + /** Two URLs can reach the same core; keep the already connected machine. */ + #discardAlias(machine: Machine): boolean { + const target = machine.store; + const alias = target.core?.hostname && this.machines.find(m => m.store !== target && m.store.core?.hostname && profileKey(m) === profileKey(machine)); + if (!alias) return false; + target.client?.close(); + target.detach(); + this.machines = this.machines.filter(m => m.store !== target); + removeEnvironment(machine.id); + this.error = null; + return true; + } + + /** Keep a connected machine's display name and resumable session credentials. */ + #rememberConnected(machine: Machine, endpoint: Endpoint, label: string | undefined, host: string): void { + machine.label = label?.trim() || machine.store.core?.hostname || host; + this.restoreProfile(machine); + // Grant credentials are persisted by WsClient's onSession, never the grant itself. + if (!endpoint.grant && !endpoint.local) + upsertEnvironment({ url: machine.id, token: endpoint.token, paired: endpoint.paired ?? false, label: machine.label }); + else { + const saved = readEnvironments().find((e) => e.url === machine.id); + if (saved) upsertEnvironment({ ...saved, label: machine.label }); + } + this.machines = [...this.machines]; + if (machine.store === store) { + const saved = readEnvironments().find((e) => e.url === machine.id); + if (saved) storeEndpoint(saved); + } + } + async add(endpoint: Endpoint, label?: string): Promise { - let url: URL; - try { - url = new URL(endpoint.url); - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) - throw new Error(); - } catch { + const lifecycle = this.#lifecycle; + const identity = endpointIdentity(endpoint); + if (!identity) { this.error = strings.machines.invalidUrl; return false; } - const id = url.toString().replace(/\/+$/, ''); + const { id, host } = identity; const existing = this.machines.find((m) => m.id === id); if (existing && existing.store.connection !== 'closed') { this.error = strings.machines.duplicate; @@ -146,41 +215,22 @@ export class Workspace { target.detach(); target.machineId = id; target.visible = this.active === target; - const machine: Machine = existing ?? { id, label: label?.trim() || url.host, store: target }; + const machine: Machine = existing ?? { id, label: label?.trim() || host, store: target }; if (!existing) this.machines = [...this.machines, machine]; await target.connectEndpoint({ ...endpoint, url: id }); - if (!this.machines.some((m) => m.store === target)) { - target.client?.close(); - target.detach(); + if (!this.#current(lifecycle) || !this.machines.some((m) => m.store === target)) { + if (!this.machines.some((m) => m.store === target)) { + target.client?.close(); + target.detach(); + } return false; } if (target.connection !== 'ready') { this.error = `${machine.label}: ${target.error ?? strings.connection.closed}`; return false; } - const alias = target.core?.hostname && this.machines.find(m => m.store !== target && m.store.core?.hostname && profileKey(m) === profileKey(machine)); - if (alias) { - target.client?.close(); - target.detach(); - this.machines = this.machines.filter(m => m.store !== target); - removeEnvironment(id); - this.error = null; - return true; - } - machine.label = label?.trim() || target.core?.hostname || url.host; - this.restoreProfile(machine); - // Grant credentials are persisted by WsClient's onSession, never the grant itself. - if (!endpoint.grant && !endpoint.local) - upsertEnvironment({ url: id, token: endpoint.token, paired: endpoint.paired ?? false, label: machine.label }); - else { - const saved = readEnvironments().find((e) => e.url === id); - if (saved) upsertEnvironment({ ...saved, label: machine.label }); - } - this.machines = [...this.machines]; - if (target === store) { - const saved = readEnvironments().find((e) => e.url === id); - if (saved) storeEndpoint(saved); - } + if (this.#discardAlias(machine)) return true; + this.#rememberConnected(machine, endpoint, label, host); this.error = null; return true; } diff --git a/packages/ui/src/lib/workspace.test.ts b/packages/ui/src/lib/workspace.test.ts index d3ae92d..9629b97 100644 --- a/packages/ui/src/lib/workspace.test.ts +++ b/packages/ui/src/lib/workspace.test.ts @@ -25,6 +25,54 @@ test('restoring a remote selection also connects the shell local core', async () expect(add).toHaveBeenCalledWith({ url: 'http://127.0.0.1:41000', token: 'test', local: true }, 'My computer'); }); +test('closing while the shell endpoint loads does not add a machine afterward', async () => { + const { w, a } = await setup(); + a.localCore = false; + a.endpointUrl = 'http://remote.test'; + Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); + vi.spyOn(a, 'boot').mockResolvedValue(); + let resolveLocal!: (endpoint: endpoints.Endpoint) => void; + const local = new Promise((resolve) => { resolveLocal = resolve; }); + const fromTauri = vi.spyOn(endpoints, 'fromTauri').mockReturnValue(local); + const add = vi.spyOn(w, 'add'); + const boot = w.boot(); + await waitFor(() => fromTauri.mock.calls.length === 1); + w.close(); + resolveLocal({ url: 'http://127.0.0.1:41000', token: 'test', local: true }); + await boot; + expect(add).not.toHaveBeenCalled(); + expect(w.machines).toEqual([]); +}); + +test('an old endpoint connection cannot update a newer workspace lifecycle', async () => { + const { w } = await setup(); + let release!: () => void; + const connected = new Promise((resolve) => { release = resolve; }); + vi.spyOn(Store.prototype, 'connectEndpoint').mockImplementation(async function (this: Store) { + await connected; + this.connection = 'ready'; + }); + const endpoint = { url: 'http://late.test', token: 'test', paired: true }; + const adding = w.add(endpoint, 'Old name'); + await waitFor(() => w.machines.some((machine) => machine.id === endpoint.url)); + const late = w.machines.find((machine) => machine.id === endpoint.url)!; + w.close(); + const current = { ...late, label: 'New name' }; + w.machines = [current]; + release(); + expect(await adding).toBe(false); + expect(w.machines[0]?.label).toBe('New name'); + expect(endpoints.readEnvironments().some((saved) => saved.url === endpoint.url)).toBe(false); +}); + +async function waitFor(check: () => boolean): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (check()) return; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error('workspace did not reach the expected state'); +} + test('an empty local core yields to the remembered journal on the same computer', async () => { const { w, a, b } = await setup(); a.localCore = true; diff --git a/tests/e2e/ui.test.ts b/tests/e2e/ui.test.ts index 36b7ba4..1afe0ca 100644 --- a/tests/e2e/ui.test.ts +++ b/tests/e2e/ui.test.ts @@ -758,6 +758,25 @@ test( TIMEOUT, ); +test('inline code stays literal at desktop and phone widths', async () => { + await page.type(testid('composer-input'), 'Keep `**literal**` and `[link](https://example.com)` as code.'); + await clickWhenEnabled(testid('composer-send')); + const codes = `Array.from(document.querySelectorAll('[data-role=assistant] [data-testid=text-part] code'))`; + await page.waitFor(`${codes}.some(node => node.textContent === '**literal**')`); + await page.waitFor(`document.querySelector('[data-testid=thread-status]').dataset.status === 'idle'`); + expect(await page.evaluate(`${codes}.some(node => node.querySelector('strong, a'))`)).toBe(false); + expect(await page.evaluate(`${codes}.some(node => node.textContent === '[link](https://example.com)')`)).toBe(true); + try { + for (const [name, width, height] of [['desktop', 1280, 900], ['phone', 390, 844]] as const) { + await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: name === 'phone' }); + await page.evaluate(`Promise.all([document.fonts.ready, ...document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {}))])`); + await page.screenshot(join(import.meta.dir, '.artifacts', `markdown-${name}.png`)); + } + } finally { + await page.send('Emulation.clearDeviceMetricsOverride'); + } +}, TIMEOUT); + test( 'the service worker caches the shell, and the app still paints when the core is gone', async () => {