@vellumai/assistant 0.8.9-staging.1 → 0.8.9-staging.2
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/ARCHITECTURE.md +6 -6
- package/openapi.yaml +6 -7
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +33 -18
- package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
- package/src/__tests__/app-executors.test.ts +132 -0
- package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
- package/src/__tests__/context-overflow-reducer.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +19 -16
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +48 -29
- package/src/__tests__/conversation-agent-loop.test.ts +48 -96
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +52 -49
- package/src/__tests__/memory-retrieval-hook.test.ts +68 -30
- package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
- package/src/acp/__tests__/helpers/exec-file-stub.ts +12 -7
- package/src/acp/__tests__/prepare-agent-env.test.ts +0 -40
- package/src/acp/__tests__/session-manager-resume.test.ts +179 -27
- package/src/acp/auto-install.test.ts +130 -46
- package/src/acp/auto-install.ts +86 -31
- package/src/acp/prepare-agent-env.ts +8 -9
- package/src/acp/resolve-agent.test.ts +26 -123
- package/src/acp/resolve-agent.ts +26 -85
- package/src/acp/resume-hint.ts +1 -3
- package/src/acp/session-manager.ts +32 -20
- package/src/acp/types.ts +0 -8
- package/src/agent/loop.ts +42 -26
- package/src/config/bundled-skills/acp/SKILL.md +14 -14
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
- package/src/config/bundled-skills/app-builder/TOOLS.json +43 -2
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/schemas/call-site-catalog.ts +0 -14
- package/src/config/schemas/llm.ts +0 -2
- package/src/context/compactor.ts +22 -4
- package/src/daemon/conversation-agent-loop.ts +15 -57
- package/src/daemon/conversation.ts +23 -3
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +0 -7
- package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
- package/src/plugins/defaults/compaction/manager-store.ts +13 -0
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +6 -23
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +24 -25
- package/src/runtime/routes/__tests__/acp-routes.test.ts +71 -39
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +29 -4
- package/src/runtime/routes/plugins-routes.ts +10 -9
- package/src/tools/acp/list-agents.test.ts +2 -2
- package/src/tools/acp/spawn.test.ts +103 -225
- package/src/tools/acp/spawn.ts +10 -120
- package/src/tools/apps/executors.ts +153 -42
- package/src/proactive-artifact/aux-message-injector.ts +0 -97
- package/src/proactive-artifact/decision.test.ts +0 -226
- package/src/proactive-artifact/decision.ts +0 -165
- package/src/proactive-artifact/index.ts +0 -7
- package/src/proactive-artifact/job.test.ts +0 -962
- package/src/proactive-artifact/job.ts +0 -372
- package/src/proactive-artifact/message-copy.ts +0 -58
- package/src/proactive-artifact/trigger-state.test.ts +0 -286
- package/src/proactive-artifact/trigger-state.ts +0 -123
|
@@ -68,6 +68,73 @@ export type ProxyResolver = (
|
|
|
68
68
|
input: Record<string, unknown>,
|
|
69
69
|
) => Promise<ExecutorResult>;
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Validate a `source_files` map (LLM input is not type-checked at runtime).
|
|
73
|
+
* Returns an error ExecutorResult when invalid, or null when absent/valid.
|
|
74
|
+
*/
|
|
75
|
+
function validateSourceFiles(sourceFiles: unknown): ExecutorResult | null {
|
|
76
|
+
if (sourceFiles == null) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (typeof sourceFiles !== "object" || Array.isArray(sourceFiles)) {
|
|
80
|
+
return {
|
|
81
|
+
content: JSON.stringify({
|
|
82
|
+
error:
|
|
83
|
+
"source_files must be an object mapping relative file paths to string contents",
|
|
84
|
+
}),
|
|
85
|
+
isError: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
for (const [key, val] of Object.entries(sourceFiles)) {
|
|
89
|
+
if (typeof val !== "string") {
|
|
90
|
+
return {
|
|
91
|
+
content: JSON.stringify({
|
|
92
|
+
error: `source_files["${key}"] must be a string, got ${typeof val}`,
|
|
93
|
+
}),
|
|
94
|
+
isError: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve an app name when the model omits it. The preview card's title is
|
|
103
|
+
* "always include" guidance and almost always present, so it's the best
|
|
104
|
+
* fallback — a real, meaningful name — before a generic placeholder.
|
|
105
|
+
*/
|
|
106
|
+
function resolveAppName(input: AppCreateInput): string {
|
|
107
|
+
const previewTitle =
|
|
108
|
+
input.preview && typeof input.preview.title === "string"
|
|
109
|
+
? input.preview.title
|
|
110
|
+
: undefined;
|
|
111
|
+
for (const candidate of [input.name, previewTitle]) {
|
|
112
|
+
if (typeof candidate === "string" && candidate.trim() !== "") {
|
|
113
|
+
return candidate.trim();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return "New App";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compile-result fields shared by the app_refresh and app_update responses.
|
|
121
|
+
* On failure the errors/warnings are surfaced so the agent can fix them.
|
|
122
|
+
*/
|
|
123
|
+
function compileResultPayload(
|
|
124
|
+
compileResult: Awaited<ReturnType<typeof compileApp>>,
|
|
125
|
+
): Record<string, unknown> {
|
|
126
|
+
return {
|
|
127
|
+
compiled: compileResult.ok,
|
|
128
|
+
...(compileResult.ok
|
|
129
|
+
? { compile_duration_ms: compileResult.durationMs }
|
|
130
|
+
: {
|
|
131
|
+
compile_errors: compileResult.errors,
|
|
132
|
+
compile_warnings: compileResult.warnings,
|
|
133
|
+
compile_duration_ms: compileResult.durationMs,
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
71
138
|
// ---------------------------------------------------------------------------
|
|
72
139
|
// app_create
|
|
73
140
|
// ---------------------------------------------------------------------------
|
|
@@ -92,7 +159,9 @@ export async function executeAppCreate(
|
|
|
92
159
|
store: AppStore,
|
|
93
160
|
proxyToolResolver?: ProxyResolver,
|
|
94
161
|
): Promise<ExecutorResult> {
|
|
95
|
-
|
|
162
|
+
// The model sometimes omits a name; resolve a sensible one rather than
|
|
163
|
+
// erroring out so the build still succeeds. Users can rename via app_update.
|
|
164
|
+
const name = resolveAppName(input);
|
|
96
165
|
const description = input.description;
|
|
97
166
|
const schemaJson = input.schema_json ?? "{}";
|
|
98
167
|
|
|
@@ -121,39 +190,9 @@ export async function executeAppCreate(
|
|
|
121
190
|
const autoOpen = input.auto_open !== false; // default true
|
|
122
191
|
const preview = input.preview;
|
|
123
192
|
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
return
|
|
127
|
-
content: JSON.stringify({
|
|
128
|
-
error: "name is required and must be a non-empty string",
|
|
129
|
-
}),
|
|
130
|
-
isError: true,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (input.source_files != null) {
|
|
135
|
-
if (
|
|
136
|
-
typeof input.source_files !== "object" ||
|
|
137
|
-
Array.isArray(input.source_files)
|
|
138
|
-
) {
|
|
139
|
-
return {
|
|
140
|
-
content: JSON.stringify({
|
|
141
|
-
error:
|
|
142
|
-
"source_files must be an object mapping relative file paths to string contents",
|
|
143
|
-
}),
|
|
144
|
-
isError: true,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
for (const [key, val] of Object.entries(input.source_files)) {
|
|
148
|
-
if (typeof val !== "string") {
|
|
149
|
-
return {
|
|
150
|
-
content: JSON.stringify({
|
|
151
|
-
error: `source_files["${key}"] must be a string, got ${typeof val}`,
|
|
152
|
-
}),
|
|
153
|
-
isError: true,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
}
|
|
193
|
+
const sourceFilesError = validateSourceFiles(input.source_files);
|
|
194
|
+
if (sourceFilesError) {
|
|
195
|
+
return sourceFilesError;
|
|
157
196
|
}
|
|
158
197
|
|
|
159
198
|
// Extract icon from preview if provided - only persist emoji-like values,
|
|
@@ -356,14 +395,7 @@ export async function executeAppRefresh(
|
|
|
356
395
|
refreshed: true,
|
|
357
396
|
appId: updated.id,
|
|
358
397
|
name: updated.name,
|
|
359
|
-
|
|
360
|
-
...(compileResult.ok
|
|
361
|
-
? { compile_duration_ms: compileResult.durationMs }
|
|
362
|
-
: {
|
|
363
|
-
compile_errors: compileResult.errors,
|
|
364
|
-
compile_warnings: compileResult.warnings,
|
|
365
|
-
compile_duration_ms: compileResult.durationMs,
|
|
366
|
-
}),
|
|
398
|
+
...compileResultPayload(compileResult),
|
|
367
399
|
}),
|
|
368
400
|
isError: false,
|
|
369
401
|
};
|
|
@@ -379,6 +411,85 @@ export async function executeAppRefresh(
|
|
|
379
411
|
};
|
|
380
412
|
}
|
|
381
413
|
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// app_update
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
export interface AppUpdateInput {
|
|
419
|
+
app_id: string;
|
|
420
|
+
name?: string;
|
|
421
|
+
description?: string;
|
|
422
|
+
schema_json?: string;
|
|
423
|
+
source_files?: Record<string, string>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function executeAppUpdate(
|
|
427
|
+
input: AppUpdateInput,
|
|
428
|
+
store: AppStore,
|
|
429
|
+
): Promise<ExecutorResult> {
|
|
430
|
+
const app = store.getApp(input.app_id);
|
|
431
|
+
if (!app) {
|
|
432
|
+
return {
|
|
433
|
+
content: JSON.stringify({ error: `App '${input.app_id}' not found` }),
|
|
434
|
+
isError: true,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const sourceFilesError = validateSourceFiles(input.source_files);
|
|
439
|
+
if (sourceFilesError) {
|
|
440
|
+
return sourceFilesError;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (input.source_files) {
|
|
444
|
+
for (const [filePath, content] of Object.entries(input.source_files)) {
|
|
445
|
+
store.writeAppFile(input.app_id, filePath, content);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const updates: Partial<
|
|
450
|
+
Pick<AppDefinition, "name" | "description" | "schemaJson">
|
|
451
|
+
> = {};
|
|
452
|
+
if (typeof input.name === "string" && input.name.trim() !== "") {
|
|
453
|
+
updates.name = input.name.trim();
|
|
454
|
+
}
|
|
455
|
+
if (typeof input.description === "string") {
|
|
456
|
+
updates.description = input.description;
|
|
457
|
+
}
|
|
458
|
+
if (typeof input.schema_json === "string") {
|
|
459
|
+
updates.schemaJson = input.schema_json;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// An empty update still bumps updatedAt, triggering a client surface refresh.
|
|
463
|
+
const updated = store.updateApp(input.app_id, updates);
|
|
464
|
+
|
|
465
|
+
// Multifile apps recompile so the agent sees any errors from the edited
|
|
466
|
+
// source instead of serving a stale dist (mirrors app_refresh).
|
|
467
|
+
if (app.formatVersion === 2) {
|
|
468
|
+
const appDir = getAppDirPath(input.app_id);
|
|
469
|
+
const compileResult = await compileApp(appDir);
|
|
470
|
+
return {
|
|
471
|
+
content: JSON.stringify({
|
|
472
|
+
updated: true,
|
|
473
|
+
appId: updated.id,
|
|
474
|
+
name: updated.name,
|
|
475
|
+
description: updated.description,
|
|
476
|
+
...compileResultPayload(compileResult),
|
|
477
|
+
}),
|
|
478
|
+
isError: false,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
content: JSON.stringify({
|
|
484
|
+
updated: true,
|
|
485
|
+
appId: updated.id,
|
|
486
|
+
name: updated.name,
|
|
487
|
+
description: updated.description,
|
|
488
|
+
}),
|
|
489
|
+
isError: false,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
382
493
|
// ---------------------------------------------------------------------------
|
|
383
494
|
// app_generate_icon
|
|
384
495
|
// ---------------------------------------------------------------------------
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auxiliary message injection for proactive artifacts.
|
|
3
|
-
*
|
|
4
|
-
* Injects an assistant message into a conversation without going through
|
|
5
|
-
* the normal agent loop. Defers injection while the conversation is
|
|
6
|
-
* actively processing to preserve chronological message ordering.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { createAssistantMessage } from "../agent/message-types.js";
|
|
10
|
-
import { findConversation } from "../daemon/conversation-registry.js";
|
|
11
|
-
import {
|
|
12
|
-
conversationMessagesSyncTag,
|
|
13
|
-
SYNC_TAGS,
|
|
14
|
-
} from "../daemon/message-types/sync.js";
|
|
15
|
-
import { addMessage } from "../memory/conversation-crud.js";
|
|
16
|
-
import type { BroadcastFn } from "../notifications/adapters/macos.js";
|
|
17
|
-
import { getLogger } from "../util/logger.js";
|
|
18
|
-
|
|
19
|
-
const log = getLogger("aux-message-injector");
|
|
20
|
-
const IDLE_POLL_MS = 200;
|
|
21
|
-
const IDLE_TIMEOUT_MS = 60_000;
|
|
22
|
-
|
|
23
|
-
async function waitForIdle(conversationId: string): Promise<boolean> {
|
|
24
|
-
const start = Date.now();
|
|
25
|
-
while (Date.now() - start < IDLE_TIMEOUT_MS) {
|
|
26
|
-
const conv = findConversation(conversationId);
|
|
27
|
-
if (!conv || !conv.isProcessing()) return true;
|
|
28
|
-
await new Promise((resolve) => setTimeout(resolve, IDLE_POLL_MS));
|
|
29
|
-
}
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function injectAuxAssistantMessage(params: {
|
|
34
|
-
conversationId: string;
|
|
35
|
-
text: string;
|
|
36
|
-
broadcastMessage: BroadcastFn;
|
|
37
|
-
}): Promise<void> {
|
|
38
|
-
const conv = findConversation(params.conversationId);
|
|
39
|
-
if (conv?.isProcessing()) {
|
|
40
|
-
const reachedIdle = await waitForIdle(params.conversationId);
|
|
41
|
-
if (!reachedIdle) {
|
|
42
|
-
log.warn(
|
|
43
|
-
{ conversationId: params.conversationId },
|
|
44
|
-
"Timed out waiting for conversation idle; injecting anyway",
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const msg = await addMessage(
|
|
50
|
-
params.conversationId,
|
|
51
|
-
"assistant",
|
|
52
|
-
JSON.stringify([{ type: "text", text: params.text }]),
|
|
53
|
-
{ skipIndexing: true },
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
const current = findConversation(params.conversationId);
|
|
57
|
-
if (current && !current.isProcessing()) {
|
|
58
|
-
current.getMessages().push(createAssistantMessage(params.text));
|
|
59
|
-
|
|
60
|
-
params.broadcastMessage({
|
|
61
|
-
type: "assistant_text_delta",
|
|
62
|
-
text: params.text,
|
|
63
|
-
conversationId: params.conversationId,
|
|
64
|
-
});
|
|
65
|
-
params.broadcastMessage({
|
|
66
|
-
type: "message_complete",
|
|
67
|
-
conversationId: params.conversationId,
|
|
68
|
-
messageId: msg.id,
|
|
69
|
-
source: "aux",
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// The injected aux message bumps `lastMessageAt`, which can move the
|
|
74
|
-
// row in the paginated sidebar window — that's a shape change. macOS
|
|
75
|
-
// refreshes its full list on `conversation_list_invalidated`; web
|
|
76
|
-
// refreshes via the paired `sync_changed` `conversationsList` tag.
|
|
77
|
-
//
|
|
78
|
-
// TODO(electron-cutover): drop the `conversation_list_invalidated`
|
|
79
|
-
// emission once macOS migrates to the Electron client and consumes
|
|
80
|
-
// `sync_changed` directly. See `runtime/sync/resource-sync-events.ts`
|
|
81
|
-
// for the symmetric helper used by route handlers.
|
|
82
|
-
params.broadcastMessage(
|
|
83
|
-
{
|
|
84
|
-
type: "conversation_list_invalidated",
|
|
85
|
-
reason: "reordered",
|
|
86
|
-
},
|
|
87
|
-
undefined,
|
|
88
|
-
{ targetInterfaceId: "macos" },
|
|
89
|
-
);
|
|
90
|
-
params.broadcastMessage({
|
|
91
|
-
type: "sync_changed",
|
|
92
|
-
tags: [
|
|
93
|
-
SYNC_TAGS.conversationsList,
|
|
94
|
-
conversationMessagesSyncTag(params.conversationId),
|
|
95
|
-
],
|
|
96
|
-
});
|
|
97
|
-
}
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
type DecisionOutput,
|
|
5
|
-
formatTranscript,
|
|
6
|
-
parseDecisionOutput,
|
|
7
|
-
} from "./decision.js";
|
|
8
|
-
|
|
9
|
-
describe("parseDecisionOutput", () => {
|
|
10
|
-
test("parses SHOULD_BUILD: yes with all fields correctly", () => {
|
|
11
|
-
const text = `SHOULD_BUILD: yes
|
|
12
|
-
ARTIFACT_TYPE: app
|
|
13
|
-
ARTIFACT_TITLE: Sarah's Marathon Training Pace Calculator
|
|
14
|
-
ARTIFACT_DESCRIPTION: An interactive pace calculator that accounts for Sarah's goal of a sub-4-hour marathon, her current 9:30/mile easy pace, and the hilly terrain of the Boston course.`;
|
|
15
|
-
|
|
16
|
-
const result = parseDecisionOutput(text);
|
|
17
|
-
expect(result).toEqual({
|
|
18
|
-
shouldBuild: true,
|
|
19
|
-
artifactType: "app",
|
|
20
|
-
artifactTitle: "Sarah's Marathon Training Pace Calculator",
|
|
21
|
-
artifactDescription:
|
|
22
|
-
"An interactive pace calculator that accounts for Sarah's goal of a sub-4-hour marathon, her current 9:30/mile easy pace, and the hilly terrain of the Boston course.",
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("parses SHOULD_BUILD: no with skip reason", () => {
|
|
27
|
-
const text = `SHOULD_BUILD: no
|
|
28
|
-
SKIP_REASON: The conversation is too early and generic — the user has only asked a factual question with no personal context.`;
|
|
29
|
-
|
|
30
|
-
const result = parseDecisionOutput(text);
|
|
31
|
-
expect(result).toEqual({
|
|
32
|
-
shouldBuild: false,
|
|
33
|
-
skipReason:
|
|
34
|
-
"The conversation is too early and generic — the user has only asked a factual question with no personal context.",
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("parses SHOULD_BUILD: no without skip reason defaults to 'no reason given'", () => {
|
|
39
|
-
const text = `SHOULD_BUILD: no`;
|
|
40
|
-
|
|
41
|
-
const result = parseDecisionOutput(text);
|
|
42
|
-
expect(result).toEqual({
|
|
43
|
-
shouldBuild: false,
|
|
44
|
-
skipReason: "no reason given",
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("returns null for missing SHOULD_BUILD line", () => {
|
|
49
|
-
const text = `ARTIFACT_TYPE: app
|
|
50
|
-
ARTIFACT_TITLE: Some Title
|
|
51
|
-
ARTIFACT_DESCRIPTION: Some description.`;
|
|
52
|
-
|
|
53
|
-
const result = parseDecisionOutput(text);
|
|
54
|
-
expect(result).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("returns null when yes but ARTIFACT_TYPE is missing", () => {
|
|
58
|
-
const text = `SHOULD_BUILD: yes
|
|
59
|
-
ARTIFACT_TITLE: Some Title
|
|
60
|
-
ARTIFACT_DESCRIPTION: Some description.`;
|
|
61
|
-
|
|
62
|
-
const result = parseDecisionOutput(text);
|
|
63
|
-
expect(result).toBeNull();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("returns null when yes but ARTIFACT_TYPE is invalid", () => {
|
|
67
|
-
const text = `SHOULD_BUILD: yes
|
|
68
|
-
ARTIFACT_TYPE: widget
|
|
69
|
-
ARTIFACT_TITLE: Some Title
|
|
70
|
-
ARTIFACT_DESCRIPTION: Some description.`;
|
|
71
|
-
|
|
72
|
-
const result = parseDecisionOutput(text);
|
|
73
|
-
expect(result).toBeNull();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("returns null when yes but ARTIFACT_TITLE is missing", () => {
|
|
77
|
-
const text = `SHOULD_BUILD: yes
|
|
78
|
-
ARTIFACT_TYPE: document
|
|
79
|
-
ARTIFACT_DESCRIPTION: Some description.`;
|
|
80
|
-
|
|
81
|
-
const result = parseDecisionOutput(text);
|
|
82
|
-
expect(result).toBeNull();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("returns null when yes but ARTIFACT_DESCRIPTION is missing", () => {
|
|
86
|
-
const text = `SHOULD_BUILD: yes
|
|
87
|
-
ARTIFACT_TYPE: document
|
|
88
|
-
ARTIFACT_TITLE: Some Title`;
|
|
89
|
-
|
|
90
|
-
const result = parseDecisionOutput(text);
|
|
91
|
-
expect(result).toBeNull();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("returns null when yes but ARTIFACT_TITLE is empty", () => {
|
|
95
|
-
const text = `SHOULD_BUILD: yes
|
|
96
|
-
ARTIFACT_TYPE: app
|
|
97
|
-
ARTIFACT_TITLE:
|
|
98
|
-
ARTIFACT_DESCRIPTION: Some description.`;
|
|
99
|
-
|
|
100
|
-
const result = parseDecisionOutput(text);
|
|
101
|
-
expect(result).toBeNull();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("handles multi-line ARTIFACT_DESCRIPTION", () => {
|
|
105
|
-
const text = `SHOULD_BUILD: yes
|
|
106
|
-
ARTIFACT_TYPE: document
|
|
107
|
-
ARTIFACT_TITLE: Jake's Q3 OKR Tracker
|
|
108
|
-
ARTIFACT_DESCRIPTION: A structured comparison table for Jake's three competing priorities:
|
|
109
|
-
scaling the data pipeline from 10M to 50M events/day,
|
|
110
|
-
hiring two senior engineers by September,
|
|
111
|
-
and reducing p99 latency below 200ms.`;
|
|
112
|
-
|
|
113
|
-
const result = parseDecisionOutput(text) as DecisionOutput & {
|
|
114
|
-
shouldBuild: true;
|
|
115
|
-
};
|
|
116
|
-
expect(result).not.toBeNull();
|
|
117
|
-
expect(result.shouldBuild).toBe(true);
|
|
118
|
-
expect(result.artifactType).toBe("document");
|
|
119
|
-
expect(result.artifactTitle).toBe("Jake's Q3 OKR Tracker");
|
|
120
|
-
expect(result.artifactDescription).toContain(
|
|
121
|
-
"A structured comparison table",
|
|
122
|
-
);
|
|
123
|
-
expect(result.artifactDescription).toContain(
|
|
124
|
-
"reducing p99 latency below 200ms",
|
|
125
|
-
);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("handles case-insensitive SHOULD_BUILD value", () => {
|
|
129
|
-
const text = `SHOULD_BUILD: Yes
|
|
130
|
-
ARTIFACT_TYPE: app
|
|
131
|
-
ARTIFACT_TITLE: Test Title
|
|
132
|
-
ARTIFACT_DESCRIPTION: Test description.`;
|
|
133
|
-
|
|
134
|
-
const result = parseDecisionOutput(text);
|
|
135
|
-
expect(result).not.toBeNull();
|
|
136
|
-
expect(result!.shouldBuild).toBe(true);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("handles case-insensitive ARTIFACT_TYPE value", () => {
|
|
140
|
-
const text = `SHOULD_BUILD: yes
|
|
141
|
-
ARTIFACT_TYPE: Document
|
|
142
|
-
ARTIFACT_TITLE: Test Title
|
|
143
|
-
ARTIFACT_DESCRIPTION: Test description.`;
|
|
144
|
-
|
|
145
|
-
const result = parseDecisionOutput(text);
|
|
146
|
-
expect(result).not.toBeNull();
|
|
147
|
-
expect((result as { artifactType: string }).artifactType).toBe("document");
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe("formatTranscript", () => {
|
|
152
|
-
test("formats plain text messages correctly", () => {
|
|
153
|
-
const messages = [
|
|
154
|
-
{ role: "user", content: "Hello, I need help with my project" },
|
|
155
|
-
{
|
|
156
|
-
role: "assistant",
|
|
157
|
-
content: "I'd be happy to help! What kind of project?",
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
role: "user",
|
|
161
|
-
content: "I'm building a fitness tracker for marathon training",
|
|
162
|
-
},
|
|
163
|
-
];
|
|
164
|
-
|
|
165
|
-
const result = formatTranscript(messages);
|
|
166
|
-
expect(result).toBe(
|
|
167
|
-
`[User]: Hello, I need help with my project
|
|
168
|
-
|
|
169
|
-
[Assistant]: I'd be happy to help! What kind of project?
|
|
170
|
-
|
|
171
|
-
[User]: I'm building a fitness tracker for marathon training`,
|
|
172
|
-
);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("handles JSON content blocks", () => {
|
|
176
|
-
const messages = [
|
|
177
|
-
{
|
|
178
|
-
role: "user",
|
|
179
|
-
content: JSON.stringify([{ type: "text", text: "What is 2+2?" }]),
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
role: "assistant",
|
|
183
|
-
content: JSON.stringify([
|
|
184
|
-
{ type: "text", text: "The answer is 4." },
|
|
185
|
-
{ type: "text", text: "Would you like to know more?" },
|
|
186
|
-
]),
|
|
187
|
-
},
|
|
188
|
-
];
|
|
189
|
-
|
|
190
|
-
const result = formatTranscript(messages);
|
|
191
|
-
expect(result).toBe(
|
|
192
|
-
`[User]: What is 2+2?
|
|
193
|
-
|
|
194
|
-
[Assistant]: The answer is 4.
|
|
195
|
-
Would you like to know more?`,
|
|
196
|
-
);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test("handles mixed JSON and plain text messages", () => {
|
|
200
|
-
const messages = [
|
|
201
|
-
{
|
|
202
|
-
role: "user",
|
|
203
|
-
content: JSON.stringify([
|
|
204
|
-
{ type: "text", text: "Help me plan my week" },
|
|
205
|
-
]),
|
|
206
|
-
},
|
|
207
|
-
{ role: "assistant", content: "Sure, what do you have coming up?" },
|
|
208
|
-
];
|
|
209
|
-
|
|
210
|
-
const result = formatTranscript(messages);
|
|
211
|
-
expect(result).toBe(
|
|
212
|
-
`[User]: Help me plan my week
|
|
213
|
-
|
|
214
|
-
[Assistant]: Sure, what do you have coming up?`,
|
|
215
|
-
);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("handles unknown roles", () => {
|
|
219
|
-
const messages = [
|
|
220
|
-
{ role: "system", content: "You are a helpful assistant" },
|
|
221
|
-
];
|
|
222
|
-
|
|
223
|
-
const result = formatTranscript(messages);
|
|
224
|
-
expect(result).toBe("[system]: You are a helpful assistant");
|
|
225
|
-
});
|
|
226
|
-
});
|