@vellumai/assistant 0.5.8 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/ARCHITECTURE.md +8 -8
- package/README.md +1 -1
- package/docs/architecture/integrations.md +4 -4
- package/docs/architecture/keychain-broker.md +17 -18
- package/docs/architecture/security.md +5 -5
- package/eslint.config.mjs +0 -31
- package/package.json +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
- package/src/__tests__/credentials-cli.test.ts +3 -3
- package/src/__tests__/stt-hints.test.ts +22 -22
- package/src/__tests__/voice-quality.test.ts +2 -2
- package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +5 -238
- package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -206
- package/src/__tests__/workspace-migrations-runner.test.ts +15 -7
- package/src/cli/commands/credentials.ts +4 -4
- package/src/cli/commands/oauth/apps.ts +3 -3
- package/src/daemon/conversation-agent-loop.ts +6 -0
- package/src/daemon/conversation-runtime-assembly.ts +61 -1
- package/src/daemon/lifecycle.ts +2 -3
- package/src/memory/migrations/validate-migration-state.ts +14 -1
- package/src/prompts/system-prompt.ts +22 -0
- package/src/prompts/templates/NOW.md +26 -0
- package/src/prompts/templates/SOUL.md +10 -0
- package/src/prompts/update-bulletin-format.ts +0 -2
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/skills/inline-command-expansions.ts +7 -7
- package/src/tools/sensitive-output-placeholders.ts +2 -2
- package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +13 -148
- package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +7 -145
- package/src/workspace/migrations/AGENTS.md +11 -0
- package/src/workspace/migrations/runner.ts +16 -6
- package/src/workspace/migrations/types.ts +7 -0
- package/src/__tests__/keychain-broker-client.test.ts +0 -800
- package/src/security/keychain-broker-client.ts +0 -446
package/AGENTS.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
For error handling conventions (throw vs result objects vs null), see [docs/error-handling.md](docs/error-handling.md).
|
|
4
4
|
|
|
5
|
-
Subdirectory-scoped rules live in local AGENTS.md files: `src/cli/`, `src/runtime/`, `src/approvals/`, `src/notifications/`.
|
|
5
|
+
Subdirectory-scoped rules live in local AGENTS.md files: `src/cli/`, `src/runtime/`, `src/approvals/`, `src/notifications/`, `src/workspace/migrations/`.
|
package/ARCHITECTURE.md
CHANGED
|
@@ -321,7 +321,7 @@ The WhatsApp channel enables inbound and outbound messaging via the Meta WhatsAp
|
|
|
321
321
|
- `WHATSAPP_APP_SECRET` — App secret for webhook signature verification
|
|
322
322
|
- `WHATSAPP_WEBHOOK_VERIFY_TOKEN` — Token for the Meta webhook subscription handshake
|
|
323
323
|
|
|
324
|
-
These can be set via environment variables or stored in the credential vault (
|
|
324
|
+
These can be set via environment variables or stored in the credential vault (CES / encrypted store) under the `whatsapp` service prefix.
|
|
325
325
|
|
|
326
326
|
**Limitations (v1)**: Rich approval UI (inline buttons) is not supported. Contacts and location message types are acknowledged but not forwarded.
|
|
327
327
|
|
|
@@ -343,7 +343,7 @@ All endpoints are JWT-authenticated via `Authorization: Bearer <jwt>`.
|
|
|
343
343
|
|
|
344
344
|
**Credential storage pattern:**
|
|
345
345
|
|
|
346
|
-
Both tokens are stored in the secure key store (
|
|
346
|
+
Both tokens are stored in the secure key store (CES credential store with encrypted file fallback):
|
|
347
347
|
|
|
348
348
|
| Secure key | Content |
|
|
349
349
|
| ------------------------------------ | -------------------------------------------------------------------------- |
|
|
@@ -667,9 +667,9 @@ All six enforcement points derive the flag key via `skillFlagKey(skill)` — whi
|
|
|
667
667
|
|
|
668
668
|
```mermaid
|
|
669
669
|
graph LR
|
|
670
|
-
subgraph "
|
|
671
|
-
K1["API Key<br/>service: vellum-assistant<br/>account: anthropic<br/>stored via
|
|
672
|
-
K2["Credential Secrets<br/>key: credential/{service}/{field}<br/>stored via secure-keys.ts<br/>(encrypted file fallback
|
|
670
|
+
subgraph "Credential Store"
|
|
671
|
+
K1["API Key<br/>service: vellum-assistant<br/>account: anthropic<br/>stored via CES"]
|
|
672
|
+
K2["Credential Secrets<br/>key: credential/{service}/{field}<br/>stored via secure-keys.ts<br/>(encrypted file fallback)"]
|
|
673
673
|
end
|
|
674
674
|
|
|
675
675
|
subgraph "UserDefaults (plist)"
|
|
@@ -1937,10 +1937,10 @@ Connected channels are resolved at signal emission time: vellum is always includ
|
|
|
1937
1937
|
|
|
1938
1938
|
| What | Where | Format | ORM/Driver | Retention |
|
|
1939
1939
|
| ---------------------------------------- | ----------------------------------------------------------------- | ----------------------------------- | ---------------------------------- | ------------------------------------------------------- |
|
|
1940
|
-
| API key |
|
|
1941
|
-
| Credential secrets |
|
|
1940
|
+
| API key | CES / encrypted file store | Encrypted binary | CES API / `secure-keys.ts` | Permanent |
|
|
1941
|
+
| Credential secrets | CES / encrypted file store | Encrypted binary | `secure-keys.ts` wrapper | Permanent (until deleted via tool) |
|
|
1942
1942
|
| Credential metadata | `~/.vellum/workspace/data/credentials/metadata.json` | JSON | Atomic file write | Permanent (until deleted via tool) |
|
|
1943
|
-
| Integration OAuth tokens |
|
|
1943
|
+
| Integration OAuth tokens | CES / encrypted file store (via `secure-keys.ts`) | Encrypted binary | `TokenManager` auto-refresh | Until disconnected or revoked |
|
|
1944
1944
|
| User preferences | UserDefaults | plist | Foundation | Permanent |
|
|
1945
1945
|
| Session logs | `~/Library/.../logs/session-*.json` | JSON per session | Swift Codable | Unbounded |
|
|
1946
1946
|
| Conversations & messages | `~/.vellum/workspace/data/db/assistant.db` | SQLite + WAL | Drizzle ORM (Bun) | Permanent |
|
package/README.md
CHANGED
|
@@ -204,7 +204,7 @@ The runtime exposes a RESTful HTTP API for Twilio configuration, credential mana
|
|
|
204
204
|
| Method | Path | Description |
|
|
205
205
|
| ------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
206
206
|
| GET | `/v1/integrations/twilio/config` | Returns current state: `hasCredentials` (boolean) and `phoneNumber` (if assigned) |
|
|
207
|
-
| POST | `/v1/integrations/twilio/credentials` | Validates and stores Account SID and Auth Token in secure storage (
|
|
207
|
+
| POST | `/v1/integrations/twilio/credentials` | Validates and stores Account SID and Auth Token in secure storage (CES / encrypted file store) |
|
|
208
208
|
| DELETE | `/v1/integrations/twilio/credentials` | Removes stored credentials. Preserves the phone number in config so re-entering credentials resumes working without reassigning the number. |
|
|
209
209
|
| GET | `/v1/integrations/twilio/numbers` | Lists all incoming phone numbers on the Twilio account with their capabilities |
|
|
210
210
|
| POST | `/v1/integrations/twilio/numbers/provision` | Purchases a new phone number. Accepts optional `areaCode` and `country`. Auto-assigns and configures webhooks when ingress is available. |
|
|
@@ -122,7 +122,7 @@ sequenceDiagram
|
|
|
122
122
|
participant Browser as System Browser
|
|
123
123
|
participant Google as Google OAuth Server
|
|
124
124
|
participant Store as SQLite OAuth Store
|
|
125
|
-
participant Vault as
|
|
125
|
+
participant Vault as Credential Store
|
|
126
126
|
participant TokenMgr as TokenManager
|
|
127
127
|
participant Tool as Gmail Tool Executor
|
|
128
128
|
participant API as Gmail REST API
|
|
@@ -173,7 +173,7 @@ sequenceDiagram
|
|
|
173
173
|
|
|
174
174
|
| Decision | Rationale |
|
|
175
175
|
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
176
|
-
| PKCE by default, optional client_secret | Desktop apps prefer PKCE; some providers (Slack) require a secret, which is stored in the
|
|
176
|
+
| PKCE by default, optional client_secret | Desktop apps prefer PKCE; some providers (Slack) require a secret, which is stored in the credential store (`oauth_app/{id}/client_secret`) for autonomous refresh |
|
|
177
177
|
| Shared connect orchestrator | All OAuth providers route through `orchestrateOAuthConnect()`, which resolves profiles, enforces scope policy, runs the flow, stores tokens, and verifies identity. Adding a provider is a declarative profile entry, not new orchestration code |
|
|
178
178
|
| Canonical credential naming | All reads and writes use `client_id`/`client_secret` as canonical field names |
|
|
179
179
|
| Gateway callback transport | OAuth callbacks are now routed through the gateway at `${ingress.publicBaseUrl}/webhooks/oauth/callback` instead of a loopback redirect URI. This enables OAuth flows to work in remote and tunneled deployments. |
|
|
@@ -261,7 +261,7 @@ Result is a discriminated union: `{ success, deferred, grantedScopes, accountInf
|
|
|
261
261
|
|
|
262
262
|
`assistant/src/daemon/handlers/oauth-connect.ts` handles `oauth_connect_start` messages. The handler:
|
|
263
263
|
|
|
264
|
-
1. Resolves client credentials from the
|
|
264
|
+
1. Resolves client credentials from the credential store using canonical names (`client_id`, `client_secret`).
|
|
265
265
|
2. Validates that required credentials exist (including `client_secret` when the provider requires it).
|
|
266
266
|
3. Delegates to `orchestrateOAuthConnect()`.
|
|
267
267
|
4. Sends `oauth_connect_result` back to the client.
|
|
@@ -286,7 +286,7 @@ This replaces provider-specific handlers — any provider in the registry can be
|
|
|
286
286
|
| `assistant/src/oauth/scope-policy.ts` | Scope resolution and policy enforcement (pure, no I/O) |
|
|
287
287
|
| `assistant/src/oauth/connect-orchestrator.ts` | Shared connect orchestrator (profile → scopes → flow → tokens) |
|
|
288
288
|
| `assistant/src/oauth/connect-types.ts` | Shared types (`OAuthProviderBehavior`, `OAuthScopePolicy`, `OAuthConnectResult`) |
|
|
289
|
-
| `assistant/src/oauth/token-persistence.ts` | Token storage:
|
|
289
|
+
| `assistant/src/oauth/token-persistence.ts` | Token storage: credential store writes, metadata upsert, post-connect hooks |
|
|
290
290
|
| `assistant/src/daemon/handlers/oauth-connect.ts` | Generic `oauth_connect_start` / `oauth_connect_result` handler |
|
|
291
291
|
|
|
292
292
|
---
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# macOS Keychain Broker Architecture (Legacy)
|
|
2
2
|
|
|
3
3
|
**Status:** Superseded by CES credential routing
|
|
4
|
-
**Last Updated:** 2026-03-
|
|
4
|
+
**Last Updated:** 2026-03-24
|
|
5
5
|
**Owners:** macOS client + assistant runtime
|
|
6
6
|
|
|
7
7
|
## Current State
|
|
@@ -16,24 +16,23 @@ See [`assistant/docs/credential-execution-service.md`](../credential-execution-s
|
|
|
16
16
|
|
|
17
17
|
### What remains
|
|
18
18
|
|
|
19
|
-
The keychain broker is
|
|
19
|
+
The keychain broker is fully deleted. Only these historical migrations remain:
|
|
20
20
|
|
|
21
|
-
| Component
|
|
22
|
-
|
|
23
|
-
|
|
|
24
|
-
| Migration 015 | `assistant/src/workspace/migrations/015-migrate-credentials-to-keychain.ts` | Historical migration that copied encrypted store credentials into keychain |
|
|
21
|
+
| Component | Location | Why it exists |
|
|
22
|
+
| ------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| Migration 015 | `assistant/src/workspace/migrations/015-migrate-credentials-to-keychain.ts` | Historical migration that copied encrypted store credentials into keychain |
|
|
25
24
|
| Migration 016 | `assistant/src/workspace/migrations/016-migrate-credentials-from-keychain.ts` | Reverse migration that copies keychain credentials back to the encrypted store for CES unification |
|
|
26
|
-
| Swift broker server | `clients/macos/vellum-assistant/Security/KeychainBrokerServer.swift` | UDS server in the macOS app; still compiled for release builds (`#if !DEBUG`) |
|
|
27
|
-
| Swift broker service | `clients/macos/vellum-assistant/Security/KeychainBrokerService.swift` | `SecItem*` wrapper used by the broker server |
|
|
28
|
-
| Gateway credential reader | `gateway/src/credential-reader.ts` | Still tries the keychain broker as a secondary fallback after CES, before the encrypted store |
|
|
29
25
|
|
|
30
|
-
|
|
26
|
+
Migrations 015/016 use inline `security` CLI helpers that shell out to `/usr/bin/security` directly, replacing the former broker client and UDS protocol. These migrations are append-only and cannot be removed.
|
|
31
27
|
|
|
32
28
|
### What was removed
|
|
33
29
|
|
|
30
|
+
- **Keychain broker client** (`keychain-broker-client.ts`) -- the TypeScript client that communicated with the Swift UDS server. Migrations 015/016 now use inline `security` CLI helpers instead.
|
|
34
31
|
- **`KeychainBackend`** class and `createKeychainBackend()` factory -- the daemon's `CredentialBackend` implementation that wrapped the broker client. Removed from `credential-backend.ts`.
|
|
35
32
|
- **`resolveBackendAsync()` keychain resolution path** -- the daemon no longer considers `VELLUM_DESKTOP_APP` or `VELLUM_DEV` for backend selection. Backend resolution in `secure-keys.ts` now follows the CES RPC > CES HTTP > encrypted store priority.
|
|
36
33
|
- **Dual-writing and broker-unavailable commit behavior** -- the daemon previously committed to the keychain backend even when the broker socket was unreachable, causing operations to fail visibly. This behavior is gone; CES RPC is the primary backend with encrypted store as a graceful fallback.
|
|
34
|
+
- **Swift broker server** (`KeychainBrokerServer.swift`) -- the UDS server in the macOS app that accepted credential requests from the daemon and gateway. Deleted along with its `SecItem*` wrapper (`KeychainBrokerService.swift`).
|
|
35
|
+
- **Gateway broker fallback** -- the gateway's `credential-reader.ts` no longer tries the keychain broker as a secondary fallback. Credential resolution is now CES HTTP > encrypted file store.
|
|
37
36
|
|
|
38
37
|
## Original Design (Historical)
|
|
39
38
|
|
|
@@ -51,15 +50,15 @@ The macOS app embedded a `KeychainBrokerServer` (NWListener on a Unix domain soc
|
|
|
51
50
|
|
|
52
51
|
Transport: Unix domain socket at `~/.vellum/keychain-broker.sock`, newline-delimited JSON.
|
|
53
52
|
|
|
54
|
-
| Method
|
|
55
|
-
|
|
56
|
-
| `broker.ping` | none
|
|
57
|
-
| `key.get`
|
|
58
|
-
| `key.set`
|
|
59
|
-
| `key.delete`
|
|
60
|
-
| `key.list`
|
|
53
|
+
| Method | Params | Result |
|
|
54
|
+
| ------------- | -------------------- | ------------------------ |
|
|
55
|
+
| `broker.ping` | none | `{ pong: true }` |
|
|
56
|
+
| `key.get` | `{ account }` | `{ found, value? }` |
|
|
57
|
+
| `key.set` | `{ account, value }` | `{ stored: true }` |
|
|
58
|
+
| `key.delete` | `{ account }` | `{ deleted: true }` |
|
|
59
|
+
| `key.list` | none | `{ accounts: string[] }` |
|
|
61
60
|
|
|
62
|
-
This protocol is
|
|
61
|
+
This protocol is no longer used. Migrations 015/016 now use inline `security` CLI helpers that shell out to `/usr/bin/security` directly instead of communicating over UDS.
|
|
63
62
|
|
|
64
63
|
### Security model
|
|
65
64
|
|
|
@@ -222,7 +222,7 @@ sequenceDiagram
|
|
|
222
222
|
participant Prompter as SecretPrompter
|
|
223
223
|
participant HTTP as HTTP Transport
|
|
224
224
|
participant UI as SecretPromptManager (Swift)
|
|
225
|
-
participant
|
|
225
|
+
participant Store as Credential Store (CES / encrypted file)
|
|
226
226
|
|
|
227
227
|
Model->>Vault: action: "prompt", service, field, label
|
|
228
228
|
Vault->>Prompter: requestSecret(service, field, label, ...)
|
|
@@ -233,7 +233,7 @@ sequenceDiagram
|
|
|
233
233
|
UI->>HTTP: secret_response {requestId, value, delivery: "store"}
|
|
234
234
|
HTTP->>Prompter: resolve(value, "store")
|
|
235
235
|
Prompter->>Vault: {value, delivery: "store"}
|
|
236
|
-
Vault->>
|
|
236
|
+
Vault->>Store: setSecureKeyAsync("credential/svc/field", value)
|
|
237
237
|
Vault->>Model: "Credential stored securely" (no value in output)
|
|
238
238
|
else One-Time Send (if enabled)
|
|
239
239
|
UI->>HTTP: secret_response {requestId, value, delivery: "transient_send"}
|
|
@@ -265,7 +265,7 @@ graph TB
|
|
|
265
265
|
The `allowOneTimeSend` config gate (default: `false`) enables a secondary "Send Once" button in the secret prompt UI. When used:
|
|
266
266
|
|
|
267
267
|
- The secret value is handed to the `CredentialBroker`, which holds it in memory for the next `consume` or `browserFill` call
|
|
268
|
-
- The value is **not** persisted to the
|
|
268
|
+
- The value is **not** persisted to the credential store
|
|
269
269
|
- The broker discards the value after a single use
|
|
270
270
|
- The vault tool output confirms delivery without including the secret value — the value is never returned to the model
|
|
271
271
|
- The config gate must be explicitly enabled by the operator
|
|
@@ -274,7 +274,7 @@ The `allowOneTimeSend` config gate (default: `false`) enables a secondary "Send
|
|
|
274
274
|
|
|
275
275
|
| Component | Location | What it stores |
|
|
276
276
|
| ------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
277
|
-
| Secret values |
|
|
277
|
+
| Secret values | CES credential store or encrypted file store | Encrypted credential values keyed as `credential/{service}/{field}`. Stored via CES RPC (primary), CES HTTP (containerized), or encrypted file store (fallback). |
|
|
278
278
|
| Credential metadata | `~/.vellum/workspace/data/credentials/metadata.json` | Service, field, label, policy (allowedTools, allowedDomains), timestamps |
|
|
279
279
|
| Config | `~/.vellum/workspace/config.*` | `secretDetection` settings: enabled, action, entropyThreshold, allowOneTimeSend |
|
|
280
280
|
|
|
@@ -283,7 +283,7 @@ The `allowOneTimeSend` config gate (default: `false`) enables a secondary "Send
|
|
|
283
283
|
| File | Role |
|
|
284
284
|
| ---------------------------------------------------- | --------------------------------------------------------------------- |
|
|
285
285
|
| `assistant/src/tools/credentials/vault.ts` | `credential_store` tool — store, list, delete, prompt actions |
|
|
286
|
-
| `assistant/src/security/secure-keys.ts` | Async secure key CRUD via
|
|
286
|
+
| `assistant/src/security/secure-keys.ts` | Async secure key CRUD via CES and encrypted file store |
|
|
287
287
|
| `assistant/src/tools/credentials/metadata-store.ts` | JSON file metadata CRUD for credential records |
|
|
288
288
|
| `assistant/src/tools/credentials/broker.ts` | Brokered credential access with policy enforcement and transient send |
|
|
289
289
|
| `assistant/src/tools/credentials/policy-validate.ts` | Policy input validation (allowedTools, allowedDomains) |
|
package/eslint.config.mjs
CHANGED
|
@@ -29,37 +29,6 @@ const eslintConfig = defineConfig([
|
|
|
29
29
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
30
30
|
],
|
|
31
31
|
"@typescript-eslint/no-explicit-any": "error",
|
|
32
|
-
|
|
33
|
-
// Standardize on `undefined` only — avoid `null` in new code.
|
|
34
|
-
// Prefer `=== undefined`, `?? fallback`, or `?.` optional chaining
|
|
35
|
-
// instead of `=== null`.
|
|
36
|
-
"no-restricted-syntax": [
|
|
37
|
-
"error",
|
|
38
|
-
{
|
|
39
|
-
selector:
|
|
40
|
-
"BinaryExpression[operator='==='][right.type='Literal'][right.raw='null']",
|
|
41
|
-
message:
|
|
42
|
-
"Avoid `=== null`. Prefer `=== undefined`, `?? fallback`, or optional chaining `?.` instead.",
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
selector:
|
|
46
|
-
"BinaryExpression[operator='==='][left.type='Literal'][left.raw='null']",
|
|
47
|
-
message:
|
|
48
|
-
"Avoid `null ===`. Prefer `=== undefined`, `?? fallback`, or optional chaining `?.` instead.",
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
selector:
|
|
52
|
-
"BinaryExpression[operator='!=='][right.type='Literal'][right.raw='null']",
|
|
53
|
-
message:
|
|
54
|
-
"Avoid `!== null`. Prefer `!== undefined`, nullish coalescing `??`, or optional chaining `?.` instead.",
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
selector:
|
|
58
|
-
"BinaryExpression[operator='!=='][left.type='Literal'][left.raw='null']",
|
|
59
|
-
message:
|
|
60
|
-
"Avoid `null !==`. Prefer `!== undefined`, nullish coalescing `??`, or optional chaining `?.` instead.",
|
|
61
|
-
},
|
|
62
|
-
],
|
|
63
32
|
},
|
|
64
33
|
},
|
|
65
34
|
{
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
injectChannelCapabilityContext,
|
|
12
12
|
injectChannelCommandContext,
|
|
13
13
|
injectInboundActorContext,
|
|
14
|
+
injectNowScratchpad,
|
|
14
15
|
injectTemporalContext,
|
|
15
16
|
injectTurnContext,
|
|
16
17
|
isGroupChatType,
|
|
@@ -18,6 +19,8 @@ import {
|
|
|
18
19
|
stripChannelCapabilityContext,
|
|
19
20
|
stripChannelTurnContext,
|
|
20
21
|
stripInboundActorContext,
|
|
22
|
+
stripInjectedContext,
|
|
23
|
+
stripNowScratchpad,
|
|
21
24
|
stripTemporalContext,
|
|
22
25
|
} from "../daemon/conversation-runtime-assembly.js";
|
|
23
26
|
import type { Message } from "../providers/types.js";
|
|
@@ -1318,6 +1321,7 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
1318
1321
|
canonicalActorIdentity: "user-1",
|
|
1319
1322
|
trustClass: "guardian",
|
|
1320
1323
|
} as InboundActorContext,
|
|
1324
|
+
nowScratchpad: "Current focus: shipping PR 3",
|
|
1321
1325
|
isNonInteractive: true,
|
|
1322
1326
|
};
|
|
1323
1327
|
|
|
@@ -1337,6 +1341,7 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
1337
1341
|
expect(allText).toContain("<turn_context>");
|
|
1338
1342
|
expect(allText).toContain("<inbound_actor_context>");
|
|
1339
1343
|
expect(allText).toContain("<non_interactive_context>");
|
|
1344
|
+
expect(allText).toContain("<now_scratchpad>");
|
|
1340
1345
|
});
|
|
1341
1346
|
|
|
1342
1347
|
test("explicit mode: 'full' behaves the same as default", () => {
|
|
@@ -1353,6 +1358,7 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
1353
1358
|
expect(allText).toContain("<temporal_context>");
|
|
1354
1359
|
expect(allText).toContain("<channel_command_context>");
|
|
1355
1360
|
expect(allText).toContain("<active_workspace>");
|
|
1361
|
+
expect(allText).toContain("<now_scratchpad>");
|
|
1356
1362
|
});
|
|
1357
1363
|
|
|
1358
1364
|
test("minimal mode skips high-token optional blocks", () => {
|
|
@@ -1370,6 +1376,7 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
1370
1376
|
expect(allText).not.toContain("<temporal_context>");
|
|
1371
1377
|
expect(allText).not.toContain("<channel_command_context>");
|
|
1372
1378
|
expect(allText).not.toContain("<active_workspace>");
|
|
1379
|
+
expect(allText).not.toContain("<now_scratchpad>");
|
|
1373
1380
|
});
|
|
1374
1381
|
|
|
1375
1382
|
test("minimal mode preserves safety-critical blocks", () => {
|
|
@@ -1417,3 +1424,223 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
1417
1424
|
expect(texts).toContain("Hello");
|
|
1418
1425
|
});
|
|
1419
1426
|
});
|
|
1427
|
+
|
|
1428
|
+
// ---------------------------------------------------------------------------
|
|
1429
|
+
// injectNowScratchpad
|
|
1430
|
+
// ---------------------------------------------------------------------------
|
|
1431
|
+
|
|
1432
|
+
describe("injectNowScratchpad", () => {
|
|
1433
|
+
const baseUserMessage: Message = {
|
|
1434
|
+
role: "user",
|
|
1435
|
+
content: [{ type: "text", text: "What should I work on?" }],
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
test("appends now_scratchpad block to user message", () => {
|
|
1439
|
+
const result = injectNowScratchpad(
|
|
1440
|
+
baseUserMessage,
|
|
1441
|
+
"Current focus: shipping PR 3",
|
|
1442
|
+
);
|
|
1443
|
+
expect(result.content.length).toBe(2);
|
|
1444
|
+
// Original content comes first
|
|
1445
|
+
expect((result.content[0] as { type: "text"; text: string }).text).toBe(
|
|
1446
|
+
"What should I work on?",
|
|
1447
|
+
);
|
|
1448
|
+
// Scratchpad is appended (not prepended)
|
|
1449
|
+
const injected = result.content[1];
|
|
1450
|
+
expect(injected.type).toBe("text");
|
|
1451
|
+
const text = (injected as { type: "text"; text: string }).text;
|
|
1452
|
+
expect(text).toBe(
|
|
1453
|
+
"<now_scratchpad>\nCurrent focus: shipping PR 3\n</now_scratchpad>",
|
|
1454
|
+
);
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
test("preserves existing multi-block content and appends at end", () => {
|
|
1458
|
+
const multiBlockMessage: Message = {
|
|
1459
|
+
role: "user",
|
|
1460
|
+
content: [
|
|
1461
|
+
{ type: "text", text: "First block" },
|
|
1462
|
+
{ type: "text", text: "Second block" },
|
|
1463
|
+
],
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
const result = injectNowScratchpad(multiBlockMessage, "scratchpad notes");
|
|
1467
|
+
expect(result.content.length).toBe(3);
|
|
1468
|
+
expect((result.content[0] as { type: "text"; text: string }).text).toBe(
|
|
1469
|
+
"First block",
|
|
1470
|
+
);
|
|
1471
|
+
expect((result.content[1] as { type: "text"; text: string }).text).toBe(
|
|
1472
|
+
"Second block",
|
|
1473
|
+
);
|
|
1474
|
+
expect(
|
|
1475
|
+
(result.content[2] as { type: "text"; text: string }).text,
|
|
1476
|
+
).toContain("<now_scratchpad>");
|
|
1477
|
+
});
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
// ---------------------------------------------------------------------------
|
|
1481
|
+
// stripNowScratchpad
|
|
1482
|
+
// ---------------------------------------------------------------------------
|
|
1483
|
+
|
|
1484
|
+
describe("stripNowScratchpad", () => {
|
|
1485
|
+
test("strips now_scratchpad blocks from user messages", () => {
|
|
1486
|
+
const messages: Message[] = [
|
|
1487
|
+
{
|
|
1488
|
+
role: "user",
|
|
1489
|
+
content: [
|
|
1490
|
+
{ type: "text", text: "Hello" },
|
|
1491
|
+
{
|
|
1492
|
+
type: "text",
|
|
1493
|
+
text: "<now_scratchpad>\nSome notes\n</now_scratchpad>",
|
|
1494
|
+
},
|
|
1495
|
+
],
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
role: "assistant",
|
|
1499
|
+
content: [{ type: "text", text: "Hi there" }],
|
|
1500
|
+
},
|
|
1501
|
+
];
|
|
1502
|
+
|
|
1503
|
+
const result = stripNowScratchpad(messages);
|
|
1504
|
+
|
|
1505
|
+
expect(result.length).toBe(2);
|
|
1506
|
+
expect(result[0].content.length).toBe(1);
|
|
1507
|
+
expect((result[0].content[0] as { type: "text"; text: string }).text).toBe(
|
|
1508
|
+
"Hello",
|
|
1509
|
+
);
|
|
1510
|
+
// Assistant message untouched
|
|
1511
|
+
expect(result[1].content.length).toBe(1);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
test("removes user messages that only contain now_scratchpad", () => {
|
|
1515
|
+
const messages: Message[] = [
|
|
1516
|
+
{
|
|
1517
|
+
role: "user",
|
|
1518
|
+
content: [
|
|
1519
|
+
{
|
|
1520
|
+
type: "text",
|
|
1521
|
+
text: "<now_scratchpad>\nSome notes\n</now_scratchpad>",
|
|
1522
|
+
},
|
|
1523
|
+
],
|
|
1524
|
+
},
|
|
1525
|
+
];
|
|
1526
|
+
|
|
1527
|
+
const result = stripNowScratchpad(messages);
|
|
1528
|
+
expect(result.length).toBe(0);
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
test("leaves messages without now_scratchpad untouched", () => {
|
|
1532
|
+
const messages: Message[] = [
|
|
1533
|
+
{
|
|
1534
|
+
role: "user",
|
|
1535
|
+
content: [{ type: "text", text: "Normal message" }],
|
|
1536
|
+
},
|
|
1537
|
+
];
|
|
1538
|
+
|
|
1539
|
+
const result = stripNowScratchpad(messages);
|
|
1540
|
+
expect(result.length).toBe(1);
|
|
1541
|
+
expect(result[0]).toBe(messages[0]); // Same reference — untouched
|
|
1542
|
+
});
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
// ---------------------------------------------------------------------------
|
|
1546
|
+
// stripInjectedContext removes now_scratchpad blocks
|
|
1547
|
+
// ---------------------------------------------------------------------------
|
|
1548
|
+
|
|
1549
|
+
describe("stripInjectedContext with now_scratchpad", () => {
|
|
1550
|
+
test("strips now_scratchpad blocks alongside other injections", () => {
|
|
1551
|
+
const messages: Message[] = [
|
|
1552
|
+
{
|
|
1553
|
+
role: "user",
|
|
1554
|
+
content: [
|
|
1555
|
+
{
|
|
1556
|
+
type: "text",
|
|
1557
|
+
text: "<channel_capabilities>\nchannel: telegram\n</channel_capabilities>",
|
|
1558
|
+
},
|
|
1559
|
+
{ type: "text", text: "Hello" },
|
|
1560
|
+
{
|
|
1561
|
+
type: "text",
|
|
1562
|
+
text: "<now_scratchpad>\nCurrent focus\n</now_scratchpad>",
|
|
1563
|
+
},
|
|
1564
|
+
],
|
|
1565
|
+
},
|
|
1566
|
+
];
|
|
1567
|
+
|
|
1568
|
+
const result = stripInjectedContext(messages);
|
|
1569
|
+
expect(result.length).toBe(1);
|
|
1570
|
+
expect(result[0].content.length).toBe(1);
|
|
1571
|
+
expect((result[0].content[0] as { type: "text"; text: string }).text).toBe(
|
|
1572
|
+
"Hello",
|
|
1573
|
+
);
|
|
1574
|
+
});
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
// ---------------------------------------------------------------------------
|
|
1578
|
+
// applyRuntimeInjections with nowScratchpad
|
|
1579
|
+
// ---------------------------------------------------------------------------
|
|
1580
|
+
|
|
1581
|
+
describe("applyRuntimeInjections with nowScratchpad", () => {
|
|
1582
|
+
const baseMessages: Message[] = [
|
|
1583
|
+
{
|
|
1584
|
+
role: "user",
|
|
1585
|
+
content: [{ type: "text", text: "What should I do?" }],
|
|
1586
|
+
},
|
|
1587
|
+
];
|
|
1588
|
+
|
|
1589
|
+
test("injects now_scratchpad block when provided", () => {
|
|
1590
|
+
const result = applyRuntimeInjections(baseMessages, {
|
|
1591
|
+
nowScratchpad: "Current focus: fix the bug",
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
expect(result.length).toBe(1);
|
|
1595
|
+
expect(result[0].content.length).toBe(2);
|
|
1596
|
+
const injected = result[0].content[1];
|
|
1597
|
+
const text = (injected as { type: "text"; text: string }).text;
|
|
1598
|
+
expect(text).toContain("<now_scratchpad>");
|
|
1599
|
+
expect(text).toContain("Current focus: fix the bug");
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
test("appended block appears after user's original text content", () => {
|
|
1603
|
+
const result = applyRuntimeInjections(baseMessages, {
|
|
1604
|
+
nowScratchpad: "scratchpad notes",
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
// Original text is first
|
|
1608
|
+
expect(
|
|
1609
|
+
(result[0].content[0] as { type: "text"; text: string }).text,
|
|
1610
|
+
).toBe("What should I do?");
|
|
1611
|
+
// Scratchpad is appended after
|
|
1612
|
+
expect(
|
|
1613
|
+
(result[0].content[1] as { type: "text"; text: string }).text,
|
|
1614
|
+
).toContain("<now_scratchpad>");
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
test("does not inject when nowScratchpad is null", () => {
|
|
1618
|
+
const result = applyRuntimeInjections(baseMessages, {
|
|
1619
|
+
nowScratchpad: null,
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
expect(result.length).toBe(1);
|
|
1623
|
+
expect(result[0].content.length).toBe(1);
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
test("does not inject when nowScratchpad is omitted", () => {
|
|
1627
|
+
const result = applyRuntimeInjections(baseMessages, {});
|
|
1628
|
+
|
|
1629
|
+
expect(result.length).toBe(1);
|
|
1630
|
+
expect(result[0].content.length).toBe(1);
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
test("skipped in minimal mode", () => {
|
|
1634
|
+
const result = applyRuntimeInjections(baseMessages, {
|
|
1635
|
+
nowScratchpad: "Current focus: fix the bug",
|
|
1636
|
+
mode: "minimal",
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
const allText = result[0].content
|
|
1640
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
1641
|
+
.map((b) => b.text)
|
|
1642
|
+
.join("\n");
|
|
1643
|
+
|
|
1644
|
+
expect(allText).not.toContain("<now_scratchpad>");
|
|
1645
|
+
});
|
|
1646
|
+
});
|
|
@@ -895,7 +895,7 @@ describe("assistant credentials CLI", () => {
|
|
|
895
895
|
expect(result.exitCode).toBe(0);
|
|
896
896
|
const parsed = JSON.parse(result.stdout);
|
|
897
897
|
expect(parsed.ok).toBe(true);
|
|
898
|
-
expect(parsed.scrubbedValue).toBe("(
|
|
898
|
+
expect(parsed.scrubbedValue).toBe("(credential store unreachable)");
|
|
899
899
|
expect(parsed.brokerUnreachable).toBe(true);
|
|
900
900
|
});
|
|
901
901
|
|
|
@@ -913,7 +913,7 @@ describe("assistant credentials CLI", () => {
|
|
|
913
913
|
expect(result.exitCode).toBe(1);
|
|
914
914
|
const parsed = JSON.parse(result.stdout);
|
|
915
915
|
expect(parsed.ok).toBe(false);
|
|
916
|
-
expect(parsed.error).toContain("
|
|
916
|
+
expect(parsed.error).toContain("Credential store is unreachable");
|
|
917
917
|
});
|
|
918
918
|
});
|
|
919
919
|
|
|
@@ -1029,7 +1029,7 @@ describe("assistant credentials CLI", () => {
|
|
|
1029
1029
|
expect(result.exitCode).toBe(1);
|
|
1030
1030
|
const parsed = JSON.parse(result.stdout);
|
|
1031
1031
|
expect(parsed.ok).toBe(false);
|
|
1032
|
-
expect(parsed.error).toContain("
|
|
1032
|
+
expect(parsed.error).toContain("Credential store is unreachable");
|
|
1033
1033
|
});
|
|
1034
1034
|
|
|
1035
1035
|
test("returns credential-not-found when broker is up", async () => {
|