@vellumai/assistant 0.11.7-staging.1 → 0.11.7

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.
Files changed (44) hide show
  1. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  2. package/node_modules/@vellumai/ces-client/src/http-credentials.ts +161 -0
  3. package/node_modules/@vellumai/ces-client/src/index.ts +1 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  5. package/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  6. package/openapi.yaml +1 -1
  7. package/package.json +1 -1
  8. package/scripts/sync-llm-catalog.ts +6 -0
  9. package/src/__tests__/credential-record-write-through.test.ts +78 -0
  10. package/src/__tests__/delete-propagation.test.ts +92 -2
  11. package/src/__tests__/edit-propagation.test.ts +42 -2
  12. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  13. package/src/__tests__/oauth-connect-orchestrator.test.ts +95 -0
  14. package/src/__tests__/provider-catalog-visibility.test.ts +17 -0
  15. package/src/__tests__/provider-platform-proxy-integration.test.ts +8 -1
  16. package/src/calls/__tests__/voice-session-bridge.test.ts +37 -0
  17. package/src/calls/voice-session-bridge.ts +13 -0
  18. package/src/config/feature-flag-registry.json +16 -0
  19. package/src/config/schemas/llm.ts +4 -4
  20. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +64 -0
  21. package/src/live-voice/live-voice-session.ts +11 -1
  22. package/src/messaging/read-provider-metadata.ts +32 -0
  23. package/src/oauth/connect-orchestrator.ts +16 -0
  24. package/src/oauth/seed-providers.ts +1 -0
  25. package/src/persistence/conversation-crud.ts +3 -1
  26. package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
  27. package/src/providers/__tests__/vellum-connection-routing.test.ts +21 -3
  28. package/src/providers/inference/adapter-factory.ts +15 -0
  29. package/src/providers/inference/auth.ts +14 -14
  30. package/src/providers/model-catalog.ts +25 -0
  31. package/src/providers/platform-proxy/constants.ts +5 -0
  32. package/src/providers/provider-secret-catalog.ts +3 -2
  33. package/src/providers/vellum/client.ts +29 -0
  34. package/src/providers/vellum-model-routing.test.ts +2 -0
  35. package/src/providers/vellum-model-routing.ts +3 -3
  36. package/src/runtime/routes/inbound-message-handler.ts +26 -39
  37. package/src/runtime/routes/inbound-stages/edit-intercept.ts +20 -2
  38. package/src/runtime/routes/migration-routes.ts +3 -1
  39. package/src/security/ces-rpc-record-backend.ts +123 -0
  40. package/src/security/secure-keys.ts +15 -0
  41. package/src/tools/credentials/metadata-store.ts +65 -16
  42. package/src/tools/credentials/store.ts +2 -0
  43. package/src/watch/__tests__/watch-retro.test.ts +48 -1
  44. package/src/watch/watch-retro.ts +46 -4
@@ -64,6 +64,7 @@ import {
64
64
  type SlackMessageMetadata,
65
65
  writeSlackMetadata,
66
66
  } from "../../messaging/providers/slack/message-metadata.js";
67
+ import { mergeProviderMessageMetadata } from "../../messaging/read-provider-metadata.js";
67
68
  import { MESSAGE_PREVIEW_MAX_LENGTH } from "../../notifications/notification-utils.js";
68
69
  import {
69
70
  attachInlineAttachmentToMessage,
@@ -94,6 +95,7 @@ import { upsertBinding } from "../../persistence/external-conversation-store.js"
94
95
  import type { ContentBlock } from "../../providers/types.js";
95
96
  import { checkIngressForSecrets } from "../../security/secret-ingress.js";
96
97
  import { canonicalizeInboundIdentity } from "../../util/canonicalize-identity.js";
98
+ import { safeParseRecord } from "../../util/json.js";
97
99
  import { getLogger } from "../../util/logger.js";
98
100
  import { truncate } from "../../util/truncate.js";
99
101
  import {
@@ -567,43 +569,15 @@ export async function handleChannelInbound({
567
569
  return { accepted: true, deleted: false };
568
570
  }
569
571
 
570
- // Merge deletedAt into the existing slackMeta sub-key. If the row has
571
- // no slackMeta (legacy pre-upgrade row), skip — the renderer's flat
572
- // fallback ignores deletedAt for those rows anyway, and synthesizing
573
- // a partial slackMeta here would produce metadata that fails
574
- // readSlackMetadata validation.
572
+ // Merge deletedAt into the existing slackMeta sub-key when the row has
573
+ // one, so the Slack transcript renderer keeps seeing its own envelope.
574
+ // A row without it (a legacy pre-enrichment row) stamps the neutral
575
+ // shape instead: readProviderMetadata serves either to every
576
+ // channel-agnostic reader, so no delete goes unmarked over a metadata
577
+ // accident.
575
578
  const row = getMessageById(original.messageId);
576
- if (!row?.metadata) {
577
- log.debug(
578
- {
579
- conversationExternalId,
580
- deletedMessageTs,
581
- messageId: original.messageId,
582
- },
583
- "Stored Slack message has no metadata; skipping delete marker",
584
- );
585
- return { accepted: true, deleted: false };
586
- }
587
-
588
- let parentMetadata: Record<string, unknown>;
589
- try {
590
- const parsed = JSON.parse(row.metadata) as unknown;
591
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
592
- parentMetadata = parsed as Record<string, unknown>;
593
- } else {
594
- parentMetadata = {};
595
- }
596
- } catch {
597
- log.debug(
598
- {
599
- conversationExternalId,
600
- deletedMessageTs,
601
- messageId: original.messageId,
602
- },
603
- "Failed to parse stored metadata; skipping delete marker",
604
- );
605
- return { accepted: true, deleted: false };
606
- }
579
+ const parentMetadata: Record<string, unknown> =
580
+ row?.metadata != null ? safeParseRecord(row.metadata) : {};
607
581
 
608
582
  const existingSlackMeta =
609
583
  typeof parentMetadata.slackMeta === "string"
@@ -611,15 +585,28 @@ export async function handleChannelInbound({
611
585
  : null;
612
586
 
613
587
  if (!existingSlackMeta) {
614
- log.debug(
588
+ const providerMeta = mergeProviderMessageMetadata(
589
+ row?.metadata ?? null,
590
+ {
591
+ source: sourceChannel,
592
+ conversationExternalId,
593
+ messageId: deletedMessageTs,
594
+ ...(sourceMetadata?.threadId
595
+ ? { threadId: sourceMetadata.threadId }
596
+ : {}),
597
+ },
598
+ { deletedAt: Date.now() },
599
+ );
600
+ updateMessageMetadata(original.messageId, { providerMeta });
601
+ log.info(
615
602
  {
616
603
  conversationExternalId,
617
604
  deletedMessageTs,
618
605
  messageId: original.messageId,
619
606
  },
620
- "Stored Slack message has no slackMeta; skipping delete marker",
607
+ "Marked message deleted via neutral metadata",
621
608
  );
622
- return { accepted: true, deleted: false };
609
+ return { accepted: true, deleted: true, messageId: original.messageId };
623
610
  }
624
611
 
625
612
  const updatedSlackMeta = mergeSlackMetadata(existingSlackMeta, {
@@ -20,10 +20,10 @@ import {
20
20
  mergeSlackMetadata,
21
21
  readSlackMetadata,
22
22
  } from "../../../messaging/providers/slack/message-metadata.js";
23
+ import { mergeProviderMessageMetadata } from "../../../messaging/read-provider-metadata.js";
23
24
  import type { MessageRow } from "../../../persistence/conversation-crud.js";
24
25
  import {
25
26
  getMessageById,
26
- updateMessageContent,
27
27
  updateMessageContentAndMetadata,
28
28
  } from "../../../persistence/conversation-crud.js";
29
29
  import {
@@ -31,6 +31,7 @@ import {
31
31
  findMessageBySourceId,
32
32
  recordInbound,
33
33
  } from "../../../persistence/delivery-crud.js";
34
+ import { markProcessed } from "../../../persistence/delivery-status.js";
34
35
  import { enqueueLexicalIndexForMessage } from "../../../persistence/job-handlers/message-lexical.js";
35
36
  import { stringifyMessageContent } from "../../../persistence/message-content.js";
36
37
  import { safeParseRecord } from "../../../util/json.js";
@@ -182,6 +183,7 @@ export async function handleEditIntercept(
182
183
  },
183
184
  "Edit text unchanged; skipping update",
184
185
  );
186
+ markProcessed(editResult.eventId);
185
187
  return {
186
188
  accepted: true,
187
189
  duplicate: false,
@@ -203,13 +205,29 @@ export async function handleEditIntercept(
203
205
  newContent,
204
206
  });
205
207
  } else {
206
- updateMessageContent(original.messageId, newContent);
208
+ // Every channel marks its edits. Slack's envelope carries the extra
209
+ // fields its own renderer needs; the rest stamp the neutral shape that
210
+ // readProviderMetadata serves to every channel-agnostic reader.
211
+ const providerMeta = mergeProviderMessageMetadata(
212
+ existingRow?.metadata ?? null,
213
+ {
214
+ source: sourceChannel,
215
+ conversationExternalId,
216
+ messageId: sourceMessageId,
217
+ ...(sourceThreadId ? { threadId: sourceThreadId } : {}),
218
+ },
219
+ { editedAt: Date.now() },
220
+ );
221
+ updateMessageContentAndMetadata(original.messageId, newContent, {
222
+ providerMeta,
223
+ });
207
224
  }
208
225
  // The edit changed searchable text (the no-op guard above already returned
209
226
  // for identical content) and this path bypasses the `addMessage` persist
210
227
  // path, so reindex the message into the lexical index — the idempotent
211
228
  // upsert replaces the stale Qdrant point with the edited content.
212
229
  enqueueLexicalIndexForMessage(original.messageId);
230
+ markProcessed(editResult.eventId);
213
231
  log.info(
214
232
  { assistantId, sourceMessageId, messageId: original.messageId },
215
233
  "Updated message content from edited_message",
@@ -44,6 +44,7 @@ import {
44
44
  } from "../../security/secure-keys.js";
45
45
  import {
46
46
  getCredentialMetadata,
47
+ persistCredentialMetadata,
47
48
  upsertCredentialMetadata,
48
49
  } from "../../tools/credentials/metadata-store.js";
49
50
  import { getLogger } from "../../util/logger.js";
@@ -156,7 +157,8 @@ export async function reconcileVellumMetadataFromCes(warningSink: {
156
157
  if (getCredentialMetadata("vellum", field)) {
157
158
  continue;
158
159
  }
159
- upsertCredentialMetadata("vellum", field, {});
160
+ const metadata = upsertCredentialMetadata("vellum", field, {});
161
+ await persistCredentialMetadata(metadata);
160
162
  log.info(
161
163
  { field },
162
164
  "Reconciled vellum:* metadata entry from CES after import",
@@ -0,0 +1,123 @@
1
+ /**
2
+ * CES RPC backend for non-secret credential records (identity + policy).
3
+ */
4
+
5
+ import type { CredentialRecord } from "@vellumai/service-contracts/credential-rpc";
6
+ import { CesRpcMethod } from "@vellumai/service-contracts/credential-rpc";
7
+
8
+ import type { CesClient } from "../credential-execution/client.js";
9
+ import { getLogger } from "../util/logger.js";
10
+
11
+ const log = getLogger("ces-rpc-record-backend");
12
+
13
+ export interface CredentialRecordBackend {
14
+ isAvailable(): boolean;
15
+ get(account: string): Promise<CredentialRecord | undefined>;
16
+ set(account: string, record: CredentialRecord): Promise<boolean>;
17
+ delete(account: string): Promise<"deleted" | "not-found" | "error">;
18
+ /**
19
+ * List all records. Returns `null` when CES cannot be reached so callers
20
+ * can distinguish an outage from an empty catalog.
21
+ */
22
+ list(): Promise<Array<{
23
+ account: string;
24
+ record: CredentialRecord;
25
+ }> | null>;
26
+ bulkSet(
27
+ records: Array<{ account: string; record: CredentialRecord }>,
28
+ ): Promise<Array<{ account: string; ok: boolean }>>;
29
+ }
30
+
31
+ export class CesRpcRecordBackend implements CredentialRecordBackend {
32
+ constructor(private readonly client: CesClient) {}
33
+
34
+ isAvailable(): boolean {
35
+ return this.client.isReady();
36
+ }
37
+
38
+ async get(account: string): Promise<CredentialRecord | undefined> {
39
+ if (!this.isAvailable()) {
40
+ return undefined;
41
+ }
42
+ try {
43
+ const result = await this.client.call(CesRpcMethod.GetCredentialRecord, {
44
+ account,
45
+ });
46
+ return result.found ? result.record : undefined;
47
+ } catch (err) {
48
+ log.warn({ err, account }, "CES RPC credential record get failed");
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ async set(account: string, record: CredentialRecord): Promise<boolean> {
54
+ if (!this.isAvailable()) {
55
+ return false;
56
+ }
57
+ try {
58
+ const result = await this.client.call(CesRpcMethod.SetCredentialRecord, {
59
+ account,
60
+ record,
61
+ });
62
+ return result.ok;
63
+ } catch (err) {
64
+ log.warn({ err, account }, "CES RPC credential record set failed");
65
+ return false;
66
+ }
67
+ }
68
+
69
+ async delete(
70
+ account: string,
71
+ ): Promise<"deleted" | "not-found" | "error"> {
72
+ if (!this.isAvailable()) {
73
+ return "error";
74
+ }
75
+ try {
76
+ const result = await this.client.call(
77
+ CesRpcMethod.DeleteCredentialRecord,
78
+ { account },
79
+ );
80
+ return result.result;
81
+ } catch (err) {
82
+ log.warn({ err, account }, "CES RPC credential record delete failed");
83
+ return "error";
84
+ }
85
+ }
86
+
87
+ async list(): Promise<Array<{
88
+ account: string;
89
+ record: CredentialRecord;
90
+ }> | null> {
91
+ if (!this.isAvailable()) {
92
+ return null;
93
+ }
94
+ try {
95
+ const result = await this.client.call(
96
+ CesRpcMethod.ListCredentialRecords,
97
+ {},
98
+ );
99
+ return result.records ?? [];
100
+ } catch (err) {
101
+ log.warn({ err }, "CES RPC credential record list failed");
102
+ return null;
103
+ }
104
+ }
105
+
106
+ async bulkSet(
107
+ records: Array<{ account: string; record: CredentialRecord }>,
108
+ ): Promise<Array<{ account: string; ok: boolean }>> {
109
+ if (!this.isAvailable()) {
110
+ return records.map((entry) => ({ account: entry.account, ok: false }));
111
+ }
112
+ try {
113
+ const result = await this.client.call(
114
+ CesRpcMethod.BulkSetCredentialRecords,
115
+ { records },
116
+ );
117
+ return result.results;
118
+ } catch (err) {
119
+ log.warn({ err }, "CES RPC credential record bulk set failed");
120
+ return records.map((entry) => ({ account: entry.account, ok: false }));
121
+ }
122
+ }
123
+ }
@@ -156,6 +156,21 @@ export function setCesClient(client: CesClient | undefined): void {
156
156
  "CES client updated; resetting resolved credential backend cache",
157
157
  );
158
158
  _cesClientListener?.(client);
159
+ void attachCredentialRecordBackend(client);
160
+ }
161
+
162
+ async function attachCredentialRecordBackend(
163
+ client: CesClient | undefined,
164
+ ): Promise<void> {
165
+ const { CesRpcRecordBackend } = await import("./ces-rpc-record-backend.js");
166
+ const { setCredentialRecordBackend } = await import(
167
+ "../tools/credentials/metadata-store.js"
168
+ );
169
+ if (!client) {
170
+ setCredentialRecordBackend(undefined);
171
+ return;
172
+ }
173
+ setCredentialRecordBackend(new CesRpcRecordBackend(client));
159
174
  }
160
175
 
161
176
  /**
@@ -1,24 +1,29 @@
1
1
  /**
2
2
  * Credential metadata store.
3
3
  *
4
- * Thin wrapper around the portable StaticCredentialMetadataStore from
5
- * @vellumai/credential-storage. Wires in the platform-specific data
6
- * directory and preserves the existing module-level API so that call
7
- * sites throughout the assistant daemon do not need to change.
4
+ * Reads stay on the workspace `metadata.json` file (sync). When a CES
5
+ * record backend is injected, upserts and deletes also write-through to
6
+ * CES so the CES catalog stays current.
8
7
  *
9
- * OAuth-specific fields (expiresAt, grantedScopes, oauth2TokenUrl,
10
- * oauth2ClientId, oauth2TokenEndpointAuthMethod, hasRefreshToken) are now
11
- * exclusively managed by the SQLite oauth-store and have been removed
12
- * from this interface as of v5.
8
+ * Tests: `_setMetadataPath` keeps the file-backed store and skips CES
9
+ * write-through.
13
10
  */
14
11
 
15
12
  import { join } from "node:path";
16
13
 
17
- import { StaticCredentialMetadataStore } from "@vellumai/credential-storage";
14
+ import {
15
+ credentialKey,
16
+ StaticCredentialMetadataStore,
17
+ } from "@vellumai/credential-storage";
18
+ import type { CredentialRecord } from "@vellumai/service-contracts/credential-rpc";
18
19
 
20
+ import type { CredentialRecordBackend } from "../../security/ces-rpc-record-backend.js";
21
+ import { getLogger } from "../../util/logger.js";
19
22
  import { getDataDir } from "../../util/platform.js";
20
23
  import type { CredentialInjectionTemplate } from "./policy-types.js";
21
24
 
25
+ const log = getLogger("credential-metadata-store");
26
+
22
27
  /**
23
28
  * CredentialMetadata extends the shared StaticCredentialRecord with
24
29
  * assistant-specific injection template fields (composeWith, valueTransform).
@@ -43,12 +48,9 @@ export interface CredentialMetadata {
43
48
  // Singleton store instance
44
49
  // ---------------------------------------------------------------------------
45
50
 
46
- /**
47
- * Lazily initialised store instance. The path is determined on first access
48
- * (or overridden via `_setMetadataPath` for tests).
49
- */
50
51
  let _store: StaticCredentialMetadataStore | undefined;
51
52
  let _overridePath: string | null = null;
53
+ let _recordBackend: CredentialRecordBackend | undefined;
52
54
 
53
55
  function getStore(): StaticCredentialMetadataStore {
54
56
  if (!_store) {
@@ -59,6 +61,48 @@ function getStore(): StaticCredentialMetadataStore {
59
61
  return _store;
60
62
  }
61
63
 
64
+ function toRecord(metadata: CredentialMetadata): CredentialRecord {
65
+ return {
66
+ credentialId: metadata.credentialId,
67
+ service: metadata.service,
68
+ field: metadata.field,
69
+ allowedTools: metadata.allowedTools,
70
+ allowedDomains: metadata.allowedDomains,
71
+ usageDescription: metadata.usageDescription,
72
+ alias: metadata.alias,
73
+ injectionTemplates: metadata.injectionTemplates,
74
+ createdAt: metadata.createdAt,
75
+ updatedAt: metadata.updatedAt,
76
+ };
77
+ }
78
+
79
+ async function persistRecord(metadata: CredentialMetadata): Promise<void> {
80
+ if (!_recordBackend || _overridePath) {
81
+ return;
82
+ }
83
+ const account = credentialKey(metadata.service, metadata.field);
84
+ const ok = await _recordBackend.set(account, toRecord(metadata));
85
+ if (!ok) {
86
+ log.warn(
87
+ { service: metadata.service, field: metadata.field },
88
+ "Failed to persist credential record to CES",
89
+ );
90
+ }
91
+ }
92
+
93
+ /** Await CES write-through for a record already in the local file store. */
94
+ export async function persistCredentialMetadata(
95
+ metadata: CredentialMetadata,
96
+ ): Promise<void> {
97
+ await persistRecord(metadata);
98
+ }
99
+
100
+ export function setCredentialRecordBackend(
101
+ backend: CredentialRecordBackend | undefined,
102
+ ): void {
103
+ _recordBackend = backend;
104
+ }
105
+
62
106
  // ---------------------------------------------------------------------------
63
107
  // Public API - unchanged signatures, delegates to shared store
64
108
  // ---------------------------------------------------------------------------
@@ -89,7 +133,9 @@ export function upsertCredentialMetadata(
89
133
  injectionTemplates?: CredentialInjectionTemplate[] | null;
90
134
  },
91
135
  ): CredentialMetadata {
92
- return getStore().upsert(service, field, policy) as CredentialMetadata;
136
+ const record = getStore().upsert(service, field, policy) as CredentialMetadata;
137
+ void persistRecord(record);
138
+ return record;
93
139
  }
94
140
 
95
141
  /**
@@ -127,13 +173,16 @@ export function deleteCredentialMetadata(
127
173
  service: string,
128
174
  field: string,
129
175
  ): boolean {
130
- return getStore().delete(service, field);
176
+ const deleted = getStore().delete(service, field);
177
+ if (deleted && _recordBackend && !_overridePath) {
178
+ void _recordBackend.delete(credentialKey(service, field));
179
+ }
180
+ return deleted;
131
181
  }
132
182
 
133
183
  /** @internal Test-only: override the metadata file path. */
134
184
  export function _setMetadataPath(path: string | null): void {
135
185
  _overridePath = path;
136
- // Reset the store so it picks up the new path
137
186
  if (_store) {
138
187
  if (path) {
139
188
  _store.setPath(path);
@@ -37,6 +37,7 @@ import {
37
37
  import { getLogger } from "../../util/logger.js";
38
38
  import {
39
39
  assertMetadataWritable,
40
+ persistCredentialMetadata,
40
41
  upsertCredentialMetadata,
41
42
  } from "./metadata-store.js";
42
43
  import type { CredentialInjectionTemplate } from "./policy-types.js";
@@ -161,6 +162,7 @@ export async function storeCredentialValue(
161
162
  allowedDomains: input.allowedDomains,
162
163
  injectionTemplates: input.injectionTemplates,
163
164
  });
165
+ await persistCredentialMetadata(metadata);
164
166
  const { syncManualTokenConnection } =
165
167
  await import("../../oauth/manual-token-connection.js");
166
168
  await syncManualTokenConnection(service);
@@ -247,6 +247,51 @@ describe("watch retrospective", () => {
247
247
  );
248
248
  });
249
249
 
250
+ test("puts the report last in the turn, after the skill it hands off to", async () => {
251
+ const summary = recordSession(["renaming the export"]);
252
+ const { calls, dispatch } = recordingDispatch();
253
+
254
+ await runWatchRetro(summary, { dispatch });
255
+
256
+ const { prompt } = calls[0]!;
257
+ // The report is what the user is shown when a session ends, so it has to
258
+ // be the turn's last prose. A client renders the final text block as the
259
+ // response and folds everything before it into collapsed intermediate
260
+ // work, so a `skill_load` issued after the report demotes the report to
261
+ // an "Earlier activity" row the user has to unfold to read.
262
+ expect(prompt).toContain("Load the `skill-management` skill first");
263
+ expect(
264
+ prompt.indexOf("Load the `skill-management` skill first"),
265
+ ).toBeLessThan(prompt.indexOf("What I need from you"));
266
+ expect(prompt).toContain("That report is the last thing you do this turn");
267
+ // A sign-off is another text block after the report, which puts the report
268
+ // back on the wrong side of the same rule, so it is refused by name.
269
+ expect(prompt).toContain("no sign-off");
270
+ expect(prompt).toContain("no further tool call");
271
+ });
272
+
273
+ test("writes the report even when the skill it hands off to will not load", async () => {
274
+ const summary = recordSession(["filing the receipt"]);
275
+ const { calls, dispatch } = recordingDispatch();
276
+
277
+ await runWatchRetro(summary, { dispatch });
278
+
279
+ const { prompt } = calls[0]!;
280
+ // `skill-management` is a selector, and a managed or workspace skill of the
281
+ // same id replaces the bundled one in the catalog. Putting the load ahead
282
+ // of the report is what lets a shadow, or the refusal a clientless wake
283
+ // gives an inline-command load, land before the user has been told
284
+ // anything. Neither may become the retro: the session is recorded and the
285
+ // account of it is what the user is owed.
286
+ expect(prompt).toContain(
287
+ "Write the report below whether or not that load succeeds",
288
+ );
289
+ expect(prompt).toContain("do not report on the load and do not retry it");
290
+ // The failure is still named, so a missing handoff does not read as a
291
+ // report that simply chose not to ask about authoring.
292
+ expect(prompt).toContain("could not open the skill-authoring flow");
293
+ });
294
+
250
295
  test("authors nothing until the user has confirmed", async () => {
251
296
  const summary = recordSession(["exporting the sheet"]);
252
297
  const { calls, dispatch } = recordingDispatch();
@@ -605,7 +650,9 @@ describe("watch retrospective", () => {
605
650
  expect(payload).toBeGreaterThan(opened);
606
651
  expect(payload).toBeLessThan(closed);
607
652
  // The retrospective's own instructions sit outside it.
608
- expect(prompt.indexOf("Write back to the user")).toBeGreaterThan(closed);
653
+ expect(
654
+ prompt.indexOf("Load the `skill-management` skill first"),
655
+ ).toBeGreaterThan(closed);
609
656
 
610
657
  // One real envelope, so nothing in the recording can pass for a second.
611
658
  expect(prompt.match(/<external_content/g)).toHaveLength(1);
@@ -76,16 +76,58 @@ const WATCH_RETRO_WAKE_SOURCE = "watch-retro";
76
76
  * gap between those two is the whole risk of turning a demonstration into a
77
77
  * skill. `skill-management` will not scaffold until that step is settled
78
78
  * either, so an unasked one stalls the flow it was meant to feed.
79
+ *
80
+ * **The skill loads before the report is written, and nothing follows the
81
+ * report.** This is about where the report lands on screen, not about the
82
+ * order the work happens in. A client renders an assistant turn as its final
83
+ * prose plus the intermediate work that led there, and the web transcript
84
+ * collapses that intermediate half into an "Earlier activity" disclosure that
85
+ * a settled turn shows closed (`finalResponseGroupIndex` in
86
+ * `transcript-message-body.tsx`). Everything before the turn's last non-empty
87
+ * text block is intermediate by that rule, including prose. Writing the report
88
+ * first and loading the skill after it puts a `skill_load` and whatever the
89
+ * model says once the skill is in hand *after* the report, which demotes the
90
+ * report to intermediate work and hides it behind a collapsed row next to the
91
+ * reasoning rows. The user presses stop on a session and is shown a folded-up
92
+ * link where the account of it should be.
93
+ *
94
+ * So the load comes first and the report is last. `skill-management` opens on
95
+ * "Ask before doing anything", so a turn that loads it and then asks is
96
+ * following it rather than jumping the flow, and the report is the alignment
97
+ * its first step calls for. The instruction that nothing follows the report is
98
+ * as load-bearing as the ordering: one trailing line of sign-off is another
99
+ * text block after it, and the report is intermediate again.
100
+ *
101
+ * **The report does not depend on the load succeeding.** `skill-management` is
102
+ * a selector, not a fixed definition: `loadSkillCatalog` lets a managed or
103
+ * workspace skill of the same id replace the bundled one, and
104
+ * `resolveSkillSelector` hands back whichever entry won. So the load can come
105
+ * back as someone else's skill, or as a refusal, and putting it ahead of the
106
+ * report is what makes either one land before the user has been told anything.
107
+ * A refusal is the likelier of the two here: this wake is `clientless`, and an
108
+ * inline-command load with no human present is denied outright
109
+ * (`permissions/checker.ts`, `isDynamicSkillLoadInvocation`). The bundled skill
110
+ * carries no such expansions, so the ordinary path is unaffected, but a shadow
111
+ * that carries them is denied on arrival.
112
+ *
113
+ * Neither outcome is allowed to become the retro. The session was recorded, the
114
+ * timeline is already in this prompt, and the account of it is the one thing the
115
+ * user is owed for having pressed stop; a permission error where that account
116
+ * should be is the same empty thread the `surfaceConversation` guard exists to
117
+ * prevent, just with prose in it. So the instructions write the report either
118
+ * way and say the handoff did not happen, rather than reporting on the load.
79
119
  */
80
- const RETRO_INSTRUCTIONS = `Write back to the user in two sections, in this order, both as level-2 headings.
120
+ const RETRO_INSTRUCTIONS = `Load the \`skill-management\` skill first, before you write anything, and follow it. Do not author or scaffold a skill until the four points that step names are settled: the report below is the alignment its first step calls for.
121
+
122
+ Write the report below whether or not that load succeeds. If it fails, is refused, or comes back as something other than the skill-management flow, do not report on the load and do not retry it: write the report, and end it with one line saying you could not open the skill-authoring flow, so the user knows the handoff is the part that did not happen.
123
+
124
+ Then write back to the user in two sections, in this order, both as level-2 headings. That report is the last thing you do this turn, and nothing follows it: no sign-off, no note about what you loaded, no further tool call. Their answers come back as an ordinary reply and the flow picks up from there.
81
125
 
82
126
  First, "What I need from you". The questions you cannot answer from the recording, numbered, most consequential first. Each one concrete enough to answer in a sentence, and each one about something you are genuinely guessing at: a value you could not read, a choice whose rule you could not infer, a step you only saw the result of. Always ask what they would say to start this task, in their own words, because the recording cannot tell you that. Ask about the done condition if it is unclear. Always confirm any destructive or irreversible step, even one the recording showed plainly: watching someone do a thing once is not agreement to have it done again unattended, and this is the one place the rule below does not apply. Otherwise do not ask them to confirm something the recording already showed you.
83
127
 
84
128
  Second, "What I saw". Open with one sentence naming the task and what it is for, on its own and not as a list item. Then the steps in order beneath it, one line each and concrete enough to follow, carrying no purpose of their own. This is the record your questions sit on top of, so state it rather than asking about it.
85
129
 
86
- Open on the first heading. No preamble, no announcing what you are about to do, no narrating which skills you are loading.
87
-
88
- Then load the \`skill-management\` skill and follow it, treating the answers to your questions as the alignment its first step calls for. Do not author or scaffold a skill until the four points that step names are settled. Correct your reading against whatever they tell you. If they decide this is not worth keeping, say so and stop.`;
130
+ Open on the first heading. No preamble, no announcing what you are about to do, no narrating which skills you are loading. Correct your reading against whatever they tell you. If they decide this is not worth keeping, say so and stop.`;
89
131
 
90
132
  /**
91
133
  * Told to the model whenever the render was bounded, naming the bound that