@vellumai/assistant 0.12.0-staging.1 → 0.12.0

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 (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Tests for the activation turn hooks.
3
+ *
4
+ * The hooks are fire-and-forget, so the contract under test is: they
5
+ * normalize what the agent loop hands them, they reach the store, and a
6
+ * store failure never escapes as a rejection.
7
+ */
8
+
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { homedir, tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
13
+
14
+ // Mocking the publisher (one exported function) rather than the event hub
15
+ // keeps the mock harmless for any sibling file bun runs in the same process.
16
+ mock.module("../runtime/sync/sync-publisher.js", () => ({
17
+ publishSyncInvalidation: async (tags: string[]) => ({
18
+ type: "sync_changed",
19
+ tags,
20
+ }),
21
+ }));
22
+
23
+ const {
24
+ markActivationTurnComplete,
25
+ readActivationProgress,
26
+ resetActivationStepThrottleForTesting,
27
+ startActivationTask,
28
+ } = await import("./progress-store.js");
29
+ const {
30
+ collectActivationArtifacts,
31
+ onActivationToolCall,
32
+ onActivationTurnComplete,
33
+ } = await import("./turn-hooks.js");
34
+
35
+ let workspaceDir: string;
36
+ let origWorkspaceDir: string | undefined;
37
+
38
+ beforeEach(() => {
39
+ workspaceDir = mkdtempSync(join(tmpdir(), "vellum-activation-hooks-"));
40
+ origWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
41
+ process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
42
+ resetActivationStepThrottleForTesting(0);
43
+ });
44
+
45
+ afterEach(() => {
46
+ resetActivationStepThrottleForTesting();
47
+ if (origWorkspaceDir === undefined) {
48
+ delete process.env.VELLUM_WORKSPACE_DIR;
49
+ } else {
50
+ process.env.VELLUM_WORKSPACE_DIR = origWorkspaceDir;
51
+ }
52
+ rmSync(workspaceDir, { recursive: true, force: true });
53
+ });
54
+
55
+ async function waitFor(predicate: () => boolean): Promise<void> {
56
+ const deadline = Date.now() + 2_000;
57
+ while (Date.now() < deadline) {
58
+ if (predicate()) {
59
+ return;
60
+ }
61
+ await Bun.sleep(5);
62
+ }
63
+ throw new Error("Timed out waiting for condition");
64
+ }
65
+
66
+ describe("collectActivationArtifacts", () => {
67
+ test("prefers the explicit filename and falls back to the basename", () => {
68
+ expect(
69
+ collectActivationArtifacts([
70
+ {
71
+ path: join(workspaceDir, "notes", "plan.md"),
72
+ filename: "Weekly plan.md",
73
+ sourceType: "sandbox_file",
74
+ },
75
+ {
76
+ path: join(workspaceDir, "notes", "summary.md"),
77
+ filename: undefined,
78
+ sourceType: "sandbox_file",
79
+ },
80
+ { path: " ", filename: "ignored", sourceType: "sandbox_file" },
81
+ ]),
82
+ ).toEqual([
83
+ { workspacePath: "notes/plan.md", displayName: "Weekly plan.md" },
84
+ { workspacePath: "notes/summary.md", displayName: "summary.md" },
85
+ ]);
86
+ });
87
+
88
+ test("stores a workspace file relative to the workspace", () => {
89
+ expect(
90
+ collectActivationArtifacts([
91
+ {
92
+ path: join(workspaceDir, "notes", "plan.md"),
93
+ filename: undefined,
94
+ sourceType: "sandbox_file",
95
+ },
96
+ ]),
97
+ ).toEqual([{ workspacePath: "notes/plan.md", displayName: "plan.md" }]);
98
+ });
99
+
100
+ test("drops a host file, whose path names the user's machine", () => {
101
+ expect(
102
+ collectActivationArtifacts([
103
+ {
104
+ path: join(homedir(), "Documents", "taxes.pdf"),
105
+ filename: "taxes.pdf",
106
+ sourceType: "host_file",
107
+ },
108
+ // Dropped on its source type alone: a host read that happens to
109
+ // land on a path shaped like a workspace one is still a host file.
110
+ {
111
+ path: join(workspaceDir, "notes", "taxes.pdf"),
112
+ filename: "taxes.pdf",
113
+ sourceType: "host_file",
114
+ },
115
+ ]),
116
+ ).toEqual([]);
117
+ });
118
+
119
+ test("drops a path that escapes the workspace", () => {
120
+ expect(
121
+ collectActivationArtifacts([
122
+ {
123
+ path: join(workspaceDir, "..", "elsewhere", "secret.md"),
124
+ filename: undefined,
125
+ sourceType: "sandbox_file",
126
+ },
127
+ { path: workspaceDir, filename: undefined, sourceType: "sandbox_file" },
128
+ ]),
129
+ ).toEqual([]);
130
+ });
131
+
132
+ test("returns an empty list when the turn attached nothing", () => {
133
+ expect(collectActivationArtifacts([])).toEqual([]);
134
+ });
135
+ });
136
+
137
+ describe("onActivationToolCall", () => {
138
+ test("moves the linked task's step count", async () => {
139
+ await startActivationTask({
140
+ taskId: "draft-email",
141
+ conversationId: "conv-1",
142
+ });
143
+
144
+ onActivationToolCall("conv-1");
145
+
146
+ await waitFor(
147
+ () => readActivationProgress().tasks["draft-email"].stepCount === 1,
148
+ );
149
+ });
150
+
151
+ test("does not throw for an unlinked conversation", async () => {
152
+ expect(() => onActivationToolCall("conv-unlinked")).not.toThrow();
153
+ await Bun.sleep(10);
154
+ expect(readActivationProgress().tasks).toEqual({});
155
+ });
156
+ });
157
+
158
+ describe("onActivationTurnComplete", () => {
159
+ test("completes the linked task with normalized artifacts", async () => {
160
+ await startActivationTask({
161
+ taskId: "draft-email",
162
+ conversationId: "conv-1",
163
+ });
164
+
165
+ onActivationTurnComplete({
166
+ conversationId: "conv-1",
167
+ toolCallCount: 3,
168
+ endedAwaitingUser: false,
169
+ attachedFiles: [
170
+ {
171
+ path: join(workspaceDir, "notes", "plan.md"),
172
+ filename: undefined,
173
+ sourceType: "sandbox_file",
174
+ },
175
+ ],
176
+ });
177
+
178
+ await waitFor(
179
+ () => readActivationProgress().tasks["draft-email"].status === "done",
180
+ );
181
+ expect(readActivationProgress().tasks["draft-email"]).toMatchObject({
182
+ stepCount: 3,
183
+ artifacts: [{ workspacePath: "notes/plan.md", displayName: "plan.md" }],
184
+ });
185
+ });
186
+
187
+ test("is a no-op for a conversation no task points at", async () => {
188
+ onActivationTurnComplete({
189
+ conversationId: "conv-unlinked",
190
+ toolCallCount: 2,
191
+ endedAwaitingUser: false,
192
+ attachedFiles: [],
193
+ });
194
+ await Bun.sleep(10);
195
+ expect(readActivationProgress().tasks).toEqual({});
196
+ });
197
+
198
+ test("a turn that ended waiting on the user leaves the task running", async () => {
199
+ await startActivationTask({
200
+ taskId: "draft-email",
201
+ conversationId: "conv-1",
202
+ });
203
+
204
+ onActivationTurnComplete({
205
+ conversationId: "conv-1",
206
+ toolCallCount: 1,
207
+ endedAwaitingUser: true,
208
+ attachedFiles: [],
209
+ });
210
+ await Bun.sleep(10);
211
+
212
+ expect(readActivationProgress().tasks["draft-email"].status).toBe(
213
+ "started",
214
+ );
215
+ });
216
+
217
+ test("a second terminal turn changes nothing", async () => {
218
+ await startActivationTask({
219
+ taskId: "draft-email",
220
+ conversationId: "conv-1",
221
+ });
222
+ await markActivationTurnComplete({
223
+ conversationId: "conv-1",
224
+ toolCallCount: 2,
225
+ endedAwaitingUser: false,
226
+ artifacts: [],
227
+ });
228
+ const done = readActivationProgress().tasks["draft-email"];
229
+
230
+ onActivationTurnComplete({
231
+ conversationId: "conv-1",
232
+ toolCallCount: 7,
233
+ endedAwaitingUser: false,
234
+ attachedFiles: [
235
+ {
236
+ path: join(workspaceDir, "notes", "other.md"),
237
+ filename: undefined,
238
+ sourceType: "sandbox_file",
239
+ },
240
+ ],
241
+ });
242
+ await Bun.sleep(10);
243
+
244
+ expect(readActivationProgress().tasks["draft-email"]).toEqual(done);
245
+ });
246
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Turn-boundary hooks that keep activation progress in step with the
3
+ * conversations the checklist launched.
4
+ *
5
+ * The agent loop calls these fire-and-forget: they own their own error
6
+ * handling and never reject, so a regression here cannot fail a turn.
7
+ * They take plain data rather than loop types, which keeps this module
8
+ * importable (and testable) on its own.
9
+ */
10
+
11
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
12
+
13
+ import type { ActivationArtifact } from "../api/responses/activation.js";
14
+ import type { AttachmentSourceType } from "../daemon/assistant-attachments.js";
15
+ import { getLogger } from "../util/logger.js";
16
+ import { getWorkspaceDir } from "../util/platform.js";
17
+ import {
18
+ bumpActivationStepCount,
19
+ markActivationTurnComplete,
20
+ } from "./progress-store.js";
21
+
22
+ const log = getLogger("activation-turn-hooks");
23
+
24
+ /**
25
+ * A file the assistant attached this turn, in the shape the loop already
26
+ * has on hand (`PersistedAttachmentFile`): where it was read from, the
27
+ * display name it was stored under, and which boundary the path belongs to.
28
+ */
29
+ export interface ActivationAttachedFile {
30
+ path: string;
31
+ filename?: string | undefined;
32
+ sourceType: AttachmentSourceType;
33
+ }
34
+
35
+ /**
36
+ * The workspace-relative form of `path`, or `null` when it does not name a
37
+ * file inside the workspace. Relative paths are read as workspace-relative
38
+ * already. Separators are normalized to `/` so the stored path is the same
39
+ * on every platform the clients render it on.
40
+ */
41
+ function toWorkspacePath(workspaceDir: string, path: string): string | null {
42
+ const trimmed = path.trim();
43
+ if (trimmed.length === 0) {
44
+ return null;
45
+ }
46
+ const absolute = isAbsolute(trimmed)
47
+ ? trimmed
48
+ : resolve(workspaceDir, trimmed);
49
+ const relativePath = relative(workspaceDir, absolute);
50
+ if (
51
+ relativePath.length === 0 ||
52
+ relativePath === ".." ||
53
+ relativePath.startsWith(`..${sep}`) ||
54
+ isAbsolute(relativePath)
55
+ ) {
56
+ return null;
57
+ }
58
+ return relativePath.split(sep).join("/");
59
+ }
60
+
61
+ /**
62
+ * Normalize this turn's attached files into checklist artifacts.
63
+ *
64
+ * Only files the assistant produced in its own workspace are recorded. A
65
+ * host file the user approved a read of is dropped outright: its path names
66
+ * a location on the user's machine, and the progress file is a synced
67
+ * resource every client of this assistant reads. Everything kept is stored
68
+ * workspace-relative for the same reason, so no absolute host path reaches
69
+ * `GET /v1/activation/progress`.
70
+ *
71
+ * The display name falls back to the path's basename, which is what the
72
+ * client renders on the file card.
73
+ */
74
+ export function collectActivationArtifacts(
75
+ attached: readonly ActivationAttachedFile[],
76
+ ): ActivationArtifact[] {
77
+ const workspaceDir = getWorkspaceDir();
78
+ const artifacts: ActivationArtifact[] = [];
79
+ for (const file of attached) {
80
+ if (file.sourceType !== "sandbox_file") {
81
+ continue;
82
+ }
83
+ const workspacePath = toWorkspacePath(workspaceDir, file.path);
84
+ if (workspacePath === null) {
85
+ continue;
86
+ }
87
+ const displayName =
88
+ file.filename?.trim() || basename(workspacePath) || workspacePath;
89
+ artifacts.push({ workspacePath, displayName });
90
+ }
91
+ return artifacts;
92
+ }
93
+
94
+ /**
95
+ * Count one tool call against the activation task linked to this
96
+ * conversation. A no-op when no task points at it.
97
+ */
98
+ export function onActivationToolCall(conversationId: string): void {
99
+ void bumpActivationStepCount(conversationId).catch((err: unknown) => {
100
+ log.warn({ err, conversationId }, "Activation step bump failed");
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Mark the activation task linked to this conversation done. A no-op when
106
+ * no task points at it, and when the turn ended waiting on the user
107
+ * (`endedAwaitingUser`). Idempotent once a task is done.
108
+ */
109
+ export function onActivationTurnComplete(params: {
110
+ conversationId: string;
111
+ toolCallCount: number;
112
+ attachedFiles: readonly ActivationAttachedFile[];
113
+ endedAwaitingUser: boolean;
114
+ }): void {
115
+ void markActivationTurnComplete({
116
+ conversationId: params.conversationId,
117
+ toolCallCount: params.toolCallCount,
118
+ artifacts: collectActivationArtifacts(params.attachedFiles),
119
+ endedAwaitingUser: params.endedAwaitingUser,
120
+ }).catch((err: unknown) => {
121
+ log.warn(
122
+ { err, conversationId: params.conversationId },
123
+ "Activation turn completion failed",
124
+ );
125
+ });
126
+ }
@@ -6,11 +6,13 @@
6
6
  * `error` message (typically present when transitioning into
7
7
  * `failed`), and an optional rolling `usage` snapshot.
8
8
  *
9
- * NOTE: no `conversationId` field. Like `subagent_spawned`, status
10
- * transitions route to the parent conversation's SSE stream via
11
- * `parentSendToClient` closure, not via conversation-scoped seq
12
- * stamping. The subagent is identified by `subagentId`; clients
13
- * already know the parent association from the prior `spawned` event.
9
+ * No `conversationId` field: the parent conversation is the envelope's,
10
+ * stamped by that conversation's sink (`conversationEventSink` in
11
+ * `daemon/conversation-event-sink.ts`), which is what the hub filters
12
+ * and seq-stamps on. Clients read the parent from the envelope. Adding
13
+ * it to this payload instead would be a breaking wire change, since
14
+ * this schema is `.strict()` and already-deployed clients reject an
15
+ * unknown key by dropping the whole event.
14
16
  *
15
17
  * Canonical wire-contract source. Daemon code imports the type
16
18
  * directly from this file; external consumers import via
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Wire contract for the activation-checklist REST endpoints.
3
+ *
4
+ * - `GET /v1/activation/progress` → `ActivationProgress`
5
+ * - `POST /v1/activation/tasks/:taskId/start` → `ActivationProgress`
6
+ * - `POST /v1/activation/dismiss` → `ActivationProgress`
7
+ *
8
+ * Holds the canonical progress shape shared by the on-disk store
9
+ * (`activation/progress-store.ts`), the route handlers, the OpenAPI
10
+ * generator, and every external client, so none of them can drift.
11
+ *
12
+ * Task ids and list ids are opaque to the daemon: the task catalog lives
13
+ * client-side and only its identifiers are persisted here.
14
+ */
15
+
16
+ import { z } from "zod";
17
+
18
+ /** Schema version of the on-disk progress file. */
19
+ export const ACTIVATION_PROGRESS_VERSION = 1;
20
+
21
+ /**
22
+ * Character class every activation task id and list id must match.
23
+ * Lowercase kebab-case, bounded so a hostile id cannot become a giant
24
+ * JSON key on disk.
25
+ */
26
+ export const ACTIVATION_ID_PATTERN = /^[a-z0-9-]{1,64}$/;
27
+
28
+ /**
29
+ * Upper bound on a stored conversation id. The daemon mints uuids, so this
30
+ * is generous headroom rather than a fit; it exists so a client cannot turn
31
+ * the field into an unbounded blob on disk, the same reason task and list
32
+ * ids are bounded.
33
+ */
34
+ export const ACTIVATION_CONVERSATION_ID_MAX_LENGTH = 128;
35
+
36
+ export const ActivationIdSchema = z
37
+ .string()
38
+ .regex(ACTIVATION_ID_PATTERN)
39
+ .describe("Opaque lowercase-kebab identifier, 1-64 characters");
40
+
41
+ /** A file the assistant produced while working a task. */
42
+ export const ActivationArtifactSchema = z.object({
43
+ workspacePath: z
44
+ .string()
45
+ .describe("Path of the produced file, as the assistant attached it"),
46
+ displayName: z.string().describe("Human-readable file name for the card"),
47
+ });
48
+ export type ActivationArtifact = z.infer<typeof ActivationArtifactSchema>;
49
+
50
+ /** Per-task progress record. */
51
+ export const ActivationTaskProgressSchema = z.object({
52
+ status: z
53
+ .enum(["started", "done"])
54
+ .describe("`started` while the linked conversation is working the task"),
55
+ conversationId: z
56
+ .string()
57
+ .max(ACTIVATION_CONVERSATION_ID_MAX_LENGTH)
58
+ .describe("Conversation the task was launched into"),
59
+ startedAt: z.string().describe("ISO timestamp of the launch"),
60
+ completedAt: z
61
+ .string()
62
+ .nullable()
63
+ .describe("ISO timestamp of the completing turn, null while started"),
64
+ stepCount: z
65
+ .number()
66
+ .int()
67
+ .nullable()
68
+ .describe("Tool calls observed in the linked conversation so far"),
69
+ artifacts: z
70
+ .array(ActivationArtifactSchema)
71
+ .describe("Files the completing turn attached"),
72
+ });
73
+ export type ActivationTaskProgress = z.infer<
74
+ typeof ActivationTaskProgressSchema
75
+ >;
76
+
77
+ /** Full activation progress resource. */
78
+ export const ActivationProgressSchema = z.object({
79
+ version: z.literal(ACTIVATION_PROGRESS_VERSION).describe("Schema version"),
80
+ listId: z
81
+ .string()
82
+ .nullable()
83
+ .describe("Task list frozen on the first write, null before then"),
84
+ modalDismissedAt: z
85
+ .string()
86
+ .nullable()
87
+ .describe("ISO timestamp the welcome modal was dismissed"),
88
+ allDoneShownAt: z
89
+ .string()
90
+ .nullable()
91
+ .describe("ISO timestamp the celebration modal was dismissed"),
92
+ tasks: z
93
+ .record(z.string(), ActivationTaskProgressSchema)
94
+ .describe("Progress keyed by task id"),
95
+ });
96
+ export type ActivationProgress = z.infer<typeof ActivationProgressSchema>;
97
+
98
+ export const ActivationTaskStartRequestSchema = z.object({
99
+ conversationId: z
100
+ .string()
101
+ .min(1)
102
+ .max(ACTIVATION_CONVERSATION_ID_MAX_LENGTH)
103
+ .describe("Conversation the task prompt was sent to"),
104
+ listId: ActivationIdSchema.optional().describe(
105
+ "List the task came from, stored only while no list is frozen",
106
+ ),
107
+ });
108
+ export type ActivationTaskStartRequest = z.infer<
109
+ typeof ActivationTaskStartRequestSchema
110
+ >;
111
+
112
+ export const ActivationDismissKindSchema = z
113
+ .enum(["modal", "all-done"])
114
+ .describe("Which surface was dismissed");
115
+ export type ActivationDismissKind = z.infer<typeof ActivationDismissKindSchema>;
116
+
117
+ export const ActivationDismissRequestSchema = z.object({
118
+ kind: ActivationDismissKindSchema,
119
+ listId: ActivationIdSchema.optional().describe(
120
+ "List the surface showed, stored only while no list is frozen",
121
+ ),
122
+ });
123
+ export type ActivationDismissRequest = z.infer<
124
+ typeof ActivationDismissRequestSchema
125
+ >;
126
+
127
+ /** Empty progress, returned before the store file exists. */
128
+ export function emptyActivationProgress(): ActivationProgress {
129
+ return {
130
+ version: ACTIVATION_PROGRESS_VERSION,
131
+ listId: null,
132
+ modalDismissedAt: null,
133
+ allDoneShownAt: null,
134
+ tasks: {},
135
+ };
136
+ }
@@ -34,6 +34,8 @@ import {
34
34
  resolve,
35
35
  } from "node:path";
36
36
 
37
+ import { bridgeEmojiAppIcon } from "@vellumai/app-icons";
38
+
37
39
  import { resolveConversationLineage } from "../daemon/conversation-lineage.js";
38
40
  import { rawAll } from "../persistence/raw-query.js";
39
41
  import { isPluginDisabled } from "../plugins/disabled-state.js";
@@ -625,6 +627,9 @@ export function getApp(id: string): AppDefinition | null {
625
627
  }
626
628
  const raw = readFileSync(filePath, "utf-8");
627
629
  const app = JSON.parse(raw) as AppDefinition;
630
+ /* An emoji icon reads back as the registry name it maps to, so every client
631
+ draws the same glyph set. */
632
+ app.icon = bridgeEmojiAppIcon(app.icon);
628
633
 
629
634
  // Read htmlDefinition from {dirName}/index.html on disk
630
635
  const indexPath = join(appDir, "index.html");
@@ -674,6 +679,9 @@ export function listApps(): AppDefinition[] {
674
679
  try {
675
680
  const raw = readFileSync(filePath, "utf-8");
676
681
  const app = JSON.parse(raw) as AppDefinition;
682
+ /* An emoji from before the icon registry reads back as its registry name
683
+ (see app-icons.ts), so every client draws the same glyph set. */
684
+ app.icon = bridgeEmojiAppIcon(app.icon);
677
685
 
678
686
  apps.push(app);
679
687
  } catch {
@@ -13,9 +13,9 @@ import { channelsHelp } from "../commands/channels/index.help.js";
13
13
  import { pluginsHelp } from "../commands/plugins.help.js";
14
14
  import { skillsHelp } from "../commands/skills.help.js";
15
15
 
16
- function searchHelp(
17
- help: { subcommands?: Array<{ name: string; helpText?: string }> },
18
- ): { name: string; helpText?: string } {
16
+ function searchHelp(help: {
17
+ subcommands?: Array<{ name: string; helpText?: string }>;
18
+ }): { name: string; helpText?: string } {
19
19
  const search = help.subcommands?.find((sub) => sub.name === "search");
20
20
  if (!search) {
21
21
  throw new Error("expected a search subcommand");
@@ -59,8 +59,10 @@ describe("catalog search help for setup-intent retrieval", () => {
59
59
  expect(list?.helpText).toBeDefined();
60
60
  expect(indexed).toContain("assistant plugins search <name>");
61
61
  expect(indexed).toContain("not listed");
62
- expect(channelsHelp.description).toBe(
63
- "Inspect and repair messaging channels",
62
+ // The description names the surface generically; a provider name in it
63
+ // would present the built-in set as the whole catalog.
64
+ expect(channelsHelp.description.toLowerCase()).toContain(
65
+ "messaging channels",
64
66
  );
65
67
  expect(channelsHelp.description.toLowerCase()).not.toContain("slack");
66
68
  expect(channelsHelp.description.toLowerCase()).not.toContain("telegram");
@@ -3,9 +3,10 @@
3
3
  * Commander program, runs it against captured output sinks, and returns what
4
4
  * the command emitted plus the resulting exit code.
5
5
  *
6
- * `process.stdout.write`, `console.log`, and `console.error` are captured for
7
- * the duration of the run, so command output lands in the result regardless of
8
- * which sink the command writes to; `process.exitCode` is reset afterwards.
6
+ * `process.stdout.write`, `process.stderr.write`, `console.log`, and
7
+ * `console.error` are captured for the duration of the run, so command output
8
+ * lands in the result regardless of which sink the command writes to;
9
+ * `process.exitCode` is reset afterwards.
9
10
  * The caller passes its own (possibly mock-backed) registration function, so
10
11
  * this helper imports nothing from `src/` beyond what any test file may
11
12
  * import itself (see the test-machinery isolation rules in assistant/CLAUDE.md).
@@ -26,6 +27,7 @@ export async function runCliCommand(
26
27
  args: string[],
27
28
  ): Promise<CliCommandRunResult> {
28
29
  const originalStdoutWrite = process.stdout.write.bind(process.stdout);
30
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
29
31
  const originalConsoleLog = console.log;
30
32
  const originalConsoleError = console.error;
31
33
  const stdoutChunks: string[] = [];
@@ -38,6 +40,12 @@ export async function runCliCommand(
38
40
  events.push(text);
39
41
  return true;
40
42
  }) as typeof process.stdout.write;
43
+ process.stderr.write = ((chunk: unknown) => {
44
+ const text = typeof chunk === "string" ? chunk : String(chunk);
45
+ stderrChunks.push(text);
46
+ events.push(text);
47
+ return true;
48
+ }) as typeof process.stderr.write;
41
49
  console.log = (...logArgs: unknown[]) => {
42
50
  const text = logArgs.map(String).join(" ") + "\n";
43
51
  stdoutChunks.push(text);
@@ -66,6 +74,7 @@ export async function runCliCommand(
66
74
  }
67
75
  } finally {
68
76
  process.stdout.write = originalStdoutWrite;
77
+ process.stderr.write = originalStderrWrite;
69
78
  console.log = originalConsoleLog;
70
79
  console.error = originalConsoleError;
71
80
  }
@@ -9,7 +9,9 @@ import { Command } from "commander";
9
9
  let mockCalls: Array<[string, Record<string, unknown> | undefined]> = [];
10
10
  let mockResponses: unknown[] = [];
11
11
 
12
+ const actualCliClient = await import("../../../../ipc/cli-client.js");
12
13
  mock.module("../../../../ipc/cli-client.js", () => ({
14
+ ...actualCliClient,
13
15
  cliIpcCall: async (method: string, params?: Record<string, unknown>) => {
14
16
  mockCalls.push([method, params]);
15
17
  return mockResponses.shift() ?? { ok: true, result: { success: true } };