@vellumai/assistant 0.11.11-staging.2 → 0.11.11-staging.3

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 (30) hide show
  1. package/openapi.yaml +61 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/avatar-identity-sync.test.ts +3 -0
  4. package/src/__tests__/conversation-surfaces-settle-task-progress.test.ts +218 -0
  5. package/src/__tests__/surface-completion-nudge-hook.test.ts +52 -2
  6. package/src/api/surface-show-result.test.ts +78 -0
  7. package/src/api/surface-show-result.ts +118 -0
  8. package/src/avatar/__tests__/avatar-changed-telemetry.test.ts +217 -0
  9. package/src/avatar/__tests__/avatar-store.test.ts +188 -21
  10. package/src/avatar/avatar-changed-telemetry.ts +128 -0
  11. package/src/avatar/avatar-manifest.ts +11 -7
  12. package/src/avatar/avatar-store.ts +91 -16
  13. package/src/avatar/ensure-raster.ts +8 -4
  14. package/src/daemon/conversation-agent-loop.ts +12 -1
  15. package/src/daemon/conversation-surfaces.ts +180 -95
  16. package/src/plugin-api/index.ts +6 -0
  17. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +2 -12
  18. package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +56 -9
  19. package/src/runtime/routes/__tests__/oauth-proxy-routes.test.ts +4 -1
  20. package/src/runtime/routes/avatar-routes.ts +26 -27
  21. package/src/runtime/routes/oauth-proxy-passthrough.test.ts +1 -0
  22. package/src/runtime/routes/oauth-proxy-passthrough.ts +3 -0
  23. package/src/telemetry/AGENTS.md +2 -2
  24. package/src/telemetry/__tests__/telemetry-event-fixtures.ts +18 -0
  25. package/src/telemetry/telemetry-event-sources.test.ts +1 -0
  26. package/src/telemetry/telemetry-wire-source.json +1 -1
  27. package/src/telemetry/telemetry-wire.generated.ts +21 -0
  28. package/src/telemetry/types.ts +6 -0
  29. package/src/tools/ui-surface/definitions.ts +6 -5
  30. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
package/openapi.yaml CHANGED
@@ -34306,6 +34306,67 @@ paths:
34306
34306
  required:
34307
34307
  - type
34308
34308
  - fields
34309
+ - type: object
34310
+ properties:
34311
+ type:
34312
+ type: string
34313
+ const: avatar_changed
34314
+ fields:
34315
+ type: object
34316
+ properties:
34317
+ action:
34318
+ type: string
34319
+ minLength: 1
34320
+ maxLength: 32
34321
+ kind:
34322
+ type: string
34323
+ minLength: 1
34324
+ maxLength: 16
34325
+ previous_kind:
34326
+ type: string
34327
+ minLength: 1
34328
+ maxLength: 16
34329
+ body_shape:
34330
+ type: string
34331
+ minLength: 1
34332
+ maxLength: 256
34333
+ eye_style:
34334
+ type: string
34335
+ minLength: 1
34336
+ maxLength: 256
34337
+ color:
34338
+ type: string
34339
+ minLength: 1
34340
+ maxLength: 256
34341
+ accent_hex:
34342
+ type: string
34343
+ minLength: 1
34344
+ maxLength: 7
34345
+ accent_source:
34346
+ type: string
34347
+ minLength: 1
34348
+ maxLength: 16
34349
+ client_os:
34350
+ type: string
34351
+ minLength: 1
34352
+ maxLength: 64
34353
+ required:
34354
+ - action
34355
+ - kind
34356
+ - previous_kind
34357
+ description:
34358
+ Wire event fields, excluding the daemon-stamped base fields (type, daemon_event_id, recorded_at,
34359
+ assistant_version).
34360
+ daemon_event_id:
34361
+ description:
34362
+ "Optional collapse key: rows sharing an id collapse downstream (e.g. a retried report). Defaults to a fresh
34363
+ per-row id."
34364
+ type: string
34365
+ minLength: 1
34366
+ maxLength: 128
34367
+ required:
34368
+ - type
34369
+ - fields
34309
34370
  type: object
34310
34371
  responses:
34311
34372
  "200":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.11-staging.2",
3
+ "version": "0.11.11-staging.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,6 +3,9 @@ import { describe, expect, mock, test } from "bun:test";
3
3
  mock.module("../platform/sync-avatar.js", () => ({
4
4
  syncAvatarToPlatform: () => {},
5
5
  }));
6
+ mock.module("../telemetry/telemetry-events-outbox.js", () => ({
7
+ recordTelemetryEvent: () => ({ id: "evt", createdAt: 0 }),
8
+ }));
6
9
 
7
10
  import type { AssistantEventEnvelope } from "../api/index.js";
8
11
  import { clearAvatar } from "../avatar/avatar-store.js";
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Tests for `settleRunningTaskProgressSurfaces`: the turn-end floor that keeps
3
+ * a task_progress card from spinning after the turn it belonged to is over.
4
+ *
5
+ * Covers:
6
+ * - A card or step still `in_progress` becomes `pending`, via a real
7
+ * `ui_surface_update` and the stored surface state.
8
+ * - Steps already `completed` or `failed` keep their status; nothing is
9
+ * promoted to `completed`.
10
+ * - A card in a terminal state, a card with no running step, and non-card /
11
+ * non-task_progress surfaces are left untouched.
12
+ * - A card from an earlier turn (absent from `currentTurnSurfaces`) is settled
13
+ * too; the settle is idempotent.
14
+ */
15
+
16
+ import { describe, expect, test } from "bun:test";
17
+
18
+ import type pino from "pino";
19
+
20
+ import type { AssistantEvent } from "../api/index.js";
21
+ import type { Conversation } from "../daemon/conversation.js";
22
+ import {
23
+ createSurfaceMutex,
24
+ settleRunningTaskProgressSurfaces,
25
+ surfaceProxyResolver,
26
+ } from "../daemon/conversation-surfaces.js";
27
+ import type {
28
+ CardSurfaceData,
29
+ SurfaceType,
30
+ UISurfaceUpdateEvent,
31
+ } from "../daemon/message-protocol.js";
32
+ import { asConversation } from "./helpers/mock-conversation.js";
33
+
34
+ const noopLogger = {
35
+ info: () => {},
36
+ warn: () => {},
37
+ error: () => {},
38
+ debug: () => {},
39
+ } as unknown as pino.Logger;
40
+
41
+ function makeContext(sent: AssistantEvent[] = []): Conversation {
42
+ return asConversation({
43
+ conversationId: "session-1",
44
+ emit: (msg) => sent.push(msg),
45
+ pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
46
+ lastSurfaceAction: new Map<
47
+ string,
48
+ { actionId: string; data?: Record<string, unknown> }
49
+ >(),
50
+ surfaceState: new Map(),
51
+ surfaceUndoStacks: new Map<string, string[]>(),
52
+ accumulatedSurfaceState: new Map<string, Record<string, unknown>>(),
53
+ surfaceActionRequestIds: new Set<string>(),
54
+ currentTurnSurfaces: [],
55
+ isProcessing: () => false,
56
+ enqueueMessage: () => ({ queued: false, requestId: "req-1" }),
57
+ getQueueDepth: () => 0,
58
+ processMessage: async () => "ok",
59
+ withSurface: createSurfaceMutex(),
60
+ });
61
+ }
62
+
63
+ async function showTaskProgress(
64
+ ctx: Conversation,
65
+ templateData: Record<string, unknown>,
66
+ ): Promise<string> {
67
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
68
+ surface_type: "card",
69
+ title: "Working",
70
+ data: { template: "task_progress", templateData },
71
+ });
72
+ expect(result.isError).toBe(false);
73
+ return (JSON.parse(result.content as string) as { surfaceId: string })
74
+ .surfaceId;
75
+ }
76
+
77
+ function updatesFor(sent: AssistantEvent[], surfaceId: string) {
78
+ return sent.filter(
79
+ (msg): msg is UISurfaceUpdateEvent =>
80
+ msg.type === "ui_surface_update" && msg.surfaceId === surfaceId,
81
+ );
82
+ }
83
+
84
+ function storedTemplateData(
85
+ ctx: Conversation,
86
+ surfaceId: string,
87
+ ): Record<string, unknown> {
88
+ const stored = ctx.surfaceState.get(surfaceId);
89
+ if (!stored || stored.surfaceType !== "card") {
90
+ throw new Error(`no stored card for ${surfaceId}`);
91
+ }
92
+ return (stored.data as CardSurfaceData).templateData as Record<
93
+ string,
94
+ unknown
95
+ >;
96
+ }
97
+
98
+ describe("settleRunningTaskProgressSurfaces", () => {
99
+ test("settles a running card and its running step to pending, keeping finished steps", async () => {
100
+ const sent: AssistantEvent[] = [];
101
+ const ctx = makeContext(sent);
102
+ const surfaceId = await showTaskProgress(ctx, {
103
+ title: "Send the follow-up",
104
+ status: "in_progress",
105
+ steps: [
106
+ { label: "Draft email", status: "completed" },
107
+ { label: "Send to contact", status: "in_progress" },
108
+ { label: "Log it", status: "pending" },
109
+ { label: "Archive", status: "failed" },
110
+ ],
111
+ });
112
+
113
+ settleRunningTaskProgressSurfaces(ctx, noopLogger);
114
+
115
+ const updates = updatesFor(sent, surfaceId);
116
+ expect(updates).toHaveLength(1);
117
+ const templateData = (updates[0].data as CardSurfaceData)
118
+ .templateData as Record<string, unknown>;
119
+ expect(templateData.status).toBe("pending");
120
+ expect(templateData.steps).toEqual([
121
+ { label: "Draft email", status: "completed" },
122
+ { label: "Send to contact", status: "pending" },
123
+ { label: "Log it", status: "pending" },
124
+ { label: "Archive", status: "failed" },
125
+ ]);
126
+ expect(storedTemplateData(ctx, surfaceId)).toEqual(templateData);
127
+ });
128
+
129
+ test("settles a running step under a card whose own status is already terminal", async () => {
130
+ const sent: AssistantEvent[] = [];
131
+ const ctx = makeContext(sent);
132
+ const surfaceId = await showTaskProgress(ctx, {
133
+ status: "failed",
134
+ steps: [{ label: "Only step", status: "in_progress" }],
135
+ });
136
+
137
+ settleRunningTaskProgressSurfaces(ctx, noopLogger);
138
+
139
+ const templateData = storedTemplateData(ctx, surfaceId);
140
+ expect(templateData.status).toBe("failed");
141
+ expect(templateData.steps).toEqual([
142
+ { label: "Only step", status: "pending" },
143
+ ]);
144
+ });
145
+
146
+ test("leaves a card alone when nothing on it is running", async () => {
147
+ const sent: AssistantEvent[] = [];
148
+ const ctx = makeContext(sent);
149
+ const completed = await showTaskProgress(ctx, {
150
+ status: "completed",
151
+ steps: [{ label: "Done", status: "completed" }],
152
+ });
153
+ const parked = await showTaskProgress(ctx, {
154
+ status: "pending",
155
+ steps: [{ label: "Later", status: "pending" }],
156
+ });
157
+ sent.length = 0;
158
+
159
+ settleRunningTaskProgressSurfaces(ctx, noopLogger);
160
+
161
+ expect(updatesFor(sent, completed)).toHaveLength(0);
162
+ expect(updatesFor(sent, parked)).toHaveLength(0);
163
+ expect(storedTemplateData(ctx, parked).status).toBe("pending");
164
+ });
165
+
166
+ test("ignores surfaces that are not task_progress cards", async () => {
167
+ const sent: AssistantEvent[] = [];
168
+ const ctx = makeContext(sent);
169
+ const plainCard = await surfaceProxyResolver(ctx, "ui_show", {
170
+ surface_type: "card",
171
+ data: { title: "Note", body: "status: in_progress" },
172
+ });
173
+ const workResult = await surfaceProxyResolver(ctx, "ui_show", {
174
+ surface_type: "work_result",
175
+ data: { status: "in_progress", summary: "Crunching" },
176
+ });
177
+ expect(plainCard.isError).toBe(false);
178
+ expect(workResult.isError).toBe(false);
179
+ sent.length = 0;
180
+
181
+ settleRunningTaskProgressSurfaces(ctx, noopLogger);
182
+
183
+ expect(sent.filter((msg) => msg.type === "ui_surface_update")).toHaveLength(
184
+ 0,
185
+ );
186
+ });
187
+
188
+ test("settles a card shown on an earlier turn and is idempotent", async () => {
189
+ const sent: AssistantEvent[] = [];
190
+ const ctx = makeContext(sent);
191
+ const surfaceId = await showTaskProgress(ctx, {
192
+ status: "in_progress",
193
+ steps: [{ label: "Step", status: "in_progress" }],
194
+ });
195
+ // The prior turn's snapshot has been persisted and cleared.
196
+ ctx.currentTurnSurfaces = [];
197
+ sent.length = 0;
198
+
199
+ settleRunningTaskProgressSurfaces(ctx, noopLogger);
200
+ settleRunningTaskProgressSurfaces(ctx, noopLogger);
201
+
202
+ expect(updatesFor(sent, surfaceId)).toHaveLength(1);
203
+ const templateData = storedTemplateData(ctx, surfaceId);
204
+ expect(templateData.status).toBe("pending");
205
+ expect(templateData.steps).toEqual([{ label: "Step", status: "pending" }]);
206
+ });
207
+
208
+ test("a card shown with a pending status keeps it", async () => {
209
+ const sent: AssistantEvent[] = [];
210
+ const ctx = makeContext(sent);
211
+ const surfaceId = await showTaskProgress(ctx, {
212
+ status: "pending",
213
+ steps: [{ label: "Step", status: "pending" }],
214
+ });
215
+
216
+ expect(storedTemplateData(ctx, surfaceId).status).toBe("pending");
217
+ });
218
+ });
@@ -12,6 +12,9 @@
12
12
  * turn, and a non-main-agent call site.
13
13
  * - The signal is scoped to the current response cycle — a surface left open in
14
14
  * a prior cycle (before the last genuine user prompt) does not trigger it.
15
+ * - The `surface_id` correlation reads the tool result the real `ui_show` tool
16
+ * produces, including the update hint it carries on a task_progress card, so
17
+ * the producer and this consumer cannot drift apart unnoticed.
15
18
  * - The one-shot bound is split across the two hooks: `post-model-call` marks it
16
19
  * (nudging at most once per run) and `stop` clears it so the next run nudges
17
20
  * afresh.
@@ -36,6 +39,7 @@ import {
36
39
  resetSurfaceCompletionNudgeStoreForTests,
37
40
  } from "../plugins/defaults/surface-completion-nudge/nudge-state-store.js";
38
41
  import type { ContentBlock, Message } from "../providers/types.js";
42
+ import { uiShowTool } from "../tools/ui-surface/definitions.js";
39
43
 
40
44
  // ─── Fixtures ────────────────────────────────────────────────────────────────
41
45
 
@@ -54,7 +58,11 @@ let surfaceCounter = 0;
54
58
  * An assistant `ui_show` turn paired with its `{ surfaceId }` tool result.
55
59
  * Returns both messages plus the assigned surface id.
56
60
  */
57
- function showSurface(input: Record<string, unknown>): {
61
+ function showSurface(
62
+ input: Record<string, unknown>,
63
+ resultContent: (surfaceId: string) => string = (surfaceId) =>
64
+ JSON.stringify({ surfaceId }),
65
+ ): {
58
66
  messages: Message[];
59
67
  surfaceId: string;
60
68
  } {
@@ -74,7 +82,7 @@ function showSurface(input: Record<string, unknown>): {
74
82
  {
75
83
  type: "tool_result",
76
84
  tool_use_id: toolUseId,
77
- content: JSON.stringify({ surfaceId }),
85
+ content: resultContent(surfaceId),
78
86
  },
79
87
  ],
80
88
  },
@@ -82,6 +90,28 @@ function showSurface(input: Record<string, unknown>): {
82
90
  };
83
91
  }
84
92
 
93
+ /**
94
+ * The tool result the real `ui_show` tool hands the model for `input`, with
95
+ * the daemon's proxy answering `{ surfaceId }`. Exercises the same code path
96
+ * production history is written from, hint and all.
97
+ */
98
+ async function realUiShowResult(
99
+ input: Record<string, unknown>,
100
+ surfaceId: string,
101
+ ): Promise<string> {
102
+ const result = await uiShowTool.execute(input, {
103
+ conversationId: "conv-scn",
104
+ workingDir: "/tmp",
105
+ trustClass: "guardian",
106
+ proxyToolResolver: async () => ({
107
+ content: JSON.stringify({ surfaceId }),
108
+ isError: false,
109
+ }),
110
+ });
111
+ expect(result.isError).toBe(false);
112
+ return result.content as string;
113
+ }
114
+
85
115
  function updateSurface(
86
116
  surfaceId: string,
87
117
  data: Record<string, unknown>,
@@ -190,6 +220,26 @@ describe("surface-completion-nudge — nudges on a dangling progress surface", (
190
220
  expect(isSurfaceCompletionNudged("conv-scn")).toBe(true);
191
221
  });
192
222
 
223
+ test("task_progress card shown via the real ui_show tool result → continue with nudge", async () => {
224
+ const input = taskProgressShow("in_progress");
225
+ const shown = showSurface(input, () => "placeholder");
226
+ const content = await realUiShowResult(input, shown.surfaceId);
227
+ // The real result carries the ui_update hint alongside the id.
228
+ expect(content).toContain("ui_update");
229
+ const result = shown.messages[1].content[0];
230
+ if (result.type !== "tool_result") {
231
+ throw new Error("expected a tool_result block");
232
+ }
233
+ result.content = content;
234
+ const ctx = makeCtx({
235
+ messages: [userPrompt("do the thing"), ...shown.messages],
236
+ });
237
+
238
+ await postModelCall(ctx);
239
+
240
+ expect(ctx.decision).toBe("continue");
241
+ });
242
+
193
243
  test("task_progress card shown with no explicit status → continue with nudge", async () => {
194
244
  const shown = showSurface(taskProgressShow());
195
245
  const ctx = makeCtx({
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ parseSurfaceShowResultId,
5
+ withSurfaceShowNote,
6
+ } from "./surface-show-result.js";
7
+
8
+ const HINT_WITH_BRACES =
9
+ 'As each step finishes, call ui_update { surface_id: "<id>", data: { templateData: { steps: [{ label: "x", status: "completed" }] } } }';
10
+
11
+ describe("parseSurfaceShowResultId", () => {
12
+ test("reads the id from a bare envelope", () => {
13
+ expect(parseSurfaceShowResultId('{"surfaceId":"s-1"}')).toBe("s-1");
14
+ });
15
+
16
+ test("reads the id from an envelope carrying advisory fields", () => {
17
+ expect(
18
+ parseSurfaceShowResultId(
19
+ JSON.stringify({ surfaceId: "s-2", note: "hi", status: "displayed" }),
20
+ ),
21
+ ).toBe("s-2");
22
+ });
23
+
24
+ test("tolerates prose appended after the envelope, even prose with braces", () => {
25
+ expect(
26
+ parseSurfaceShowResultId(`{"surfaceId":"s-3"}\n\n${HINT_WITH_BRACES}`),
27
+ ).toBe("s-3");
28
+ });
29
+
30
+ test("ignores braces inside string values when finding the envelope end", () => {
31
+ expect(
32
+ parseSurfaceShowResultId(
33
+ '{"surfaceId":"s-4","note":"use { and } freely \\" }"} trailing',
34
+ ),
35
+ ).toBe("s-4");
36
+ });
37
+
38
+ test("returns undefined for non-envelope content", () => {
39
+ expect(parseSurfaceShowResultId("Surface updated")).toBeUndefined();
40
+ expect(parseSurfaceShowResultId("")).toBeUndefined();
41
+ expect(parseSurfaceShowResultId('{"surfaceId":1}')).toBeUndefined();
42
+ expect(parseSurfaceShowResultId('{"other":"x"}')).toBeUndefined();
43
+ expect(parseSurfaceShowResultId('{"surfaceId":"unterminated')).toBe(
44
+ undefined,
45
+ );
46
+ });
47
+ });
48
+
49
+ describe("withSurfaceShowNote", () => {
50
+ test("carries the note inside the envelope so the id stays parseable", () => {
51
+ const content = withSurfaceShowNote(
52
+ JSON.stringify({ surfaceId: "s-1" }),
53
+ HINT_WITH_BRACES,
54
+ );
55
+ expect(JSON.parse(content)).toEqual({
56
+ surfaceId: "s-1",
57
+ note: HINT_WITH_BRACES,
58
+ });
59
+ expect(parseSurfaceShowResultId(content)).toBe("s-1");
60
+ });
61
+
62
+ test("appends to an existing note rather than replacing it", () => {
63
+ const content = withSurfaceShowNote(
64
+ JSON.stringify({ surfaceId: "s-1", note: "first" }),
65
+ "second",
66
+ );
67
+ expect(JSON.parse(content)).toEqual({
68
+ surfaceId: "s-1",
69
+ note: "first\n\nsecond",
70
+ });
71
+ });
72
+
73
+ test("appends as prose when the content is not a surface envelope", () => {
74
+ expect(withSurfaceShowNote("Surface displayed", "note")).toBe(
75
+ "Surface displayed\n\nnote",
76
+ );
77
+ });
78
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The `ui_show` tool-result envelope.
3
+ *
4
+ * A successful `ui_show` reports the created surface back to the model as a
5
+ * JSON object carrying the `surfaceId`, optionally alongside advisory fields
6
+ * (`status`, `note`, `message`) that coach the model on what to do next.
7
+ *
8
+ * The envelope is a contract, not just a string: the surface-completion nudge
9
+ * reads `surfaceId` back out of tool history to tell which progress surfaces
10
+ * the model left spinning. Producer and consumer therefore share this module,
11
+ * so guidance can only ever be added in a way the parser still understands.
12
+ */
13
+
14
+ /** Fields a `ui_show` result envelope may carry beyond the surface id. */
15
+ export interface SurfaceShowResult {
16
+ surfaceId: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ /**
21
+ * Read the `surfaceId` out of a `ui_show` tool result, or `undefined` when the
22
+ * content is not a surface envelope.
23
+ *
24
+ * Trailing prose after the JSON object is tolerated: guidance belongs in a
25
+ * `note` field, but a caller that appends it instead must not silently cost
26
+ * the reader its `surfaceId`: losing the id makes a live progress surface
27
+ * invisible to the completion nudge, and the user watches it spin forever.
28
+ */
29
+ export function parseSurfaceShowResultId(content: string): string | undefined {
30
+ const parsed = parseLeadingJsonObject(content);
31
+ return typeof parsed?.surfaceId === "string" ? parsed.surfaceId : undefined;
32
+ }
33
+
34
+ /**
35
+ * Attach advisory guidance to a `ui_show` result as a `note` field, keeping the
36
+ * content a single parseable JSON envelope. Content that is not a surface
37
+ * envelope (an error string, a non-JSON acknowledgment) falls back to appending
38
+ * the note as prose, which is still the right thing for the model to read.
39
+ */
40
+ export function withSurfaceShowNote(content: string, note: string): string {
41
+ const parsed = parseLeadingJsonObject(content);
42
+ if (parsed === undefined || typeof parsed.surfaceId !== "string") {
43
+ return `${content}\n\n${note}`;
44
+ }
45
+ const existing = typeof parsed.note === "string" ? parsed.note : undefined;
46
+ return JSON.stringify({
47
+ ...parsed,
48
+ note: existing ? `${existing}\n\n${note}` : note,
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Parse `content` as a JSON object, tolerating trailing text after the closing
54
+ * brace. Returns `undefined` for anything that is not a leading JSON object.
55
+ *
56
+ * The object's extent is found by matching braces rather than by scanning for
57
+ * the last `}`, because appended guidance routinely contains braces of its own
58
+ * (a worked `ui_update { ... }` example, say).
59
+ */
60
+ function parseLeadingJsonObject(
61
+ content: string,
62
+ ): Record<string, unknown> | undefined {
63
+ const trimmed = content.trim();
64
+ const end = endOfLeadingJsonObject(trimmed);
65
+ if (end === undefined) {
66
+ return undefined;
67
+ }
68
+ try {
69
+ const parsed: unknown = JSON.parse(trimmed.slice(0, end + 1));
70
+ return parsed !== null && typeof parsed === "object"
71
+ ? (parsed as Record<string, unknown>)
72
+ : undefined;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Index of the `}` closing the object that opens `text`, or `undefined` when
80
+ * `text` does not open with an object or the braces never balance. String
81
+ * literals (and their escapes) are skipped so braces inside values don't count.
82
+ */
83
+ function endOfLeadingJsonObject(text: string): number | undefined {
84
+ if (!text.startsWith("{")) {
85
+ return undefined;
86
+ }
87
+ let depth = 0;
88
+ let inString = false;
89
+ let escaped = false;
90
+ for (let i = 0; i < text.length; i++) {
91
+ const char = text[i];
92
+ if (inString) {
93
+ if (escaped) {
94
+ escaped = false;
95
+ } else if (char === "\\") {
96
+ escaped = true;
97
+ } else if (char === '"') {
98
+ inString = false;
99
+ }
100
+ continue;
101
+ }
102
+ if (char === '"') {
103
+ inString = true;
104
+ continue;
105
+ }
106
+ if (char === "{") {
107
+ depth++;
108
+ continue;
109
+ }
110
+ if (char === "}") {
111
+ depth--;
112
+ if (depth === 0) {
113
+ return i;
114
+ }
115
+ }
116
+ }
117
+ return undefined;
118
+ }