@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.
- package/openapi.yaml +61 -0
- package/package.json +1 -1
- package/src/__tests__/avatar-identity-sync.test.ts +3 -0
- package/src/__tests__/conversation-surfaces-settle-task-progress.test.ts +218 -0
- package/src/__tests__/surface-completion-nudge-hook.test.ts +52 -2
- package/src/api/surface-show-result.test.ts +78 -0
- package/src/api/surface-show-result.ts +118 -0
- package/src/avatar/__tests__/avatar-changed-telemetry.test.ts +217 -0
- package/src/avatar/__tests__/avatar-store.test.ts +188 -21
- package/src/avatar/avatar-changed-telemetry.ts +128 -0
- package/src/avatar/avatar-manifest.ts +11 -7
- package/src/avatar/avatar-store.ts +91 -16
- package/src/avatar/ensure-raster.ts +8 -4
- package/src/daemon/conversation-agent-loop.ts +12 -1
- package/src/daemon/conversation-surfaces.ts +180 -95
- package/src/plugin-api/index.ts +6 -0
- package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +2 -12
- package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +56 -9
- package/src/runtime/routes/__tests__/oauth-proxy-routes.test.ts +4 -1
- package/src/runtime/routes/avatar-routes.ts +26 -27
- package/src/runtime/routes/oauth-proxy-passthrough.test.ts +1 -0
- package/src/runtime/routes/oauth-proxy-passthrough.ts +3 -0
- package/src/telemetry/AGENTS.md +2 -2
- package/src/telemetry/__tests__/telemetry-event-fixtures.ts +18 -0
- package/src/telemetry/telemetry-event-sources.test.ts +1 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +21 -0
- package/src/telemetry/types.ts +6 -0
- package/src/tools/ui-surface/definitions.ts +6 -5
- 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
|
@@ -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(
|
|
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:
|
|
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
|
+
}
|