From 4fb4f44056e8a39bc736af570d4771529e795753 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 15:39:46 +0200 Subject: [PATCH 1/3] fix(backends): retain stable session prompts and owned turn context --- .changeset/stable-session-prompt-context.md | 9 ++ api-report/ui-sdk.txt | 3 + docs/decisions/README.md | 1 + docs/decisions/action-notifications.md | 2 +- docs/decisions/async-collaboration.md | 4 +- .../decisions/claude-2.1.287-compatibility.md | 2 +- docs/decisions/claude-code-runtime.md | 10 +- docs/decisions/design-kit/answer-blocks.md | 2 +- .../design-kit/backend-measurements.md | 2 +- docs/decisions/design-kit/show-block-brief.md | 2 +- docs/decisions/design-kit/tool-loading.md | 2 +- docs/decisions/governed-writable-workers.md | 2 +- docs/decisions/live-conversation.md | 2 +- docs/decisions/pi-endpoint-routing.md | 2 +- docs/decisions/policy-write-boundary.md | 2 +- docs/decisions/session-principals.md | 2 +- docs/decisions/session-prompt-context.md | 44 ++++++ docs/decisions/voice-permission.md | 10 +- docs/extending/agent-backends.md | 7 +- docs/hosting/autonomous-prompt-cache.md | 5 +- docs/hosting/session-prompt-context.md | 63 ++++++++ .../live-conversation-investigation.md | 2 +- .../plans/async-collaboration-requirements.md | 6 +- docs/plans/async-collaboration.md | 8 +- docs/plans/host-owned-web-search.md | 2 +- docs/plans/navigation-recovery.md | 2 +- docs/plans/policy-write-boundary.md | 2 +- packages/ui-backend-claude/src/history.ts | 5 +- packages/ui-backend-claude/src/options.ts | 12 +- .../ui-backend-claude/src/permission-hooks.ts | 2 +- .../ui-backend-claude/src/prompt-context.ts | 30 ++++ packages/ui-backend-claude/src/sdk-options.ts | 39 ++++- .../ui-backend-claude/tests/backend.test.ts | 20 ++- .../tests/fixtures/session-prompt-probe.ts | 65 ++++++++ .../ui-backend-claude/tests/follow-up.test.ts | 2 +- .../tests/helpers/worker-host.ts | 22 ++- .../tests/history-api-failure.test.ts | 2 +- .../ui-backend-claude/tests/history.test.ts | 18 ++- .../tests/no-grant-surface.test.ts | 12 +- .../tests/replayed-user-text.test.ts | 149 ++++++------------ .../tests/sdk-options-mcp.test.ts | 20 ++- .../tests/session-prompt.test.ts | 47 ++++++ .../tests/subscription-gate.test.ts | 2 +- .../tests/version-requirements.test.ts | 4 +- packages/ui-backend-pi/src/backend-options.ts | 19 ++- packages/ui-backend-pi/src/history.ts | 13 +- packages/ui-backend-pi/src/session-pool.ts | 13 ++ .../ui-backend-pi/src/session-resources.ts | 15 +- packages/ui-backend-pi/src/worker-entry.ts | 5 +- packages/ui-backend-pi/src/worker-protocol.ts | 2 +- packages/ui-backend-pi/src/worker-session.ts | 34 +++- .../fixtures/native-session-version-worker.ts | 2 +- .../tests/fixtures/read-base-worker.ts | 2 +- .../tests/fixtures/session-prompt-probe.ts | 73 +++++++++ packages/ui-backend-pi/tests/history.test.ts | 3 +- .../tests/replayed-user-text.test.ts | 3 +- .../tests/session-prompt.test.ts | 43 +++++ .../src/components/chat/chat-page.tsx | 4 +- .../ui-react/src/lib/client-environment.ts | 6 +- .../browser/client-environment.layout.tsx | 58 +++++++ .../ui-react/tests/client-environment.test.ts | 17 +- packages/ui-sdk/src/internal.ts | 5 + packages/ui-sdk/src/server/backend.ts | 10 +- .../src/server/session-prompt-context.ts | 82 ++++++++++ packages/ui-sdk/src/server/system-prompt.ts | 8 +- .../tests/device-unknown-prompt.test.ts | 10 ++ .../tests/session-prompt-context.test.ts | 73 +++++++++ packages/ui-server/src/brain/turn-budgets.ts | 10 ++ packages/ui-server/src/ws/message-sources.ts | 6 +- packages/ui-server/src/ws/run-session.ts | 7 +- packages/ui-server/tests/turn-budgets.test.ts | 21 ++- tests/worker-sandbox-note.test.ts | 57 ++++--- 72 files changed, 1011 insertions(+), 241 deletions(-) create mode 100644 .changeset/stable-session-prompt-context.md create mode 100644 docs/decisions/session-prompt-context.md create mode 100644 docs/hosting/session-prompt-context.md create mode 100644 packages/ui-backend-claude/src/prompt-context.ts create mode 100644 packages/ui-backend-claude/tests/fixtures/session-prompt-probe.ts create mode 100644 packages/ui-backend-claude/tests/session-prompt.test.ts create mode 100644 packages/ui-backend-pi/tests/fixtures/session-prompt-probe.ts create mode 100644 packages/ui-backend-pi/tests/session-prompt.test.ts create mode 100644 packages/ui-react/tests/browser/client-environment.layout.tsx create mode 100644 packages/ui-sdk/src/server/session-prompt-context.ts create mode 100644 packages/ui-sdk/tests/device-unknown-prompt.test.ts create mode 100644 packages/ui-sdk/tests/session-prompt-context.test.ts diff --git a/.changeset/stable-session-prompt-context.md b/.changeset/stable-session-prompt-context.md new file mode 100644 index 000000000..7228aa4fb --- /dev/null +++ b/.changeset/stable-session-prompt-context.md @@ -0,0 +1,9 @@ +--- +"@schlessera/brain-backend-claude": patch +"@schlessera/brain-backend-pi": patch +"@schlessera/brain-ui-sdk": patch +"@schlessera/brain-ui-react": patch +"@schlessera/brain-ui-server": patch +--- + +Retain session-stable prompt appends and deliver changed host facts in owned new tail context, preserving user history and unknown device capabilities. diff --git a/api-report/ui-sdk.txt b/api-report/ui-sdk.txt index b2cde460b..8dfbd2657 100644 --- a/api-report/ui-sdk.txt +++ b/api-report/ui-sdk.txt @@ -889,6 +889,7 @@ export "./internal" (./src/internal.ts) type RestrictedEnvelope STALE_NATIVE_WRITE SUBPROCESS_ENV + type SessionPromptFacts type StateLedger TURN_TEMP_BUDGET_ENV TURN_TEMP_BYTES_ENV @@ -961,6 +962,7 @@ export "./internal" (./src/internal.ts) createWorkerFileObserver createWorkerStartupView denyWorkerCommand + describeClient describeLimitHits execWrapperSpawnOptions handleTurnTemp @@ -1014,6 +1016,7 @@ export "./internal" (./src/internal.ts) retainWorkerStartupView runMaintenanceWorker runMaintenanceWorkerSync + sessionPromptContext startInferenceRelay turnTempFullMessage turnTempLimit diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 5c46cb89b..88b33244d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -67,6 +67,7 @@ somebody already learn the hard way?* | [experiment-tier.md](experiment-tier.md) | Why measurement-harness tests live under `experiments/`, outside product discovery, and run as a receipt-checked gate before each release, with the file- and assertion-level inventory behind #1386. | | [runtime-requirements.md](runtime-requirements.md) | Why package ranges and package/host runtime minimums compose, which loaded/executed identities are checked, how unknown versions and overrides behave, and why compatibility stays separate from measurement. | | [codex-subscription-auth.md](codex-subscription-auth.md) | Why pi and the Codex CLI use separate ChatGPT OAuth grants, the source-based no-sharing ruling, and the observed separate-login limits. | +| [session-prompt-context.md](session-prompt-context.md) | Why the exact session append is retained, changed facts use owned new-tail context, and replay preserves the actual user text. | | [pi-endpoint-routing.md](pi-endpoint-routing.md) | Why declared built-in pi models use the configured inference runtime, native per-request authentication endpoints take precedence, and the routing migration requires real adapter destination proof. | | [reranker-activation.md](reranker-activation.md) | Why model judgment requires persistent opt-in across search entry points, why credentials and modes cannot enable it, and why the measured Jev provider and seam remain available. | | [gemini-agent-contract.md](gemini-agent-contract.md) | Why Gemini embeds the single installed contract, how migration preserves user text and refuses ambiguous markers, and why sync leaves exact root/nested instruction conflicts for judgment. | diff --git a/docs/decisions/action-notifications.md b/docs/decisions/action-notifications.md index 8a5d544f9..1c76454a8 100644 --- a/docs/decisions/action-notifications.md +++ b/docs/decisions/action-notifications.md @@ -187,7 +187,7 @@ but add the Actions-aware aggregate and durable constituent/destination records. This decision authorizes no new channel, extension seam or daemon. The client already reads a zone for client environment -(`const zone`, `packages/ui-react/src/lib/client-environment.ts:107-108`). +(`const zone`, `packages/ui-react/src/lib/client-environment.ts:109-110`). Push rebind sends the subscription and label (`rebindPushSubscriptionAfterLogin`, `packages/ui-react/src/lib/push-registration.ts:62-76`); the server subscribe path does not persist that client's delivery zone diff --git a/docs/decisions/async-collaboration.md b/docs/decisions/async-collaboration.md index 5e14f1d38..0e8d1db8f 100644 --- a/docs/decisions/async-collaboration.md +++ b/docs/decisions/async-collaboration.md @@ -71,11 +71,11 @@ requires one enforcement mechanism with different named memberships for voice and unattended work. Keep that requirement when implementing availability control. Existing mandatory backend conformance supports safe rejection of an unsupported restricted request; silently ignoring it is forbidden. The current -optional inputs are (`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:535`) and (`noGrantSurface?: boolean`, `packages/ui-sdk/src/server/backend.ts:559`). These are permission primitives, +optional inputs are (`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:539`) and (`noGrantSurface?: boolean`, `packages/ui-sdk/src/server/backend.ts:563`). These are permission primitives, not a claim of filesystem or network containment. The ordinary Claude assembly loads project settings and appends bridge tools -(`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`). +(`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:88-399`). Its environment is already filtered (`envSnapshot`, `packages/ui-backend-claude/src/config/env.ts:182-190`), with profile credentials and operator extras. An autonomous envelope needs its own narrower credential diff --git a/docs/decisions/claude-2.1.287-compatibility.md b/docs/decisions/claude-2.1.287-compatibility.md index de328bb39..f3b4f9d0c 100644 --- a/docs/decisions/claude-2.1.287-compatibility.md +++ b/docs/decisions/claude-2.1.287-compatibility.md @@ -122,7 +122,7 @@ The property under test is: with `enforceAllowedTools: true`, a `Bash` call outside the turn's roster cannot execute unless the host bridge grants that call, including when settings or a rewrite could otherwise auto-approve it. The backend builds the actual turn options and production permission handlers -(`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-202`). +(`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:88-399`). The explicit ask lives in (`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`) and is registered under enforcement in diff --git a/docs/decisions/claude-code-runtime.md b/docs/decisions/claude-code-runtime.md index 21c7b669e..f7e269d22 100644 --- a/docs/decisions/claude-code-runtime.md +++ b/docs/decisions/claude-code-runtime.md @@ -47,7 +47,7 @@ next to it. `packages/ui-server/src/agent/backend.ts:497`), read back as a string (`const claudeCodePath`, `packages/ui-backend-claude/src/module.ts:266-270`) and handed to the SDK (`backend.claudeCodePath`, - `packages/ui-backend-claude/src/sdk-options.ts:262`). Because of the `||` + `packages/ui-backend-claude/src/sdk-options.ts:285`). Because of the `||` default the value was never empty, so **the server always overrode the SDK's own binary**. The variable is withheld from every subprocess (`CLAUDE_CODE_PATH: NONE`, @@ -388,7 +388,7 @@ until re-measured. | `The runtime precedence modelled by`, `packages/ui-backend-claude/tests/allowlist-enforcement.test.ts:11-20` | The precedence `runToolCall` models, (a)–(e). The test cannot re-measure it. | The cases above plus the composition case. A changed result changes the model in the test in the same PR. | | `the one answer that beats`, `packages/ui-backend-claude/tests/no-grant-surface.test.ts:13-18` | The same three opinions, and that `ask` is what forces the decision. | Same probe cases. | | `That third vector is stated here`, `docs/decisions/voice-permission.md:645-671` | An in-process `deny` beats a project-settings `allow`. | Probe case: settings `allow` against in-process `deny`, with the positive control of the settings hook alone running the tool. | -| `Three measured examples from the Claude SDK`, `docs/extending/agent-backends.md:291-304` | The same three mechanisms and the `ask`, restated for backend authors with no version attached. | Updated in the same PR as the constant whenever a probe result changes. | +| `Three measured examples from the Claude SDK`, `docs/extending/agent-backends.md:294-307` | The same three mechanisms and the `ask`, restated for backend authors with no version attached. | Updated in the same PR as the constant whenever a probe result changes. | | `createSdkMcpServer({ alwaysLoad: true })`, `docs/decisions/design-kit/tool-loading.md:21-44` (D44) | Two different kinds of claim. That `createSdkMcpServer({ alwaysLoad })` stamps `_meta["anthropic/alwaysLoad"]` is SDK behaviour, asserted keylessly by `"anthropic/alwaysLoad"`, `packages/ui-backend-claude/tests/sdk-options-mcp.test.ts:82` and `"anthropic/alwaysLoad"`, `tests/bridge-tools.test.ts:816,834`. That the CLI honours the stamp, and that first-frame latency did not move, is CLI behaviour. | The SDK half by the existing tests. The CLI half needs a live run of both arms — stamp set and unset — on the new pair, recording the pair from `init` and observing whether the bridge tools reached the model undeferred. `scripts/measure-show-block.ts` can run either arm (with and without `--always-load`; `ALWAYS_LOAD`, `scripts/measure-show-block.ts:304,369`), but it records no version and nothing in it compares the two arms or checks deferral, so it does not re-check this as it stands. Extending it is part of #209. `--tokens` prices schemas through the API and never runs the CLI, so it re-checks nothing here. | > **2026-09-30 — Implementation context (D44's harness gap in the sites table).** @@ -453,9 +453,9 @@ into them. - **The default profile passes both through.** The built-in `claude` profile declares no credential (`DEFAULT_PROFILES`, `packages/ui-backend-claude/src/profiles.ts:139-141`). A turn's environment is the filtered agent environment plus the profile's - additions (`export function turnEnv`, `packages/ui-backend-claude/src/sdk-options.ts:65-71`, + additions (`export function turnEnv`, `packages/ui-backend-claude/src/sdk-options.ts:68-74`, `envSnapshot`, `packages/ui-backend-claude/src/config/env.ts:182-190`), handed to the SDK - whole (`sdkOptions.env = childEnv`, `sdk-options.ts:332`). A declared bearer-token profile clears both + whole (`sdkOptions.env = childEnv`, `sdk-options.ts:356`). A declared bearer-token profile clears both ambient credentials (`input.authTokenEnv !== undefined`, `profiles.ts:116-122`); a declared API-key profile sets the key on purpose (`input.apiKeyEnv !== undefined`, `profiles.ts:123-125`). - **Billing originally relied on ambient credential classification.** The @@ -603,7 +603,7 @@ subscription authenticates. environment is necessary, whether or not a subscription credential is present. It is not sufficient: the CLI also takes an API key from an `apiKeyHelper` in settings — the backend loads the brain repo's project - settings (`settingSources: ["project"]`, `sdk-options.ts:210`) — and from a stored Console login, reported + settings (`settingSources: ["project"]`, `sdk-options.ts:232`) — and from a stored Console login, reported as `/login managed key` (`sdk.d.ts:5585`). So the turn has to check which credential the CLI selected **before the prompt is sent**, and end the turn if it is not a subscription. The check reads the account from the SDK's diff --git a/docs/decisions/design-kit/answer-blocks.md b/docs/decisions/design-kit/answer-blocks.md index fd635e1d5..337141d4a 100644 --- a/docs/decisions/design-kit/answer-blocks.md +++ b/docs/decisions/design-kit/answer-blocks.md @@ -434,7 +434,7 @@ turns the complete roster the model reached for was `bash`, `show_block`, `brain_read`, `grep`, `brain_search`, `read_file`, `brain_list`, `brain_graph`: no search-then-load round trip, ever. The brief is in the prompt on every turn unconditionally (`block: "show_block"`, -`packages/ui-backend-pi/src/session-resources.ts:193`, not behind a capability +`packages/ui-backend-pi/src/session-resources.ts:200`, not behind a capability check like the four bridge tools beside it). So pi is the structural twin of D43's `--always-load` arm and has never run any other configuration. diff --git a/docs/decisions/design-kit/backend-measurements.md b/docs/decisions/design-kit/backend-measurements.md index 50d1fba28..8cd1f684c 100644 --- a/docs/decisions/design-kit/backend-measurements.md +++ b/docs/decisions/design-kit/backend-measurements.md @@ -39,7 +39,7 @@ and each closed before the measured runs: - **The brain has to live outside the operator's home directory, not only outside a checkout.** `settingSources: ["project"]`, - `packages/ui-backend-claude/src/sdk-options.ts:210`, makes the CLI walk up + `packages/ui-backend-claude/src/sdk-options.ts:232`, makes the CLI walk up from the cwd, and at every ancestor it reads `.claude/CLAUDE.md`, `.claude/skills/` and `.claude/agents/`. A brain anywhere under a home directory therefore loads `~/.claude/CLAUDE.md` as *project* instructions, diff --git a/docs/decisions/design-kit/show-block-brief.md b/docs/decisions/design-kit/show-block-brief.md index 25427b110..91b288cb2 100644 --- a/docs/decisions/design-kit/show-block-brief.md +++ b/docs/decisions/design-kit/show-block-brief.md @@ -204,7 +204,7 @@ is in the prompt on every pi turn by construction. That makes pi's shipping configuration the structural equivalent of this record's `--always-load` **brief** arm — and only that one. pi has no no-brief arm and no supported way to have one: `block: "show_block"`, -`packages/ui-backend-pi/src/session-resources.ts:193` passes the +`packages/ui-backend-pi/src/session-resources.ts:200` passes the block brief unconditionally, where the four lines above it gate their briefs on a capability. So pi can corroborate the loaded *rate* and can say nothing at all about whether the brief matters; the 77%/77% here and #148's 76%/77% are diff --git a/docs/decisions/design-kit/tool-loading.md b/docs/decisions/design-kit/tool-loading.md index 384ac0441..444480f7a 100644 --- a/docs/decisions/design-kit/tool-loading.md +++ b/docs/decisions/design-kit/tool-loading.md @@ -275,7 +275,7 @@ Two things stop that residue being read as a like-for-like gap, and both cut against reading pi as a second replication of the brief result. pi is on a different harness driving the model directly, and — this is the one that matters — **pi has no no-brief arm and no supported way to have one.** -`block: "show_block"`, `packages/ui-backend-pi/src/session-resources.ts:193` +`block: "show_block"`, `packages/ui-backend-pi/src/session-resources.ts:200` passes the block brief into `buildSystemPromptAppend` unconditionally, not behind a capability check like `askUser`, `location`, `activity` and `mask` on the lines above it. So every pi number was measured with the brief present. The diff --git a/docs/decisions/governed-writable-workers.md b/docs/decisions/governed-writable-workers.md index ea2907773..337a43374 100644 --- a/docs/decisions/governed-writable-workers.md +++ b/docs/decisions/governed-writable-workers.md @@ -958,7 +958,7 @@ classification: `packages/ui-backend-claude/src/tool-policy.ts:246`). The parent process is shared across sessions, so the lock is too. - Pi takes its lock inside the worker (`brain: createBrainAccess(init.backend.brainPath), lock: init.sharedLock ?`, - `packages/ui-backend-pi/src/worker-entry.ts:115`). Under `governed`, pi's tool + `packages/ui-backend-pi/src/worker-entry.ts:117`). Under `governed`, pi's tool lock must instead be the server's keyed lock, acquired over the worker RPC. Otherwise two pi sessions can interleave `git add` and `git commit`. diff --git a/docs/decisions/live-conversation.md b/docs/decisions/live-conversation.md index d78462c92..58c03c947 100644 --- a/docs/decisions/live-conversation.md +++ b/docs/decisions/live-conversation.md @@ -30,7 +30,7 @@ tool admission and execution, permissions, cancellation, terminal state and resynchronization. Engine task requests are advisory until mapped to committed user input. No native search, code-execution or alternate permission loop is enabled. Both adapters can delegate through the existing `AgentBackend` -(`export interface AgentBackend`, `packages/ui-sdk/src/server/backend.ts:640-670`); +(`export interface AgentBackend`, `packages/ui-sdk/src/server/backend.ts:644-674`); the host serializes work in a session and uses capability-checked follow-ups. The [voice-permission decision](voice-permission.md) binds this interface. diff --git a/docs/decisions/pi-endpoint-routing.md b/docs/decisions/pi-endpoint-routing.md index 02b7e3e3b..1ad56cebd 100644 --- a/docs/decisions/pi-endpoint-routing.md +++ b/docs/decisions/pi-endpoint-routing.md @@ -74,7 +74,7 @@ The concrete adapter integration points are: | Declared-model validation (`export function toModel`, `packages/ui-backend-pi/src/profiles.ts:57-77`) | Keep built-in validation and identity refusal, then select the configured model from the inference runtime. The current function returns the raw catalog object. | | Ordinary/autonomous new sessions (`async newSession`, `packages/ui-backend-pi/src/native-session-runtime.ts:41-72`) | Supply the native configured runtime and its selected model to the real `createAgentSession` call. The worker uses `SessionManager.inMemory`; the server persists ordinary JSONL over pipes, and autonomous turns remain nonpersistent. | | Ordinary persisted resumes (`async openSession`, `packages/ui-backend-pi/src/native-session-runtime.ts:74-109`) | Supply the configured runtime without a new model override; reject/dispose if `modelFallbackMessage` is present. | -| Native resources (`const agentDir = getAgentDir()`, `packages/ui-backend-pi/src/session-resources.ts:86-120`) | Use the same native agent-directory discovery as settings/resources. Preserve loader failure behavior and its inline permission gate. | +| Native resources (`const agentDir = getAgentDir()`, `packages/ui-backend-pi/src/session-resources.ts:89-128`) | Use the same native agent-directory discovery as settings/resources. Preserve loader failure behavior and its inline permission gate. | Runtime construction belongs at these concrete session boundaries. No runtime factory option, new public seam or authentication wrapper is needed. Each hosted turn reconstructs its native inference runtime inside a fresh diff --git a/docs/decisions/policy-write-boundary.md b/docs/decisions/policy-write-boundary.md index 87a3bdbc7..122c6e585 100644 --- a/docs/decisions/policy-write-boundary.md +++ b/docs/decisions/policy-write-boundary.md @@ -43,7 +43,7 @@ application, refusal and recovery. This is a change to the execution architecture. Current Claude assembly creates SDK options and in-process bridge tools (`createClaudeSdkTurn`, -`packages/ui-backend-claude/src/sdk-options.ts:85-261`); pi builds its worker-local resource +`packages/ui-backend-claude/src/sdk-options.ts:88-399`); pi builds its worker-local resource loader and calls `reload()` (`createSessionResources`, `packages/ui-backend-pi/src/session-resources.ts:29-113`). A child-only wrapper cannot be treated as proof for those parent-side paths. The investigation records diff --git a/docs/decisions/session-principals.md b/docs/decisions/session-principals.md index 0dc85b40b..33d1bb66a 100644 --- a/docs/decisions/session-principals.md +++ b/docs/decisions/session-principals.md @@ -102,7 +102,7 @@ Both reviewers verified every row below against the source. | WS upgrade | `ws/connection.ts:262-264` | `createWsUpgrade` ignores the request context | | WS admission | `ws/connection.ts:84` | `clients.add(ws)` — the socket's identity is unknown | | Turn record | `ws/run-session.ts:134-142` | recorder built from `{turnId, sessionId, billing}`; `RunningTurn` (`ws/turns.ts:55-77`) has no actor | -| Follow-up queue | `QueuedFollowUp`, `ws/turns.ts:61-103`; `slot.queue.push(entry)`, `run-session.ts:896` | queued entries re-mint `turnId` in the same slot and may come from another socket | +| Follow-up queue | `QueuedFollowUp`, `ws/turns.ts:61-103`; `slot.queue.push(entry)`, `run-session.ts:899` | queued entries re-mint `turnId` in the same slot and may come from another socket | | Password login | `acquirePasswordVerification(key)`, `auth.ts:740` | argon2id verify, failure counting, in-flight reservation, then `issueSessionCookie` | | Passkey login | `issueLoginSession`, `packages/ui-server/src/middleware/passkeys.ts:442` | assertion verified, then `issueSessionCookie`; `row.id` is in scope | | Passkey **registration** | `INSERT INTO passkey_credentials`, `passkeys.ts:540` | inserts a credential; calls **neither** helper | diff --git a/docs/decisions/session-prompt-context.md b/docs/decisions/session-prompt-context.md new file mode 100644 index 000000000..7b1578944 --- /dev/null +++ b/docs/decisions/session-prompt-context.md @@ -0,0 +1,44 @@ +# Session-stable appends and owned new-turn context + +The maintainer selected option B on [#2047](https://github.com/schlessera/brain-kit/issues/2047#issuecomment-6212451069) +on 2026-10-16, within the provider-history work tracked by +[#2053](https://github.com/schlessera/brain-kit/issues/2053). + +A conversation retains one exact system append in internal session storage. +The model receives relevant changed facts only in newly appended user content +or equivalent new-turn hook context. Earlier provider messages, tool results, +tool arrays and the stored append remain unchanged. Authoritative host state, +change tracking and replay provenance remain internal. This is the selected +exception to the parent epic's internal-only wording, not an exception to +append-only provider history. + +The fixed append carries static configured inference warn/stop limits. +Relevant device/capability, sandbox/profile/protected-path, writable-area, +journal/revert and actionable threshold changes belong in new context. Routine +running totals do not. Unknown device probes never mean absent hardware. +Enforcement remains in worker boundaries and permission/budget checks; a +prompt paragraph grants no authority. + +Replay uses structured internal message ownership to present the actual +user's text. It never strips substrings or host-looking patterns. Literal +user-authored lookalikes remain intact. Delivery tracking must survive resumed +workers and retry after a send that never reached saved history. + +Keeping every changed fact internal was rejected because the model would +retain stale device and operating facts until a refusal. Updating only +operational constraints was rejected because device and reading-layout facts +would remain stale. Rebuilding the system append was rejected because it +either changes the provider's cached prefix or is ignored by native system +snapshot recording. The selected new-tail addition costs context tokens on +later requests while preserving the existing prefix. + +The provider-history guard's existing scope excludes native `cache_control` +breakpoint movement from message-content comparison: changing cache metadata +does not edit prior content. Other provider content remains compared exactly. +Offline fake-origin request-byte proof does not establish first-party provider +cache hits, savings or current account-specific snapshot availability. + +[Session prompt context](../hosting/session-prompt-context.md) describes +first-turn facts, changes, unknowns, retries, persistence and the independent +Claude snapshot-off control. The broader provider-history record and its +cross-cause guards remain the scope of #2053. diff --git a/docs/decisions/voice-permission.md b/docs/decisions/voice-permission.md index de4bde26d..dee078ddf 100644 --- a/docs/decisions/voice-permission.md +++ b/docs/decisions/voice-permission.md @@ -55,7 +55,7 @@ functions**: `decideToolPermission` with `DEFAULT_ALLOWED_TOOLS` (`DEFAULT_ALLOWED_TOOLS`, `packages/ui-backend-claude/src/tool-policy.ts:27`), the six bridge tools the backend appends per turn (the `allowed.push` block in `createClaudeSdkTurn`, from `const allowed`, -`packages/ui-backend-claude/src/sdk-options.ts:127-152`) +`packages/ui-backend-claude/src/sdk-options.ts:130-155`) and `DEFAULT_CONFIRM_BASH_PATTERNS` (`DEFAULT_CONFIRM_BASH_PATTERNS`, `packages/ui-sdk/src/server/confirm-patterns.ts:55`). Tools that exist only in the recording harness and have no counterpart in this @@ -228,7 +228,7 @@ as its allowlist and declares both `enforceAllowedTools` and `noGrantSurface`. | `mcp__brain-ui__brain_update` | Edits an existing document. "Add this to my note about X" is the second most valuable eyes-free action, and the handler's shape is why it is safe enough to allow: it **never rewrites the body**, it only appends to it (`const appendTo`, `packages/core/src/lib/frontmatter-edit.ts:303-308`), so no prose can be lost. The exact bound on what it *can* destroy: the six frontmatter params are independent optionals on one call (`summary: z.string()`, `packages/core/src/mcp-server.ts:426-431`), applied independently (`input.summary !== undefined`, `packages/core/src/lib/ops/write.ts:88-100`), so **a single call can overwrite all six** — and `tags` replaces the whole tag list rather than merging into it (the terminal MCP accepts a comma-separated string, while the hosted route accepts a string array; `input.tags !== undefined`, `packages/core/src/lib/ops/write.ts:91`), while `deadline` and `next_review` take `""` as *delete the field* (`input.deadline !== undefined`, `packages/core/src/lib/ops/write.ts:92-99`). `updated` is bumped unconditionally (`updates.updated = updated`, `packages/core/src/lib/ops/write.ts:115`). None of it is checkpointed, so git recovers a prior value only if the document was committed. The claim this row rests on is therefore "loses no prose, and at most the six declared frontmatter fields, recoverable only if committed" — not "recoverable". `status` is the field that matters and it is handled separately below. | | `Read`, `Glob`, `Grep` | Read-only over the brain repo, for the questions the brain tools do not cover. No mutation, no egress. | | `WebSearch`, `WebFetch` | Read-only egress. Kept, with the exposure stated below. | -| the bridge tools, minus the mask editor | `ask_user`, `get_current_location`, `query_activity`, `show_block`, `turn_temp` are auto-allowed today and none of them is a permission decision (the same `allowed.push` block, from `const allowed`, `sdk-options.ts:127-152`). `request_image_mask` needs the user to paint a region, so it needs eyes; it is out. | +| the bridge tools, minus the mask editor | `ask_user`, `get_current_location`, `query_activity`, `show_block`, `turn_temp` are auto-allowed today and none of them is a permission decision (the same `allowed.push` block, from `const allowed`, `sdk-options.ts:130-155`). `request_image_mask` needs the user to paint a region, so it needs eyes; it is out. | Module tools are admitted only by exact name, never by their own annotations, as [module-mcp-tools.md §6](module-mcp-tools.md#6-annotations-and-permissions) @@ -487,8 +487,8 @@ to a turn — `InferenceProfile.allowedTools` (`allowedTools?: string[]`, `packages/ui-backend-claude/src/profiles.ts:27`) and `ClaudeBackendOptions.allowedTools` (`Backend-wide tool allowlist`, `options.ts:50-51`), resolved into the SDK's `allowedTools` per turn (the -`allowed` array, from `const allowed`, `sdk-options.ts:127`, and what it -becomes, `allowedTools: allowed`, `:223`). The voice posture is one +`allowed` array, from `const allowed`, `sdk-options.ts:130`, and what it +becomes, `allowedTools: allowed`, `:246`). The voice posture is one named entry in that mechanism. **#51's U15 originally chose availability control for the same bypass reason.** @@ -590,7 +590,7 @@ matters because a reader cannot otherwise tell a live hazard from a fixed one. **Closed, by #141 (`42a4d86`), which closed #124.** A turn now declares `StartTurnRequest.enforceAllowedTools` -(`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:535`) +(`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:539`) — the declaration this record asked for, by that name — and under it: - The input-rewrite hooks no longer grant. `createAgentHook` and `createRtkHook` diff --git a/docs/extending/agent-backends.md b/docs/extending/agent-backends.md index 55f9ac243..00c91bd07 100644 --- a/docs/extending/agent-backends.md +++ b/docs/extending/agent-backends.md @@ -230,8 +230,11 @@ by the cross-backend suite: actually sit on the execution path of every mutating tool. `buildSystemPromptAppend` (`packages/ui-sdk/src/server/system-prompt.ts`) -builds the shared brain-ui system-prompt block; backends that can vary their -system prompt per turn feed it the advisory `req.client` environment. +builds the shared brain-ui system-prompt block. The first-party backends retain +one exact session append and deliver changed advisory `req.client` and host +facts only in owned new tail context. See the +[session prompt context decision](../decisions/session-prompt-context.md) and +[its persistence/replay behavior](../hosting/session-prompt-context.md). ### Explicit autonomous turns (`@experimental`) diff --git a/docs/hosting/autonomous-prompt-cache.md b/docs/hosting/autonomous-prompt-cache.md index b042a1236..064f72613 100644 --- a/docs/hosting/autonomous-prompt-cache.md +++ b/docs/hosting/autonomous-prompt-cache.md @@ -10,8 +10,9 @@ remain the shared worker/permission boundary, independent of prompt text. The installed SDK's `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` terminates the custom system prefix. The server's `systemPromptAppend` for a restricted turn becomes turn context in the user message, before the item prompt. Item state, decision, -capability, remaining budget and attempt metadata belong there. Interactive and -nonrestricted nonpersistent turns keep their existing preset/append behavior. +capability, remaining budget and attempt metadata belong there. Interactive turns retain a [session-stable append](session-prompt-context.md) +and deliver relevant changed facts only in owned new-tail context. +Nonrestricted nonpersistent turns keep their existing preset/append behavior. The custom prompt needs no preset `excludeDynamicSections`: that option only applies to presets. The restricted envelope still disables ambient setting sources and project MCP configuration, keeps the credential in the relay and diff --git a/docs/hosting/session-prompt-context.md b/docs/hosting/session-prompt-context.md new file mode 100644 index 000000000..05ff0b446 --- /dev/null +++ b/docs/hosting/session-prompt-context.md @@ -0,0 +1,63 @@ +# Session prompt context + +The [session prompt context decision](../decisions/session-prompt-context.md) +records the selected policy and rejected alternatives. + +Interactive Claude and Pi conversations retain the exact first system append +in host-owned session storage outside the brain. Claude also explicitly asks +the installed runtime to snapshot its rendered system prompt. The host copy +keeps the append stable when runtime snapshot recording is unavailable; a +worker restart or a changed device does not rebuild it from new facts. + +The fixed append contains the response surface, execution guidance, stable +bridge-tool descriptions and the configured inference warn/stop limits. +Running usage, reading width, device probes, sandbox/profile/protected-path +facts, writable areas and journal/revert availability are current-turn facts. +Their first known values and relevant changes reach the model only after the +new user's text in that new message. An unchanged acknowledged fact adds no +new context. Prompt text grants no permission: worker mounts, tool boundaries +and budget checks continue to enforce the actual current state. + +The host keeps the user's original text, a digest of the exact transmitted +text and its provider-message identity as internal ownership records. Replay +uses those records without editing provider transcripts or matching a textual +host-block pattern. A user's literal lookalike block remains their text. A send that never reaches +saved native history does not mark facts delivered. Claude acknowledges +only an exact UUID/text match in the saved native transcript after copy-out; +Pi binds the host record to the new user entry in the worker's transcript +snapshot. For image normalization, the host records the complete observed +provider text digest at that delivery boundary, including native hints; replay +uses that exact ownership record and never infers a removable suffix. Retries +therefore retain facts that never reached saved history. + +Camera and microphone values remain unknown until enumeration settles. +Unknown is withheld from absence claims; a settled probe can explicitly report +absence. The welcome and message states both expose the same capped reading +column, so the first message does not measure the whole window. + +Restricted autonomous turns retain their separate immutable envelope and do +not use conversation snapshots or this host context ledger. For a conversation +created before host snapshots existed, the host retains the first append it +observes after upgrade; it cannot retroactively change earlier provider +requests. Native Claude prompt recording, where available, continues to retain +that conversation's original rendered system prompt. + +Offline tests run three turns through the real installed Claude CLI and fresh +Pi workers against loopback fake origins. Claude is checked both with native +snapshot recording requested and explicitly disabled. The tests capture the +request bodies and compare system bytes and prior message content. +As specified by the provider-history guard, native `cache_control` breakpoint +movement is the sole ignored message metadata field. These checks establish +request stability, not first-party cache hits or savings. + +The fixed lifecycle brief uses no per-turn duration. The exact current timeout +travels as a changed-only tail fact, so a later timeout change cannot leave +the model with the first turn's number. Enforcement still uses the current +host deadline independently of this advisory text. + +Capability booleans reuse the backend's current handler/posture availability +predicate. They are advisory availability facts, not permission grants; tool +allowlists, input-sensitive confirmations and server policy still decide each +call. Inbox read handlers are reported independently from write handlers. +Claude voice controls use enforced no-grant posture; Pi refuses voice before +inference and delivers no new prompt context for that rejected request. diff --git a/docs/investigations/live-conversation-investigation.md b/docs/investigations/live-conversation-investigation.md index 9d2186cd7..ae37c3fa2 100644 --- a/docs/investigations/live-conversation-investigation.md +++ b/docs/investigations/live-conversation-investigation.md @@ -239,7 +239,7 @@ Backend reuse is structurally feasible for **both** adapters. `startTurn` is already asynchronous and accepts a host AbortSignal. A busy same-session turn is queued; new committed details use `followUp` only when the backend advertises it, otherwise remain queued/reviewable -(`export interface AgentBackend`, `packages/ui-sdk/src/server/backend.ts:640-670`). +(`export interface AgentBackend`, `packages/ui-sdk/src/server/backend.ts:644-674`). Continued voice interaction never requires unsafe concurrent `startTurn`s. This is source evidence and design mapping, not a real provider/backend trial. diff --git a/docs/plans/async-collaboration-requirements.md b/docs/plans/async-collaboration-requirements.md index 4aa6eea8a..7438c54e6 100644 --- a/docs/plans/async-collaboration-requirements.md +++ b/docs/plans/async-collaboration-requirements.md @@ -256,7 +256,7 @@ Stated before the requirements because five of them derive from it. (`export function createTurnRecorder(`, `packages/ui-server/src/activity/recorder.ts:100-171`). - R25. **Autonomous work gets its own pool** (`MAX_AUTONOMOUS_RUNS`, default 2) — but a second counter alone does not deliver "interactive always wins". The host cap applies only when - starting WS sessions (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1106-1118`), and an autonomous + starting WS sessions (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1109-1121`), and an autonomous turn can hold a path write lock while an interactive turn waits or is denied at 30 seconds (`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`). Required: an admission controller with reserved interactive capacity and hybrid yield at an explicit denial-risk @@ -289,7 +289,7 @@ Stated before the requirements because five of them derive from it. AGENTS.md ("Testing expectations") records for the renderer. - R29. **Autonomous runs do not inherit ambient project configuration.** Claude loads project settings/instructions and also appends explicit bridge tools - (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`). If containment fails once, `.claude/settings*`, `.mcp.json`, + (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:88-399`). If containment fails once, `.claude/settings*`, `.mcp.json`, repo instructions, or a skill become durable escalation targets for later higher-privilege runs. Autonomous mode uses an explicit tool roster, `strictMcpConfig`, and a read-only trusted instruction snapshot. @@ -359,7 +359,7 @@ Stated before the requirements because five of them derive from it. bounded reserve; when the reserve is spent, everything stops and one `fyi` is filed. - R44. **Cache stability requires an autonomous prompt mode.** The current prefix is assembled per turn from client environment, turn budget, and bridge tool availability - (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`), and the SDK preset adds dynamic cwd/memory/git sections + (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:88-399`), and the SDK preset adds dynamic cwd/memory/git sections unless `excludeDynamicSections: true`. Required: a fixed tool roster, dynamic sections excluded, deterministic trusted-instruction render (policy digest is v2), and an explicit static/dynamic boundary with every per-item value after it. Whether the provider honors cache reads across independent SDK diff --git a/docs/plans/async-collaboration.md b/docs/plans/async-collaboration.md index 081fa0c8a..bf459fe46 100644 --- a/docs/plans/async-collaboration.md +++ b/docs/plans/async-collaboration.md @@ -141,7 +141,7 @@ system gate. It is not a prerequisite to rebuild or a new independent package. (`export const DEFAULT_ALLOWED_TOOLS = [`, `packages/ui-backend-claude/src/tool-policy.ts:27-80`) is still a broad interactive roster. A shell-command classifier is not a process write/network boundary. - **Filtered environment and project settings:** (`export function envSnapshot(`, `packages/ui-backend-claude/src/config/env.ts:182-190`) and - (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`). Restricted execution needs + (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:88-399`). Restricted execution needs narrower credentials/configuration; it does not start from the old full-host-env assumption. - **Pi resources and extension gate:** (`export function createSessionResources(`, `packages/ui-backend-pi/src/session-resources.ts:29-144`) and (`export function createPermissionGate(`, `packages/ui-backend-pi/src/permission-gate.ts:95-124`). Built-ins are disabled, @@ -156,7 +156,7 @@ system gate. It is not a prerequisite to rebuild or a new independent package. - **Share provenance/limits:** (`const result = await stageShareAt(`, `packages/ui-server/src/inbox/intake.ts:95`) assigns the source in server code. (`export const SHARE_MAX_TEXT_BYTES =`, `packages/ui-sdk/src/protocol.ts:2325`) bounds text, not binary uploads; extracted T1 context needs its own byte/token bound. -- **Interactive locks:** (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1106-1118`) gates WS starts; +- **Interactive locks:** (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1109-1121`) gates WS starts; (`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`) owns tool locks. Shared-target priority and cooperative yield belong to the keyed lock and backend lifecycle. @@ -1274,7 +1274,7 @@ full-v1 enablement; predicates and schema tests cannot substitute for it. **Approach:** **hybrid — reserve capacity normally, yield only at denial risk.** `MAX_AUTONOMOUS_RUNS` (default 2) is necessary but not sufficient: the host cap applies only at -WS session start (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1106-1118`) and an autonomous turn can hold a path write lock +WS session start (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1109-1121`) and an autonomous turn can hold a path write lock while an interactive turn waits or is denied at 30s (`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`). @@ -1318,7 +1318,7 @@ exists to make, paid only when it would otherwise fail. **Files:** - Modify: `packages/ui-backend-claude/src/backend.ts` (prompt assembly at - (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`)) + (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:88-399`)) - Test: `packages/ui-backend-claude/tests/autonomous-prompt.test.ts` **Approach:** a fixed tool roster, deterministic package-owned trusted instructions, diff --git a/docs/plans/host-owned-web-search.md b/docs/plans/host-owned-web-search.md index 90303c562..3aa7e236e 100644 --- a/docs/plans/host-owned-web-search.md +++ b/docs/plans/host-owned-web-search.md @@ -216,7 +216,7 @@ runtime authority or a public plugin seam. Both candidates need them: Current source confirms why a bridge is needed: ordinary initialization copies only auth/models/settings (`const init`, -`packages/ui-backend-pi/src/worker-session.ts:108-110`), then selects private Pi +`packages/ui-backend-pi/src/worker-session.ts:111-113`), then selects private Pi state. The worker RPC schema has an existing bounded, grant-checked bridge but no search method (`export const piWorkerMessage`, `packages/ui-backend-pi/src/worker-protocol.ts:64-70`). Resource discovery remains diff --git a/docs/plans/navigation-recovery.md b/docs/plans/navigation-recovery.md index 3a2f993ab..3f2b68eb6 100644 --- a/docs/plans/navigation-recovery.md +++ b/docs/plans/navigation-recovery.md @@ -399,7 +399,7 @@ implementation. Any PR moving these lines owns their citation repairs. - API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:746`). -- Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:879-923`). +- Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:882-926`). - Approval target: (`function mutateLastAssistant(`, `packages/ui-react/src/stores/chat-state.ts:1217-1247`). diff --git a/docs/plans/policy-write-boundary.md b/docs/plans/policy-write-boundary.md index bf88ab032..5b40bff26 100644 --- a/docs/plans/policy-write-boundary.md +++ b/docs/plans/policy-write-boundary.md @@ -24,7 +24,7 @@ remain unchanged. Approval is not production containment evidence. ## Why a tool hook or child-only wrapper is insufficient Claude assembles the ordinary SDK turn, project settings and in-process bridge -tools (`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-255`). +tools (`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:88-399`). The installed SDK exposes `spawnClaudeCodeProcess`; at the investigation baseline, the adapter wrapped the child with piped stdio ([historical `createWrappedSpawn`](https://github.com/schlessera/brain-kit/blob/ca1facc1e7086bd2119c260150e9f091e16817f1/packages/ui-backend-claude/src/spawn-wrapper.ts)). diff --git a/packages/ui-backend-claude/src/history.ts b/packages/ui-backend-claude/src/history.ts index 7a4fc466c..e39e0e6e8 100644 --- a/packages/ui-backend-claude/src/history.ts +++ b/packages/ui-backend-claude/src/history.ts @@ -1,3 +1,4 @@ +import { claudePromptContext } from "./prompt-context.js"; import { FIRST_PARTY_FAILURE_CLASSES } from "@schlessera/brain-ui-sdk/protocol"; import { listSessions as sdkListSessions, @@ -121,13 +122,14 @@ export async function buildSessionHistory( } } + const context = claudePromptContext(dir, sessionId); const messages: SessionHistoryMessage[] = []; for (const entry of history) { const content = (entry as any).message?.content; if (entry.type === "user") { - const text = Array.isArray(content) + const wire = Array.isArray(content) ? content .filter((b: any) => b.type === "text") .map((b: any) => b.text) @@ -135,6 +137,7 @@ export async function buildSessionHistory( : typeof content === "string" ? content : ""; + const text = context.userText(String((entry as any).uuid ?? ""), wire); const attachmentCount = Array.isArray(content) ? content.filter((b: any) => b.type === "image").length : 0; diff --git a/packages/ui-backend-claude/src/options.ts b/packages/ui-backend-claude/src/options.ts index b5951d170..14af0bad8 100644 --- a/packages/ui-backend-claude/src/options.ts +++ b/packages/ui-backend-claude/src/options.ts @@ -72,12 +72,12 @@ export interface ClaudeBackendOptions { */ log?: BackendLogFn; /** - * Text appended to the Claude Code system prompt, describing the chat - * surface the answer renders on. Defaults to `buildSystemPromptAppend(...)`, - * rebuilt per turn from the client's reported device; setting this pins one - * STATIC string instead. Repo-specific instructions belong in the brain - * repo's own CLAUDE.md, which the SDK already loads; this is only for facts - * about the UI. Pass an empty string to append nothing. + * Optional override for the chat-surface brief retained once per session. + * The default describes static UI/execution facts; current device, sandbox + * and handler facts arrive only in owned new tail context. The first value + * stays exact across resumed workers. Repo-specific instructions belong + * in CLAUDE.md, which the SDK loads. An empty string omits this brief; + * current host context still uses the new user message. */ systemPromptAppend?: string; /** diff --git a/packages/ui-backend-claude/src/permission-hooks.ts b/packages/ui-backend-claude/src/permission-hooks.ts index 62b44859c..281d930ec 100644 --- a/packages/ui-backend-claude/src/permission-hooks.ts +++ b/packages/ui-backend-claude/src/permission-hooks.ts @@ -368,7 +368,7 @@ export function createPermissionWiring(options: { // one; this is just its runtime binding. // // Allowlisting is not the only reason it belongs here. This backend loads - // the brain repo's project settings (`settingSources`, sdk-options.ts:207), + // the brain repo's project settings (`settingSources`, sdk-options.ts:232), // and a hook // declared in those can answer a call before canUseTool is reached at all // — #124 has the measurements. This hook fires either way. None of that diff --git a/packages/ui-backend-claude/src/prompt-context.ts b/packages/ui-backend-claude/src/prompt-context.ts new file mode 100644 index 000000000..bc2302081 --- /dev/null +++ b/packages/ui-backend-claude/src/prompt-context.ts @@ -0,0 +1,30 @@ +import { existsSync, realpathSync } from "node:fs"; +import { regular } from "./worker-state.js"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { sessionPromptContext } from "@schlessera/brain-ui-sdk/internal"; +import { envSnapshot } from "./config/env.js"; +/** Uses host native storage; profile overrides cannot change replay ownership. */ +export function claudePromptContext(brainPath: string, sessionId: string, env = envSnapshot()) { + return sessionPromptContext(env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), ".claude"), brainPath, sessionId); +} + +/** A successful send becomes delivered only after its exact UUID/bytes survive native copy-out. */ +export function acknowledgeClaudeContext(brainPath: string, sessionId: string, owned: { id: string; text: string }, + env: Record) { + if (!existsSync(brainPath)) return; + const root = env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), ".claude"); + const project = `projects/${realpathSync(brainPath).replace(/[^a-zA-Z0-9]/g, "-")}`; + const path = join(root, project, `${sessionId}.jsonl`); + if (!existsSync(path)) return; + const lines = regular(path, 64 * 1024 * 1024).toString("utf8").split("\n"); + for (const line of lines) { + if (!line) continue; + const entry = JSON.parse(line); + if (entry.type !== "user" || entry.uuid !== owned.id) continue; + const content = entry.message?.content; + const text = typeof content === "string" ? content : Array.isArray(content) + ? content.filter((part: any) => part.type === "text").map((part: any) => part.text).join("") : undefined; + if (text === owned.text) claudePromptContext(brainPath, sessionId).acknowledge(owned.id); + } +} diff --git a/packages/ui-backend-claude/src/sdk-options.ts b/packages/ui-backend-claude/src/sdk-options.ts index 61df482aa..99b1425e1 100644 --- a/packages/ui-backend-claude/src/sdk-options.ts +++ b/packages/ui-backend-claude/src/sdk-options.ts @@ -1,3 +1,6 @@ +import { toolAvailabilityRefusal } from "./tool-availability.js"; +import { claudePromptContext, acknowledgeClaudeContext } from "./prompt-context.js"; +import { describeClient } from "@schlessera/brain-ui-sdk/internal"; import { autonomousSystemPrompt } from "./autonomous-prompt.js"; import { ASK_USER_FORM_TOOL_NAME } from "./ask-user-form-tool.js"; import type { @@ -189,7 +192,26 @@ function assembleClaudeSdkTurn( }, req.turnBudgetMs ); - const append = brief ? `${brief}\n\n${sandboxNote}` : sandboxNote; + const candidate = brief ? `${brief}\n\n${sandboxNote}` : sandboxNote; + const contextId = req.sessionId ?? crypto.randomUUID(); + const context = req.autonomous ? undefined : claudePromptContext(backend.brainPath, contextId); + const append = context ? context.retainAppend([buildAppend(backend, undefined, { + policyChange: true, askUser: true, askUserList: true, askUserRank: true, askUserForm: true, + location: true, mask: true, activity: true, inbox: true, block: true, turnTemp: true, + }, undefined), req.sessionBudgetLimits].filter(Boolean).join("\n\n"), req.sessionBudgetLimits ? { limits: req.sessionBudgetLimits } : {}) : candidate; + const available = (name: string) => toolAvailabilityRefusal({ ...req, workerSandbox: sandbox }, name) === undefined; + const owned = context?.prepare(req.prompt, { + device: req.client ? describeClient(req.client) : "Device unknown; hardware probe results are withheld until known.", + sandbox: sandboxNote, + capabilities: JSON.stringify({ posture: req.posture ?? "default", noGrantSurface: req.noGrantSurface === true, + askUser: available("ask_user"), askUserList: available("ask_user_list"), askUserRank: available("ask_user_rank"), askUserForm: available("ask_user_form"), + location: available("get_current_location"), mask: available("request_image_mask"), policyChange: available("propose_policy_change"), + activity: available("query_activity"), inbox: { list: available("inbox_list"), get: available("inbox_get"), + add: available("inbox_add"), resolveAction: available("resolve_action"), completeShare: available("complete_share") } }), + ...(req.turnBudgetMs && req.turnBudgetMs > 0 ? { timeout: `The host hard budget for this turn is ${req.turnBudgetMs} ms (about ${Math.round(req.turnBudgetMs / 60000)} minutes); finish inside this cap.` } : {}), + ...(req.sessionBudgetLimits ? { limits: req.sessionBudgetLimits } : {}), + ...(req.budgetContext ? { budget: req.budgetContext } : {}), + }); const subscriptionOnly = profile.billing !== "api"; const sdkOptions: Options = { ...(req.autonomous ? { persistSession: false } : {}), @@ -214,6 +236,7 @@ function assembleClaudeSdkTurn( systemPrompt: envelope ? autonomousSystemPrompt(allowed, sandboxNote) : { type: "preset", preset: "claude_code", + snapshot: true, append, }, // Preserve manual approvals across SDK versions that leave an omitted @@ -306,6 +329,7 @@ function assembleClaudeSdkTurn( }; } if (req.sessionId !== undefined) sdkOptions.resume = req.sessionId; + else if (context) sdkOptions.sessionId = contextId; // Interactive schema membership is deployment-stable, even without a live // handler. The mandatory hook refuses unavailable calls; fallback tool // bodies also return model-readable errors instead of acquiring authority. @@ -356,13 +380,18 @@ function assembleClaudeSdkTurn( ...(envelope ? { pinnedSettingsEnv: envelope.pinned } : {}), finishWorker: envelope ? () => worker.finish().finally(() => envelope.relay.stop()) - : worker.finish, + : async () => { + await worker.finish(); + if (owned) acknowledgeClaudeContext(backend.brainPath, contextId, owned, childEnv); + }, workerLimits: { current: worker.limits, settled: worker.settledLimits }, workerFailure: worker.failure, workerStateRoom: worker.stateRoom, /** Each streamed piece of the answer, for the turn-temp purge's linked-file guard (#1703). */ observeAnswerText: (text: string) => { answerText += text; }, - prompt: + prompt: owned ? (async function* () { + yield { ...userMessage(owned.text, req.attachments), uuid: owned.id as SDKUserMessage["uuid"] }; + })() : req.attachments && req.attachments.length > 0 ? buildAttachmentPrompt(prompt, req.attachments) : prompt, @@ -387,8 +416,8 @@ function buildAppend( }, turnBudgetMs: number | undefined ): string { - // An explicit override is used verbatim; the default is rebuilt per turn so - // it can describe the device the CURRENT message came from. + // The interactive caller snapshots the fixed brief once. Live facts travel + // only on the new user message; restricted envelopes keep their own policy. return ( options.systemPromptAppend ?? buildSystemPromptAppend({ diff --git a/packages/ui-backend-claude/tests/backend.test.ts b/packages/ui-backend-claude/tests/backend.test.ts index c3d9dbe13..9a56f2985 100644 --- a/packages/ui-backend-claude/tests/backend.test.ts +++ b/packages/ui-backend-claude/tests/backend.test.ts @@ -1437,15 +1437,22 @@ describe("Agent background rewrite", () => { }); }); -describe("turn budget in the system prompt", () => { - test("req.turnBudgetMs surfaces in the appended brief", async () => { +describe("turn budget in the current tail", () => { + test("req.turnBudgetMs surfaces in owned tail beside the generic lifecycle brief", async () => { let captured: Options | undefined; - const queryFn = ((params: { options?: Options }) => { + let promptText = ""; + const queryFn = ((params: { options?: Options; prompt: string | AsyncIterable }) => { captured = params.options!; - return (async function* () { + return { + initializationResult: async () => ({ account: { tokenSource: "CLAUDE_CODE_OAUTH_TOKEN", apiProvider: "firstParty" } }), + getSettings: async () => ({ effective: {}, sources: [] }), + async *[Symbol.asyncIterator]() { + if (typeof params.prompt === "string") promptText = params.prompt; + else { const first = await params.prompt[Symbol.asyncIterator]().next(); if (!first.done) promptText = JSON.stringify(first.value.message.content); } yield initMsg("s1"); yield resultMsg("s1"); - })(); + }, + }; }) as unknown as typeof query; const backend = createClaudeBackend({ brainPath: "/brain", queryFn }); @@ -1455,7 +1462,8 @@ describe("turn budget in the system prompt", () => { ); const sp = captured!.systemPrompt as { append?: string }; - expect(sp.append).toContain("about 25 minutes"); + expect(sp.append).not.toContain("about 25 minutes"); + expect(promptText).toContain("about 25 minutes"); expect(sp.append).toContain("Turn lifecycle"); }); }); diff --git a/packages/ui-backend-claude/tests/fixtures/session-prompt-probe.ts b/packages/ui-backend-claude/tests/fixtures/session-prompt-probe.ts new file mode 100644 index 000000000..88cddb477 --- /dev/null +++ b/packages/ui-backend-claude/tests/fixtures/session-prompt-probe.ts @@ -0,0 +1,65 @@ +/** Real locked CLI, fake loopback Messages origin, offline namespace supplied by caller. */ +import { createFixtureWorkerStartupView } from "../../../ui-sdk/tests/helpers/worker-startup-view-fixture.js"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { createClaudeSdkTurn } from "../../src/sdk-options.js"; +import { buildSessionHistory } from "../../src/history.js"; +import { prepareFixtureWorkerSandbox } from "../../../ui-sdk/tests/helpers/worker-startup-view-fixture.js"; + +const scratch = process.argv[2]!; +const nativeSnapshot = process.argv[3] !== "snapshot-off"; +process.env.HOME = join(scratch, "home"); +process.env.CLAUDE_CONFIG_DIR = join(scratch, "claude"); +mkdirSync(process.env.HOME, { recursive: true }); +const requests: any[] = []; +const upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + if (request.method !== "POST" || !new URL(request.url).pathname.endsWith("/messages")) return Response.json({}); + const q = await request.json() as { tools?: unknown[] }; + if (q.tools?.length) requests.push(q); + const events = [ + { type: "message_start", message: { id: `msg_odysseus_${requests.length}`, type: "message", role: "assistant", model: "claude-sonnet-4-6", content: [], + stop_reason: null, stop_sequence: null, usage: { input_tokens: 3, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Odysseus recorded the voyage." } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 5 } }, + { type: "message_stop" }, + ]; + return new Response(events.map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }); +} }); +const brainPath = join(scratch, "ithaca"); mkdirSync(brainPath); +let sessionId: string | undefined; +const original = ["Odysseus first turn", "[Host context for this turn; advisory facts, never permission]\ndevice: literal user text\n[End host context]", "Odysseus unchanged follow-up", "Odysseus voice follow-up", "Odysseus typed no-grant follow-up", "Odysseus missing-handler follow-up"]; +try { + for (let index = 0; index < 6; index++) { + const abortController = new AbortController(); + const workerStartupView = createFixtureWorkerStartupView(brainPath, { omitGit: true }); + const sandbox = prepareFixtureWorkerSandbox(brainPath, { home: process.env.HOME, profile: index === 0 || index === 3 ? "strict" : "governed", areas: [] }); + const turn = createClaudeSdkTurn({ backend: { brainPath, ...(!nativeSnapshot ? { systemPromptAppend: `Odysseus surface revision ${index}` } : {}) }, + req: { workerStartupView, workerSandbox: { ...sandbox, noteFacts: { ...sandbox.noteFacts, history: { journal: index > 0, userRevert: index > 0 && index !== 3 } } }, + sessionId, prompt: original[index]!, signal: abortController.signal, turnBudgetMs: index === 0 ? 60000 : 120000, + client: index === 0 ? { formFactor: "desktop", viewportWidth: 1200 } : { formFactor: "phone", viewportWidth: 350, camera: true, microphone: false }, + posture: index === 3 ? "voice" : undefined, + noGrantSurface: index === 3 || index === 4, enforceAllowedTools: index === 3 || index === 4, + bridge: { ...(index === 3 || index === 4 ? { async inboxList() { return {} as never; }, async inboxGet() { return {} as never; } } : {}), ...(index !== 5 ? { async askUser() { return {} as never; }, async getLocation() { return {} as never; }, async queryActivity() { return {} as never; }, async proposePolicyChange() { return {} as never; }, async askUserList() { return {} as never; }, async askUserRank() { return {} as never; }, async askUserForm() { return {} as never; }, async requestMask() { return {} as never; } } : {}), ...(index > 0 && index < 5 ? { async inboxAdd() { return {} as never; } } : {}), emit() {}, async requestPermission() { return { behavior: "deny", message: "fixture" }; } } }, + profile: { id: "fixture", label: "Fixture", model: "claude-sonnet-4-6", billing: "api", requiredEnvKeys: [], + buildEnv: () => ({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstream.port}`, ANTHROPIC_API_KEY: "sk-odysseus-fixture" }) }, + abortController, allowedTools: ["Read"], confirmPatterns: [], + turnLock: { acquire: () => undefined, release: () => undefined } as never, log() {}, + }); + if (!nativeSnapshot && typeof turn.options.systemPrompt === "object" && !Array.isArray(turn.options.systemPrompt)) turn.options.systemPrompt.snapshot = false; + const timer = setTimeout(() => abortController.abort(), 45000); + try { + let success = false; + for await (const message of query({ prompt: turn.prompt, options: turn.options })) { + if (message.type === "system" && message.subtype === "init") sessionId = message.session_id; + if (message.type === "result") { if (message.subtype !== "success") throw new Error(JSON.stringify(message)); success = true; } + } + if (!success) throw new Error("No successful fixture inference result"); + } finally { clearTimeout(timer); await turn.finishWorker(); workerStartupView.cleanup(); } + } + const { getSessionMessages } = await import("@anthropic-ai/claude-agent-sdk"); + const history = await buildSessionHistory(sessionId!, getSessionMessages, brainPath); + writeFileSync(join(scratch, "requests.json"), JSON.stringify({ requests, history, original })); +} finally { upstream.stop(true); } diff --git a/packages/ui-backend-claude/tests/follow-up.test.ts b/packages/ui-backend-claude/tests/follow-up.test.ts index 095e0ec19..2e2ae0e47 100644 --- a/packages/ui-backend-claude/tests/follow-up.test.ts +++ b/packages/ui-backend-claude/tests/follow-up.test.ts @@ -100,7 +100,7 @@ describe("delivering a follow-up into the running turn", () => { resume(); await turn; - expect(h.first[0]?.message.content).toBe("Plan the voyage home."); + expect(h.first[0]?.message.content).toStartWith("Plan the voyage home.\n\n[Host context for this turn; advisory facts, never permission]\n"); expect(followUp).toMatchObject({ type: "user", parent_tool_use_id: null, diff --git a/packages/ui-backend-claude/tests/helpers/worker-host.ts b/packages/ui-backend-claude/tests/helpers/worker-host.ts index 4e72dbf38..68b8a152f 100644 --- a/packages/ui-backend-claude/tests/helpers/worker-host.ts +++ b/packages/ui-backend-claude/tests/helpers/worker-host.ts @@ -1,8 +1,26 @@ /** Mock only the host probe for hand-driven SDK streams; native proof never imports this. */ import { afterEach, beforeEach, spyOn } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as context from "../../src/prompt-context.js"; +import { sessionPromptContext } from "@schlessera/brain-ui-sdk/internal"; import { workerHostBoundary } from "@schlessera/brain-ui-sdk/internal"; export function mockWorkerHostForSdkStream() { let probe: ReturnType; - beforeEach(() => { probe = spyOn(workerHostBoundary, "probe").mockReturnValue({ ok: true }); }); - afterEach(() => probe.mockRestore()); + let root: string; + let prompt: ReturnType, acknowledgement: ReturnType; + beforeEach(() => { + probe = spyOn(workerHostBoundary, "probe").mockReturnValue({ ok: true }); + root = mkdtempSync(join(tmpdir(), "claude-sdk-context-")); + const brain = join(root, "ithaca"); mkdirSync(brain); + // Hand-driven SDK streams have no native transcript or real /brain directory. + // Keep their newly persistent host context in this test's own storage. + prompt = spyOn(context, "claudePromptContext").mockImplementation((_brain, id) => sessionPromptContext(join(root, "host"), brain, id)); + acknowledgement = spyOn(context, "acknowledgeClaudeContext").mockImplementation(() => {}); + }); + afterEach(() => { + prompt.mockRestore(); acknowledgement.mockRestore(); probe.mockRestore(); + rmSync(root, { recursive: true, force: true }); + }); } diff --git a/packages/ui-backend-claude/tests/history-api-failure.test.ts b/packages/ui-backend-claude/tests/history-api-failure.test.ts index 34266eb7a..71b8b7bad 100644 --- a/packages/ui-backend-claude/tests/history-api-failure.test.ts +++ b/packages/ui-backend-claude/tests/history-api-failure.test.ts @@ -19,7 +19,7 @@ import { } from "./fixtures/api-failures"; const read = (entries: SessionMessage[]) => - buildSessionHistory(SESSION, (async () => entries) as unknown as typeof getSessionMessages, "/brain"); + buildSessionHistory(SESSION, (async () => entries) as unknown as typeof getSessionMessages, import.meta.dir); describe("an API error replays as the turn's failure", () => { test("the unsupported-model turn: an empty answer that failed, with the status its text states", async () => { diff --git a/packages/ui-backend-claude/tests/history.test.ts b/packages/ui-backend-claude/tests/history.test.ts index 819ab6cc3..a637bce24 100644 --- a/packages/ui-backend-claude/tests/history.test.ts +++ b/packages/ui-backend-claude/tests/history.test.ts @@ -1,4 +1,7 @@ -import { expect, test, describe } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test, describe } from "bun:test"; import type { listSessions as sdkListSessions, getSessionMessages as sdkGetSessionMessages, @@ -7,6 +10,9 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { createHistory } from "../src/history"; +const brain = mkdtempSync(join(tmpdir(), "claude-history-context-")); +afterAll(() => rmSync(brain, { recursive: true, force: true })); + // A transcript entry as `getSessionMessages` returns it; `message` is opaque // (unknown) to the SDK, so fixtures only need the `.message.content` blocks the // normalizer reads. @@ -45,7 +51,7 @@ describe("getHistory normalization", () => { ]; const history = createHistory({ - brainPath: "/brain", + brainPath: brain, getSessionMessagesFn: messagesFn(transcript), }); const out = await history.getHistory("sess"); @@ -86,7 +92,7 @@ describe("getHistory normalization", () => { ]), ]; const history = createHistory({ - brainPath: "/brain", + brainPath: brain, getSessionMessagesFn: messagesFn(transcript), }); const [msg] = await history.getHistory("sess"); @@ -113,7 +119,7 @@ describe("getHistory normalization", () => { ]), ]; const history = createHistory({ - brainPath: "/brain", + brainPath: brain, getSessionMessagesFn: messagesFn(transcript), }); const out = await history.getHistory("sess"); @@ -154,7 +160,7 @@ describe("listSessions normalization", () => { ] as unknown as SDKSessionInfo[]; const history = createHistory({ - brainPath: "/brain", + brainPath: brain, listSessionsFn: sessionsFn(infos), }); const out = await history.listSessions(); @@ -184,7 +190,7 @@ describe("listSessions normalization", () => { throw new Error("no store"); }) as unknown as typeof sdkListSessions; const history = createHistory({ - brainPath: "/brain", + brainPath: brain, listSessionsFn: throwingFn, }); expect(await history.listSessions()).toEqual([]); diff --git a/packages/ui-backend-claude/tests/no-grant-surface.test.ts b/packages/ui-backend-claude/tests/no-grant-surface.test.ts index 827652abd..de7110db1 100644 --- a/packages/ui-backend-claude/tests/no-grant-surface.test.ts +++ b/packages/ui-backend-claude/tests/no-grant-surface.test.ts @@ -295,7 +295,7 @@ describe("a turn with no grant surface, kind command", () => { }); describe("the capability that needs eyes", () => { - test("the mask editor is not appended to a turn with no grant surface", async () => { + test("the mask editor remains in the fixed brief without gaining no-grant authority", async () => { const harness = await startTurn({ allowedTools: WITHOUT_SHELL, enforceAllowedTools: true, @@ -307,9 +307,9 @@ describe("the capability that needs eyes", () => { // append, which puts the tool INSIDE the enforced allowlist whatever the // posture declared. expect(harness.options.allowedTools).not.toContain(MASK_TOOL_NAME); - // And it is withheld, not merely un-allowlisted: nothing tells the model - // it has an editor it cannot open. - expect(JSON.stringify(harness.options.systemPrompt)).not.toContain(MASK_TOOL_NAME); + // Stable descriptions are independent of live authority; current availability + // is sent only in the new user message. + expect(JSON.stringify(harness.options.systemPrompt)).toContain(MASK_TOOL_NAME); }); test("the same bridge hands it to a turn that has a grant surface", async () => { @@ -322,7 +322,7 @@ describe("the capability that needs eyes", () => { expect(harness.options.allowedTools).toContain(MASK_TOOL_NAME); }); - test("the list card is not appended to a turn with no grant surface", async () => { + test("the list card remains in the fixed brief without gaining no-grant authority", async () => { const harness = await startTurn({ allowedTools: WITHOUT_SHELL, enforceAllowedTools: true, @@ -330,7 +330,7 @@ describe("the capability that needs eyes", () => { withListHandler: true, }); expect(harness.options.allowedTools).not.toContain(ASK_USER_LIST_TOOL_NAME); - expect(JSON.stringify(harness.options.systemPrompt)).not.toContain(ASK_USER_LIST_TOOL_NAME); + expect(JSON.stringify(harness.options.systemPrompt)).toContain(ASK_USER_LIST_TOOL_NAME); }); test("the same bridge hands the list card to a turn that has a grant surface", async () => { diff --git a/packages/ui-backend-claude/tests/replayed-user-text.test.ts b/packages/ui-backend-claude/tests/replayed-user-text.test.ts index ab73961b9..d8a9fafe9 100644 --- a/packages/ui-backend-claude/tests/replayed-user-text.test.ts +++ b/packages/ui-backend-claude/tests/replayed-user-text.test.ts @@ -1,112 +1,57 @@ -import { describe, expect, test } from "bun:test"; -import type { - getSessionMessages as sdkGetSessionMessages, - SDKUserMessage, - SessionMessage, -} from "@anthropic-ai/claude-agent-sdk"; -import type { ClientEnvironment } from "@schlessera/brain-ui-sdk/server"; - +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { getSessionMessages, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { createHistory } from "../src/history.js"; -import { createClaudeSdkTurn } from "../src/sdk-options.js"; - -/** - * What the Claude backend replays as a user message's text, for a message - * sent with attachments and with a client environment. - * - * ui-server joins what it keeps about a user message (how it was produced: - * typed, dictated, spoken) onto the replayed message by the message's EXACT - * text as the client sent it. These pin that the Claude backend replays that - * text: the prompt the turn hands the SDK is folded back through the same - * history reader the backend replays sessions with. - */ - -const PNG_1PX = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; - -const CLIENT: ClientEnvironment = { - formFactor: "phone", - viewportWidth: 390, - locale: "en-GB", - timeZone: "Europe/Berlin", -}; - -function turnFor(prompt: string, attachments: unknown[]) { - return createClaudeSdkTurn({ - backend: { brainPath: "/brain" } as never, - req: { - prompt, - attachments, - client: CLIENT, - turnBudgetMs: 180_000, - bridge: {}, - } as never, +import { createClaudeSdkTurn, type ClaudeSdkTurn } from "../src/sdk-options.js"; +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; +let root: string, brain: string, previous: string | undefined; +const turns: ClaudeSdkTurn[] = []; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "claude-replay-")); brain = join(root, "ithaca"); mkdirSync(brain); + previous = process.env.CLAUDE_CONFIG_DIR; process.env.CLAUDE_CONFIG_DIR = join(root, "native"); +}); +afterEach(async () => { + for (const turn of turns.splice(0)) await turn.finishWorker(); + if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = previous; + rmSync(root, { recursive: true, force: true }); +}); +function turnFor(prompt: string, count: number) { + const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, + req: { prompt, attachments: Array.from({ length: count }, () => ({ mediaType: "image/png", data: PNG })), + client: { formFactor: "phone", viewportWidth: 390 }, bridge: {} } as never, profile: { requiredEnvKeys: [], buildEnv: () => ({}) } as never, - abortController: new AbortController(), - allowedTools: [], - confirmPatterns: [], - turnLock: { acquire: () => undefined, release: () => undefined } as never, - log: () => undefined, + abortController: new AbortController(), allowedTools: [], confirmPatterns: [], + turnLock: { acquire: () => undefined, release: () => undefined } as never, log() {}, }); + turns.push(turn); return turn; } - -/** The user-message content the SDK would write to the transcript for this prompt. */ -async function transcriptContent(prompt: string | AsyncIterable): Promise { - if (typeof prompt === "string") return [prompt]; - const contents: unknown[] = []; - for await (const message of prompt) contents.push(message.message.content); - return contents; -} - -async function replayUserMessages(contents: unknown[]) { - const transcript = contents.flatMap((content) => [ - { type: "user", uuid: crypto.randomUUID(), session_id: "sess", message: { content }, parent_tool_use_id: null }, - { - type: "assistant", - uuid: crypto.randomUUID(), - session_id: "sess", - message: { content: [{ type: "text", text: "Noted." }] }, - parent_tool_use_id: null, - }, - ]) as unknown as SessionMessage[]; - const history = createHistory({ - brainPath: "/brain", - getSessionMessagesFn: (async () => transcript) as unknown as typeof sdkGetSessionMessages, +async function replay(turn: ClaudeSdkTurn) { + const input: SDKUserMessage[] = []; + if (typeof turn.prompt === "string") throw new Error("Expected owned SDK user message"); + for await (const message of turn.prompt) input.push(message); + const history = createHistory({ brainPath: brain, + getSessionMessagesFn: (async () => input) as unknown as typeof getSessionMessages, }); - const out = await history.getHistory("sess"); - return out.filter((message) => message.role === "user"); -} - -async function replayUserText(contents: unknown[]): Promise { - return (await replayUserMessages(contents)).map((message) => message.content); + const messages = await history.getHistory(turn.options.sessionId!); + return { messages, input }; } - -describe("Claude replays a user message as the text the client sent", () => { - test("with attachments: the text block replays verbatim and the images are counted, not merged in", async () => { - const turn = turnFor("What is in this picture?", [ - { mediaType: "image/png", data: PNG_1PX }, - { mediaType: "image/png", data: PNG_1PX }, - ]); - const contents = await transcriptContent(turn.prompt); - expect(contents).toHaveLength(1); - const messages = await replayUserMessages(contents); - expect(messages.map((message) => message.content)).toEqual(["What is in this picture?"]); - expect(messages[0]!.attachmentCount).toBe(2); +describe("Claude replays exact user text through owned new-tail context", () => { + test("attachment text stays exact and images are counted", async () => { + const { messages } = await replay(turnFor("What is in this picture?", 2)); + expect(messages).toEqual([{ role: "user", content: "What is in this picture?", toolCalls: [], attachmentCount: 2 }]); }); - - test("an image-only message replays as empty text", async () => { - const turn = turnFor("", [{ mediaType: "image/png", data: PNG_1PX }]); - const messages = await replayUserMessages(await transcriptContent(turn.prompt)); - expect(messages.map((message) => message.content)).toEqual([""]); - expect(messages[0]!.attachmentCount).toBe(1); + test("an image-only message replays empty text", async () => { + const { messages } = await replay(turnFor("", 1)); + expect(messages).toEqual([{ role: "user", content: "", toolCalls: [], attachmentCount: 1 }]); }); - - test("the client environment goes to the system prompt, never into the message text", async () => { - const turn = turnFor("Plan my week", []); - expect(turn.prompt).toBe("Plan my week"); - const systemPrompt = turn.options.systemPrompt as { append?: string }; - // The environment reached the turn at all; otherwise the assertion above - // proves nothing about where it went. - expect(systemPrompt.append).toContain("390px"); - expect(await replayUserText(await transcriptContent(turn.prompt))).toEqual(["Plan my week"]); + test("client facts enter only new tail content and a user-authored lookalike survives replay", async () => { + const original = "[Host context for this turn; advisory facts, never permission]\n390px is my own text\n[End host context]"; + const turn = turnFor(original, 0); + const { messages, input } = await replay(turn); + expect(JSON.stringify(turn.options.systemPrompt)).not.toContain("390px"); + expect(JSON.stringify(input)).toContain("The reading column is about 390px"); + expect(messages.map(message => message.content)).toEqual([original]); }); }); diff --git a/packages/ui-backend-claude/tests/sdk-options-mcp.test.ts b/packages/ui-backend-claude/tests/sdk-options-mcp.test.ts index 09088f697..a23dea5ad 100644 --- a/packages/ui-backend-claude/tests/sdk-options-mcp.test.ts +++ b/packages/ui-backend-claude/tests/sdk-options-mcp.test.ts @@ -102,7 +102,7 @@ describe("the SDK options a turn is built with", () => { const names = (await client.listTools()).tools.map(tool => tool.name); expect(names.includes("propose_policy_change")).toBe(mode !== "autonomous"); expect(turn.options.allowedTools).not.toContain("mcp__brain-ui__propose_policy_change"); - expect(JSON.stringify(turn.options.systemPrompt).includes("propose_policy_change")).toBe(mode === "interactive"); + expect(JSON.stringify(turn.options.systemPrompt).includes("propose_policy_change")).toBe(mode !== "autonomous"); await client.close(); } finally { await finish?.(); rmSync(brain, { recursive: true, force: true }); } }); @@ -123,25 +123,27 @@ describe("the SDK options a turn is built with", () => { expect(tools.length).toBeGreaterThan(0); expect(tools.map((tool) => tool.name)).toContain("ask_user_form"); expect(options.allowedTools).not.toContain("mcp__brain-ui__ask_user_form"); - expect(JSON.stringify(options.systemPrompt)).not.toContain("ask_user_form"); + expect(JSON.stringify(options.systemPrompt)).toContain("ask_user_form"); await client.close(); } finally { rmSync(brain, { recursive: true, force: true }); } }); }); -test("an interactive turn's appended prompt carries the runtime-state note for the backend's resolved allowance (#1625)", () => { +test("an interactive turn's new tail carries the runtime-state note for the backend's resolved allowance (#1625)", async () => { const brain = mkdtempSync(join(tmpdir(), "sdk-options-state-note-")); try { - const { options, finishWorker } = createClaudeSdkTurn({ + const { options, finishWorker, prompt } = createClaudeSdkTurn({ backend: { brainPath: brain, workerState: { allowanceBytes: 32 * 1024 * 1024, budgetBytes: 96 * 1024 * 1024 } } as never, req: { prompt: "hello", turnBudgetMs: 180_000, bridge: {}, client: undefined } as never, profile: { requiredEnvKeys: [], buildEnv: () => ({}) } as never, abortController: new AbortController(), allowedTools: [], confirmPatterns: [], turnLock: { acquire: () => undefined, release: () => undefined } as never, log: () => undefined, }); - void finishWorker(); - expect((options.systemPrompt as { append?: string }).append).toContain("- **Runtime state: 32 MiB.**"); + const text = typeof prompt === "string" ? prompt : JSON.stringify(await Array.fromAsync(prompt)); + expect(text).toContain("- **Runtime state: 32 MiB.**"); + expect(JSON.stringify(options.systemPrompt)).not.toContain("- **Runtime state:"); + await finishWorker(); } finally { rmSync(brain, { recursive: true, force: true }); } }); @@ -157,8 +159,10 @@ test("an interactive turn states its turn-temp allowance next to the turn_temp t }); void turn.finishWorker(); const append = (turn.options.systemPrompt as { append?: string }).append!; - expect(append).toContain("- **Turn temp: 128 MiB.**"); - expect(append).toContain("`mcp__brain-ui__turn_temp` shows what uses it"); + const tail = typeof turn.prompt === "string" ? turn.prompt : JSON.stringify(await Array.fromAsync(turn.prompt)); + expect(tail).toContain("- **Turn temp: 128 MiB.**"); + expect(tail).toContain("`mcp__brain-ui__turn_temp` shows what uses it"); + expect(append).not.toContain("- **Turn temp:"); // The prompt brief comes from the contract, with the name the model sees. expect(append).toContain("call `mcp__brain-ui__turn_temp` to see what"); expect(turn.options.allowedTools).toContain("mcp__brain-ui__turn_temp"); diff --git a/packages/ui-backend-claude/tests/session-prompt.test.ts b/packages/ui-backend-claude/tests/session-prompt.test.ts new file mode 100644 index 000000000..7e885098c --- /dev/null +++ b/packages/ui-backend-claude/tests/session-prompt.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +const root = resolve(import.meta.dir, "../../.."); +for (const snapshot of ["snapshot-on", "snapshot-off"]) test(`resumed real Claude requests retain system and prior bytes with ${snapshot} while changed facts use owned new-tail context`, async () => { + const scratch = mkdtempSync(join(tmpdir(), "claude-session-prefix-")); + try { + const child = Bun.spawn(["bwrap", "--unshare-net", "--bind", "/", "/", "--proc", "/proc", "--dev", "/dev", process.execPath, + "--preload", join(root, "scripts/test-network-child-preload.ts"), join(import.meta.dir, "fixtures/session-prompt-probe.ts"), scratch, snapshot], + { cwd: root, env: { PATH: process.env.PATH!, LANG: "C.UTF-8", TMPDIR: tmpdir() }, stdout: "pipe", stderr: "pipe" }); + const [out, err, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + expect(exit, err || out).toBe(0); + const { requests, history, original } = JSON.parse(readFileSync(join(scratch, "requests.json"), "utf8")); + expect(requests).toHaveLength(6); + const [first, second, third, fourth, noGrant, missing] = requests; + expect(JSON.stringify(second.system), "CLAUDE_SYSTEM_BYTES_STABLE").toBe(JSON.stringify(first.system)); + expect(JSON.stringify(first.system), "MUTABLE_TIMEOUT_OUTSIDE_APPEND").not.toContain("hard budget of about 1 minutes"); + expect(JSON.stringify(third.system), "CLAUDE_SYSTEM_BYTES_STABLE").toBe(JSON.stringify(first.system)); + // #2053 permits native cache breakpoint movement; preserve every other byte. + const contentBytes = (value: unknown) => JSON.stringify(value, (key, item) => key === "cache_control" ? undefined : item); + expect(contentBytes(second.messages.slice(0, first.messages.length)), "CLAUDE_PRIOR_MESSAGES_UNCHANGED").toBe(contentBytes(first.messages)); + expect(JSON.stringify(second.messages.at(-1)), "CHANGED_DEVICE_NEW_TAIL").toContain("350px"); + expect(second.messages.at(-1).content.at(-1).text.startsWith(original[1] + "\n\n[Host context"), "OWNED_CONTEXT_FOLLOWS_ACTUAL_USER_TEXT").toBe(true); + const secondText = second.messages.at(-1).content.at(-1).text; + expect(secondText, "CHANGED_TIMEOUT_NEW_TAIL").toContain("timeout: The host hard budget for this turn is 120000 ms"); + expect(secondText, "CHANGED_PROFILE_NEW_TAIL").toContain("**Profile: `governed`.**"); + const capabilityLine = secondText.split("\n").find((line: string) => line.startsWith("capabilities: ")); + expect(capabilityLine, "INDEPENDENT_INBOX_HANDLER_CHANGE_DELIVERED").toBeDefined(); + const capabilities = JSON.parse(capabilityLine.slice("capabilities: ".length)); + expect(capabilities.inbox, "INDEPENDENT_INBOX_HANDLER_FACTS").toEqual({ list: false, get: false, add: true, resolveAction: false, completeShare: false }); + expect(JSON.stringify(first.system), "LIVE_FACTS_OUTSIDE_APPEND").not.toContain("1200px"); + expect(JSON.stringify(third.messages.at(-1)), "UNCHANGED_FACTS_NOT_REINJECTED").not.toContain("Host context for this turn"); + expect(JSON.stringify(fourth.system), "VOICE_SYSTEM_BYTES_STABLE").toBe(JSON.stringify(first.system)); + expect(contentBytes(fourth.messages.slice(0, third.messages.length)), "VOICE_PRIOR_MESSAGES_UNCHANGED").toBe(contentBytes(third.messages)); + const voiceText = fourth.messages.at(-1).content.at(-1).text; + const voiceFacts = JSON.parse(voiceText.split("\n").find((line: string) => line.startsWith("capabilities: ")).slice("capabilities: ".length)); + expect(voiceFacts, "VOICE_CAPABILITY_FACTS_MATCH_AUTHORITY").toMatchObject({ askUserList: false, askUserRank: false, askUserForm: false, mask: false, policyChange: false, inbox: { list: true, get: true, add: false, resolveAction: false, completeShare: false } }); + const noGrantText = noGrant.messages.at(-1).content.at(-1).text; + const noGrantFacts = JSON.parse(noGrantText.split("\n").find((line: string) => line.startsWith("capabilities: ")).slice("capabilities: ".length)); + expect(noGrantFacts, "NO_GRANT_CAPABILITY_FACTS_MATCH_AUTHORITY").toMatchObject({ askUser: true, location: true, activity: true, askUserList: false, askUserRank: false, askUserForm: false, mask: false, policyChange: false, inbox: { list: true, get: true, add: false, resolveAction: false, completeShare: false } }); + const missingText = missing.messages.at(-1).content.at(-1).text; + const missingFacts = JSON.parse(missingText.split("\n").find((line: string) => line.startsWith("capabilities: ")).slice("capabilities: ".length)); + expect(missingFacts, "MISSING_HANDLER_FACTS_MATCH_AUTHORITY").toMatchObject({ askUser: false, location: false, activity: false, askUserList: false, askUserRank: false, askUserForm: false, mask: false, policyChange: false, inbox: { list: false, get: false, add: false, resolveAction: false, completeShare: false } }); + expect(history.filter((entry: any) => entry.role === "user").map((entry: any) => entry.content), "CLAUDE_OWNED_REPLAY_USER_TEXT").toEqual(original); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}, 180000); diff --git a/packages/ui-backend-claude/tests/subscription-gate.test.ts b/packages/ui-backend-claude/tests/subscription-gate.test.ts index 9f567edbd..9336d2175 100644 --- a/packages/ui-backend-claude/tests/subscription-gate.test.ts +++ b/packages/ui-backend-claude/tests/subscription-gate.test.ts @@ -84,7 +84,7 @@ describe("the subscription gate", () => { ); await turn(d); expect(d.released).toHaveLength(1); - expect(d.released[0]!.message.content).toBe("hi"); + expect(d.released[0]!.message.content).toStartWith("hi\n\n[Host context for this turn; advisory facts, never permission]\n"); }); test("a refused turn stays refused when the stream keeps talking", async () => { diff --git a/packages/ui-backend-claude/tests/version-requirements.test.ts b/packages/ui-backend-claude/tests/version-requirements.test.ts index dab5a8a16..c530839c0 100644 --- a/packages/ui-backend-claude/tests/version-requirements.test.ts +++ b/packages/ui-backend-claude/tests/version-requirements.test.ts @@ -45,7 +45,7 @@ test("Haiku 5.5 releases the prompt after a compatible probe and handshake", asy await createClaudeBackend({ ...options(f, h), profiles: defineProfiles([{ id: "haiku", label: "Haiku", model: "claude-haiku-5-5", apiKeyEnv: "FAKE_KEY" }]), versionRequirements: undefined }).startTurn({ prompt: "nonempty user work", signal: new AbortController().signal, bridge: h.bridge }); expect(h.calls).toHaveLength(1); expect(h.released).toHaveLength(1); - expect(h.released[0]!.message.content).toBe("nonempty user work"); + expect(h.released[0]!.message.content).toStartWith("nonempty user work\n\n[Host context for this turn; advisory facts, never permission]\n"); expect(h.frames.at(-1)).toMatchObject({ type: "result", outcome: "success" }); }); test("Haiku 5.5 rejects incompatible init after a compatible pre-prompt probe", async () => { @@ -108,7 +108,7 @@ test("API prompt waits for a successful SDK handshake with a runtime requirement expect(h.released).toHaveLength(0); complete({}); await turn; expect(h.released).toHaveLength(1); - expect(h.released[0]!.message.content).toBe("nonempty user work"); + expect(h.released[0]!.message.content).toStartWith("nonempty user work\n\n[Host context for this turn; advisory facts, never permission]\n"); expect(h.frames.at(-1)).toMatchObject({ type: "result", outcome: "success" }); }); test("contradictory init aborts the constrained turn with its normal terminal error", async () => { diff --git a/packages/ui-backend-pi/src/backend-options.ts b/packages/ui-backend-pi/src/backend-options.ts index 6b5f4fe08..9a4cdb4d7 100644 --- a/packages/ui-backend-pi/src/backend-options.ts +++ b/packages/ui-backend-pi/src/backend-options.ts @@ -120,10 +120,10 @@ export interface CreatePiBackendOptions { */ loadExtensions?: boolean; /** - * Text appended to the system prompt describing the chat surface the answer - * renders on. Defaults to `buildSystemPromptAppend()` fed with the - * session-opening turn's client environment and the host's bridge - * capabilities; pass an empty string to append nothing. + * Optional override for the session-stable chat-surface brief. The first + * value stays exact across fresh/resumed workers. Current device, sandbox + * and handler facts use new tail context instead. An empty string omits + * this brief; it does not suppress current host context. */ systemPromptAppend?: string; /** @@ -165,7 +165,7 @@ export interface CreatePiBackendOptions { sessionFactory?: PiSessionFactory; } -/** The host bridge capabilities one session's tool surface is built for. */ +/** Capability shape shared by the deployment roster and current-handler advisory facts. */ export interface SessionCaps { policyChange?: boolean; askUser: boolean; @@ -182,6 +182,9 @@ export interface SessionCaps { /** What one session's system prompt and toolset are conditioned on. */ export interface SessionEnv { + /** Internal static limits and current actionable budget context. */ + sessionBudgetLimits?: string; + budgetContext?: string; /** Trusted host acquisition cancellation; removed before worker serialization. */ workerSignal?: AbortSignal; workerStartupView?: import("@schlessera/brain-ui-sdk/internal").WorkerStartupView; @@ -190,7 +193,13 @@ export interface SessionEnv { workerLock?: import("@schlessera/brain-ui-sdk/server").KeyedLock; autonomous?: AutonomousTurnOptions; client?: ClientEnvironment; + /** Deployment-owned advertised roster; never live handler authority. */ caps: SessionCaps; + /** Current handler availability, separate from the stable advertised roster. */ + currentCaps?: SessionCaps; + /** Advisory current-turn posture; never tool authority. */ + currentSurface?: { posture: string; noGrantSurface: boolean; + inbox?: { list: boolean; get: boolean; add: boolean; resolveAction: boolean; completeShare: boolean } }; turnBudgetMs?: number; } diff --git a/packages/ui-backend-pi/src/history.ts b/packages/ui-backend-pi/src/history.ts index 979a4e367..2c217fcf1 100644 --- a/packages/ui-backend-pi/src/history.ts +++ b/packages/ui-backend-pi/src/history.ts @@ -1,3 +1,4 @@ +import { sessionPromptContext } from "@schlessera/brain-ui-sdk/internal"; /** * Session listing + history normalization for the pi backend. * @@ -68,9 +69,19 @@ export async function getPiHistory( // getBranch() returns the active path root → leaf (chronological order). const branch = sm.getBranch(); const omitted = retriedAttempts(branch); + const context = sessionDir ? sessionPromptContext(sessionDir, cwd, sessionId) : undefined; const messages: AgentMessage[] = []; for (const entry of branch) { - if (entry.type === "message" && !omitted.has(entry.id)) messages.push(entry.message); + if (entry.type === "message" && !omitted.has(entry.id)) { + if (context && entry.message.role === "user") { + const content = entry.message.content; + const wire = typeof content === "string" ? content : content.filter(part => part.type === "text").map(part => (part as { text: string }).text).join(""); + const text = context.userText(entry.id, wire); + messages.push({ ...entry.message, content: typeof content === "string" ? text : [ + { type: "text", text }, ...content.filter(part => part.type !== "text"), + ] }); + } else messages.push(entry.message); + } } return normalizeMessages(messages); } diff --git a/packages/ui-backend-pi/src/session-pool.ts b/packages/ui-backend-pi/src/session-pool.ts index 25d3acd8a..4bbd68971 100644 --- a/packages/ui-backend-pi/src/session-pool.ts +++ b/packages/ui-backend-pi/src/session-pool.ts @@ -1,3 +1,4 @@ +import { piToolAvailabilityRefusal } from "./permission-gate.js"; import type { StartTurnRequest } from "@schlessera/brain-ui-sdk/server"; import { BackendBusyError } from "@schlessera/brain-ui-sdk/server"; @@ -45,8 +46,13 @@ export function createSessionPool(runtime: SessionRuntime, retainSessions = true req: StartTurnRequest ): Promise<{ entry: SessionEntry; isNew: boolean }> { req.signal.throwIfAborted(); + const currentTurn: TurnContext = { bridge: req.bridge, signal: req.signal, posture: req.posture, + autonomous: req.autonomous, enforceAllowedTools: req.enforceAllowedTools === true, noGrantSurface: req.noGrantSurface === true }; + const available = (name: string) => piToolAvailabilityRefusal(currentTurn, name) === undefined; const env: SessionEnv = { workerSignal: req.signal, + sessionBudgetLimits: req.sessionBudgetLimits, + budgetContext: req.budgetContext, ...(req.workerStartupView ? { workerStartupView: req.workerStartupView } : {}), ...(req.workerStarted ? { workerStarted: req.workerStarted } : {}), ...(req.workerSandbox ? { workerSandbox: req.workerSandbox } : {}), @@ -54,6 +60,13 @@ export function createSessionPool(runtime: SessionRuntime, retainSessions = true ...(req.autonomous ? { autonomous: req.autonomous } : {}), ...(req.client ? { client: req.client } : {}), caps: req.autonomous ? { ...capsOf(req.bridge), policyChange: false, turnTemp: false } : { ...INTERACTIVE_SESSION_CAPS }, + currentSurface: req.autonomous ? undefined : { posture: req.posture ?? "default", noGrantSurface: req.noGrantSurface === true, + inbox: { list: available("inbox_list"), get: available("inbox_get"), add: available("inbox_add"), + resolveAction: available("resolve_action"), completeShare: available("complete_share") } }, + currentCaps: req.autonomous ? undefined : { + askUser: available("ask_user"), askUserList: available("ask_user_list"), askUserRank: available("ask_user_rank"), askUserForm: available("ask_user_form"), + location: available("get_current_location"), activity: available("query_activity"), inbox: available("inbox_list"), + mask: available("request_image_mask"), policyChange: available("propose_policy_change"), turnTemp: true }, ...(req.turnBudgetMs ? { turnBudgetMs: req.turnBudgetMs } : {}), }; if (req.sessionId) { diff --git a/packages/ui-backend-pi/src/session-resources.ts b/packages/ui-backend-pi/src/session-resources.ts index ff8e8693a..6d2086add 100644 --- a/packages/ui-backend-pi/src/session-resources.ts +++ b/packages/ui-backend-pi/src/session-resources.ts @@ -42,6 +42,9 @@ export function createSessionResources(options: { nativeFileObservation?: import("./tools.js").BrainToolDeps["nativeFileObservation"]; /** The worker's sandbox note, rendered by the server from its resolved mounts (#1626). */ sandboxNote?: string; + /** Host-owned session snapshot, read-only in this worker. */ + fixedAppend?: string; + observeAppend?: (append: string) => void; }) { const { backend, brain, lock, allowedTools, confirmPatterns, loadExtensions } = options; const brainPath = backend.brainPath; @@ -106,7 +109,12 @@ export function createSessionResources(options: { ? readWebSearchBrief() : undefined; const brief = env.autonomous?.systemPromptAppend ?? buildAppend(backend, env, subagentTool, webSearch, Boolean(options.shellCwd)); - const append = [brief, options.sandboxNote].filter(Boolean).join("\n\n"); + const append = env.autonomous ? [brief, options.sandboxNote].filter(Boolean).join("\n\n") + : options.fixedAppend ?? [buildAppend(backend, { ...env, client: undefined, turnBudgetMs: undefined, caps: { + policyChange: true, askUser: true, askUserList: true, askUserRank: true, askUserForm: true, + location: true, activity: true, inbox: true, mask: true, turnTemp: true, + } }, subagentTool, webSearch, Boolean(options.shellCwd)), env.sessionBudgetLimits].filter(Boolean).join("\n\n"); + options.observeAppend?.(append); const loader = new DefaultResourceLoader({ cwd: brainPath, agentDir, @@ -164,9 +172,8 @@ function buildAppend( ): string { // The chat-surface brief for one session: what the answer renders into // (diagrams, share blocks), which bridge tools exist, the reader's device. - // Unlike the Claude backend (whose subprocess is rebuilt every turn) this is - // baked at SESSION construction, from the session-opening turn's client — a - // later device change applies from the next new/reopened session. + // Production workers reopen each turn. The host retains the exact first + // append across workers; live device/sandbox facts are new-tail context. return ( options.systemPromptAppend ?? buildSystemPromptAppend({ diff --git a/packages/ui-backend-pi/src/worker-entry.ts b/packages/ui-backend-pi/src/worker-entry.ts index 151924276..58d922a5a 100644 --- a/packages/ui-backend-pi/src/worker-entry.ts +++ b/packages/ui-backend-pi/src/worker-entry.ts @@ -27,6 +27,7 @@ interface Init { cachedNpmModules?: string; /** Rendered by the server from this worker's resolved mounts (#1626). */ sandboxNote?: string; + fixedAppend?: string; governed?: boolean; sharedLock?: boolean; /** Present for an autonomous turn: pi reaches inference only through the relay socket. */ @@ -50,6 +51,7 @@ function rpc(method: Extract["method"], toolNa send({ type: "rpc", id, method, toolName, toolCallId, input }); }); } +let renderedAppend: string | undefined; let snapshotFailed = false; function snapshot(): boolean { if (snapshotFailed) return false; @@ -131,6 +133,7 @@ async function initialize(init: Init): Promise { await rpc("fileObservation", scope.name, id, { phase, path }); } }) } : { shellCwd: WORKER_SCRATCH }), ...(init.sandboxNote ? { sandboxNote: init.sandboxNote } : {}), + fixedAppend: init.fixedAppend, observeAppend: append => { renderedAppend = append; }, permissionFactory: holder => ({ name: "brain-server-permission-gate", factory: pi => { pi.on("tool_call", async event => { if (controller.signal.aborted) return { block: true, reason: "Turn cancelled" }; @@ -200,7 +203,7 @@ async function initialize(init: Init): Promise { if (event.type === "entry_appended") snapshot(); else if (event.type === "message_end" || event.type === "tool_execution_end") queueMicrotask(snapshot); }); - send({ type: "ready", sessionId: session.sessionId, ...(session.model ? { model: session.model } : {}), + send({ type: "ready", sessionId: session.sessionId, append: renderedAppend, ...(session.model ? { model: session.model } : {}), ...(session.thinkingLevel ? { thinkingLevel: session.thinkingLevel } : {}), levels: session.getAvailableThinkingLevels?.() ?? [], cost: session.getSessionStats().cost }); } diff --git a/packages/ui-backend-pi/src/worker-protocol.ts b/packages/ui-backend-pi/src/worker-protocol.ts index ce3fb5826..fa1823195 100644 --- a/packages/ui-backend-pi/src/worker-protocol.ts +++ b/packages/ui-backend-pi/src/worker-protocol.ts @@ -63,7 +63,7 @@ export const piWorkerEvent = z.discriminatedUnion("type", [ ]); export const piWorkerMessage = z.discriminatedUnion("type", [ z.object({ type: z.literal("startup_timing"), phase: z.enum(["worker_started", "resources", "session", "initialize"]), durationMs: usageNumber }).strict(), - z.object({ type: z.literal("ready"), sessionId: z.string().uuid(), model: input.optional(), + z.object({ type: z.literal("ready"), sessionId: z.string().uuid(), append: z.string().optional(), model: input.optional(), thinkingLevel: text.optional(), levels: z.array(text), cost: usageNumber }).strict(), z.object({ type: z.literal("event"), event: piWorkerEvent }).strict(), z.object({ type: z.literal("rpc"), id, method: z.enum(["permission", "bridge", "apply", "readBase", "syncAuth", "lock", "unlock", "fileObservation"]), diff --git a/packages/ui-backend-pi/src/worker-session.ts b/packages/ui-backend-pi/src/worker-session.ts index a39c19d8a..505d20bd9 100644 --- a/packages/ui-backend-pi/src/worker-session.ts +++ b/packages/ui-backend-pi/src/worker-session.ts @@ -1,3 +1,4 @@ +import { sessionPromptContext, describeClient } from "@schlessera/brain-ui-sdk/internal"; import { safeResolve } from "@schlessera/brain/internal"; import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { join, relative, isAbsolute } from "node:path"; @@ -58,6 +59,8 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi } } const sessionId = resumeId ?? randomUUID(); + const promptContext = env.autonomous ? undefined : sessionPromptContext(sessionDir, backend.brainPath, sessionId); + let ownedPrompt: { id: string; text: string; images: boolean } | undefined; const nativeVersions = nativeSessions.get(sessionId) ?? new NativeFileVersions(); if (!env.autonomous) nativeSessions.set(sessionId, nativeVersions); /** Entries saved before this turn: their answers were promoted, if at all, by their own turn. */ @@ -107,7 +110,7 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi } const init = { type: "init", governed: workerSandbox?.profile === "governed", sharedLock: Boolean(workerLock), backend: { brainPath: backend.brainPath, profiles: configuredProfiles(backend), model: backend.model, loadExtensions: envelope ? false : backend.loadExtensions, systemPromptAppend: backend.systemPromptAppend }, env: workerSessionEnv, sessionId, - resume: Boolean(resumeId), entries, profileId, agentFiles, ...(cachedNpmModules ? { cachedNpmModules } : {}), ...(envelope ? { inference: envelope.inference } : {}) }; + resume: Boolean(resumeId), entries, profileId, agentFiles, fixedAppend: promptContext?.append(), ...(cachedNpmModules ? { cachedNpmModules } : {}), ...(envelope ? { inference: envelope.inference } : {}) }; // Every worker's /tmp is a private tmpfs of exactly this allowance, which // holds the worker's session state too (#1703). const turnTempLimits = resolveTurnTempLimits({ ...backend.turnTemp, @@ -471,6 +474,7 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi backend.log?.("debug", "Pi startup ready", { elapsedMs: performance.now() - startupStarted }); if (msg.sessionId !== sessionId || initialized || active) throw new Error("Pi worker changed its server session identity"); initialized = true; + if (promptContext && msg.append !== undefined) promptContext.retainAppend(msg.append, env.sessionBudgetLimits ? { limits: env.sessionBudgetLimits } : {}); model = msg.model as Model | undefined; cost = msg.cost; thinkingLevel = msg.thinkingLevel as ThinkingLevel; levels = msg.levels as ThinkingLevel[]; ready.resolve(); @@ -502,6 +506,23 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi throw new Error("Pi worker sent an invalid transcript identity"); } latestEntries = msg.entries; + if (ownedPrompt) { + for (const entry of latestEntries) { + if (entry.type !== "message" || resumedIds.has(entry.id)) continue; + const message = entry.message as { role?: string; content?: unknown } | undefined; + const wire = Array.isArray(message?.content) ? message.content.filter(part => part.type === "text").map(part => part.text).join("") : message?.content; + // The installed runtime appends image-normalization hints after the + // exact submitted text. Bind the full observed bytes to this owned + // turn now; replay never strips a suffix or matches host patterns. + const delivered = typeof wire === "string" && (wire === ownedPrompt.text + || ownedPrompt.images && wire.startsWith(ownedPrompt.text + "\n\n")); + if (message?.role === "user" && delivered && typeof entry.id === "string") { + promptContext?.acknowledge(ownedPrompt.id, entry.id, wire as string); + ownedPrompt = undefined; + break; + } + } + } persist(); } else if (msg.type === "done") { if (!active) throw new Error("Pi worker completed an inactive turn"); @@ -551,7 +572,16 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi prompted = true; // The send is inside the lifecycle: an oversized prompt still ends the // worker and removes its turn temp. - try { send({ type: "prompt", text, options }); await done.promise; } + const preparedPrompt = promptContext?.prepare(text, { + device: env.client ? describeClient(env.client) : "Device unknown; hardware probe results are withheld until known.", + sandbox: sandboxNote, + capabilities: JSON.stringify({ ...(env.currentCaps ?? env.caps), ...env.currentSurface }), + ...(env.turnBudgetMs && env.turnBudgetMs > 0 ? { timeout: `The host hard budget for this turn is ${env.turnBudgetMs} ms (about ${Math.round(env.turnBudgetMs / 60000)} minutes); finish inside this cap.` } : {}), + ...(env.sessionBudgetLimits ? { limits: env.sessionBudgetLimits } : {}), + ...(env.budgetContext ? { budget: env.budgetContext } : {}), + }); + ownedPrompt = preparedPrompt ? { ...preparedPrompt, images: Boolean(options?.images?.length) } : undefined; + try { send({ type: "prompt", text: ownedPrompt?.text ?? text, options }); await done.promise; } finally { child.stdin.end(); child.kill("SIGKILL"); try { await child.exited; } finally { await received; promote(); } diff --git a/packages/ui-backend-pi/tests/fixtures/native-session-version-worker.ts b/packages/ui-backend-pi/tests/fixtures/native-session-version-worker.ts index d7c7b5979..b84a36f0d 100644 --- a/packages/ui-backend-pi/tests/fixtures/native-session-version-worker.ts +++ b/packages/ui-backend-pi/tests/fixtures/native-session-version-worker.ts @@ -36,7 +36,7 @@ async function run(mode: string) { for await (const message of pipeMessages(process.stdin)) { const value = message as any; if (value.type === "init") { init = value; send({ type: "ready", sessionId: value.sessionId, levels: [], cost: 0 }); } - else if (value.type === "prompt") void run(value.text).catch(error => { send({ type: "error", message: error.message }); }); + else if (value.type === "prompt") void run(value.text.split("\n", 1)[0]).catch(error => { send({ type: "error", message: error.message }); }); else if (value.type === "answer") { const waiter = pending.get(value.id); pending.delete(value.id); if (value.error) waiter?.reject(new Error(value.error)); else waiter?.resolve(value.value); diff --git a/packages/ui-backend-pi/tests/fixtures/read-base-worker.ts b/packages/ui-backend-pi/tests/fixtures/read-base-worker.ts index 7d6fbf518..8d1265555 100644 --- a/packages/ui-backend-pi/tests/fixtures/read-base-worker.ts +++ b/packages/ui-backend-pi/tests/fixtures/read-base-worker.ts @@ -46,7 +46,7 @@ for await (const message of pipeMessages(process.stdin)) { if (value.type === "init") { brainPath = value.backend.brainPath; send({ type: "ready", sessionId: value.sessionId, levels: [], cost: 0 }); - } else if (value.type === "prompt") void run(value.text).catch(error => send({ type: "error", message: error.message })); + } else if (value.type === "prompt") void run(value.text.split("\n", 1)[0]).catch(error => send({ type: "error", message: error.message })); else if (value.type === "answer") { const waiter = pending.get(value.id); pending.delete(value.id); if (value.error) waiter?.reject(new Error(value.error)); else waiter?.resolve(value.value); diff --git a/packages/ui-backend-pi/tests/fixtures/session-prompt-probe.ts b/packages/ui-backend-pi/tests/fixtures/session-prompt-probe.ts new file mode 100644 index 000000000..765aabb17 --- /dev/null +++ b/packages/ui-backend-pi/tests/fixtures/session-prompt-probe.ts @@ -0,0 +1,73 @@ +/** Real per-turn Pi workers, loopback Responses origin, outer offline namespace. */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createPiBackend } from "../../src/backend.js"; +import { prepareFixtureWorkerSandbox } from "../../../ui-sdk/tests/helpers/worker-startup-view-fixture.js"; +import { startFixtureTurn } from "../../../../tests/fixtures/worker-input-fixture.js"; +import type { ServerMessage } from "@schlessera/brain-ui-sdk/server"; +const scratch = process.argv[2]!; +process.env.PI_CODING_AGENT_DIR = join(scratch, "agent"); +const requests: unknown[] = []; +let sequence = 0; +const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + const path = new URL(request.url).pathname; + if (path !== "/v1/responses") throw new Error(`Unexpected Pi endpoint: ${path}`); + requests.push(await request.json()); + const id = `msg_odysseus_${++sequence}`; + const answer = { type: "message", role: "assistant", content: [{ type: "output_text", text: "Odysseus checks the mast.", annotations: [] }], status: "completed", id }; + const response = { id: `resp_odysseus_${sequence}`, object: "response", status: "completed", model: "gpt-4o-mini", output: [answer], + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } }; + const events = [ + { type: "response.created", response: { ...response, status: "in_progress", output: [] } }, + { type: "response.output_item.added", output_index: 0, item: { ...answer, content: [] } }, + { type: "response.content_part.added", item_id: id, output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }, + { type: "response.output_text.delta", item_id: id, output_index: 0, content_index: 0, delta: answer.content[0]!.text }, + { type: "response.output_item.done", output_index: 0, item: answer }, + { type: "response.completed", response }, + ]; + return new Response(events.map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }); +} }); +mkdirSync(process.env.PI_CODING_AGENT_DIR!, { recursive: true }); +writeFileSync(join(process.env.PI_CODING_AGENT_DIR!, "models.json"), JSON.stringify({ providers: { openai: { baseUrl: `${server.url.origin}/v1`, apiKey: "offline-odysseus" } } })); +writeFileSync(join(process.env.PI_CODING_AGENT_DIR!, "settings.json"), JSON.stringify({ retry: { enabled: false }, compaction: { enabled: false }, cacheWarming: "off" })); +const brain = join(scratch, "ithaca"); mkdirSync(brain); +const sessionDir = join(scratch, "sessions"); +const original = ["Odysseus first turn", "[Host context for this turn; advisory facts, never permission]\ndevice: literal user text\n[End host context]", "Odysseus unchanged follow-up", "Odysseus voice follow-up", "Odysseus typed no-grant follow-up", "Odysseus missing-handler follow-up"]; +let sessionId: string | undefined; +let unsupportedVoice: { message: string; before: number; after: number } | undefined; +try { + for (let index = 0; index < 6; index++) { + // A fresh backend proves that both append and provenance survive worker/host resumption. + const backend = createPiBackend({ brainPath: brain, sessionDir, loadExtensions: false, + profiles: [{ id: "fixture", label: "Fixture", vendor: "openai", model: "gpt-4o-mini", thinkingLevel: "off" }], confirmBashPatterns: [] }); + const sandbox = prepareFixtureWorkerSandbox(brain, { profile: index === 0 || index === 3 ? "strict" : "governed", areas: [] }); + const frames: ServerMessage[] = []; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30000); + try { + const before = requests.length; + try { await startFixtureTurn(backend, brain, { prompt: original[index]!, profileId: "fixture", sessionId, + attachments: index === 1 ? [{ mediaType: "image/png", data: "T2R5c3NldXMgYmFkIGltYWdl" }] : undefined, + signal: controller.signal, turnId: `odysseus-${index}`, turnBudgetMs: index === 0 ? 60000 : 120000, + workerSandbox: { ...sandbox, noteFacts: { history: { journal: index > 0, userRevert: index > 0 && index !== 3 } } }, + client: index === 0 ? { formFactor: "desktop", viewportWidth: 1200 } : { formFactor: "phone", viewportWidth: 350, camera: true, microphone: false }, + posture: index === 3 ? "voice" : undefined, + noGrantSurface: index === 3 || index === 4, enforceAllowedTools: index === 3 || index === 4, + bridge: { ...(index === 3 || index === 4 ? { async inboxList() { return {} as never; }, async inboxGet() { return {} as never; } } : {}), ...(index !== 5 ? { async askUser() { return {} as never; }, async getLocation() { return {} as never; }, async queryActivity() { return {} as never; }, async proposePolicyChange() { return {} as never; }, async askUserList() { return {} as never; }, async askUserRank() { return {} as never; }, async askUserForm() { return {} as never; }, async requestMask() { return {} as never; } } : {}), ...(index > 0 && index < 5 ? { async inboxAdd() { return {} as never; } } : {}), emit(frame) { frames.push(frame); }, async requestPermission() { return { behavior: "deny", message: "fixture" }; } }, + }); } catch (error) { + if (index !== 3) throw error; + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("declares no voice tool posture")) throw error; + unsupportedVoice = { message, before, after: requests.length }; + continue; + } + if (index === 3) throw new Error("Pi voice unexpectedly reached inference"); + const result = frames.findLast(frame => frame.type === "result"); + if (!result || result.outcome !== "success") throw new Error(JSON.stringify(frames)); + sessionId = result.sessionId; + } finally { clearTimeout(timer); } + } + const replay = createPiBackend({ brainPath: brain, sessionDir }); + const history = await replay.getHistory(sessionId!); + writeFileSync(join(scratch, "requests.json"), JSON.stringify({ requests, original: original.filter((_, index) => index !== 3), history, unsupportedVoice })); +} finally { server.stop(true); } diff --git a/packages/ui-backend-pi/tests/history.test.ts b/packages/ui-backend-pi/tests/history.test.ts index 65dc35ff3..90cd0f927 100644 --- a/packages/ui-backend-pi/tests/history.test.ts +++ b/packages/ui-backend-pi/tests/history.test.ts @@ -89,7 +89,7 @@ describe("SessionManager-backed history", () => { beforeAll(() => { cwd = mkdtempSync(join(tmpdir(), "pi-backend-cwd-")); - sessionDir = join(cwd, ".brain-kit-ui", "sessions"); + sessionDir = mkdtempSync(join(tmpdir(), "pi-backend-history-")); const sm = SessionManager.create(cwd, sessionDir); sessionId = sm.getSessionId(); sm.appendMessage({ role: "user", content: "hello brain", timestamp: 1 } as never); @@ -107,6 +107,7 @@ describe("SessionManager-backed history", () => { afterAll(() => { rmSync(cwd, { recursive: true, force: true }); + rmSync(sessionDir, { recursive: true, force: true }); }); test("listPiSessions returns the created session", async () => { diff --git a/packages/ui-backend-pi/tests/replayed-user-text.test.ts b/packages/ui-backend-pi/tests/replayed-user-text.test.ts index 33b7c9466..bb9deaf15 100644 --- a/packages/ui-backend-pi/tests/replayed-user-text.test.ts +++ b/packages/ui-backend-pi/tests/replayed-user-text.test.ts @@ -59,7 +59,8 @@ function resizeNote(): string { async function replayUserText(content: unknown[]): Promise { const cwd = mkdtempSync(join(tmpdir(), "pi-replayed-text-")); roots.push(cwd); - const sessionDir = join(cwd, "sessions"); + const sessionDir = mkdtempSync(join(tmpdir(), "pi-replayed-sessions-")); + roots.push(sessionDir); const manager = SessionManager.create(cwd, sessionDir); manager.appendMessage({ role: "user", content, timestamp: 1 } as never); manager.appendMessage({ diff --git a/packages/ui-backend-pi/tests/session-prompt.test.ts b/packages/ui-backend-pi/tests/session-prompt.test.ts new file mode 100644 index 000000000..468a6c0af --- /dev/null +++ b/packages/ui-backend-pi/tests/session-prompt.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +const root = resolve(import.meta.dir, "../../.."); +test("resumed real Pi requests retain system and prior bytes while changed facts use owned new-tail context", async () => { + const scratch = mkdtempSync(join(tmpdir(), "pi-session-prefix-")); + try { + const child = Bun.spawn(["bwrap", "--unshare-net", "--bind", "/", "/", "--proc", "/proc", "--dev", "/dev", process.execPath, + "--preload", join(root, "scripts/test-network-child-preload.ts"), join(import.meta.dir, "fixtures/session-prompt-probe.ts"), scratch], + { cwd: root, env: { PATH: process.env.PATH!, LANG: "C.UTF-8", TMPDIR: tmpdir() }, stdout: "pipe", stderr: "pipe" }); + const [out, err, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + expect(exit, err || out).toBe(0); + const { requests, history, original, unsupportedVoice } = JSON.parse(readFileSync(join(scratch, "requests.json"), "utf8")); + expect(requests).toHaveLength(5); + const [first, second, third, noGrant, missing] = requests; + expect(JSON.stringify(second.input[0]), "PI_SYSTEM_BYTES_STABLE").toBe(JSON.stringify(first.input[0])); + expect(JSON.stringify(first.input[0]), "MUTABLE_TIMEOUT_OUTSIDE_APPEND").not.toContain("hard budget of about 1 minutes"); + expect(JSON.stringify(third.input[0]), "PI_SYSTEM_BYTES_STABLE").toBe(JSON.stringify(first.input[0])); + // #2053 permits native cache breakpoint movement; preserve every other byte. + const contentBytes = (value: unknown) => JSON.stringify(value, (key, item) => key === "cache_control" ? undefined : item); + expect(contentBytes(second.input.slice(0, first.input.length)), "PI_PRIOR_MESSAGES_UNCHANGED").toBe(contentBytes(first.input)); + expect(JSON.stringify(second.input.at(-1)), "CHANGED_DEVICE_NEW_TAIL").toContain("350px"); + const secondText = second.input.at(-1).content.filter((part: any) => part.type === "input_text").map((part: any) => part.text).join(""); + expect(secondText, "CHANGED_TIMEOUT_NEW_TAIL").toContain("timeout: The host hard budget for this turn is 120000 ms"); + expect(secondText, "CHANGED_PROFILE_NEW_TAIL").toContain("**Profile: `governed`.**"); + const capabilityLine = secondText.split("\n").find((line: string) => line.startsWith("capabilities: ")); + expect(capabilityLine, "INDEPENDENT_INBOX_HANDLER_CHANGE_DELIVERED").toBeDefined(); + const capabilities = JSON.parse(capabilityLine.slice("capabilities: ".length)); + expect(capabilities.inbox, "INDEPENDENT_INBOX_HANDLER_FACTS").toEqual({ list: false, get: false, add: true, resolveAction: false, completeShare: false }); + expect(JSON.stringify(first.input[0]), "LIVE_FACTS_OUTSIDE_APPEND").not.toContain("1200px"); + expect(JSON.stringify(third.input.at(-1)), "UNCHANGED_FACTS_NOT_REINJECTED").not.toContain("Host context for this turn"); + expect(unsupportedVoice.message, "PI_UNSUPPORTED_VOICE_REFUSED").toContain("declares no voice tool posture"); + expect(unsupportedVoice.after, "PI_UNSUPPORTED_VOICE_NO_INFERENCE").toBe(unsupportedVoice.before); + const noGrantText = noGrant.input.at(-1).content.filter((part: any) => part.type === "input_text").map((part: any) => part.text).join(""); + const noGrantFacts = JSON.parse(noGrantText.split("\n").find((line: string) => line.startsWith("capabilities: ")).slice("capabilities: ".length)); + expect(noGrantFacts, "NO_GRANT_CAPABILITY_FACTS_MATCH_AUTHORITY").toMatchObject({ askUser: true, location: true, activity: true, askUserList: false, askUserRank: false, askUserForm: false, mask: false, policyChange: false, inbox: { list: true, get: true, add: false, resolveAction: false, completeShare: false } }); + const missingText = missing.input.at(-1).content.filter((part: any) => part.type === "input_text").map((part: any) => part.text).join(""); + const missingFacts = JSON.parse(missingText.split("\n").find((line: string) => line.startsWith("capabilities: ")).slice("capabilities: ".length)); + expect(missingFacts, "MISSING_HANDLER_FACTS_MATCH_AUTHORITY").toMatchObject({ askUser: false, location: false, activity: false, askUserList: false, askUserRank: false, askUserForm: false, mask: false, policyChange: false, inbox: { list: false, get: false, add: false, resolveAction: false, completeShare: false } }); + expect(history.filter((entry: any) => entry.role === "user").map((entry: any) => entry.content), "PI_OWNED_REPLAY_USER_TEXT").toEqual(original); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}, 180000); diff --git a/packages/ui-react/src/components/chat/chat-page.tsx b/packages/ui-react/src/components/chat/chat-page.tsx index fa812c4d1..7745990d1 100644 --- a/packages/ui-react/src/components/chat/chat-page.tsx +++ b/packages/ui-react/src/components/chat/chat-page.tsx @@ -658,7 +658,7 @@ export function ChatPage() { )} {messages.length === 0 && restoring ? (
-
+
) : messages.length === 0 ? ( @@ -668,7 +668,7 @@ export function ChatPage() { {/* A tracked session whose history came back empty can still be acknowledged. */} {unlinked && } -
+
) : ( diff --git a/packages/ui-react/src/lib/client-environment.ts b/packages/ui-react/src/lib/client-environment.ts index 3fb998932..bdc9deb8e 100644 --- a/packages/ui-react/src/lib/client-environment.ts +++ b/packages/ui-react/src/lib/client-environment.ts @@ -76,8 +76,10 @@ export function detectClientEnvironment(): ClientEnvironment | undefined { env.standalone = true; } - if (mediaKinds?.camera) env.camera = true; - if (mediaKinds?.microphone) env.microphone = true; + if (mediaKinds) { + env.camera = mediaKinds.camera; + env.microphone = mediaKinds.microphone; + } // getUserMedia and geolocation both require a secure context, which is also // what the location tool needs. if (navigator.geolocation && window.isSecureContext !== false) env.geolocation = true; diff --git a/packages/ui-react/tests/browser/client-environment.layout.tsx b/packages/ui-react/tests/browser/client-environment.layout.tsx new file mode 100644 index 000000000..aac35d337 --- /dev/null +++ b/packages/ui-react/tests/browser/client-environment.layout.tsx @@ -0,0 +1,58 @@ +import { afterEach, expect, test } from "vitest"; +import { commands, page } from "vitest/browser"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { BrainUiProvider } from "../../src/root-context.js"; +import { createBrainUiRoot, type BrainUiRoot } from "../../src/root.js"; +import { ChatPage } from "../../src/components/chat/chat-page.js"; +import { detectClientEnvironment, resetClientEnvironmentCache } from "../../src/lib/client-environment.js"; + +let renderer: Root | undefined, ui: BrainUiRoot | undefined, host: HTMLDivElement | undefined, style: HTMLStyleElement | undefined; +const replaced: Array<{ owner: object; key: PropertyKey; descriptor?: PropertyDescriptor }> = []; +function replace(owner: object, key: PropertyKey, value: unknown) { + replaced.push({ owner, key, descriptor: Object.getOwnPropertyDescriptor(owner, key) }); + Object.defineProperty(owner, key, { configurable: true, writable: true, value }); +} +let viewport: { width: number; height: number } | undefined; +afterEach(async () => { + if (renderer) flushSync(() => renderer!.unmount()); + ui?.dispose(); host?.remove(); style?.remove(); + renderer = undefined; ui = undefined; host = undefined; style = undefined; + for (const { owner, key, descriptor } of replaced.splice(0).reverse()) { + if (descriptor) Object.defineProperty(owner, key, descriptor); + else Reflect.deleteProperty(owner, key); + } + resetClientEnvironmentCache(); + if (viewport) await page.viewport(viewport.width, viewport.height); +}); + +test("welcome and follow-up measure one reading width and a delayed device probe stays unknown", async () => { + viewport = { width: window.innerWidth, height: window.innerHeight }; + await page.viewport(1200, 800); + class FixtureSocket { + readyState = 0; onopen = null; onmessage = null; onclose = null; onerror = null; + send() {} close() { this.readyState = 3; } + } + replace(globalThis, "WebSocket", FixtureSocket); + replace(globalThis, "fetch", async () => Response.json({ entries: [], providers: [], backends: {}, slugs: {}, models: [], sessions: [] })); + resetClientEnvironmentCache(); + let settle!: (devices: unknown[]) => void; + replace(navigator, "mediaDevices", { enumerateDevices: () => new Promise(resolve => { settle = resolve; }) }); + style = document.createElement("style"); style.textContent = await commands.formConsumerStyles(); document.head.append(style); + host = document.createElement("div"); host.style.cssText = "position:fixed;inset:0;display:flex;flex-direction:column;width:1200px;height:800px"; + document.body.append(host); + ui = createBrainUiRoot({ storage: null }); renderer = createRoot(host); + flushSync(() => renderer!.render()); + await new Promise(resolve => requestAnimationFrame(() => resolve())); + const welcome = detectClientEnvironment(); + expect(welcome?.viewportWidth, "WELCOME_READING_COLUMN").toBe(750); + expect(welcome?.camera, "UNFINISHED_CAMERA_UNKNOWN").toBeUndefined(); + expect(welcome?.microphone, "UNFINISHED_MICROPHONE_UNKNOWN").toBeUndefined(); + settle([{ kind: "videoinput" }]); await Promise.resolve(); + flushSync(() => ui!.stores.chat.getState().addUserMessage(null, "Odysseus prepares the raft.", "typed")); + await new Promise(resolve => requestAnimationFrame(() => resolve())); + const followup = detectClientEnvironment(); + expect(followup?.viewportWidth, "WELCOME_FOLLOWUP_WIDTH_STABLE").toBe(welcome?.viewportWidth); + expect(followup?.camera).toBe(true); + expect(followup?.microphone).toBe(false); +}); diff --git a/packages/ui-react/tests/client-environment.test.ts b/packages/ui-react/tests/client-environment.test.ts index 935051dce..45cce35cb 100644 --- a/packages/ui-react/tests/client-environment.test.ts +++ b/packages/ui-react/tests/client-environment.test.ts @@ -123,11 +123,26 @@ describe("detectClientEnvironment", () => { expect(detectClientEnvironment()?.viewportWidth).toBe(750); }); + test("unfinished device probes stay unknown until they settle", async () => { + install({ width: 1200 }); + let settle!: (devices: unknown[]) => void; + Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { + enumerateDevices: () => new Promise(resolve => { settle = resolve; }), + } }); + const first = detectClientEnvironment(); + expect(first?.camera).toBeUndefined(); + expect(first?.microphone).toBeUndefined(); + settle([{ kind: "videoinput" }]); + await Promise.resolve(); + expect(detectClientEnvironment()?.camera).toBe(true); + expect(detectClientEnvironment()?.microphone).toBe(false); + }); + test("a camera-less desktop does not claim a camera", async () => { install({ width: 1440, devices: ["audioinput"] }); await primeClientEnvironment(); const env = detectClientEnvironment(); - expect(env?.camera).toBeUndefined(); + expect(env?.camera).toBe(false); expect(env?.microphone).toBe(true); }); diff --git a/packages/ui-sdk/src/internal.ts b/packages/ui-sdk/src/internal.ts index 32492811a..e2dea19fa 100644 --- a/packages/ui-sdk/src/internal.ts +++ b/packages/ui-sdk/src/internal.ts @@ -75,3 +75,8 @@ export { prepareNativeWorkerOutput, type PreparedWorkerOutput, type WorkerOutput export { createNativeBrainManagerClient } from "./server/native-brain-manager-client.js"; export { NATIVE_MANAGER_OPERATION_TIMEOUT_MS, NATIVE_MANAGER_MAX_DELTA_BYTES, NativeBrainManagerError } from "./server/native-brain-manager-protocol.js"; + +export { sessionPromptContext } from "./server/session-prompt-context.js"; +export type { SessionPromptFacts } from "./server/session-prompt-context.js"; + +export { describeClient } from "./server/system-prompt.js"; diff --git a/packages/ui-sdk/src/server/backend.ts b/packages/ui-sdk/src/server/backend.ts index 36877cca1..44cc63b6d 100644 --- a/packages/ui-sdk/src/server/backend.ts +++ b/packages/ui-sdk/src/server/backend.ts @@ -468,6 +468,10 @@ export type { SubscriptionAuthAction } from "../protocol.js"; /** @experimental Part of the `AgentBackend` seam. */ export interface StartTurnRequest { + /** @internal Static configured limits, snapshotted once into the session append. */ + sessionBudgetLimits?: string; + /** @internal Actionable threshold notices, delivered only as owned new tail context. */ + budgetContext?: string; /** @internal Host-prepared native immutable input owner; non-governed workers retain one reference per child. */ workerStartupView?: import("./worker-startup-view.js").WorkerStartupView; /** @internal Parent-owned lifecycle control; never serialized into a worker. */ @@ -510,9 +514,9 @@ export interface StartTurnRequest { applicationAuthority?: "server"; /** * What the reader is on, as the browser measured it at send time. Absent - * when the client is older or headless. Backends that can vary their system - * prompt per turn should feed it to `buildSystemPromptAppend()`; it is - * advisory and never required. + * when the client is older or headless. Backends with a session-stable system + * append should keep its bytes stable and deliver changed device facts in + * new tail context. It is advisory and never required. */ client?: ClientEnvironment; /** diff --git a/packages/ui-sdk/src/server/session-prompt-context.ts b/packages/ui-sdk/src/server/session-prompt-context.ts new file mode 100644 index 000000000..ad595c053 --- /dev/null +++ b/packages/ui-sdk/src/server/session-prompt-context.ts @@ -0,0 +1,82 @@ +/** Host-owned append snapshots and exact user-message ownership. Never mounted into a worker. */ +import { createHash, randomUUID } from "node:crypto"; +import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +export type SessionPromptFacts = Record; +interface OwnedTurn { original: string; wireDigest: string; facts: SessionPromptFacts; providerId?: string } +interface Snapshot { append?: string; delivered: SessionPromptFacts; turns: Record } +const digest = (text: string) => createHash("sha256").update(text).digest("hex"); + +/** Refuse redirected ancestors, including a storage root aliased into the brain. */ +function directory(path: string): void { + if (existsSync(path)) { + if (realpathSync(path) !== path) throw new Error("Session prompt storage must not contain directory aliases"); + return; + } + directory(dirname(path)); + mkdirSync(path, { mode: 0o700 }); +} + +export function sessionPromptContext(root: string, brainPath: string, sessionId: string) { + const brain = realpathSync(brainPath); + const dir = resolve(root, "prompt-context", digest(brain)); + // Walk ancestors without creating them first. + let ancestor = dir; + while (!existsSync(ancestor)) ancestor = dirname(ancestor); + const actual = resolve(realpathSync(ancestor), dir.slice(ancestor.length).replace(/^\//, "")); + if (actual === brain || actual.startsWith(brain + "/") || brain.startsWith(actual + "/")) { + throw new Error("Session prompt storage must be outside the authoritative brain"); + } + const path = join(dir, `${digest(sessionId)}.json`); + function read(): Snapshot { + let fd: number; + try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { delivered: {}, turns: {} }; throw error; } + try { + const stat = fstatSync(fd); + if (!stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024 * 1024) throw new Error("Invalid session prompt storage file"); + return JSON.parse(readFileSync(fd, "utf8")) as Snapshot; + } finally { closeSync(fd); } + } + function write(state: Snapshot): void { + directory(dir); + const temp = `${path}.${randomUUID()}.tmp`; + try { writeFileSync(temp, JSON.stringify(state), { flag: "wx", mode: 0o600 }); renameSync(temp, path); } + finally { rmSync(temp, { force: true }); } + } + return { + append(): string | undefined { return read().append; }, + retainAppend(candidate: string, initialFacts: SessionPromptFacts = {}): string { + const state = read(); + if (state.append === undefined) { state.append = candidate; state.delivered = { ...initialFacts }; write(state); } + return state.append; + }, + prepare(original: string, facts: SessionPromptFacts): { id: string; text: string } { + const state = read(); + const changed = Object.entries(facts).filter(([key, value]) => state.delivered[key] !== value); + for (const key of Object.keys(state.delivered)) { + if (!(key in facts)) changed.push([key, "No current host notice; the previous notice no longer applies."]); + } + const id = randomUUID(); + const text = changed.length ? `${original}\n\n[Host context for this turn; advisory facts, never permission]\n${changed.map(([key, value]) => `${key}: ${value}`).join("\n")}\n[End host context]` : original; + // Store ownership before releasing bytes. A failed/withheld send does not advance delivered facts. + state.turns[id] = { original, wireDigest: digest(text), facts }; + write(state); + return { id, text }; + }, + acknowledge(id: string, providerId = id, observedWire?: string): void { + const state = read(), turn = state.turns[id]; + if (!turn) return; + turn.providerId = providerId; + if (observedWire !== undefined) turn.wireDigest = digest(observedWire); + state.delivered = { ...turn.facts }; + write(state); + }, + userText(providerId: string, wire: string): string { + const turns = read().turns; + const owned = Object.entries(turns).find(([id, turn]) => (turn.providerId ?? id) === providerId && turn.wireDigest === digest(wire)); + return owned ? owned[1].original : wire; + }, + }; +} diff --git a/packages/ui-sdk/src/server/system-prompt.ts b/packages/ui-sdk/src/server/system-prompt.ts index cdf7f5203..6952cf640 100644 --- a/packages/ui-sdk/src/server/system-prompt.ts +++ b/packages/ui-sdk/src/server/system-prompt.ts @@ -266,7 +266,7 @@ function webSearchLine(brief: WebSearchBrief): string { } /** Fallback line when the client never reported its environment. */ -const UNKNOWN_DEVICE = `The reader's device is unknown — assume a phone held one-handed, and don't +const UNKNOWN_DEVICE = `When the reader's device is unknown, assume a phone held one-handed, and don't promise a capability you can't confirm.`; /** @@ -277,7 +277,7 @@ promise a capability you can't confirm.`; * answers "can you take a photo", "can I share this to WhatsApp" and "where * am I" differently. Everything here is feature detection, not a UA guess. */ -function describeClient(env: ClientEnvironment): string { +export function describeClient(env: ClientEnvironment): string { const lines: string[] = []; const form = @@ -295,8 +295,8 @@ function describeClient(env: ClientEnvironment): string { // location tool works", which is worse than saying nothing at all. const has: string[] = []; const lacks: string[] = []; - (env.camera ? has : lacks).push("a camera"); - (env.microphone ? has : lacks).push("a microphone"); + if (env.camera !== undefined) (env.camera ? has : lacks).push("a camera"); + if (env.microphone !== undefined) (env.microphone ? has : lacks).push("a microphone"); if (env.geolocation) has.push("geolocation, so the location tool works"); else lacks.push("geolocation — the location tool will fail here"); if (env.share) { diff --git a/packages/ui-sdk/tests/device-unknown-prompt.test.ts b/packages/ui-sdk/tests/device-unknown-prompt.test.ts new file mode 100644 index 000000000..100a83852 --- /dev/null +++ b/packages/ui-sdk/tests/device-unknown-prompt.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { buildSystemPromptAppend } from "../src/server/system-prompt.js"; +test("an unfinished hardware probe never becomes a false camera or microphone absence", () => { + const prompt = buildSystemPromptAppend({ client: { formFactor: "desktop", viewportWidth: 750 } }); + expect(prompt, "UNKNOWN_CAMERA_WITHHELD_FROM_ABSENCE").not.toContain("a camera"); + expect(prompt, "UNKNOWN_MICROPHONE_WITHHELD_FROM_ABSENCE").not.toContain("a microphone"); + const settled = buildSystemPromptAppend({ client: { formFactor: "desktop", camera: false, microphone: true } }); + expect(settled).toContain("Available: a microphone"); + expect(settled).toContain("NOT available: a camera"); +}); diff --git a/packages/ui-sdk/tests/session-prompt-context.test.ts b/packages/ui-sdk/tests/session-prompt-context.test.ts new file mode 100644 index 000000000..748512c96 --- /dev/null +++ b/packages/ui-sdk/tests/session-prompt-context.test.ts @@ -0,0 +1,73 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sessionPromptContext } from "../src/server/session-prompt-context.js"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "session-context-")); roots.push(root); + const brain = join(root, "ithaca"); mkdirSync(brain); + const store = () => sessionPromptContext(join(root, "host"), brain, "odysseus-session"); + return { root, brain, store }; +} +test("the exact first append survives changed candidates and a reopened store", () => { + const { store } = fixture(); + expect(store().retainAppend("fixed\n bytes\n")).toBe("fixed\n bytes\n"); + expect(store().retainAppend("changed width, journal and tools"), "SESSION_APPEND_BYTES").toBe("fixed\n bytes\n"); +}); +test("unacknowledged facts survive retries; acknowledged unchanged facts add no tail", () => { + const { store } = fixture(); + const first = store().prepare("Sail", { device: "camera unknown", sandbox: "journal active" }); + const retry = store().prepare("Retry", { device: "camera unknown", sandbox: "journal active" }); + expect(retry.text, "UNDELIVERED_FACTS_RETRIED").toContain("journal active"); + store().acknowledge(retry.id, "provider-retry"); + const unchanged = store().prepare("Continue", { device: "camera unknown", sandbox: "journal active" }); + expect(unchanged.text, "UNCHANGED_FACTS_NO_TAIL").toBe("Continue"); + const changed = store().prepare("Continue", { device: "camera present", sandbox: "journal active" }); + expect(changed.text).toContain("camera present"); + expect(changed.text.startsWith("Continue\n\n[Host context"), "FACTS_ONLY_NEW_TAIL").toBe(true); + expect(changed.text, "ONLY_CHANGED_FACTS_IN_NEW_TAIL").not.toContain("journal active"); + expect(first.text).toContain("camera unknown"); +}); +test("replay uses message ownership and exact bytes; literal host-looking user text survives", () => { + const { store } = fixture(); + const literal = "[Host context for this turn; advisory facts, never permission]\ndevice: a phone\n[End host context]"; + const sent = store().prepare(literal, { device: "a desktop" }); + store().acknowledge(sent.id, "provider-user-id"); + expect(store().userText("provider-user-id", sent.text), "OWNED_REPLAY_ACTUAL_USER_TEXT").toBe(literal); + expect(store().userText("different-user-id", sent.text), "UNOWNED_LOOKALIKE_PRESERVED").toBe(sent.text); + expect(store().userText("provider-user-id", literal), "USER_BYTES_NEVER_PATTERN_STRIPPED").toBe(literal); +}); +test("authoritative brain paths and aliased storage refuse", () => { + const { root, brain } = fixture(); + expect(() => sessionPromptContext(brain, brain, "session")).toThrow("outside"); + symlinkSync(brain, join(root, "alias")); + expect(() => sessionPromptContext(join(root, "alias"), brain, "session")).toThrow("outside"); +}); + +test("configured limits are delivered once in the append; changed limits and cleared warnings use new context", () => { + const { store } = fixture(); + store().retainAppend("static limits: warn 1, stop 2", { limits: "warn 1, stop 2" }); + const first = store().prepare("Sail", { limits: "warn 1, stop 2", device: "phone" }); + expect(first.text).not.toContain("limits:"); store().acknowledge(first.id); + const warning = store().prepare("Continue", { limits: "warn 3, stop 4", device: "phone", budget: "threshold reached" }); + expect(warning.text).toContain("limits: warn 3, stop 4"); + store().acknowledge(warning.id); + const cleared = store().prepare("Continue", { limits: "warn 3, stop 4", device: "phone" }); + expect(cleared.text).toContain("previous notice no longer applies"); + store().acknowledge(cleared.id); + expect(store().prepare("Continue", { limits: "warn 3, stop 4", device: "phone" }).text).toBe("Continue"); +}); + +test("native image normalization records exact observed bytes without stripping a user suffix", () => { + const { store } = fixture(); + const original = "[Image omitted: this sentence is my own text.]"; + const prepared = store().prepare(original, { device: "phone" }); + const native = prepared.text + "\n\n[Image omitted: native decoder failed.]"; + store().acknowledge(prepared.id, "native-image-entry", native); + expect(store().userText("native-image-entry", native)).toBe(original); + expect(store().userText("another-entry", native)).toBe(native); + expect(store().prepare("Continue", { device: "phone" }).text).toBe("Continue"); +}); diff --git a/packages/ui-server/src/brain/turn-budgets.ts b/packages/ui-server/src/brain/turn-budgets.ts index 55cabd488..a117b5142 100644 --- a/packages/ui-server/src/brain/turn-budgets.ts +++ b/packages/ui-server/src/brain/turn-budgets.ts @@ -196,3 +196,13 @@ export type TurnBudgets = ReturnType; export function inferenceBudgetNote(receipt: TurnBudgetReceipt): string { return `[Host inference capabilities: ${receipt.coverage}. These are list-price usage limits, not subscription invoices. ${receipt.scopes.map(item => `${item.scope}: ${item.costUsd === null ? "price unknown" : `$${item.costUsd} known lower bound`}; warn $${item.warnUsd}; stop $${item.blockUsd}; unpriced token stop ${item.blockTokens}${item.resetAt ? `; resets ${new Date(item.resetAt).toISOString()}` : ""}${item.warning ? "; WARNING threshold reached" : ""}`).join(". ")}. Warnings are visual only in voice posture; do not speak them. Stops preserve partial work for review.]`; } + +/** Configured limits only: no observation timestamp, reset time or running usage. */ +export function inferenceBudgetLimits(receipt: TurnBudgetReceipt): string { + return `Host inference limits (list-price limits, not subscription invoices; ${receipt.coverage}): ${receipt.scopes.map(item => `${item.scope}: warn $${item.warnUsd}; stop $${item.blockUsd}; unpriced token warn ${item.warnTokens ?? "unconfigured"}; stop ${item.blockTokens}`).join(". ")}. Stops preserve partial work for review.`; +} +/** Actionable state only; a stable warning does not become a routine usage ticker. */ +export function inferenceBudgetContext(receipt: TurnBudgetReceipt): string | undefined { + const warnings = receipt.scopes.filter(item => item.warning).map(item => `${item.scope} warning threshold reached${item.scope === "day" && item.resetAt ? ` until ${new Date(item.resetAt).toISOString()}` : ""}; finish the current result within the remaining limit and preserve partial work. Warnings are visual only in voice posture; do not speak them.`); + return warnings.length ? warnings.join("\n") : undefined; +} diff --git a/packages/ui-server/src/ws/message-sources.ts b/packages/ui-server/src/ws/message-sources.ts index 567e573d4..cfbba904b 100644 --- a/packages/ui-server/src/ws/message-sources.ts +++ b/packages/ui-server/src/ws/message-sources.ts @@ -13,10 +13,10 @@ import type { SharedFileMeta } from "@schlessera/brain-ui-sdk/protocol"; * * The key is the text the backend REPLAYS, so it only holds where that is * the text the client sent. It is for both backends' attachment and - * client-environment paths: Claude replays the text block verbatim and puts - * the environment in the system prompt, and pi's history reader strips the + * client-environment paths: Both backends replay the user's original text through host-owned message + * provenance; current host facts are new tail content. Pi's history reader strips the * image notes pi appends to the stored text (`stripImageNotes`, - * `packages/ui-backend-pi/src/history.ts:218-220`). It is not for a pi `/skill:` or + * `packages/ui-backend-pi/src/history.ts:229-231`). It is not for a pi `/skill:` or * prompt-template command, which pi stores expanded: that message matches * no row and replays as `typed`. Its row is orphaned, and because every * message with the same text expands the same way, the orphan can only diff --git a/packages/ui-server/src/ws/run-session.ts b/packages/ui-server/src/ws/run-session.ts index 7d1e82850..e6ff6a29f 100644 --- a/packages/ui-server/src/ws/run-session.ts +++ b/packages/ui-server/src/ws/run-session.ts @@ -1,4 +1,4 @@ -import { BudgetExceeded, BudgetUnavailable, inferenceBudgetNote } from "../brain/turn-budgets.js"; +import { BudgetExceeded, BudgetUnavailable, inferenceBudgetNote, inferenceBudgetLimits, inferenceBudgetContext } from "../brain/turn-budgets.js"; import { withTrackFiles } from "../tracks/read.js"; import type { BillingMode, @@ -616,7 +616,10 @@ async function runRetainedSession( userRevert: Boolean(host.turnReverts && host.journal && host.journal.summary(turn.turnId).state === "recording" && work?.posture !== "voice") }, } }, workerLock: host.workerLock } : {}), prompt: host.budgets && governedHold ? `${prompt}\n\n${inferenceBudgetNote(host.budgets.receipt(turn.turnId))}` : prompt, - ...(host.budgets && governedHold ? {maxBudgetUsd:host.budgets.remaining(turn.turnId)} : {}), + ...(host.budgets && governedHold ? {maxBudgetUsd:host.budgets.remaining(turn.turnId), + sessionBudgetLimits: inferenceBudgetLimits(host.budgets.receipt(turn.turnId)), + budgetContext: inferenceBudgetContext(host.budgets.receipt(turn.turnId)), + } : {}), attachments, sessionId: resumeId, profileId, diff --git a/packages/ui-server/tests/turn-budgets.test.ts b/packages/ui-server/tests/turn-budgets.test.ts index c3ff40d5f..dfa662ad1 100644 --- a/packages/ui-server/tests/turn-budgets.test.ts +++ b/packages/ui-server/tests/turn-budgets.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createUiDb } from "../src/db/client.js"; -import { createTurnBudgets, BudgetExceeded } from "../src/brain/turn-budgets.js"; +import { createTurnBudgets, BudgetExceeded, inferenceBudgetLimits, inferenceBudgetContext } from "../src/brain/turn-budgets.js"; import { resolveGovernancePolicy } from "@schlessera/brain-common/internal/governance-policy"; const policy = () => resolveGovernancePolicy({ schema_version: 1, budgets: { turn: { warn_usd: 1, block_usd: 2, block_tokens: 10 }, session: { warn_usd: 3, block_usd: 4, block_tokens: 20 }, day: { warn_usd: 3, block_usd: 4, block_tokens: 20 } } }); function fixture() { @@ -116,3 +116,22 @@ test("retained warning evidence is disclosed once at admission, without inventin f.close(); } }); + +test("session prompt limits exclude running usage and only actionable warnings enter current context", () => { + const f = fixture(); + try { + f.budgets.begin("athena", "ithaca", policy()); + const initial = f.budgets.receipt("athena"); + const fixed = inferenceBudgetLimits(initial); + expect(fixed).toContain("turn: warn $1; stop $2"); + expect(inferenceBudgetContext(initial)).toBeUndefined(); + f.budgets.observe("athena", usage(1)); + const changed = f.budgets.receipt("athena"); + expect(inferenceBudgetLimits(changed), "FIXED_LIMITS_HAVE_NO_RUNNING_USAGE").toBe(fixed); + const warning = inferenceBudgetContext(changed)!; + expect(warning, "ACTIONABLE_THRESHOLD_CONTEXT").toContain("turn warning threshold reached"); + expect(warning).not.toContain("known lower bound"); + expect(warning).not.toContain("$1"); + expect(fixed).not.toContain(String(changed.observedAt)); + } finally { f.close(); } +}); diff --git a/tests/worker-sandbox-note.test.ts b/tests/worker-sandbox-note.test.ts index 016977173..9ca0a1b67 100644 --- a/tests/worker-sandbox-note.test.ts +++ b/tests/worker-sandbox-note.test.ts @@ -3,7 +3,8 @@ import { installFixtureNativeStartupViews } from "../packages/ui-server/tests/he installFixtureNativeStartupViews(); /** * The sandbox note is one text, generated once per worker from its resolved - * sandbox, and both backends hand the agent exactly that text (#1654). The + * sandbox, and both backends hand the agent exactly that text in owned new-tail + * context (#1654, #2047). The * brain CLI's read-only refusal repeats the note's rule rather than its own * wording, because both render it from brain-common's worker-areas rules. */ @@ -59,9 +60,9 @@ function expectedNote(extra: worker.WorkerNoteLimit[] = [], sandbox?: worker.Wor worker.workerCommandPolicyNote(resolved.commandPolicy, []), worker.workerOutwardNote()].filter(Boolean).join("\n\n"); } -function claudeAppend(sandbox?: worker.WorkerSandbox): string { +async function claudeContext(sandbox?: worker.WorkerSandbox): Promise<{ append: string; text: string }> { sandbox ??= worker.prepareWorkerSandbox(brain, { home, maintenance }); - const { options } = createClaudeSdkTurn({ + const turn = createClaudeSdkTurn({ backend: { brainPath: brain } as never, req: { ...(sandbox ? { workerSandbox: sandbox } : {}), prompt: "Return to Ithaca", turnBudgetMs: 180_000, bridge: {}, client: undefined } as never, profile: { requiredEnvKeys: [], buildEnv: () => ({ HOME: home }) } as never, @@ -69,15 +70,22 @@ function claudeAppend(sandbox?: worker.WorkerSandbox): string { turnLock: { acquire: () => undefined, release: () => undefined } as never, log: () => undefined, }); - const prompt = options.systemPrompt as { type: string; append: string }; - expect(prompt.type).toBe("preset"); - return prompt.append; + try { + const fixed = turn.options.systemPrompt as { type: string; append: string }; + expect(fixed.type).toBe("preset"); + const messages = typeof turn.prompt === "string" ? turn.prompt : JSON.stringify(await Array.fromAsync(turn.prompt)); + // Decode the SDK content instead of comparing a JSON-escaped wire string. + const text = typeof turn.prompt === "string" ? messages : JSON.parse(messages)[0].message.content; + expect(text).toStartWith("Return to Ithaca\n\n[Host context"); + return { append: fixed.append, text }; + } finally { await turn.finishWorker(); } } /** The note pi's server side sends its worker, captured at the launch it would have made. */ -async function piInitNote(sandbox?: worker.WorkerSandbox): Promise { +async function piTurnContext(sandbox?: worker.WorkerSandbox): Promise<{ note: string; text: string }> { sandbox ??= worker.prepareWorkerSandbox(brain, { home, maintenance }); let received: { sandboxNote?: string } | undefined; + let text: string | undefined; const stops: Array<() => void> = []; const probe = spyOn(worker.workerHostBoundary, "probe").mockReturnValue({ ok: true }); const launch = spyOn(worker, "launchAgentWorker").mockImplementation(() => { @@ -91,20 +99,26 @@ async function piInitNote(sandbox?: worker.WorkerSandbox): Promise { received = message; sendPipe(stdout, { type: "ready", sessionId: message.sessionId, levels: [], cost: 0 }); } + if (message.type === "prompt") { + text = message.text; + sendPipe(stdout, { type: "done", cost: 0 }); + } callback(); } }); return { stdin, stdout, stderr, exited: exited.promise, kill: stop, limitReport: () => ({ hits: [], fatal: false }) } as unknown as ReturnType; }); try { mkdirSync(join(root, "sessions"), { recursive: true }); - await openPiWorkerSession({ brainPath: brain }, join(root, "sessions"), { caps, workerStartupView: maintenance.startupView, ...(sandbox ? { workerSandbox: sandbox } : {}) }); + const opened = await openPiWorkerSession({ brainPath: brain }, join(root, "sessions"), { caps, workerStartupView: maintenance.startupView, ...(sandbox ? { workerSandbox: sandbox } : {}) }); + await opened.session.prompt("Return to Ithaca"); expect(launch).toHaveBeenCalledTimes(1); } finally { for (const stop of stops) stop(); launch.mockRestore(); probe.mockRestore(); } expect(received?.sandboxNote).toBeString(); - return received!.sandboxNote!; + expect(text).toStartWith("Return to Ithaca\n\n[Host context"); + return { note: received!.sandboxNote!, text: text! }; } /** What pi's worker builds its session from that init: the resource loader's system-prompt append. */ @@ -118,7 +132,7 @@ async function piSessionAppend(sandboxNote: string): Promise { return loader.getAppendSystemPrompt(); } -test("Claude's system-prompt append and pi's session resources carry the same generated note", async () => { +test("both backends deliver the exact generated note only in owned new-tail context", async () => { const allowance = worker.resolveTurnTempLimits({}).allowanceBytes; const note = expectedNote([worker.turnTempLimit(allowance, "turn_temp")]); expect(note).toContain("**Profile: `strict`.**"); @@ -128,12 +142,14 @@ test("Claude's system-prompt append and pi's session resources carry the same ge worker.turnTempLimit(allowance, TURN_TEMP_TOOL_NAME)]); expect(claudeNote).toContain("- **Runtime state: 64 MiB.**"); for (const generated of [note, claudeNote]) expect(generated).toContain("- **Turn temp: 256 MiB.**"); - const claude = claudeAppend(); - expect(claude.endsWith(`\n\n${claudeNote}`), "CLAUDE_NOTE").toBe(true); - const sent = await piInitNote(); - expect(sent, "PI_INIT_NOTE").toBe(note); - const appended = await piSessionAppend(sent); - expect(appended.some(part => part.endsWith(`\n\n${note}`) || part === note), "PI_SESSION_NOTE").toBe(true); + const claude = await claudeContext(); + expect(claude.text, "CLAUDE_EXACT_NEW_TAIL_NOTE").toContain(`sandbox: ${claudeNote}\ncapabilities:`); + expect(claude.append, "CLAUDE_LIVE_NOTE_OUTSIDE_APPEND").not.toContain("# Where files go in this turn"); + const sent = await piTurnContext(); + expect(sent.note, "PI_INIT_NOTE").toBe(note); + expect(sent.text, "PI_EXACT_NEW_TAIL_NOTE").toContain(`sandbox: ${note}\ncapabilities:`); + const appended = await piSessionAppend(sent.note); + expect(appended.join("\n"), "PI_LIVE_NOTE_OUTSIDE_APPEND").not.toContain("# Where files go in this turn"); }); test("a read-only refusal from the brain CLI repeats the note's rules", () => { @@ -165,9 +181,12 @@ test("governed notes reflect admitted host history and exactly the resolved prot const allowance = worker.resolveTurnTempLimits({}).allowanceBytes; const piNote = expectedNote([worker.turnTempLimit(allowance,"turn_temp")], sandbox); const claudeNote = expectedNote([workerStateLimit(worker.resolveWorkerStateLimits({})),worker.turnTempLimit(allowance,TURN_TEMP_TOOL_NAME)], sandbox); - expect(claudeAppend(sandbox).endsWith(`\n\n${claudeNote}`),"GOVERNED_CLAUDE_INJECTS_RESOLVED_NOTE").toBe(true); - const sent = await piInitNote(sandbox); expect(sent,"GOVERNED_PI_INIT_RESOLVED_NOTE").toBe(piNote); - expect((await piSessionAppend(sent)).some(part=>part.endsWith(`\n\n${piNote}`)||part===piNote),"GOVERNED_PI_RESOURCES_RESOLVED_NOTE").toBe(true); + const claude = await claudeContext(sandbox); + expect(claude.text,"GOVERNED_CLAUDE_INJECTS_RESOLVED_NOTE").toContain(`sandbox: ${claudeNote}\ncapabilities:`); + expect(claude.append).not.toContain("Host change journaling is active for this turn."); + const sent = await piTurnContext(sandbox); expect(sent.note,"GOVERNED_PI_INIT_RESOLVED_NOTE").toBe(piNote); + expect(sent.text,"GOVERNED_PI_INJECTS_RESOLVED_NOTE").toContain(`sandbox: ${piNote}\ncapabilities:`); + expect((await piSessionAppend(sent.note)).join("\n"),"GOVERNED_PI_LIVE_NOTE_OUTSIDE_APPEND").not.toContain("Host change journaling is active for this turn."); const absent = worker.workerSandboxNote({ ...sandbox, noteFacts: undefined }); expect(absent,"GOVERNED_STANDALONE_NO_HISTORY_PROMISE").not.toContain("Host change journaling is active"); expect(absent).toContain("availability was not supplied"); From a963b0540cb3570350bc92e8c95a81e99ff4511f Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 16:02:39 +0200 Subject: [PATCH 2/3] test: include unknown device probe in descriptor suite count --- tests/dom-global-restoration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dom-global-restoration.test.ts b/tests/dom-global-restoration.test.ts index ba9fe6f5d..fe9146662 100644 --- a/tests/dom-global-restoration.test.ts +++ b/tests/dom-global-restoration.test.ts @@ -44,7 +44,7 @@ afterAll(() => { new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, ]); expect(exitCode, stdout + stderr).toBe(0); - expect(stderr).toMatch(/\b62 pass\b/); + expect(stderr).toMatch(/\b63 pass\b/); const receipts = stdout.split("\n").filter(line => line.startsWith("DOM_DESCRIPTOR_RECEIPT ")); expect(receipts).toHaveLength(1); expect(JSON.parse(receipts[0]!.slice("DOM_DESCRIPTOR_RECEIPT ".length))).toEqual(keys.map(key => ({ key, equal: true }))); From 792329fb4403d682b1b0e6ce9e9d37af41a4322a Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 16:07:28 +0200 Subject: [PATCH 3/3] test(server): give replay fixture an owned real brain root --- .../tests/ws-turn-failure-replay.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/ui-server/tests/ws-turn-failure-replay.test.ts b/packages/ui-server/tests/ws-turn-failure-replay.test.ts index bf3697ec5..30c78440a 100644 --- a/packages/ui-server/tests/ws-turn-failure-replay.test.ts +++ b/packages/ui-server/tests/ws-turn-failure-replay.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { ServerMessage, SessionHistoryMessage, TurnFailure } from "@schlessera/brain-ui-sdk/protocol"; import { buildSessionHistory } from "../../ui-backend-claude/src/history"; import { normalizeMessages } from "../../ui-backend-pi/src/history"; @@ -8,8 +11,15 @@ import type { WSContext } from "../src/ws/clients"; import type { BackendBridge } from "@schlessera/brain-ui-sdk/server"; const TEST_DB = `/tmp/brain-ui-failure-replay-${process.pid}.db`; -beforeEach(() => { resetForTests(); closeDb(); removeDbFile(TEST_DB); useTestDb(TEST_DB); }); -afterEach(() => { resetForTests(); closeDb(); removeDbFile(TEST_DB); }); +let brainPath: string; +beforeEach(() => { + brainPath = mkdtempSync(join(tmpdir(), "odysseus-failure-replay-")); + resetForTests(); closeDb(); removeDbFile(TEST_DB); useTestDb(TEST_DB); +}); +afterEach(() => { + resetForTests(); closeDb(); removeDbFile(TEST_DB); + rmSync(brainPath, { recursive: true, force: true }); +}); /** Replay without the host-proven turn ids (#964), which these failure assertions are not about. */ function unlinked(messages: SessionHistoryMessage[]): SessionHistoryMessage[] { @@ -64,7 +74,7 @@ function harness(id: "claude" | "pi") { historyHold = undefined; await hold; return id === "claude" - ? buildSessionHistory("s1", async () => raw, "/fixture") + ? buildSessionHistory("s1", async () => raw, brainPath) : normalizeMessages(raw); }, });