@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.
- package/Dockerfile +5 -0
- package/docs/architecture/turn-actor.md +9 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/app-icons/package.json +18 -0
- package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
- package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
- package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/openapi.yaml +628 -0
- package/package.json +3 -1
- package/scripts/smoke-container-workspace-dependencies.ts +19 -0
- package/src/__tests__/app-builder-icon-names.test.ts +29 -0
- package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +2 -0
- package/src/__tests__/conversation-attachments.test.ts +142 -0
- package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
- package/src/__tests__/conversation-event-sink.test.ts +50 -0
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
- package/src/__tests__/conversation-queue.test.ts +297 -0
- package/src/__tests__/credential-routes.test.ts +185 -6
- package/src/__tests__/drain-kick-guard.test.ts +2 -0
- package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
- package/src/__tests__/messaging-send-tool.test.ts +42 -0
- package/src/__tests__/oauth-commands-routes.test.ts +47 -0
- package/src/__tests__/subagent-manager-notify.test.ts +25 -4
- package/src/activation/progress-store.test.ts +1564 -0
- package/src/activation/progress-store.ts +1296 -0
- package/src/activation/turn-hooks.test.ts +246 -0
- package/src/activation/turn-hooks.ts +126 -0
- package/src/api/events/subagent-status-changed.ts +7 -5
- package/src/api/responses/activation.ts +136 -0
- package/src/apps/app-store.ts +8 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
- package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
- package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
- package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
- package/src/cli/commands/channels/index.help.ts +70 -18
- package/src/cli/commands/channels/index.ts +17 -6
- package/src/cli/commands/channels/request.ts +66 -0
- package/src/cli/commands/oauth/request.test.ts +2 -0
- package/src/cli/commands/oauth/request.ts +227 -186
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
- package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
- package/src/config/feature-flag-registry.json +35 -5
- package/src/daemon/assistant-attachments.ts +11 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
- package/src/daemon/conversation-agent-loop.ts +71 -3
- package/src/daemon/conversation-attachments.ts +42 -1
- package/src/daemon/conversation-event-sink.ts +25 -0
- package/src/daemon/conversation-process.ts +69 -21
- package/src/daemon/conversation-store.ts +4 -3
- package/src/daemon/conversation-surfaces.ts +19 -4
- package/src/daemon/message-types/sync.ts +2 -0
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
- package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
- package/src/notifications/conversation-pairing.ts +47 -5
- package/src/notifications/delivered-post-record.ts +3 -0
- package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
- package/src/persistence/conversation-crud.ts +39 -0
- package/src/persistence/delivery-crud.ts +13 -0
- package/src/plugins/AGENTS.md +1 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
- package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
- package/src/runtime/routes/activation-routes.test.ts +415 -0
- package/src/runtime/routes/activation-routes.ts +172 -0
- package/src/runtime/routes/credential-routes.ts +46 -3
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/oauth-commands-routes.ts +21 -8
- package/src/runtime/routes/platform-managed-credentials.ts +48 -0
- package/src/runtime/routes/secret-routes.ts +3 -12
- package/src/runtime/routes/user-route-resolution.ts +21 -0
- package/src/runtime/routes/user-routes.ts +112 -9
- package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
- package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
- package/src/runtime/sync/resource-sync-events.ts +23 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
- package/src/runtime/sync/worker-daemon-notify.ts +39 -1
- 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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
+
}
|
package/src/apps/app-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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`, `
|
|
7
|
-
* the duration of the run, so command output
|
|
8
|
-
* which sink the command writes to;
|
|
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 } };
|