@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
package/Dockerfile
CHANGED
|
@@ -102,6 +102,7 @@ RUN apt-get update && apt-get install -y \
|
|
|
102
102
|
&& rm -rf /var/lib/apt/lists/*
|
|
103
103
|
|
|
104
104
|
# Desktop system packages persist through container replacement as image layers.
|
|
105
|
+
COPY assistant/docker/mousepad/90-vellum.gschema.override /usr/share/glib-2.0/schemas/90-vellum.gschema.override
|
|
105
106
|
RUN set -eu; \
|
|
106
107
|
wezterm_version=20240203-110809-5046fc22; \
|
|
107
108
|
case "$(dpkg --print-architecture)" in \
|
|
@@ -129,6 +130,7 @@ RUN set -eu; \
|
|
|
129
130
|
libgl1-mesa-dri \
|
|
130
131
|
libgtk-3-0 \
|
|
131
132
|
libvulkan1 \
|
|
133
|
+
mousepad \
|
|
132
134
|
openbox \
|
|
133
135
|
plank \
|
|
134
136
|
python3 \
|
|
@@ -144,6 +146,11 @@ RUN set -eu; \
|
|
|
144
146
|
xfonts-base \
|
|
145
147
|
xterm \
|
|
146
148
|
/tmp/wezterm.deb; \
|
|
149
|
+
/usr/lib/*/glib-2.0/glib-compile-schemas --strict /usr/share/glib-2.0/schemas; \
|
|
150
|
+
mkdir -p /etc/xdg; \
|
|
151
|
+
awk -v desktop_id=org.xfce.mousepad.desktop \
|
|
152
|
+
'BEGIN { print "[Default Applications]" } /^MimeType=/ { sub(/^MimeType=/, ""); count = split($0, types, ";"); for (i = 1; i <= count; i++) { if (types[i] != "") { print types[i] "=" desktop_id ";" } } }' \
|
|
153
|
+
/usr/share/applications/org.xfce.mousepad.desktop > /etc/xdg/mimeapps.list; \
|
|
147
154
|
wezterm --version; \
|
|
148
155
|
rm -rf /tmp/wezterm.deb /var/lib/apt/lists/* /var/cache/apt/archives/*.deb
|
|
149
156
|
|
package/README.md
CHANGED
|
@@ -70,16 +70,16 @@ bun run src/index.ts # interactive CLI session
|
|
|
70
70
|
|
|
71
71
|
### CLI commands
|
|
72
72
|
|
|
73
|
-
| Command
|
|
74
|
-
|
|
|
75
|
-
| `vellum wake`
|
|
76
|
-
| `vellum sleep`
|
|
77
|
-
| `vellum ps`
|
|
78
|
-
| `assistant`
|
|
73
|
+
| Command | Description |
|
|
74
|
+
| ---------------------------------------------------------- | ------------------------------------------------ |
|
|
75
|
+
| `vellum wake` | Start assistant + gateway from current checkout |
|
|
76
|
+
| `vellum sleep` | Stop assistant + gateway processes |
|
|
77
|
+
| `vellum ps` | List assistants and per-assistant process status |
|
|
78
|
+
| `assistant` | Launch interactive CLI session |
|
|
79
79
|
| `assistant conversations list\|search\|new\|export\|clear` | Manage conversations |
|
|
80
|
-
| `assistant config set\|get\|list`
|
|
81
|
-
| `assistant keys set\|list\|delete`
|
|
82
|
-
| `assistant trust list\|add\|update\|remove`
|
|
80
|
+
| `assistant config set\|get\|list` | Manage configuration |
|
|
81
|
+
| `assistant keys set\|list\|delete` | Manage API keys in secure storage |
|
|
82
|
+
| `assistant trust list\|add\|update\|remove` | Manage trust rules |
|
|
83
83
|
|
|
84
84
|
## Project Structure
|
|
85
85
|
|
|
@@ -183,7 +183,7 @@ Internal forwarding routes (`/v1/internal/twilio/*`) are unaffected — these ac
|
|
|
183
183
|
The `/channels/inbound` endpoint requires a JWT with the `svc_gateway` principal type and `ingress.write` scope to prove the request originated from the gateway. This ensures channel messages can only arrive via the gateway (which performs webhook-level verification) and not via direct HTTP calls that bypass signature checks.
|
|
184
184
|
|
|
185
185
|
- **JWT-based enforcement:** The route policy in `route-policy.ts` restricts `/channels/inbound` to the `svc_gateway` principal type with `ingress.write` scope. Actor and local principals are rejected with 403.
|
|
186
|
-
- **Auth bypass:** When `DISABLE_HTTP_AUTH=true` is set (platform-managed deployments),
|
|
186
|
+
- **Auth bypass:** When `DISABLE_HTTP_AUTH=true` is set (platform-managed deployments), a request that supplies no `Authorization` header gets a synthetic context. A request that supplies one is verified normally, and a token that fails verification is rejected.
|
|
187
187
|
|
|
188
188
|
## Twilio Setup Primitive
|
|
189
189
|
|
|
@@ -244,8 +244,8 @@ The channel guardian service generates verification challenge instructions with
|
|
|
244
244
|
### Operator Notes
|
|
245
245
|
|
|
246
246
|
- **Verification input format:** Channel verification accepts a bare code reply only (6-digit numeric for identity-bound sessions; 64-char hex for unbound inbound/bootstrap compatibility).
|
|
247
|
-
- **Rebind requirement:** Creating a new guardian challenge when a binding already exists requires `rebind: true` in the HTTP request. Without it, the assistant returns `already_bound`.
|
|
248
|
-
- **Takeover prevention:**
|
|
247
|
+
- **Rebind requirement:** Creating a new guardian challenge when a binding already exists requires `rebind: true` in the HTTP request. Without it, the assistant returns `already_bound`. On text channels the resulting code can only be redeemed by the account already bound, which is how the same guardian verifies again; to move the guardian to a different account, revoke the binding first and verify again. An outbound phone verification is the exception: its code replaces the bound number.
|
|
248
|
+
- **Takeover prevention:** On text channels, verification is rejected when an active binding exists for a different external user: the code is spent, the reply is the generic "invalid or expired", and the sender is made neither guardian nor contact. Same-user re-verification is allowed, including for a guardian who revoked their binding and is verifying the same account again. Phone differs: an outbound verification with `rebind: true` replaces the bound number once its code is entered, and an inbound call from a different number is not bound.
|
|
249
249
|
|
|
250
250
|
### Vellum Guardian Identity (Actor Tokens)
|
|
251
251
|
|
package/openapi.yaml
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
openapi: 3.1.0
|
|
4
4
|
info:
|
|
5
5
|
title: Vellum Assistant API
|
|
6
|
-
version: 0.12.
|
|
6
|
+
version: 0.12.4
|
|
7
7
|
description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
|
|
8
8
|
servers:
|
|
9
9
|
- url: http://127.0.0.1:7821
|
|
@@ -7985,9 +7985,13 @@ paths:
|
|
|
7985
7985
|
enum:
|
|
7986
7986
|
- background
|
|
7987
7987
|
- scheduled
|
|
7988
|
+
- all
|
|
7988
7989
|
description:
|
|
7989
7990
|
Filter by conversation type. Pass "background" to list background and scheduled conversations together (the
|
|
7990
|
-
back-compat umbrella),
|
|
7991
|
+
back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the
|
|
7992
|
+
standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with
|
|
7993
|
+
archiveStatus=all for a whole-history read; rows are recency-ordered (COALESCE(last_message_at, updated_at)
|
|
7994
|
+
descending) like every other list read, and pinned rows are never appended to it.
|
|
7991
7995
|
- name: archiveStatus
|
|
7992
7996
|
in: query
|
|
7993
7997
|
required: false
|
package/package.json
CHANGED
|
@@ -241,8 +241,10 @@ function makeConfirmationEmittingSession(opts?: {
|
|
|
241
241
|
// Tests
|
|
242
242
|
// ---------------------------------------------------------------------------
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
245
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
246
|
+
// verified against the signing key, which these tests do not set up.
|
|
247
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
246
248
|
|
|
247
249
|
describe("standalone approval endpoints — HTTP layer", () => {
|
|
248
250
|
let server: RuntimeHttpServer;
|
|
@@ -82,8 +82,10 @@ await initializeDb();
|
|
|
82
82
|
// Helpers
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
86
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
87
|
+
// verified against the signing key, which these tests do not set up.
|
|
88
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
87
89
|
|
|
88
90
|
let ensuredConvIds = new Set<string>();
|
|
89
91
|
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Done conversation comes back to the list when a message the user reads
|
|
3
|
+
* lands in it.
|
|
4
|
+
*
|
|
5
|
+
* Two seams enforce the rule: `insertMessageCore`, where every durable append
|
|
6
|
+
* lands, and the turn's finalize effect, where a streamed reply becomes
|
|
7
|
+
* readable. These tests drive the insert seam the way each ingest path does,
|
|
8
|
+
* with `addMessage` and that path's row shape, and drive the finalize seam
|
|
9
|
+
* through `resurfaceArchivedConversation` itself, which is all the finalize
|
|
10
|
+
* effect adds to the reserved row.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
mock.module("../persistence/embeddings/qdrant-client.js", () => ({
|
|
18
|
+
getQdrantClient: () => ({
|
|
19
|
+
searchWithFilter: async () => [],
|
|
20
|
+
hybridSearch: async () => [],
|
|
21
|
+
upsertPoints: async () => {},
|
|
22
|
+
deletePoints: async () => {},
|
|
23
|
+
}),
|
|
24
|
+
initQdrantClient: () => {},
|
|
25
|
+
resolveQdrantUrl: () => "http://127.0.0.1:6333",
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
let sidebarDoneEnabled = true;
|
|
29
|
+
mock.module("../config/sidebar-done-gate.js", () => ({
|
|
30
|
+
SIDEBAR_DONE_FLAG_KEY: "sidebar-done",
|
|
31
|
+
isSidebarDoneEnabled: () => sidebarDoneEnabled,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const listInvalidations: Array<{ reason: string; conversationIds: unknown }> =
|
|
35
|
+
[];
|
|
36
|
+
const actualSyncEvents =
|
|
37
|
+
await import("../runtime/sync/resource-sync-events.js");
|
|
38
|
+
mock.module("../runtime/sync/resource-sync-events.js", () => ({
|
|
39
|
+
...actualSyncEvents,
|
|
40
|
+
publishConversationListAndMetadataChanged: (
|
|
41
|
+
reason: string,
|
|
42
|
+
conversationIds: unknown,
|
|
43
|
+
) => {
|
|
44
|
+
listInvalidations.push({ reason, conversationIds });
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
addMessage,
|
|
50
|
+
archiveConversation,
|
|
51
|
+
getConversation,
|
|
52
|
+
reserveMessage,
|
|
53
|
+
resurfaceArchivedConversation,
|
|
54
|
+
} from "../persistence/conversation-crud.js";
|
|
55
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
56
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
57
|
+
import { conversations, messages } from "../persistence/schema/index.js";
|
|
58
|
+
import {
|
|
59
|
+
MEMORY_RETROSPECTIVE_INSTRUCTION_KIND,
|
|
60
|
+
SKILL_CARD_MESSAGE_KIND,
|
|
61
|
+
} from "../plugins/defaults/memory/memory-retrospective-constants.js";
|
|
62
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
63
|
+
|
|
64
|
+
setConfig("memory", { extraction: { useLLM: false } });
|
|
65
|
+
|
|
66
|
+
await initializeDb();
|
|
67
|
+
|
|
68
|
+
const DONE_CONVERSATION_ID = "conv-done";
|
|
69
|
+
const RETRO_FORK_ID = "conv-done-retro-fork";
|
|
70
|
+
|
|
71
|
+
function insertConversation(id: string): void {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
getDb()
|
|
74
|
+
.insert(conversations)
|
|
75
|
+
.values({
|
|
76
|
+
id,
|
|
77
|
+
title: null,
|
|
78
|
+
createdAt: now,
|
|
79
|
+
updatedAt: now,
|
|
80
|
+
totalInputTokens: 0,
|
|
81
|
+
totalOutputTokens: 0,
|
|
82
|
+
totalEstimatedCost: 0,
|
|
83
|
+
contextSummary: null,
|
|
84
|
+
contextCompactedMessageCount: 0,
|
|
85
|
+
contextCompactedAt: null,
|
|
86
|
+
})
|
|
87
|
+
.run();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resetTables(): void {
|
|
91
|
+
const db = getDb();
|
|
92
|
+
db.run("DELETE FROM messages");
|
|
93
|
+
db.run("DELETE FROM conversations");
|
|
94
|
+
insertConversation(DONE_CONVERSATION_ID);
|
|
95
|
+
insertConversation(RETRO_FORK_ID);
|
|
96
|
+
archiveConversation(DONE_CONVERSATION_ID);
|
|
97
|
+
listInvalidations.length = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function archivedAtOf(id: string): number | null | undefined {
|
|
101
|
+
return getConversation(id)?.archivedAt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function blocks(text: string): string {
|
|
105
|
+
return JSON.stringify([{ type: "text", text }]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("Done conversations and new activity", () => {
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
sidebarDoneEnabled = true;
|
|
111
|
+
resetTables();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("assistant output from a scheduled run brings the conversation back", async () => {
|
|
115
|
+
// The wake persists its `<background_event>` trigger first. That row is
|
|
116
|
+
// dropped from the transcript, so it must not resurface anything on its
|
|
117
|
+
// own: the run has produced nothing the user can read yet.
|
|
118
|
+
await addMessage(
|
|
119
|
+
DONE_CONVERSATION_ID,
|
|
120
|
+
"user",
|
|
121
|
+
blocks(
|
|
122
|
+
'<background_event source="schedule">Daily digest</background_event>',
|
|
123
|
+
),
|
|
124
|
+
{
|
|
125
|
+
metadata: {
|
|
126
|
+
kind: "background-event",
|
|
127
|
+
backgroundEventSource: "schedule",
|
|
128
|
+
automated: true,
|
|
129
|
+
},
|
|
130
|
+
skipIndexing: true,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
135
|
+
expect(listInvalidations).toHaveLength(0);
|
|
136
|
+
|
|
137
|
+
// The run's own output is what the user reads.
|
|
138
|
+
await addMessage(
|
|
139
|
+
DONE_CONVERSATION_ID,
|
|
140
|
+
"assistant",
|
|
141
|
+
blocks("Here is today's digest."),
|
|
142
|
+
{ metadata: { automated: true }, skipIndexing: true },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
146
|
+
expect(listInvalidations).toEqual([
|
|
147
|
+
{ reason: "reordered", conversationIds: DONE_CONVERSATION_ID },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the empty row an LLM call reserves leaves the conversation Done", async () => {
|
|
152
|
+
const reserved = await reserveMessage(
|
|
153
|
+
DONE_CONVERSATION_ID,
|
|
154
|
+
"assistant",
|
|
155
|
+
undefined,
|
|
156
|
+
"inflight/ref.jsonl",
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
160
|
+
expect(listInvalidations).toHaveLength(0);
|
|
161
|
+
|
|
162
|
+
// The finalize seam is what resurfaces it, once the row holds content.
|
|
163
|
+
expect(
|
|
164
|
+
resurfaceArchivedConversation(DONE_CONVERSATION_ID, reserved.createdAt),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
167
|
+
expect(listInvalidations).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a Done mark made after the activity wins over the resurface", () => {
|
|
171
|
+
const activityAt = Date.now() - 60_000;
|
|
172
|
+
|
|
173
|
+
expect(
|
|
174
|
+
resurfaceArchivedConversation(DONE_CONVERSATION_ID, activityAt),
|
|
175
|
+
).toBe(false);
|
|
176
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
177
|
+
expect(listInvalidations).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("an inbound channel message from a person brings the conversation back", async () => {
|
|
181
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "any updates?", {
|
|
182
|
+
metadata: {
|
|
183
|
+
userMessageChannel: "slack",
|
|
184
|
+
provenanceSourceChannel: "slack",
|
|
185
|
+
},
|
|
186
|
+
skipIndexing: true,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
190
|
+
expect(listInvalidations).toHaveLength(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("rows that never render leave the conversation Done", async () => {
|
|
194
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "internal signal", {
|
|
195
|
+
metadata: { hidden: true },
|
|
196
|
+
skipIndexing: true,
|
|
197
|
+
});
|
|
198
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "subagent finished", {
|
|
199
|
+
metadata: { subagentNotification: { subagentId: "sub-1" } },
|
|
200
|
+
skipIndexing: true,
|
|
201
|
+
});
|
|
202
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "acp session update", {
|
|
203
|
+
metadata: { acpNotification: { sessionId: "acp-1" } },
|
|
204
|
+
skipIndexing: true,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
208
|
+
expect(listInvalidations).toHaveLength(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a memory retrospective leaves the conversation it reviews Done", async () => {
|
|
212
|
+
// A retrospective runs in a fork: its instruction and its whole pass are
|
|
213
|
+
// appended to the fork, never to the conversation under review.
|
|
214
|
+
await addMessage(RETRO_FORK_ID, "user", "Review the transcript.", {
|
|
215
|
+
metadata: { kind: MEMORY_RETROSPECTIVE_INSTRUCTION_KIND, hidden: true },
|
|
216
|
+
skipIndexing: true,
|
|
217
|
+
});
|
|
218
|
+
await addMessage(RETRO_FORK_ID, "assistant", blocks("Saved one fact."), {
|
|
219
|
+
metadata: { automated: true },
|
|
220
|
+
skipIndexing: true,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
224
|
+
expect(listInvalidations).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a row its producer marked as bookkeeping leaves the conversation Done", async () => {
|
|
228
|
+
// The retrospective skill card, written the way
|
|
229
|
+
// `memory-retrospective-skill-card.ts` writes it. Persistence cannot
|
|
230
|
+
// import the memory plugin (`persistence-layering-guard`), so the producer
|
|
231
|
+
// declares the row instead of the seam recognising its kind.
|
|
232
|
+
await addMessage(
|
|
233
|
+
DONE_CONVERSATION_ID,
|
|
234
|
+
"assistant",
|
|
235
|
+
JSON.stringify([
|
|
236
|
+
{
|
|
237
|
+
type: "ui_surface",
|
|
238
|
+
surfaceId: "surface-1",
|
|
239
|
+
surfaceType: "skill_card",
|
|
240
|
+
title: "I just learned how to do expense filing",
|
|
241
|
+
display: "inline",
|
|
242
|
+
data: { skills: [] },
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
type: "text",
|
|
246
|
+
text: "I just learned how to do expense filing",
|
|
247
|
+
_surfaceFallback: true,
|
|
248
|
+
},
|
|
249
|
+
]),
|
|
250
|
+
{
|
|
251
|
+
metadata: { kind: SKILL_CARD_MESSAGE_KIND, automated: true },
|
|
252
|
+
skipIndexing: true,
|
|
253
|
+
clientMessageId: "surface-1",
|
|
254
|
+
skipResurface: true,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
259
|
+
expect(listInvalidations).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("the flag off keeps every append from clearing archived_at", async () => {
|
|
263
|
+
sidebarDoneEnabled = false;
|
|
264
|
+
|
|
265
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "any updates?", {
|
|
266
|
+
metadata: {
|
|
267
|
+
userMessageChannel: "slack",
|
|
268
|
+
provenanceSourceChannel: "slack",
|
|
269
|
+
},
|
|
270
|
+
skipIndexing: true,
|
|
271
|
+
});
|
|
272
|
+
await addMessage(
|
|
273
|
+
DONE_CONVERSATION_ID,
|
|
274
|
+
"assistant",
|
|
275
|
+
blocks("Here is today's digest."),
|
|
276
|
+
{ metadata: { automated: true }, skipIndexing: true },
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
280
|
+
expect(listInvalidations).toHaveLength(0);
|
|
281
|
+
expect(
|
|
282
|
+
getDb()
|
|
283
|
+
.select()
|
|
284
|
+
.from(messages)
|
|
285
|
+
.where(eq(messages.conversationId, DONE_CONVERSATION_ID))
|
|
286
|
+
.all(),
|
|
287
|
+
).toHaveLength(2);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { MALFORMED_TOOL_CALL_MESSAGE } from "../providers/malformed-tool-call.js";
|
|
3
4
|
import type {
|
|
4
5
|
ContentBlock,
|
|
5
6
|
Message,
|
|
@@ -209,6 +210,36 @@ describe("GeminiProvider", () => {
|
|
|
209
210
|
expect(result.stopReason).toBe("STOP");
|
|
210
211
|
});
|
|
211
212
|
|
|
213
|
+
test("throws a status-less malformed tool-call error on MALFORMED_FUNCTION_CALL", async () => {
|
|
214
|
+
fakeChunks = [
|
|
215
|
+
textChunk(",featuredImage:{_type:image},patch:true}]})"),
|
|
216
|
+
finishChunk("MALFORMED_FUNCTION_CALL", 10, 5),
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const error = await provider
|
|
220
|
+
.sendMessage([{ role: "user", content: [{ type: "text", text: "Hi" }] }])
|
|
221
|
+
.then(
|
|
222
|
+
() => undefined,
|
|
223
|
+
(e: unknown) => e,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(error).toBeInstanceOf(ProviderError);
|
|
227
|
+
const providerError = error as ProviderError;
|
|
228
|
+
expect(providerError.statusCode).toBeUndefined();
|
|
229
|
+
expect(providerError.message).toContain(MALFORMED_TOOL_CALL_MESSAGE);
|
|
230
|
+
expect(providerError.message).toContain("MALFORMED_FUNCTION_CALL");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("throws a malformed tool-call error on UNEXPECTED_TOOL_CALL", async () => {
|
|
234
|
+
fakeChunks = [finishChunk("UNEXPECTED_TOOL_CALL", 10, 0)];
|
|
235
|
+
|
|
236
|
+
await expect(
|
|
237
|
+
provider.sendMessage([
|
|
238
|
+
{ role: "user", content: [{ type: "text", text: "Hi" }] },
|
|
239
|
+
]),
|
|
240
|
+
).rejects.toThrow(MALFORMED_TOOL_CALL_MESSAGE);
|
|
241
|
+
});
|
|
242
|
+
|
|
212
243
|
// -----------------------------------------------------------------------
|
|
213
244
|
// Streaming events
|
|
214
245
|
// -----------------------------------------------------------------------
|
|
@@ -118,6 +118,7 @@ mock.module("../util/retry.js", () => {
|
|
|
118
118
|
};
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
import { malformedToolCallError } from "../providers/malformed-tool-call.js";
|
|
121
122
|
import { RetryProvider } from "../providers/retry.js";
|
|
122
123
|
import { createStreamTimeout } from "../providers/stream-timeout.js";
|
|
123
124
|
import {
|
|
@@ -941,6 +942,51 @@ describe("RetryProvider — streaming corruption retries", () => {
|
|
|
941
942
|
}
|
|
942
943
|
});
|
|
943
944
|
|
|
945
|
+
test("malformed tool-call finishes retry once with a corrective note", async () => {
|
|
946
|
+
const providerError = malformedToolCallError(
|
|
947
|
+
"openrouter",
|
|
948
|
+
"MALFORMED_FUNCTION_CALL",
|
|
949
|
+
);
|
|
950
|
+
// Providers wrap stream-loop throws in their generic catch.
|
|
951
|
+
const wrapped = new ProviderError(
|
|
952
|
+
`OpenRouter request failed: ${providerError.message}`,
|
|
953
|
+
"openrouter",
|
|
954
|
+
undefined,
|
|
955
|
+
{ cause: providerError },
|
|
956
|
+
);
|
|
957
|
+
const inner = makeCapturingFlaky(1, wrapped);
|
|
958
|
+
const provider = new RetryProvider(inner);
|
|
959
|
+
|
|
960
|
+
await provider.sendMessage(MESSAGES);
|
|
961
|
+
|
|
962
|
+
expect(inner.calls).toBe(2);
|
|
963
|
+
expect(inner.seen[0]).toBe(MESSAGES);
|
|
964
|
+
const retried = inner.seen[1]!;
|
|
965
|
+
const lastMessage = retried[retried.length - 1]!;
|
|
966
|
+
expect(lastMessage.role).toBe("user");
|
|
967
|
+
expect(lastMessage.content).toHaveLength(2);
|
|
968
|
+
const hint = lastMessage.content[1] as { type: string; text: string };
|
|
969
|
+
expect(hint.type).toBe("text");
|
|
970
|
+
expect(hint.text).toContain("did not parse");
|
|
971
|
+
expect(MESSAGES[MESSAGES.length - 1]!.content).toHaveLength(1);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("a malformed tool-call message with an HTTP status is not retried", async () => {
|
|
975
|
+
const inner = makeFailing(
|
|
976
|
+
new ProviderError(
|
|
977
|
+
malformedToolCallError("gemini", "MALFORMED_FUNCTION_CALL").message,
|
|
978
|
+
"gemini",
|
|
979
|
+
400,
|
|
980
|
+
),
|
|
981
|
+
);
|
|
982
|
+
const provider = new RetryProvider(inner);
|
|
983
|
+
|
|
984
|
+
await expect(provider.sendMessage(MESSAGES)).rejects.toThrow(
|
|
985
|
+
"malformed tool call",
|
|
986
|
+
);
|
|
987
|
+
expect(inner.calls).toBe(1);
|
|
988
|
+
});
|
|
989
|
+
|
|
944
990
|
test("other stream-corruption retries resend the messages untouched", async () => {
|
|
945
991
|
const inner = makeCapturingFlaky(
|
|
946
992
|
1,
|
|
@@ -191,6 +191,7 @@ const MANAGED_FALLBACK_PROVIDERS: string[] = [
|
|
|
191
191
|
"openai",
|
|
192
192
|
"fireworks",
|
|
193
193
|
"together",
|
|
194
|
+
"typesafe",
|
|
194
195
|
];
|
|
195
196
|
|
|
196
197
|
function enableManagedProxy() {
|
|
@@ -314,28 +315,57 @@ describe("managed proxy integration — credential precedence", () => {
|
|
|
314
315
|
},
|
|
315
316
|
);
|
|
316
317
|
|
|
317
|
-
test("managed bootstrap registers
|
|
318
|
+
test("managed bootstrap registers every managed-routable catalog provider", async () => {
|
|
318
319
|
enableManagedProxy();
|
|
319
320
|
mockProviderKeys = {};
|
|
320
321
|
await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
|
|
321
|
-
expect(listProviders()).toEqual(
|
|
322
|
-
|
|
323
|
-
"anthropic",
|
|
324
|
-
"openai",
|
|
325
|
-
"gemini",
|
|
326
|
-
"fireworks",
|
|
327
|
-
"together",
|
|
328
|
-
]),
|
|
322
|
+
expect([...listProviders()].sort()).toEqual(
|
|
323
|
+
[...MANAGED_FALLBACK_PROVIDERS].sort(),
|
|
329
324
|
);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
expect(getProviderRoutingSource("gemini")).toBe("managed-proxy");
|
|
334
|
-
expect(getProviderRoutingSource("fireworks")).toBe("managed-proxy");
|
|
335
|
-
expect(getProviderRoutingSource("together")).toBe("managed-proxy");
|
|
325
|
+
for (const provider of MANAGED_FALLBACK_PROVIDERS) {
|
|
326
|
+
expect(getProviderRoutingSource(provider)).toBe("managed-proxy");
|
|
327
|
+
}
|
|
336
328
|
expect(getProviderRoutingSource("openrouter")).toBeUndefined();
|
|
337
329
|
});
|
|
338
330
|
|
|
331
|
+
test("managed typesafe sends System One through the typesafe proxy path with the assistant key", async () => {
|
|
332
|
+
enableManagedProxy();
|
|
333
|
+
mockProviderKeys = {};
|
|
334
|
+
await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
|
|
335
|
+
|
|
336
|
+
const provider = getProvider("typesafe");
|
|
337
|
+
const originalFetch = globalThis.fetch;
|
|
338
|
+
let capturedUrl = "";
|
|
339
|
+
let capturedHeaders: Record<string, string> = {};
|
|
340
|
+
globalThis.fetch = (async (
|
|
341
|
+
input: string | URL | Request,
|
|
342
|
+
init?: RequestInit,
|
|
343
|
+
) => {
|
|
344
|
+
capturedUrl = String(input);
|
|
345
|
+
capturedHeaders = (init?.headers ?? {}) as Record<string, string>;
|
|
346
|
+
return new Response(
|
|
347
|
+
JSON.stringify({
|
|
348
|
+
model: "jev-latest",
|
|
349
|
+
answers: {},
|
|
350
|
+
usage: { input_tokens: 1, output_tokens: 0 },
|
|
351
|
+
}),
|
|
352
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
353
|
+
);
|
|
354
|
+
}) as typeof fetch;
|
|
355
|
+
try {
|
|
356
|
+
await provider.sendMessage([userMsg("hello")], {
|
|
357
|
+
config: { model: "jev-latest" },
|
|
358
|
+
});
|
|
359
|
+
} finally {
|
|
360
|
+
globalThis.fetch = originalFetch;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
expect(capturedUrl).toBe(
|
|
364
|
+
`${PLATFORM_BASE}/v1/runtime-proxy/typesafe/v1/systemone`,
|
|
365
|
+
);
|
|
366
|
+
expect(capturedHeaders.Authorization).toBe(`Bearer ${MANAGED_API_KEY}`);
|
|
367
|
+
});
|
|
368
|
+
|
|
339
369
|
test("managed anthropic uses anthropic proxy path", async () => {
|
|
340
370
|
enableManagedProxy();
|
|
341
371
|
mockProviderKeys = {};
|
|
@@ -12,7 +12,9 @@ mock.module("../util/retry.js", () => ({
|
|
|
12
12
|
import {
|
|
13
13
|
recordFallbackServed,
|
|
14
14
|
resetFallbackBreaker,
|
|
15
|
+
shouldSkipPrimary,
|
|
15
16
|
} from "../providers/fallback-breaker.js";
|
|
17
|
+
import { malformedToolCallError } from "../providers/malformed-tool-call.js";
|
|
16
18
|
import type {
|
|
17
19
|
Message,
|
|
18
20
|
Provider,
|
|
@@ -666,6 +668,26 @@ describe("RetryProvider fallback-route escalation", () => {
|
|
|
666
668
|
expect(backup.calls()).toBe(1);
|
|
667
669
|
});
|
|
668
670
|
|
|
671
|
+
test("malformed tool call exhausts retries → backup serves, breaker stays closed", async () => {
|
|
672
|
+
const primary = failingProvider("openai", () =>
|
|
673
|
+
malformedToolCallError("openai", "MALFORMED_FUNCTION_CALL"),
|
|
674
|
+
);
|
|
675
|
+
const backup = backupProvider();
|
|
676
|
+
const route = makeRoute(backup.provider);
|
|
677
|
+
const wrapped = new RetryProvider(primary.provider, {
|
|
678
|
+
resolveFallbackRoute: route.resolveFallbackRoute,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const result = await wrapped.sendMessage(MESSAGES, {
|
|
682
|
+
config: { callSite: "mainAgent" },
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
|
|
686
|
+
expect(backup.calls()).toBe(1);
|
|
687
|
+
expect(result.model).toBe("backup-model");
|
|
688
|
+
expect(shouldSkipPrimary({ upstream: "openai" })).toBe(false);
|
|
689
|
+
});
|
|
690
|
+
|
|
669
691
|
test("resolveFallbackRoute unset → behavior unchanged (original error rethrown)", async () => {
|
|
670
692
|
const finalError = new ProviderError("Service Unavailable", "openai", 503);
|
|
671
693
|
const primary = failingProvider("openai", () => finalError);
|
|
@@ -55,8 +55,10 @@ afterAll(() => {
|
|
|
55
55
|
resetDbForTesting();
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
59
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
60
|
+
// verified against the signing key, which these tests do not set up.
|
|
61
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
60
62
|
|
|
61
63
|
describe("Runtime attachment metadata", () => {
|
|
62
64
|
let server: RuntimeHttpServer;
|