@vellumai/assistant 0.10.9-staging.1 → 0.10.9
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/AGENTS.md +4 -0
- package/knip.json +1 -4
- package/openapi.yaml +27 -17
- package/package.json +1 -1
- package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
- package/src/__tests__/app-routes-csp.test.ts +13 -4
- package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
- package/src/__tests__/disk-pressure-tools.test.ts +4 -4
- package/src/__tests__/file-list-tool.test.ts +22 -12
- package/src/__tests__/list-all-apps.test.ts +204 -0
- package/src/__tests__/messaging-send-tool.test.ts +67 -0
- package/src/__tests__/mtime-cache.test.ts +42 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
- package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
- package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
- package/src/__tests__/subagent-notify-parent.test.ts +4 -3
- package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
- package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
- package/src/apps/app-store.ts +302 -2
- package/src/cli/AGENTS.md +17 -0
- package/src/cli/commands/__tests__/gateway.test.ts +55 -0
- package/src/cli/commands/apps.help.ts +17 -11
- package/src/cli/commands/apps.ts +5 -15
- package/src/cli/commands/gateway.help.ts +30 -0
- package/src/cli/commands/gateway.ts +40 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
- package/src/cli/commands/platform/index.help.ts +3 -2
- package/src/cli/commands/platform/index.ts +0 -9
- package/src/cli/commands/routes.help.ts +13 -7
- package/src/cli/commands/routes.ts +5 -3
- package/src/cli/commands/status.help.ts +24 -0
- package/src/cli/commands/status.ts +29 -0
- package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
- package/src/cli/lib/bundled-marketplace.json +187 -173
- package/src/cli/lib/global-json-option.ts +41 -0
- package/src/cli/lib/plugin-fingerprint.ts +18 -7
- package/src/cli/program.ts +5 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
- package/src/config/feature-flag-registry.json +1 -9
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
- package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
- package/src/daemon/conversation-tool-setup.ts +5 -90
- package/src/daemon/conversation.ts +2 -2
- package/src/messaging/provider-types.ts +9 -0
- package/src/messaging/providers/outlook/adapter.ts +17 -2
- package/src/messaging/providers/outlook/client.ts +11 -2
- package/src/messaging/providers/outlook/types.ts +9 -0
- package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
- package/src/monitoring/plugin-source-watch.ts +53 -0
- package/src/oauth/seed-providers.ts +6 -0
- package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
- package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
- package/src/plugin-api/conversation-turn.ts +234 -0
- package/src/plugin-api/index.ts +12 -0
- package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
- package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
- package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
- package/src/plugins/mtime-cache.ts +26 -13
- package/src/plugins/plugin-tree-walk.ts +30 -0
- package/src/plugins/source-fingerprint.ts +9 -2
- package/src/plugins/surface-import.ts +28 -0
- package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
- package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
- package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
- package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
- package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
- package/src/runtime/routes/app-management-routes.ts +96 -32
- package/src/runtime/routes/app-routes.ts +33 -17
- package/src/runtime/routes/gateway-status-routes.ts +69 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +4 -14
- package/src/runtime/routes/plugins-routes.ts +58 -1
- package/src/runtime/routes/settings-routes.ts +8 -6
- package/src/runtime/routes/user-route-dispatcher.ts +6 -86
- package/src/runtime/routes/user-route-resolution.ts +141 -0
- package/src/runtime/routes/user-routes-cli.ts +56 -35
- package/src/telemetry/AGENTS.md +15 -0
- package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
- package/src/telemetry/telemetry-wire-validation.ts +125 -0
- package/src/telemetry/telemetry-wire.generated.ts +60 -9
- package/src/telemetry/types.test.ts +38 -0
- package/src/telemetry/types.ts +183 -91
- package/src/telemetry/usage-telemetry-reporter.ts +5 -0
- package/src/telemetry/watchdog-direct-emit.ts +6 -0
- package/src/tools/skills/scaffold-managed.ts +43 -11
|
@@ -157,6 +157,46 @@ export function createSourceWatchState(): SourceWatchState {
|
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Compile every multi-file app bundled by a plugin from its `apps/<app>/src`
|
|
162
|
+
* into the sibling `apps/<app>/dist`. Single-file apps (a root `index.html`,
|
|
163
|
+
* no `src/`) need no build and are skipped.
|
|
164
|
+
*
|
|
165
|
+
* Runs here — off the daemon's hot path — because the monitor has just detected
|
|
166
|
+
* the change. The generated `dist/` is excluded from the source fingerprint
|
|
167
|
+
* ({@link isGeneratedAppBuildDir}), so writing it does not re-trigger a pass.
|
|
168
|
+
* The bundler (esbuild) is imported lazily so the monitor only pulls it in when
|
|
169
|
+
* a plugin actually ships a buildable app.
|
|
170
|
+
*/
|
|
171
|
+
export async function compilePluginApps(pluginDir: string): Promise<void> {
|
|
172
|
+
const appsDir = join(pluginDir, "apps");
|
|
173
|
+
let appEntries: string[];
|
|
174
|
+
try {
|
|
175
|
+
appEntries = readdirSync(appsDir);
|
|
176
|
+
} catch {
|
|
177
|
+
return; // No apps/ directory — nothing to build.
|
|
178
|
+
}
|
|
179
|
+
const buildable = appEntries.filter((app) => {
|
|
180
|
+
const appDir = join(appsDir, app);
|
|
181
|
+
try {
|
|
182
|
+
return statSync(appDir).isDirectory() && existsSync(join(appDir, "src"));
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
if (buildable.length === 0) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const { compileApp } = await import("../bundler/app-compiler.js");
|
|
191
|
+
for (const app of buildable) {
|
|
192
|
+
const appDir = join(appsDir, app);
|
|
193
|
+
const result = await compileApp(appDir);
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
log.warn({ appDir, errors: result.errors }, "plugin app compile failed");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
160
200
|
/**
|
|
161
201
|
* Run one detection pass: walk, compare against the last published state,
|
|
162
202
|
* and rewrite the sentinel if anything changed. Returns whether a rewrite
|
|
@@ -202,6 +242,19 @@ export function runSourceWatchPass(state: SourceWatchState): boolean {
|
|
|
202
242
|
{ generation: state.generation, changedDirs },
|
|
203
243
|
"plugin source change published",
|
|
204
244
|
);
|
|
245
|
+
|
|
246
|
+
// Rebuild multi-file apps for each changed, enabled plugin. Fire-and-forget
|
|
247
|
+
// so a slow build never stalls the watch loop; the generated dist is
|
|
248
|
+
// excluded from the fingerprint, so it does not re-trigger this pass.
|
|
249
|
+
for (const dir of changedDirs) {
|
|
250
|
+
const version = current[dir];
|
|
251
|
+
if (version !== undefined && !version.disabled) {
|
|
252
|
+
void compilePluginApps(dir).catch((err) => {
|
|
253
|
+
log.error({ err, dir }, "plugin app compile pass failed");
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
205
258
|
return true;
|
|
206
259
|
} catch (err) {
|
|
207
260
|
log.error({ err }, "plugin source watch pass failed — will retry");
|
|
@@ -131,6 +131,12 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
131
131
|
headerName: "Authorization",
|
|
132
132
|
valuePrefix: "Bearer ",
|
|
133
133
|
},
|
|
134
|
+
{
|
|
135
|
+
hostPattern: "docs.googleapis.com",
|
|
136
|
+
injectionType: "header",
|
|
137
|
+
headerName: "Authorization",
|
|
138
|
+
valuePrefix: "Bearer ",
|
|
139
|
+
},
|
|
134
140
|
],
|
|
135
141
|
revokeUrl: "https://oauth2.googleapis.com/revoke",
|
|
136
142
|
revokeBodyTemplate: { token: "{access_token}" },
|
|
@@ -143,6 +143,9 @@ describe("MessagesLexicalIndex", () => {
|
|
|
143
143
|
expect(config.vectors).toBeUndefined();
|
|
144
144
|
expect("vectors" in config).toBe(false);
|
|
145
145
|
|
|
146
|
+
// Explicit segment count — never Qdrant's CPU-count auto-detection.
|
|
147
|
+
expect(config.optimizers_config).toEqual({ default_segment_number: 2 });
|
|
148
|
+
|
|
146
149
|
// Payload indexes on conversation_id (keyword) + created_at (integer).
|
|
147
150
|
const fields = payloadIndexCalls.map((c) => c.field_name);
|
|
148
151
|
expect(fields).toContain("conversation_id");
|
|
@@ -23,6 +23,14 @@ export const MESSAGES_LEXICAL_COLLECTION = "messages_lexical";
|
|
|
23
23
|
*/
|
|
24
24
|
const MESSAGE_POINT_NAMESPACE = "b6d0d3e2-1f1a-4c3e-9a7c-0e2f5a8d4c11";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Explicit segment count for the collection. Left unset, Qdrant sizes the
|
|
28
|
+
* segment count to the node's CPU count, and each segment carries a fixed
|
|
29
|
+
* storage preallocation — a small sparse-only collection then idles at
|
|
30
|
+
* hundreds of MB. Matches the v2 concept-pages collection.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_SEGMENT_NUMBER = 2;
|
|
33
|
+
|
|
26
34
|
export interface MessagesLexicalIndexConfig {
|
|
27
35
|
url: string;
|
|
28
36
|
collection?: string;
|
|
@@ -113,6 +121,9 @@ export class MessagesLexicalIndex {
|
|
|
113
121
|
// payloads.
|
|
114
122
|
sparse: { index: { on_disk: this.onDisk } },
|
|
115
123
|
},
|
|
124
|
+
optimizers_config: {
|
|
125
|
+
default_segment_number: DEFAULT_SEGMENT_NUMBER,
|
|
126
|
+
},
|
|
116
127
|
on_disk_payload: this.onDisk,
|
|
117
128
|
});
|
|
118
129
|
} catch (err) {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-facing facade for running a full conversation agent-loop turn.
|
|
3
|
+
*
|
|
4
|
+
* This is the conversation-scoped equivalent of the daemon's
|
|
5
|
+
* `processMessage` path: it persists a user message, runs the agent loop
|
|
6
|
+
* (with all its machinery -- system prompt construction, conversation
|
|
7
|
+
* history, tool use cycles, compaction, injections), and returns the
|
|
8
|
+
* assistant's full content-block response.
|
|
9
|
+
*
|
|
10
|
+
* Plugins that need to drive conversation turns (e.g. meeting-bot
|
|
11
|
+
* flushing a transcript excerpt) should prefer this over the stateless
|
|
12
|
+
* `provider.sendMessage()` call, which has no history, no tools, and no
|
|
13
|
+
* context management.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { LLMCallSite } from "../config/schemas/llm.js";
|
|
17
|
+
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
18
|
+
import type { UserMessageAttachment } from "../daemon/message-types/shared.js";
|
|
19
|
+
import type { ContentBlock, MediaSource } from "../providers/types.js";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Public types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface RunConversationTurnOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Conversation to run the turn in. If omitted, a new conversation is
|
|
28
|
+
* created (its ID is generated with `uuidv7` and returned in the result).
|
|
29
|
+
*/
|
|
30
|
+
conversationId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* User message content blocks for this turn. Text blocks become the
|
|
33
|
+
* user message body; image/file blocks are resolved to inline
|
|
34
|
+
* attachments. Other block types (tool_use, tool_result, thinking) are
|
|
35
|
+
* ignored as they are not valid user input.
|
|
36
|
+
*/
|
|
37
|
+
content: ContentBlock[];
|
|
38
|
+
/**
|
|
39
|
+
* LLM call-site for inference profile resolution. Defaults to
|
|
40
|
+
* `"mainAgent"` inside the agent loop when omitted.
|
|
41
|
+
*/
|
|
42
|
+
callSite?: LLMCallSite;
|
|
43
|
+
/**
|
|
44
|
+
* Abort signal. When aborted, the conversation's internal abort
|
|
45
|
+
* controller fires, terminating the in-flight agent loop.
|
|
46
|
+
*/
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RunConversationTurnResult {
|
|
51
|
+
/** The assistant's full content blocks for this turn (text, tool_use, etc.). */
|
|
52
|
+
content: ContentBlock[];
|
|
53
|
+
/** The user message row ID assigned by the persistence layer. */
|
|
54
|
+
userMessageId: string;
|
|
55
|
+
/** The conversation this turn ran in. */
|
|
56
|
+
conversationId: string;
|
|
57
|
+
/** True when the message was queued because the conversation was busy. */
|
|
58
|
+
queued?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Content conversion
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract a plain-text content string and attachment list from
|
|
67
|
+
* {@link ContentBlock} input. Text blocks are concatenated (newline
|
|
68
|
+
* separated); image and file blocks are converted to
|
|
69
|
+
* {@link UserMessageAttachment} entries with their media source resolved
|
|
70
|
+
* to inline base64. Other block types are ignored.
|
|
71
|
+
*/
|
|
72
|
+
function extractContentAndAttachments(
|
|
73
|
+
blocks: ContentBlock[],
|
|
74
|
+
resolveMedia: (source: MediaSource) => { data: string; media_type: string } | null,
|
|
75
|
+
): { text: string; attachments: UserMessageAttachment[] } {
|
|
76
|
+
const textParts: string[] = [];
|
|
77
|
+
const attachments: UserMessageAttachment[] = [];
|
|
78
|
+
|
|
79
|
+
for (const block of blocks) {
|
|
80
|
+
if (block.type === "text") {
|
|
81
|
+
textParts.push(block.text);
|
|
82
|
+
} else if (block.type === "image" || block.type === "file") {
|
|
83
|
+
const source = block.source;
|
|
84
|
+
const resolved = resolveMedia(source);
|
|
85
|
+
if (resolved) {
|
|
86
|
+
attachments.push({
|
|
87
|
+
filename:
|
|
88
|
+
source.filename ?? (block.type === "image" ? "image" : "file"),
|
|
89
|
+
mimeType: resolved.media_type,
|
|
90
|
+
data: resolved.data,
|
|
91
|
+
...(block.type === "file" && block.extracted_text
|
|
92
|
+
? { extractedText: block.extracted_text }
|
|
93
|
+
: {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { text: textParts.join("\n"), attachments };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Public API
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run a full conversation agent-loop turn: persist the user message, execute
|
|
108
|
+
* the agent loop (history, tools, compaction, injections), and return the
|
|
109
|
+
* assistant's full content-block response.
|
|
110
|
+
*
|
|
111
|
+
* When `conversationId` is omitted, a new conversation is created and its ID
|
|
112
|
+
* is returned in the result.
|
|
113
|
+
*
|
|
114
|
+
* Events are fanned out to SSE/event-hub subscribers via `broadcastMessage`
|
|
115
|
+
* (the same path the daemon's HTTP message route uses) while also being
|
|
116
|
+
* collected internally so the caller receives the final response content.
|
|
117
|
+
* For streaming use cases, subscribe to the conversation's events via the
|
|
118
|
+
* host event hub (`assistantEventHub`).
|
|
119
|
+
*
|
|
120
|
+
* If the conversation is currently processing another turn, the message is
|
|
121
|
+
* queued via `enqueueMessage` and the result carries `queued: true` with
|
|
122
|
+
* empty content -- the queued turn will execute automatically when the
|
|
123
|
+
* current turn finishes.
|
|
124
|
+
*/
|
|
125
|
+
export async function runConversationTurn(
|
|
126
|
+
options: RunConversationTurnOptions,
|
|
127
|
+
): Promise<RunConversationTurnResult> {
|
|
128
|
+
const { v7: uuidv7 } = await import("uuid");
|
|
129
|
+
const { getOrCreateConversation } = await import(
|
|
130
|
+
"../daemon/conversation-store.js"
|
|
131
|
+
);
|
|
132
|
+
const { broadcastMessage } = await import(
|
|
133
|
+
"../runtime/assistant-event-hub.js"
|
|
134
|
+
);
|
|
135
|
+
const { resolveMediaSourceData } = await import(
|
|
136
|
+
"../providers/media-resolve.js"
|
|
137
|
+
);
|
|
138
|
+
const { getMessageById, getMessages } = await import(
|
|
139
|
+
"../persistence/conversation-crud.js"
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const conversationId = options.conversationId ?? uuidv7();
|
|
143
|
+
const conversation = await getOrCreateConversation(conversationId);
|
|
144
|
+
|
|
145
|
+
// Wire the external abort signal to the conversation's internal abort
|
|
146
|
+
// controller so aborting the signal terminates the in-flight agent loop.
|
|
147
|
+
if (options.signal) {
|
|
148
|
+
options.signal.addEventListener("abort", () => {
|
|
149
|
+
conversation.abortController?.abort();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Convert ContentBlock[] input to the text + attachments shape the
|
|
154
|
+
// conversation's processMessage path expects.
|
|
155
|
+
const { text, attachments } = extractContentAndAttachments(
|
|
156
|
+
options.content,
|
|
157
|
+
resolveMediaSourceData,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Build the event emitter: fan out to SSE/event-hub subscribers via
|
|
161
|
+
// broadcastMessage, then invoke the collector callback. This mirrors the
|
|
162
|
+
// buildEventEmitter pattern from process-message.ts so plugin-driven
|
|
163
|
+
// turns reach the same subscribers as HTTP-driven turns.
|
|
164
|
+
let assistantMessageId: string | undefined;
|
|
165
|
+
const onEvent = (msg: ServerMessage): void => {
|
|
166
|
+
broadcastMessage(msg, conversationId);
|
|
167
|
+
if (msg.type === "message_complete" && msg.messageId) {
|
|
168
|
+
assistantMessageId = msg.messageId;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// When the conversation is busy, enqueue the message instead of rejecting.
|
|
173
|
+
// The queue is drained automatically when the current turn finishes.
|
|
174
|
+
if (conversation.isProcessing()) {
|
|
175
|
+
const requestId = uuidv7();
|
|
176
|
+
const enqueueResult = conversation.enqueueMessage({
|
|
177
|
+
content: text,
|
|
178
|
+
attachments,
|
|
179
|
+
onEvent,
|
|
180
|
+
requestId,
|
|
181
|
+
isInteractive: false,
|
|
182
|
+
});
|
|
183
|
+
if (enqueueResult.rejected) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
"Conversation is busy and its message queue is full. Try again later.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
content: [],
|
|
190
|
+
userMessageId: requestId,
|
|
191
|
+
conversationId,
|
|
192
|
+
queued: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const userMessageId = await conversation.processMessage({
|
|
197
|
+
content: text,
|
|
198
|
+
attachments,
|
|
199
|
+
onEvent,
|
|
200
|
+
isInteractive: false,
|
|
201
|
+
...(options.callSite ? { callSite: options.callSite } : {}),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Retrieve the assistant's full content blocks from the persisted
|
|
205
|
+
// message row. The message_complete event carries the assistant
|
|
206
|
+
// message ID; if it was captured, use it directly. Otherwise fall back
|
|
207
|
+
// to scanning the conversation's messages for the first assistant
|
|
208
|
+
// message after our user message.
|
|
209
|
+
let assistantContent: ContentBlock[] = [];
|
|
210
|
+
if (assistantMessageId) {
|
|
211
|
+
const assistantRow = getMessageById(assistantMessageId, conversationId);
|
|
212
|
+
if (assistantRow) {
|
|
213
|
+
assistantContent = assistantRow.content;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (assistantContent.length === 0) {
|
|
217
|
+
const allMessages = getMessages(conversationId);
|
|
218
|
+
const userIdx = allMessages.findIndex((m) => m.id === userMessageId);
|
|
219
|
+
if (userIdx >= 0) {
|
|
220
|
+
for (let i = allMessages.length - 1; i > userIdx; i--) {
|
|
221
|
+
if (allMessages[i].role === "assistant") {
|
|
222
|
+
assistantContent = allMessages[i].content;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
content: assistantContent,
|
|
231
|
+
userMessageId,
|
|
232
|
+
conversationId,
|
|
233
|
+
};
|
|
234
|
+
}
|
package/src/plugin-api/index.ts
CHANGED
|
@@ -244,3 +244,15 @@ export {
|
|
|
244
244
|
export type { SynthesizeTextOptions } from "../tts/synthesize-text.js";
|
|
245
245
|
export { synthesizeText, TtsSynthesisError } from "../tts/synthesize-text.js";
|
|
246
246
|
export type { TtsSynthesisResult } from "../tts/types.js";
|
|
247
|
+
// Conversation agent-loop turn — run a full conversation turn (persist user
|
|
248
|
+
// message, execute the agent loop with history/tools/compaction/injections,
|
|
249
|
+
// return the assistant's full content-block response). Accepts ContentBlock[]
|
|
250
|
+
// input (text, images, files) and an optional conversationId (creates a new
|
|
251
|
+
// conversation when omitted). Plugins that need to drive conversation turns
|
|
252
|
+
// (e.g. meeting-bot flushing a transcript excerpt) should prefer this over the
|
|
253
|
+
// stateless `provider.sendMessage()` call.
|
|
254
|
+
export type {
|
|
255
|
+
RunConversationTurnOptions,
|
|
256
|
+
RunConversationTurnResult,
|
|
257
|
+
} from "./conversation-turn.js";
|
|
258
|
+
export { runConversationTurn } from "./conversation-turn.js";
|
|
@@ -81,6 +81,47 @@ describe("snapshotPluginSource", () => {
|
|
|
81
81
|
expect(names).not.toContain("/.disabled");
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
test("excludes generated apps/<app>/dist but tracks app src and top-level dist", () => {
|
|
85
|
+
const dir = makePluginDir();
|
|
86
|
+
// App source is tracked.
|
|
87
|
+
mkdirSync(join(dir, "apps", "dash", "src"), { recursive: true });
|
|
88
|
+
writeStamped(
|
|
89
|
+
join(dir, "apps", "dash", "src", "main.tsx"),
|
|
90
|
+
"export default 1;",
|
|
91
|
+
);
|
|
92
|
+
// Generated app build output is excluded (the watcher writes it).
|
|
93
|
+
mkdirSync(join(dir, "apps", "dash", "dist"), { recursive: true });
|
|
94
|
+
writeStamped(
|
|
95
|
+
join(dir, "apps", "dash", "dist", "main.js"),
|
|
96
|
+
"console.log(1)",
|
|
97
|
+
);
|
|
98
|
+
// A plugin's own top-level dist/ is NOT an app build dir — still tracked.
|
|
99
|
+
mkdirSync(join(dir, "dist"), { recursive: true });
|
|
100
|
+
writeStamped(join(dir, "dist", "bundle.js"), "x");
|
|
101
|
+
|
|
102
|
+
const names = snapshotPluginSource(dir).evictionPaths.map((p) =>
|
|
103
|
+
p.slice(dir.length),
|
|
104
|
+
);
|
|
105
|
+
expect(names).toContain("/apps/dash/src/main.tsx");
|
|
106
|
+
expect(names).toContain("/dist/bundle.js");
|
|
107
|
+
expect(names).not.toContain("/apps/dash/dist/main.js");
|
|
108
|
+
|
|
109
|
+
// Editing the generated dist does not move the fingerprint (no re-trigger)...
|
|
110
|
+
const base = snapshotPluginSource(dir).fingerprint;
|
|
111
|
+
writeStamped(
|
|
112
|
+
join(dir, "apps", "dash", "dist", "main.js"),
|
|
113
|
+
"console.log(2)",
|
|
114
|
+
);
|
|
115
|
+
expect(snapshotPluginSource(dir).fingerprint).toBe(base);
|
|
116
|
+
|
|
117
|
+
// ...but editing the app source does.
|
|
118
|
+
writeStamped(
|
|
119
|
+
join(dir, "apps", "dash", "src", "main.tsx"),
|
|
120
|
+
"export default 2;",
|
|
121
|
+
);
|
|
122
|
+
expect(snapshotPluginSource(dir).fingerprint).not.toBe(base);
|
|
123
|
+
});
|
|
124
|
+
|
|
84
125
|
test("is stable across identical walks and moves on edit, add, delete, and rename", () => {
|
|
85
126
|
const dir = makePluginDir();
|
|
86
127
|
const helper = join(dir, "hooks", "helper.ts");
|
|
@@ -92,6 +92,21 @@ let messagesByConversationId: Record<string, StubMessage[]> = {};
|
|
|
92
92
|
// the real registry's semantics for conversations not in memory.
|
|
93
93
|
let loadedConversations: Record<string, { processing: boolean }> = {};
|
|
94
94
|
|
|
95
|
+
const watchdogEvents: Array<{
|
|
96
|
+
checkName: string;
|
|
97
|
+
value?: number | null;
|
|
98
|
+
detail?: Record<string, unknown> | null;
|
|
99
|
+
}> = [];
|
|
100
|
+
mock.module("../../../../telemetry/watchdog-events-store.js", () => ({
|
|
101
|
+
recordWatchdogEvent: (record: {
|
|
102
|
+
checkName: string;
|
|
103
|
+
value?: number | null;
|
|
104
|
+
detail?: Record<string, unknown> | null;
|
|
105
|
+
}) => {
|
|
106
|
+
watchdogEvents.push(record);
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
|
|
95
110
|
mock.module("../../../../daemon/conversation-registry.js", () => ({
|
|
96
111
|
findConversation: (id: string | undefined) => {
|
|
97
112
|
if (!id) return undefined;
|
|
@@ -360,6 +375,7 @@ function priorRetroMessage(rememberContents: string[]) {
|
|
|
360
375
|
|
|
361
376
|
describe("memoryRetrospectiveJob", () => {
|
|
362
377
|
beforeEach(() => {
|
|
378
|
+
watchdogEvents.length = 0;
|
|
363
379
|
mockState = null;
|
|
364
380
|
stateUpserts = [];
|
|
365
381
|
lastRunAtBumps = [];
|
|
@@ -416,6 +432,16 @@ describe("memoryRetrospectiveJob", () => {
|
|
|
416
432
|
expect(lastRunAtBumps).toHaveLength(0);
|
|
417
433
|
expect(wakeCalls).toHaveLength(0);
|
|
418
434
|
expect(forkCalls).toHaveLength(0);
|
|
435
|
+
// Every job run emits exactly one outcome counter for the health chart.
|
|
436
|
+
expect(
|
|
437
|
+
watchdogEvents.filter((e) => e.checkName === "memory_retrospective_run"),
|
|
438
|
+
).toEqual([
|
|
439
|
+
{
|
|
440
|
+
checkName: "memory_retrospective_run",
|
|
441
|
+
value: 1,
|
|
442
|
+
detail: { outcome: "no_new_messages" },
|
|
443
|
+
},
|
|
444
|
+
]);
|
|
419
445
|
});
|
|
420
446
|
|
|
421
447
|
test("a slice whose only row is the retrospective's own skill card is no new work", async () => {
|
|
@@ -532,6 +558,17 @@ describe("memoryRetrospectiveJob", () => {
|
|
|
532
558
|
const outcome = await memoryRetrospectiveJob(makeJob(), stubConfig);
|
|
533
559
|
|
|
534
560
|
expect(outcome.kind).toBe("wake_failed");
|
|
561
|
+
// The outcome counter carries the wake-failure reason so a fleet-wide
|
|
562
|
+
// spike is attributable (e.g. provider outage vs busy conversations).
|
|
563
|
+
expect(
|
|
564
|
+
watchdogEvents.filter((e) => e.checkName === "memory_retrospective_run"),
|
|
565
|
+
).toEqual([
|
|
566
|
+
{
|
|
567
|
+
checkName: "memory_retrospective_run",
|
|
568
|
+
value: 1,
|
|
569
|
+
detail: { outcome: "wake_failed", reason: "timeout" },
|
|
570
|
+
},
|
|
571
|
+
]);
|
|
535
572
|
if (outcome.kind === "wake_failed") {
|
|
536
573
|
expect(outcome.reason).toBe("timeout");
|
|
537
574
|
}
|
|
@@ -549,6 +586,17 @@ describe("memoryRetrospectiveJob", () => {
|
|
|
549
586
|
expect(stateUpserts).toHaveLength(0);
|
|
550
587
|
expect(lastRunAtBumps).toHaveLength(1);
|
|
551
588
|
expect(deletedConversationIds).toEqual(["fork-conv-1"]);
|
|
589
|
+
// A thrown run still records an outcome counter (as "error") before the
|
|
590
|
+
// rethrow — exception-flavored outages must show in the health metric.
|
|
591
|
+
expect(
|
|
592
|
+
watchdogEvents.filter((e) => e.checkName === "memory_retrospective_run"),
|
|
593
|
+
).toEqual([
|
|
594
|
+
{
|
|
595
|
+
checkName: "memory_retrospective_run",
|
|
596
|
+
value: 1,
|
|
597
|
+
detail: { outcome: "error", reason: "LLM provider 503" },
|
|
598
|
+
},
|
|
599
|
+
]);
|
|
552
600
|
});
|
|
553
601
|
|
|
554
602
|
test("missing conversationId payload: no_new_messages, no side effects", async () => {
|
|
@@ -35,6 +35,21 @@ type ConversationStub = {
|
|
|
35
35
|
} | null;
|
|
36
36
|
let conversationOverrides: Record<string, ConversationStub> = {};
|
|
37
37
|
|
|
38
|
+
const watchdogEvents: Array<{
|
|
39
|
+
checkName: string;
|
|
40
|
+
value?: number | null;
|
|
41
|
+
detail?: Record<string, unknown> | null;
|
|
42
|
+
}> = [];
|
|
43
|
+
mock.module("../../../../telemetry/watchdog-events-store.js", () => ({
|
|
44
|
+
recordWatchdogEvent: (record: {
|
|
45
|
+
checkName: string;
|
|
46
|
+
value?: number | null;
|
|
47
|
+
detail?: Record<string, unknown> | null;
|
|
48
|
+
}) => {
|
|
49
|
+
watchdogEvents.push(record);
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
|
|
38
53
|
mock.module("../../../../persistence/conversation-crud.js", () => ({
|
|
39
54
|
getMessagesAfter: (_id: string, _afterId: string | null) => [],
|
|
40
55
|
getMessages: (_id: string) => [],
|
|
@@ -156,6 +171,7 @@ function makeInsertJob(payload: Record<string, unknown>): MemoryJob {
|
|
|
156
171
|
|
|
157
172
|
describe("memory-retrospective skill card", () => {
|
|
158
173
|
beforeEach(() => {
|
|
174
|
+
watchdogEvents.length = 0;
|
|
159
175
|
addMessageCalls = [];
|
|
160
176
|
addMessageThrowsFor = null;
|
|
161
177
|
addMessageDeduplicatesFor = null;
|
|
@@ -191,7 +207,7 @@ describe("memory-retrospective skill card", () => {
|
|
|
191
207
|
type: "ui_surface",
|
|
192
208
|
surfaceId: "skill-card-run-conv-1",
|
|
193
209
|
surfaceType: "skill_card",
|
|
194
|
-
title: "
|
|
210
|
+
title: "I just learned how to do 2 new things",
|
|
195
211
|
display: "inline",
|
|
196
212
|
data: {
|
|
197
213
|
skills: [
|
|
@@ -214,7 +230,7 @@ describe("memory-retrospective skill card", () => {
|
|
|
214
230
|
// surface-capable clients skip it via the `_surfaceFallback` flag.
|
|
215
231
|
{
|
|
216
232
|
type: "text",
|
|
217
|
-
text: "
|
|
233
|
+
text: "I just learned how to do Skill A, Skill B",
|
|
218
234
|
_surfaceFallback: true,
|
|
219
235
|
},
|
|
220
236
|
]);
|
|
@@ -317,6 +333,31 @@ describe("memory-retrospective skill card", () => {
|
|
|
317
333
|
expect(publishedConversationIds).toHaveLength(0);
|
|
318
334
|
});
|
|
319
335
|
|
|
336
|
+
test("a delivered card emits the skill_card_delivered counter; a dedup does not", async () => {
|
|
337
|
+
await insertSkillCardMessage("conv-source", "conv-run-telemetry", [
|
|
338
|
+
{ skillId: "s-1", name: "Skill One", description: "d1" },
|
|
339
|
+
{ skillId: "s-2", name: "Skill Two", description: "d2" },
|
|
340
|
+
]);
|
|
341
|
+
expect(
|
|
342
|
+
watchdogEvents.filter((e) => e.checkName === "skill_card_delivered"),
|
|
343
|
+
).toEqual([
|
|
344
|
+
{
|
|
345
|
+
checkName: "skill_card_delivered",
|
|
346
|
+
value: 2,
|
|
347
|
+
detail: { skill_count: 2 },
|
|
348
|
+
},
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
// Retried delivery for the same run dedups on clientMessageId — the
|
|
352
|
+
// counter must not fire again.
|
|
353
|
+
await insertSkillCardMessage("conv-source", "conv-run-telemetry", [
|
|
354
|
+
{ skillId: "s-1", name: "Skill One", description: "d1" },
|
|
355
|
+
]);
|
|
356
|
+
expect(
|
|
357
|
+
watchdogEvents.filter((e) => e.checkName === "skill_card_delivered"),
|
|
358
|
+
).toHaveLength(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
320
361
|
test("insert is best-effort: a persistence failure never throws", async () => {
|
|
321
362
|
addMessageThrowsFor = "src-conv-9";
|
|
322
363
|
|
|
@@ -359,7 +400,7 @@ describe("memory-retrospective skill card", () => {
|
|
|
359
400
|
type: "ui_surface",
|
|
360
401
|
surfaceId: "skill-card-run-conv-1",
|
|
361
402
|
surfaceType: "skill_card",
|
|
362
|
-
title: "
|
|
403
|
+
title: "I just learned how to do Skill A",
|
|
363
404
|
display: "inline",
|
|
364
405
|
data: {
|
|
365
406
|
skills: [
|
|
@@ -374,7 +415,7 @@ describe("memory-retrospective skill card", () => {
|
|
|
374
415
|
},
|
|
375
416
|
{
|
|
376
417
|
type: "text",
|
|
377
|
-
text: "
|
|
418
|
+
text: "I just learned how to do Skill A",
|
|
378
419
|
_surfaceFallback: true,
|
|
379
420
|
},
|
|
380
421
|
]);
|
|
@@ -448,7 +489,7 @@ describe("memory-retrospective skill card", () => {
|
|
|
448
489
|
]);
|
|
449
490
|
expect(blocks[1]).toEqual({
|
|
450
491
|
type: "text",
|
|
451
|
-
text: "
|
|
492
|
+
text: "I just learned how to do Skill A, Skill B",
|
|
452
493
|
_surfaceFallback: true,
|
|
453
494
|
});
|
|
454
495
|
expect(publishedConversationIds).toEqual(["src-conv-9"]);
|
|
@@ -242,7 +242,7 @@ export function handleDeleteMemory(
|
|
|
242
242
|
return {
|
|
243
243
|
success: false,
|
|
244
244
|
message:
|
|
245
|
-
"No memory found matching that content. Use `
|
|
245
|
+
"No memory found matching that content. Use `assistant memory nodes list` to find the exact text first.",
|
|
246
246
|
};
|
|
247
247
|
}
|
|
248
248
|
|
|
@@ -317,7 +317,7 @@ export function handleUpdateMemory(
|
|
|
317
317
|
return {
|
|
318
318
|
success: false,
|
|
319
319
|
message:
|
|
320
|
-
"No memory found matching old_content. Use `
|
|
320
|
+
"No memory found matching old_content. Use `assistant memory nodes list` to find the exact text first.",
|
|
321
321
|
};
|
|
322
322
|
}
|
|
323
323
|
|