@vellumai/assistant 0.12.3-staging.2 → 0.12.4-staging.1
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 +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/openapi.yaml +6 -2
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/util/worker-pipe.test.ts +100 -0
- package/src/util/worker-pipe.ts +44 -0
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Also covers the search-query parser: `is:archived` and its synonyms in the
|
|
10
10
|
* `q` string flip the conversation arm to `includeArchived: true`, and the
|
|
11
|
-
* filter token is stripped from the term before any backend is called.
|
|
11
|
+
* filter token is stripped from the term before any backend is called. Under
|
|
12
|
+
* the `sidebar-done` flag the unfiltered default is `true` instead and
|
|
13
|
+
* `is:unarchived` is the opt-out, so the flag-on twins assert the mirror
|
|
14
|
+
* image of the default ones.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -30,9 +33,13 @@ const searchConversationsMock = mock(
|
|
|
30
33
|
},
|
|
31
34
|
);
|
|
32
35
|
|
|
36
|
+
let sidebarDoneEnabled = false;
|
|
37
|
+
|
|
33
38
|
const actualContactStore = await import("../../../contacts/contact-store.js");
|
|
34
39
|
const actualConvQueries =
|
|
35
40
|
await import("../../../persistence/conversation-queries.js");
|
|
41
|
+
const actualSidebarDoneGate =
|
|
42
|
+
await import("../../../config/sidebar-done-gate.js");
|
|
36
43
|
|
|
37
44
|
mock.module("../../../contacts/contact-store.js", () => ({
|
|
38
45
|
...actualContactStore,
|
|
@@ -42,6 +49,10 @@ mock.module("../../../persistence/conversation-queries.js", () => ({
|
|
|
42
49
|
...actualConvQueries,
|
|
43
50
|
searchConversations: searchConversationsMock,
|
|
44
51
|
}));
|
|
52
|
+
mock.module("../../../config/sidebar-done-gate.js", () => ({
|
|
53
|
+
...actualSidebarDoneGate,
|
|
54
|
+
isSidebarDoneEnabled: () => sidebarDoneEnabled,
|
|
55
|
+
}));
|
|
45
56
|
|
|
46
57
|
const { ROUTES } = await import("../global-search-routes.js");
|
|
47
58
|
|
|
@@ -69,6 +80,7 @@ afterEach(() => {
|
|
|
69
80
|
searchConversationsCalls = [];
|
|
70
81
|
searchConversationsResult = [];
|
|
71
82
|
searchConversationsMock.mockClear();
|
|
83
|
+
sidebarDoneEnabled = false;
|
|
72
84
|
});
|
|
73
85
|
|
|
74
86
|
describe("global-search contacts recency source", () => {
|
|
@@ -268,3 +280,64 @@ describe("global-search q parser", () => {
|
|
|
268
280
|
expect(result.queryTokens).toEqual(["alpha", "beta"]);
|
|
269
281
|
});
|
|
270
282
|
});
|
|
283
|
+
|
|
284
|
+
describe("global-search q parser under sidebar-done", () => {
|
|
285
|
+
test("plain term reaches done chats by default", async () => {
|
|
286
|
+
sidebarDoneEnabled = true;
|
|
287
|
+
|
|
288
|
+
await handler({
|
|
289
|
+
queryParams: { q: "shema", categories: "conversations" },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(searchConversationsCalls[0]?.query).toBe("shema");
|
|
293
|
+
expect(searchConversationsCalls[0]?.opts).toMatchObject({
|
|
294
|
+
includeArchived: true,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("is:unarchived (and archive:no / archive:false) narrows back to live chats", async () => {
|
|
299
|
+
for (const filter of ["is:unarchived", "archive:no", "archive:false"]) {
|
|
300
|
+
sidebarDoneEnabled = true;
|
|
301
|
+
searchConversationsCalls = [];
|
|
302
|
+
searchConversationsMock.mockClear();
|
|
303
|
+
|
|
304
|
+
await handler({
|
|
305
|
+
queryParams: {
|
|
306
|
+
q: `${filter} shema`,
|
|
307
|
+
categories: "conversations",
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(searchConversationsCalls[0]?.query).toBe("shema");
|
|
312
|
+
expect(searchConversationsCalls[0]?.opts).toMatchObject({
|
|
313
|
+
includeArchived: false,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("is:archived stays a no-op the term is still stripped of", async () => {
|
|
319
|
+
sidebarDoneEnabled = true;
|
|
320
|
+
|
|
321
|
+
await handler({
|
|
322
|
+
queryParams: { q: "is:archived shema", categories: "conversations" },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
expect(searchConversationsCalls[0]?.query).toBe("shema");
|
|
326
|
+
expect(searchConversationsCalls[0]?.opts).toMatchObject({
|
|
327
|
+
includeArchived: true,
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("unknown filter tokens stay in the term and do not narrow the default", async () => {
|
|
332
|
+
sidebarDoneEnabled = true;
|
|
333
|
+
|
|
334
|
+
await handler({
|
|
335
|
+
queryParams: { q: "is:starred shema", categories: "conversations" },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(searchConversationsCalls[0]?.query).toBe("is:starred shema");
|
|
339
|
+
expect(searchConversationsCalls[0]?.opts).toMatchObject({
|
|
340
|
+
includeArchived: true,
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -31,16 +31,14 @@ import {
|
|
|
31
31
|
import { resolveConversationId } from "../../persistence/conversation-key-store.js";
|
|
32
32
|
import {
|
|
33
33
|
type ConversationListFilter,
|
|
34
|
+
type ConversationListTypeFilter,
|
|
34
35
|
countConversations,
|
|
35
36
|
countConversationSections,
|
|
36
37
|
countUnreadConversations,
|
|
37
38
|
listConversations,
|
|
38
39
|
listPinnedConversations,
|
|
39
40
|
} from "../../persistence/conversation-queries.js";
|
|
40
|
-
import {
|
|
41
|
-
ASSISTANT_INITIATED_GROUP_ID,
|
|
42
|
-
type ConversationType,
|
|
43
|
-
} from "../../persistence/conversation-types.js";
|
|
41
|
+
import { ASSISTANT_INITIATED_GROUP_ID } from "../../persistence/conversation-types.js";
|
|
44
42
|
import { getBindingsForConversations } from "../../persistence/external-conversation-store.js";
|
|
45
43
|
import { listGroups } from "../../persistence/group-crud.js";
|
|
46
44
|
import { UserError } from "../../util/errors.js";
|
|
@@ -252,12 +250,14 @@ function handleListConversations({
|
|
|
252
250
|
const limit = Number(queryParams.limit ?? 50);
|
|
253
251
|
const offset = Number(queryParams.offset ?? 0);
|
|
254
252
|
// "background" is the back-compat umbrella (background + scheduled); newer
|
|
255
|
-
// clients can pass "scheduled" to load only the Scheduled section
|
|
256
|
-
//
|
|
257
|
-
|
|
253
|
+
// clients can pass "scheduled" to load only the Scheduled section, or "all"
|
|
254
|
+
// to page a user's whole history in one cursor. Absent defaults to the
|
|
255
|
+
// standard foreground list.
|
|
256
|
+
const conversationType: ConversationListTypeFilter =
|
|
258
257
|
parseEnumQueryParam(queryParams, "conversationType", [
|
|
259
258
|
"background",
|
|
260
259
|
"scheduled",
|
|
260
|
+
"all",
|
|
261
261
|
]) ?? "standard";
|
|
262
262
|
// Defaults to `active` so sidebar restores no longer pull archived rows.
|
|
263
263
|
// The Archive page opts into `archived` to render only archived rows
|
|
@@ -294,7 +294,9 @@ function handleListConversations({
|
|
|
294
294
|
read withholds them, so they appear in their own section and nowhere
|
|
295
295
|
else. Confined to `standard`: the background and scheduled buckets are
|
|
296
296
|
back-compat umbrellas addressed by conversation type, and narrowing them
|
|
297
|
-
by source would drop rows their callers still page through
|
|
297
|
+
by source would drop rows their callers still page through, while `all`
|
|
298
|
+
is a whole-history read with no second section to defer those rows to,
|
|
299
|
+
so withholding there would simply lose them. Confined to
|
|
298
300
|
active reads too: the Archive view asks `archiveStatus=archived` with no
|
|
299
301
|
`groupId`, and the section only ever lists active rows, so withholding
|
|
300
302
|
there would leave an archived section thread visible nowhere. */
|
|
@@ -584,10 +586,7 @@ function handleMarkUnread({ body = {}, headers }: RouteHandlerArgs) {
|
|
|
584
586
|
}
|
|
585
587
|
}
|
|
586
588
|
|
|
587
|
-
function handleGetConversation({
|
|
588
|
-
pathParams = {},
|
|
589
|
-
headers,
|
|
590
|
-
}: RouteHandlerArgs) {
|
|
589
|
+
function handleGetConversation({ pathParams = {}, headers }: RouteHandlerArgs) {
|
|
591
590
|
const detail = buildConversationDetailResponse(
|
|
592
591
|
pathParams.id!,
|
|
593
592
|
localeFromAcceptLanguage(headers?.["accept-language"]),
|
|
@@ -633,8 +632,11 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
633
632
|
type: "string",
|
|
634
633
|
required: false,
|
|
635
634
|
description:
|
|
636
|
-
'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella),
|
|
637
|
-
schema: {
|
|
635
|
+
'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with archiveStatus=all for a whole-history read; rows are recency-ordered (COALESCE(last_message_at, updated_at) descending) like every other list read, and pinned rows are never appended to it.',
|
|
636
|
+
schema: {
|
|
637
|
+
type: "string",
|
|
638
|
+
enum: ["background", "scheduled", "all"],
|
|
639
|
+
},
|
|
638
640
|
},
|
|
639
641
|
{
|
|
640
642
|
name: "archiveStatus",
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
} from "../../config/schemas/llm.js";
|
|
66
66
|
import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
|
|
67
67
|
import { ServiceModeSchema } from "../../config/schemas/services.js";
|
|
68
|
+
import { isSidebarDoneEnabled } from "../../config/sidebar-done-gate.js";
|
|
68
69
|
import {
|
|
69
70
|
describeShadowedConfigSet,
|
|
70
71
|
findSubstrateShadowing,
|
|
@@ -2108,6 +2109,9 @@ async function handleSearchConversations({
|
|
|
2108
2109
|
query: q,
|
|
2109
2110
|
limit,
|
|
2110
2111
|
maxMessagesPerConversation: maxMessages,
|
|
2112
|
+
// Under `sidebar-done` a done chat is filed rather than hidden, so the
|
|
2113
|
+
// agent's own history search reaches it the same way the user's does.
|
|
2114
|
+
includeArchived: isSidebarDoneEnabled(),
|
|
2111
2115
|
});
|
|
2112
2116
|
return { query: q, results };
|
|
2113
2117
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
|
|
13
13
|
import { getConfig } from "../../config/loader.js";
|
|
14
|
+
import { isSidebarDoneEnabled } from "../../config/sidebar-done-gate.js";
|
|
14
15
|
import { searchContacts } from "../../contacts/contact-store.js";
|
|
15
16
|
import { searchConversations } from "../../persistence/conversation-queries.js";
|
|
16
17
|
import { getMemorySqlite } from "../../persistence/db-connection.js";
|
|
@@ -123,23 +124,32 @@ function parseCategories(raw: string | undefined): Set<Category> {
|
|
|
123
124
|
* cleaned term as a literal query, so unknown filters are visible to lexical
|
|
124
125
|
* matching rather than silently dropped.
|
|
125
126
|
*
|
|
126
|
-
*
|
|
127
|
+
* `defaultArchived` is what an unfiltered query means. It is `false` by
|
|
128
|
+
* default and `true` under the `sidebar-done` flag, where a done chat is a
|
|
129
|
+
* filed chat rather than a hidden one and search is how the user reaches it.
|
|
130
|
+
* Either way both filters still say exactly what they say, so `is:unarchived`
|
|
131
|
+
* is the opt-out whenever the default is on.
|
|
132
|
+
*
|
|
133
|
+
* Examples (with `defaultArchived: false`):
|
|
127
134
|
* "foo bar" -> { term: "foo bar", archived: false }
|
|
128
135
|
* "is:archived foo" -> { term: "foo", archived: true }
|
|
129
136
|
* "foo is:archived" -> { term: "foo", archived: true }
|
|
130
137
|
* "is:archived" -> { term: "", archived: true }
|
|
131
138
|
* "is:starred foo" -> { term: "is:starred foo", archived: false }
|
|
132
139
|
*/
|
|
133
|
-
function parseSearchQuery(
|
|
140
|
+
function parseSearchQuery(
|
|
141
|
+
rawQ: string | undefined,
|
|
142
|
+
defaultArchived: boolean,
|
|
143
|
+
): {
|
|
134
144
|
term: string;
|
|
135
145
|
archived: boolean;
|
|
136
146
|
} {
|
|
137
147
|
if (!rawQ) {
|
|
138
|
-
return { term: "", archived:
|
|
148
|
+
return { term: "", archived: defaultArchived };
|
|
139
149
|
}
|
|
140
150
|
const tokens = rawQ.split(/\s+/).filter((t) => t.length > 0);
|
|
141
151
|
const termTokens: string[] = [];
|
|
142
|
-
let archived =
|
|
152
|
+
let archived = defaultArchived;
|
|
143
153
|
for (const tok of tokens) {
|
|
144
154
|
const lower = tok.toLowerCase();
|
|
145
155
|
if (
|
|
@@ -291,11 +301,16 @@ async function handleGlobalSearch({
|
|
|
291
301
|
throw new BadRequestError("q query parameter is required");
|
|
292
302
|
}
|
|
293
303
|
|
|
294
|
-
// Pull the archive
|
|
304
|
+
// Pull the archive filter out of the query string itself (`is:archived` /
|
|
295
305
|
// `archive:yes` / etc.) so the user controls it the same way they control
|
|
296
306
|
// every other modifier: by typing it into the search box. The cleaned term
|
|
297
|
-
// is what the backends actually search on.
|
|
298
|
-
|
|
307
|
+
// is what the backends actually search on. Under `sidebar-done` the
|
|
308
|
+
// unfiltered default flips to including done chats, and `is:unarchived`
|
|
309
|
+
// becomes the token that narrows back to the live set.
|
|
310
|
+
const { term, archived: includeArchived } = parseSearchQuery(
|
|
311
|
+
rawQ,
|
|
312
|
+
isSidebarDoneEnabled(),
|
|
313
|
+
);
|
|
299
314
|
|
|
300
315
|
const limit = Math.max(1, Math.min(Number(queryParams.limit ?? 20), 100));
|
|
301
316
|
const categories = parseCategories(queryParams.categories);
|
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A scheduled, heartbeat, or background turn runs its document tools
|
|
3
|
-
* sidecar worker, whose local hub has no SSE
|
|
4
|
-
*
|
|
5
|
-
* the user was not looking
|
|
2
|
+
* A scheduled, heartbeat, or background turn runs its document tools and its
|
|
3
|
+
* conversation writes in a sidecar worker, whose local hub has no SSE
|
|
4
|
+
* subscriber. Those broadcasts have to be handed to the daemon there, or the
|
|
5
|
+
* document edited and the conversation changed while the user was not looking
|
|
6
|
+
* stay invisible on every client.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
10
|
|
|
10
11
|
let notifyCount = 0;
|
|
12
|
+
const listHandoffs: Array<{ reason: string; conversationIds: string[] }> = [];
|
|
11
13
|
|
|
12
14
|
mock.module("./worker-daemon-notify.js", () => ({
|
|
13
15
|
NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD:
|
|
14
16
|
"notify_conversation_persisted_externally",
|
|
17
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD:
|
|
18
|
+
"notify_conversation_list_changed_externally",
|
|
15
19
|
NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD: "notify_documents_changed_externally",
|
|
16
20
|
NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD:
|
|
17
21
|
"notify_activation_progress_changed_externally",
|
|
18
22
|
notifyDaemonActivationProgressChanged: async () => {},
|
|
19
23
|
notifyDaemonConversationPersisted: async () => {},
|
|
24
|
+
notifyDaemonConversationListChanged: async (
|
|
25
|
+
reason: string,
|
|
26
|
+
conversationIds: string[],
|
|
27
|
+
) => {
|
|
28
|
+
listHandoffs.push({ reason, conversationIds });
|
|
29
|
+
},
|
|
20
30
|
notifyDaemonDocumentsChanged: async () => {
|
|
21
31
|
notifyCount++;
|
|
22
32
|
},
|
|
@@ -30,6 +40,7 @@ import {
|
|
|
30
40
|
} from "../assistant-stream-state.js";
|
|
31
41
|
import {
|
|
32
42
|
DOCUMENTS_CHANGED_COALESCE_MS,
|
|
43
|
+
publishConversationListAndMetadataChanged,
|
|
33
44
|
publishDocumentsChanged,
|
|
34
45
|
} from "./resource-sync-events.js";
|
|
35
46
|
|
|
@@ -66,6 +77,7 @@ describe("publishDocumentsChanged in a sidecar worker", () => {
|
|
|
66
77
|
beforeEach(async () => {
|
|
67
78
|
await settle();
|
|
68
79
|
notifyCount = 0;
|
|
80
|
+
listHandoffs.length = 0;
|
|
69
81
|
_resetStreamStateForTesting();
|
|
70
82
|
});
|
|
71
83
|
|
|
@@ -102,3 +114,37 @@ describe("publishDocumentsChanged in a sidecar worker", () => {
|
|
|
102
114
|
expect(events[0].message).toMatchObject({ type: "sync_changed" });
|
|
103
115
|
});
|
|
104
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("publishConversationListAndMetadataChanged in a sidecar worker", () => {
|
|
119
|
+
beforeEach(async () => {
|
|
120
|
+
await settle();
|
|
121
|
+
listHandoffs.length = 0;
|
|
122
|
+
_resetStreamStateForTesting();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("hands the list invalidation to the daemon instead of publishing into a void", async () => {
|
|
126
|
+
disableStreamSeqStamping();
|
|
127
|
+
|
|
128
|
+
const events = await captureLocalPublishes(() => {
|
|
129
|
+
publishConversationListAndMetadataChanged("reordered", "conv-1");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(listHandoffs).toEqual([
|
|
133
|
+
{ reason: "reordered", conversationIds: ["conv-1"] },
|
|
134
|
+
]);
|
|
135
|
+
expect(events).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("publishes on the local hub in the daemon, where subscribers live", async () => {
|
|
139
|
+
const events = await captureLocalPublishes(() => {
|
|
140
|
+
publishConversationListAndMetadataChanged("reordered", "conv-1");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(listHandoffs).toEqual([]);
|
|
144
|
+
expect(events).toHaveLength(1);
|
|
145
|
+
expect(events[0].message).toMatchObject({
|
|
146
|
+
type: "sync_changed",
|
|
147
|
+
tags: ["conversations:list", "conversation:conv-1:metadata"],
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -12,6 +12,7 @@ import { isStreamSeqStampingDisabled } from "../assistant-stream-state.js";
|
|
|
12
12
|
import { publishSyncInvalidation } from "./sync-publisher.js";
|
|
13
13
|
import {
|
|
14
14
|
notifyDaemonActivationProgressChanged,
|
|
15
|
+
notifyDaemonConversationListChanged,
|
|
15
16
|
notifyDaemonConversationPersisted,
|
|
16
17
|
notifyDaemonDocumentsChanged,
|
|
17
18
|
} from "./worker-daemon-notify.js";
|
|
@@ -236,6 +237,16 @@ export function publishConversationListAndMetadataChanged(
|
|
|
236
237
|
? conversationIds
|
|
237
238
|
: [conversationIds];
|
|
238
239
|
|
|
240
|
+
// In a sidecar worker the local hub has no SSE subscribers, so a publish
|
|
241
|
+
// here reaches nobody and the sidebar keeps showing a list the worker has
|
|
242
|
+
// already changed. Hand off to the daemon, which republishes on the hub
|
|
243
|
+
// clients actually subscribe to. A background worker turn has no
|
|
244
|
+
// originating client, so no `originClientId` is forwarded.
|
|
245
|
+
if (isStreamSeqStampingDisabled()) {
|
|
246
|
+
void notifyDaemonConversationListChanged(reason, ids);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
239
250
|
// Shape-changing reasons (`created`, `deleted`, `reordered`) add or
|
|
240
251
|
// remove rows or change the order of the paginated window — web must
|
|
241
252
|
// refetch the list, so include the `conversationsList` umbrella tag.
|
|
@@ -33,13 +33,43 @@ mock.module("../../ipc/cli-client.js", () => ({
|
|
|
33
33
|
|
|
34
34
|
import {
|
|
35
35
|
NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD,
|
|
36
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
|
|
36
37
|
NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD,
|
|
37
38
|
NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD,
|
|
38
39
|
notifyDaemonActivationProgressChanged,
|
|
40
|
+
notifyDaemonConversationListChanged,
|
|
39
41
|
notifyDaemonConversationPersisted,
|
|
40
42
|
notifyDaemonDocumentsChanged,
|
|
41
43
|
} from "./worker-daemon-notify.js";
|
|
42
44
|
|
|
45
|
+
describe("notifyDaemonConversationListChanged", () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
calls.length = 0;
|
|
48
|
+
nextResult = { ok: true };
|
|
49
|
+
shouldThrow = false;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("hands the reason and ids to the daemon over the shared IPC method", async () => {
|
|
53
|
+
await notifyDaemonConversationListChanged("reordered", ["conv-1"]);
|
|
54
|
+
|
|
55
|
+
expect(calls).toHaveLength(1);
|
|
56
|
+
expect(calls[0]!.method).toBe(NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD);
|
|
57
|
+
expect(calls[0]!.params).toEqual({
|
|
58
|
+
body: { reason: "reordered", conversationIds: ["conv-1"] },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("swallows a thrown IPC error (best-effort, no throw)", async () => {
|
|
63
|
+
shouldThrow = true;
|
|
64
|
+
|
|
65
|
+
const result = await notifyDaemonConversationListChanged("reordered", [
|
|
66
|
+
"conv-1",
|
|
67
|
+
]);
|
|
68
|
+
expect(result).toBeUndefined();
|
|
69
|
+
expect(calls).toHaveLength(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
43
73
|
describe("notifyDaemonConversationPersisted", () => {
|
|
44
74
|
beforeEach(() => {
|
|
45
75
|
calls.length = 0;
|
|
@@ -31,6 +31,14 @@ export const NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD =
|
|
|
31
31
|
export const NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD =
|
|
32
32
|
"notify_documents_changed_externally";
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* IPC method the daemon exposes for the conversation-list hand-off. Shared by
|
|
36
|
+
* the worker caller and the daemon route registration so the wire name cannot
|
|
37
|
+
* drift.
|
|
38
|
+
*/
|
|
39
|
+
export const NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD =
|
|
40
|
+
"notify_conversation_list_changed_externally";
|
|
41
|
+
|
|
34
42
|
/**
|
|
35
43
|
* IPC method the daemon exposes for the activation-progress hand-off. Shared
|
|
36
44
|
* by the worker caller and the daemon route registration so the wire name
|
|
@@ -79,6 +87,42 @@ export async function notifyDaemonConversationPersisted(
|
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Ask the daemon to republish a conversation-list-and-metadata invalidation.
|
|
92
|
+
*
|
|
93
|
+
* A worker turn can change which rows the sidebar should hold, not just what
|
|
94
|
+
* one row contains: clearing `archived_at` on a Done conversation the turn
|
|
95
|
+
* woke is the case that matters. The list tag has to reach real subscribers
|
|
96
|
+
* for the conversation to reappear, and the worker's own hub has none.
|
|
97
|
+
*
|
|
98
|
+
* Best-effort by design, like the other hand-offs: an unreachable daemon is
|
|
99
|
+
* logged at debug and swallowed, and the client picks the change up on its
|
|
100
|
+
* next list fetch.
|
|
101
|
+
*/
|
|
102
|
+
export async function notifyDaemonConversationListChanged(
|
|
103
|
+
reason: string,
|
|
104
|
+
conversationIds: string[],
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
const result = await cliIpcCall(
|
|
108
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
|
|
109
|
+
{ body: { reason, conversationIds } },
|
|
110
|
+
{ timeoutMs: NOTIFY_TIMEOUT_MS },
|
|
111
|
+
);
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
log.debug(
|
|
114
|
+
{ reason, conversationIds, error: result.error },
|
|
115
|
+
"daemon conversation-list-changed notify was not acknowledged",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
log.debug(
|
|
120
|
+
{ err, reason, conversationIds },
|
|
121
|
+
"daemon conversation-list-changed notify failed",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
/**
|
|
83
127
|
* Ask the daemon to republish the documents-list invalidation.
|
|
84
128
|
*
|
|
@@ -278,7 +278,7 @@ async function startOutboundTelegram(
|
|
|
278
278
|
success: false,
|
|
279
279
|
error: "already_bound",
|
|
280
280
|
message:
|
|
281
|
-
"A guardian is already bound for this channel. Set rebind: true to
|
|
281
|
+
"A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
|
|
282
282
|
channel,
|
|
283
283
|
};
|
|
284
284
|
}
|
|
@@ -820,7 +820,7 @@ async function startOutboundTextChannel(
|
|
|
820
820
|
success: false,
|
|
821
821
|
error: "already_bound",
|
|
822
822
|
message:
|
|
823
|
-
"A guardian is already bound for this channel. Set rebind: true to
|
|
823
|
+
"A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
|
|
824
824
|
channel,
|
|
825
825
|
};
|
|
826
826
|
}
|
|
@@ -200,6 +200,44 @@ describe("AskQuestionTool.execute", () => {
|
|
|
200
200
|
expect(result.isError).toBe(false);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test("says so when the user answered in the chat instead of the card", async () => {
|
|
204
|
+
const q2 = {
|
|
205
|
+
question: "Preferred time?",
|
|
206
|
+
options: [
|
|
207
|
+
{ id: "morning", label: "Morning" },
|
|
208
|
+
{ id: "afternoon", label: "Afternoon" },
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
setNextResult({
|
|
212
|
+
entries: [
|
|
213
|
+
{
|
|
214
|
+
questionId: "q1",
|
|
215
|
+
decision: "free_text",
|
|
216
|
+
text: "banana, after lunch",
|
|
217
|
+
},
|
|
218
|
+
{ questionId: "q2", decision: "skipped" },
|
|
219
|
+
],
|
|
220
|
+
overall: "completed",
|
|
221
|
+
answeredInChat: true,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await askQuestionTool.execute(
|
|
225
|
+
{ questions: [singleQ, q2] },
|
|
226
|
+
makeContext(),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(result.isError).toBe(false);
|
|
230
|
+
const lines = String(result.content).split("\n");
|
|
231
|
+
// The model is told where the answer came from, then reads the batch: the
|
|
232
|
+
// typed text on the first question, the rest unanswered, so it can map the
|
|
233
|
+
// reply onto them itself.
|
|
234
|
+
expect(lines[0]).toContain("replied in the chat");
|
|
235
|
+
expect(lines.slice(1)).toEqual([
|
|
236
|
+
`Question "${singleQ.question}" → Free text: banana, after lunch`,
|
|
237
|
+
`Question "${q2.question}" → Skipped`,
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
203
241
|
test("formats skipped result", async () => {
|
|
204
242
|
setNextResult(singleCompleted({ decision: "skipped" }));
|
|
205
243
|
const result = await askQuestionTool.execute(validInput, makeContext());
|
|
@@ -243,6 +243,15 @@ export function toAnsweredQuestion(
|
|
|
243
243
|
};
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Model-facing note prepended when the user answered by typing in the chat
|
|
248
|
+
* instead of using the card. The text lands as free text on the first
|
|
249
|
+
* question and every other question reads as skipped, so the model is told
|
|
250
|
+
* plainly what happened and left to map the reply onto the batch itself.
|
|
251
|
+
*/
|
|
252
|
+
const CHAT_REPLY_PREAMBLE =
|
|
253
|
+
"The user replied in the chat instead of using the question card, so their message is recorded as free text on the first question below and the remaining questions are unanswered. Read their reply as the answer to whichever questions it covers, and ask again only for what it genuinely leaves open.";
|
|
254
|
+
|
|
246
255
|
// ── Tool ────────────────────────────────────────────────────────────
|
|
247
256
|
|
|
248
257
|
/**
|
|
@@ -389,7 +398,9 @@ export const askQuestionTool = {
|
|
|
389
398
|
|
|
390
399
|
switch (result.overall) {
|
|
391
400
|
case "completed": {
|
|
392
|
-
let content =
|
|
401
|
+
let content = result.answeredInChat
|
|
402
|
+
? [CHAT_REPLY_PREAMBLE, ...lines].join("\n")
|
|
403
|
+
: lines.join("\n");
|
|
393
404
|
if (desktopHelp) {
|
|
394
405
|
const entry = result.entries[0];
|
|
395
406
|
if (entry?.decision === "free_text") {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { writeWorkerLine } from "./worker-pipe.js";
|
|
4
|
+
|
|
5
|
+
/** Comfortably larger than any OS pipe buffer, so the write cannot complete. */
|
|
6
|
+
const OVERSIZED_LINE = "x".repeat(4 * 1024 * 1024);
|
|
7
|
+
|
|
8
|
+
const spawned: { kill(signal?: NodeJS.Signals): void }[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const proc of spawned.splice(0)) {
|
|
12
|
+
try {
|
|
13
|
+
proc.kill("SIGKILL");
|
|
14
|
+
} catch {
|
|
15
|
+
/* already gone */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** A live child that never reads stdin, the way a worker busy in ONNX does not. */
|
|
21
|
+
function spawnBusyWorker() {
|
|
22
|
+
const proc = Bun.spawn({
|
|
23
|
+
cmd: [process.execPath, "-e", "setTimeout(() => {}, 60_000)"],
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
stdin: "pipe",
|
|
26
|
+
stdout: "ignore",
|
|
27
|
+
stderr: "ignore",
|
|
28
|
+
});
|
|
29
|
+
spawned.push(proc);
|
|
30
|
+
return proc;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("writeWorkerLine", () => {
|
|
34
|
+
/**
|
|
35
|
+
* The production failure: the payload outruns the pipe buffer, Bun returns a
|
|
36
|
+
* pending promise, and the worker dies before draining it. Unobserved, that
|
|
37
|
+
* rejection is an `unhandledRejection`, which `bun test` reports as a failure
|
|
38
|
+
* of this test.
|
|
39
|
+
*/
|
|
40
|
+
test("reports a write still pending when the worker dies", async () => {
|
|
41
|
+
const proc = spawnBusyWorker();
|
|
42
|
+
const failure = Promise.withResolvers<unknown>();
|
|
43
|
+
|
|
44
|
+
writeWorkerLine(proc.stdin, OVERSIZED_LINE, failure.resolve);
|
|
45
|
+
proc.kill("SIGKILL");
|
|
46
|
+
|
|
47
|
+
const err = await failure.promise;
|
|
48
|
+
expect(err).toBeInstanceOf(Error);
|
|
49
|
+
expect((err as NodeJS.ErrnoException).code).toBe("EPIPE");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("reports a synchronous throw", () => {
|
|
53
|
+
const failures: unknown[] = [];
|
|
54
|
+
const epipe = Object.assign(new Error("EPIPE: broken pipe, write"), {
|
|
55
|
+
code: "EPIPE",
|
|
56
|
+
});
|
|
57
|
+
const stdin = {
|
|
58
|
+
write() {
|
|
59
|
+
throw epipe;
|
|
60
|
+
},
|
|
61
|
+
flush: () => 0,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
writeWorkerLine(stdin, "line", (err) => failures.push(err));
|
|
65
|
+
|
|
66
|
+
expect(failures).toEqual([epipe]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** `write` and `flush` both report the same broken pipe. */
|
|
70
|
+
test("reports one failure when write and flush both reject", async () => {
|
|
71
|
+
const failures: unknown[] = [];
|
|
72
|
+
const epipe = new Error("EPIPE: broken pipe, send");
|
|
73
|
+
const stdin = {
|
|
74
|
+
write: () => Promise.reject(epipe),
|
|
75
|
+
flush: () => Promise.reject(epipe),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
writeWorkerLine(stdin, "line", (err) => failures.push(err));
|
|
79
|
+
await Bun.sleep(0);
|
|
80
|
+
|
|
81
|
+
expect(failures).toEqual([epipe]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("stays silent when the line is delivered", async () => {
|
|
85
|
+
const failures: unknown[] = [];
|
|
86
|
+
const proc = Bun.spawn({
|
|
87
|
+
cmd: [process.execPath, "-e", "process.stdin.on('data', () => {})"],
|
|
88
|
+
windowsHide: true,
|
|
89
|
+
stdin: "pipe",
|
|
90
|
+
stdout: "ignore",
|
|
91
|
+
stderr: "ignore",
|
|
92
|
+
});
|
|
93
|
+
spawned.push(proc);
|
|
94
|
+
|
|
95
|
+
writeWorkerLine(proc.stdin, "hello", (err) => failures.push(err));
|
|
96
|
+
await Bun.sleep(50);
|
|
97
|
+
|
|
98
|
+
expect(failures).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
});
|