@vellumai/assistant 0.6.0 → 0.6.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.
Files changed (285) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +68 -15
  3. package/Dockerfile +2 -2
  4. package/bun.lock +6 -2
  5. package/docker-entrypoint.sh +32 -1
  6. package/docs/architecture/integrations.md +1 -1
  7. package/docs/architecture/memory.md +21 -24
  8. package/openapi.yaml +538 -3
  9. package/package.json +5 -1
  10. package/src/__tests__/anthropic-provider.test.ts +160 -95
  11. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  12. package/src/__tests__/app-executors.test.ts +47 -1
  13. package/src/__tests__/app-source-watcher.test.ts +159 -0
  14. package/src/__tests__/checker.test.ts +38 -6
  15. package/src/__tests__/config-schema.test.ts +5 -0
  16. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -6
  17. package/src/__tests__/conversation-agent-loop.test.ts +4 -51
  18. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  19. package/src/__tests__/conversation-runtime-assembly.test.ts +653 -832
  20. package/src/__tests__/conversation-runtime-workspace.test.ts +1 -93
  21. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +17 -4
  22. package/src/__tests__/conversation-wipe.test.ts +2 -6
  23. package/src/__tests__/conversation-workspace-cache-state.test.ts +6 -12
  24. package/src/__tests__/conversation-workspace-injection.test.ts +25 -26
  25. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  26. package/src/__tests__/copy-composer-tc-templates.test.ts +335 -0
  27. package/src/__tests__/date-context.test.ts +76 -210
  28. package/src/__tests__/db-schedule-syntax-migration.test.ts +16 -1
  29. package/src/__tests__/file-list-tool.test.ts +219 -0
  30. package/src/__tests__/first-greeting.test.ts +1 -1
  31. package/src/__tests__/heartbeat-service.test.ts +180 -3
  32. package/src/__tests__/identity-routes.test.ts +328 -0
  33. package/src/__tests__/injection-block.test.ts +24 -0
  34. package/src/__tests__/install-skill-routing.test.ts +7 -6
  35. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +15 -14
  36. package/src/__tests__/list-messages-tool-merge.test.ts +300 -0
  37. package/src/__tests__/llm-context-normalization.test.ts +18 -18
  38. package/src/__tests__/llm-context-route-provider.test.ts +101 -0
  39. package/src/__tests__/llm-request-log-turn-query.test.ts +162 -0
  40. package/src/__tests__/log-export-workspace.test.ts +72 -105
  41. package/src/__tests__/mcp-abort-signal.test.ts +5 -0
  42. package/src/__tests__/mcp-client-auth.test.ts +5 -0
  43. package/src/__tests__/memory-recall-log-store.test.ts +132 -0
  44. package/src/__tests__/migration-export-streaming.test.ts +304 -0
  45. package/src/__tests__/migration-import-commit-http.test.ts +11 -10
  46. package/src/__tests__/mock-fetch.ts +87 -0
  47. package/src/__tests__/notification-decision-recipient-context.test.ts +282 -0
  48. package/src/__tests__/onboarding-template-contract.test.ts +62 -14
  49. package/src/__tests__/parser.test.ts +32 -0
  50. package/src/__tests__/permission-checker-host-gate.test.ts +452 -0
  51. package/src/__tests__/permission-controls-v2-flag.test.ts +55 -0
  52. package/src/__tests__/permission-mode-sse.test.ts +418 -0
  53. package/src/__tests__/permission-mode-store.test.ts +277 -0
  54. package/src/__tests__/permission-mode.test.ts +101 -0
  55. package/src/__tests__/platform-bash-auto-approve.test.ts +359 -0
  56. package/src/__tests__/profiler-routes.test.ts +502 -0
  57. package/src/__tests__/profiler-run-store.test.ts +441 -0
  58. package/src/__tests__/proxy-approval-callback.test.ts +4 -75
  59. package/src/__tests__/registry.test.ts +1 -1
  60. package/src/__tests__/sandbox-host-parity.test.ts +5 -4
  61. package/src/__tests__/scheduler-reuse-conversation.test.ts +368 -0
  62. package/src/__tests__/scrub-corrupted-image-attachments.test.ts +278 -0
  63. package/src/__tests__/search-skills-unified.test.ts +4 -3
  64. package/src/__tests__/send-endpoint-busy.test.ts +42 -3
  65. package/src/__tests__/set-permission-mode.test.ts +274 -0
  66. package/src/__tests__/skill-load-feature-flag.test.ts +12 -0
  67. package/src/__tests__/skill-memory.test.ts +2 -783
  68. package/src/__tests__/strip-memory-injections.test.ts +187 -0
  69. package/src/__tests__/subagent-detail.test.ts +84 -0
  70. package/src/__tests__/subagent-disposal.test.ts +308 -0
  71. package/src/__tests__/subagent-manager-notify.test.ts +19 -10
  72. package/src/__tests__/subagent-notify-parent.test.ts +390 -0
  73. package/src/__tests__/subagent-role-registry.test.ts +108 -0
  74. package/src/__tests__/subagent-tool-filtering.test.ts +71 -0
  75. package/src/__tests__/subagent-tools.test.ts +464 -4
  76. package/src/__tests__/system-prompt-ask-mode.test.ts +139 -0
  77. package/src/__tests__/task-memory-cleanup.test.ts +12 -12
  78. package/src/__tests__/terminal-tools.test.ts +17 -27
  79. package/src/__tests__/test-preload.ts +4 -0
  80. package/src/__tests__/tool-executor.test.ts +4 -26
  81. package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
  82. package/src/__tests__/top-level-renderer.test.ts +10 -13
  83. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +116 -2
  84. package/src/__tests__/workspace-migration-028-recover-conversations-from-disk-view.test.ts +387 -0
  85. package/src/agent/loop.ts +6 -0
  86. package/src/approvals/guardian-request-resolvers.ts +24 -0
  87. package/src/avatar/traits-png-sync.ts +3 -3
  88. package/src/cli/__tests__/run-assistant-command.ts +29 -0
  89. package/src/cli/commands/__tests__/email-download.test.ts +245 -0
  90. package/src/cli/commands/__tests__/email-list.test.ts +192 -0
  91. package/src/cli/commands/__tests__/email-register.test.ts +186 -0
  92. package/src/cli/commands/__tests__/email-send.test.ts +291 -0
  93. package/src/cli/commands/__tests__/email-status.test.ts +181 -0
  94. package/src/cli/commands/__tests__/email-unregister.test.ts +139 -0
  95. package/src/cli/commands/__tests__/routes.test.ts +562 -0
  96. package/src/cli/commands/conversations.ts +1 -8
  97. package/src/cli/commands/email.ts +584 -835
  98. package/src/cli/commands/memory.ts +1 -34
  99. package/src/cli/commands/notifications.ts +7 -2
  100. package/src/cli/commands/oauth/connect.ts +14 -5
  101. package/src/cli/commands/routes.ts +396 -0
  102. package/src/cli/commands/skills.ts +130 -20
  103. package/src/cli/program.ts +2 -0
  104. package/src/cli.ts +1 -120
  105. package/src/config/bundled-skills/app-builder/SKILL.md +4 -1
  106. package/src/config/bundled-skills/gmail/SKILL.md +2 -2
  107. package/src/config/bundled-skills/messaging/SKILL.md +7 -0
  108. package/src/config/bundled-skills/schedule/SKILL.md +22 -2
  109. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  110. package/src/config/bundled-skills/settings/tools/avatar-get.ts +3 -13
  111. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +2 -4
  112. package/src/config/bundled-skills/settings/tools/avatar-update.ts +5 -2
  113. package/src/config/bundled-skills/slack/SKILL.md +2 -0
  114. package/src/config/bundled-skills/subagent/SKILL.md +43 -3
  115. package/src/config/bundled-skills/subagent/TOOLS.json +29 -4
  116. package/src/config/env-registry.ts +63 -0
  117. package/src/config/feature-flag-registry.json +17 -1
  118. package/src/config/schema.ts +8 -0
  119. package/src/config/schemas/filing.ts +51 -0
  120. package/src/config/schemas/heartbeat.ts +15 -12
  121. package/src/config/schemas/memory-lifecycle.ts +12 -0
  122. package/src/config/schemas/security.ts +14 -0
  123. package/src/daemon/app-source-watcher.ts +93 -0
  124. package/src/daemon/config-watcher.ts +79 -1
  125. package/src/daemon/conversation-agent-loop-handlers.ts +20 -0
  126. package/src/daemon/conversation-agent-loop.ts +158 -65
  127. package/src/daemon/conversation-history.ts +4 -19
  128. package/src/daemon/conversation-lifecycle.ts +8 -14
  129. package/src/daemon/conversation-process.ts +13 -7
  130. package/src/daemon/conversation-runtime-assembly.ts +300 -306
  131. package/src/daemon/conversation-tool-setup.ts +44 -14
  132. package/src/daemon/conversation-workspace.ts +1 -2
  133. package/src/daemon/conversation.ts +18 -0
  134. package/src/daemon/date-context.ts +26 -53
  135. package/src/daemon/first-greeting.ts +1 -1
  136. package/src/daemon/handlers/conversations.ts +4 -7
  137. package/src/daemon/handlers/shared.test.ts +143 -0
  138. package/src/daemon/handlers/shared.ts +63 -5
  139. package/src/daemon/handlers/skills.ts +11 -18
  140. package/src/daemon/lifecycle.ts +199 -157
  141. package/src/daemon/message-types/conversations.ts +25 -6
  142. package/src/daemon/message-types/messages.ts +9 -1
  143. package/src/daemon/message-types/schedules.ts +1 -0
  144. package/src/daemon/message-types/settings.ts +6 -0
  145. package/src/daemon/profiler-run-store.ts +557 -0
  146. package/src/daemon/server.ts +89 -9
  147. package/src/daemon/shutdown-handlers.ts +5 -0
  148. package/src/daemon/tool-side-effects.ts +23 -3
  149. package/src/export/transcript-formatter.ts +148 -0
  150. package/src/filing/filing-service.ts +228 -0
  151. package/src/heartbeat/heartbeat-service.ts +96 -7
  152. package/src/mcp/client.ts +6 -0
  153. package/src/mcp/mcp-oauth-provider.ts +149 -27
  154. package/src/memory/admin.ts +33 -32
  155. package/src/memory/app-store.ts +69 -0
  156. package/src/memory/conversation-bootstrap.ts +1 -1
  157. package/src/memory/conversation-crud.ts +136 -107
  158. package/src/memory/conversation-group-migration.ts +1 -1
  159. package/src/memory/conversation-queries.ts +58 -12
  160. package/src/memory/conversation-title-service.ts +1 -0
  161. package/src/memory/db-init.ts +182 -376
  162. package/src/memory/graph/bootstrap.ts +75 -66
  163. package/src/memory/graph/capability-seed.ts +167 -15
  164. package/src/memory/graph/consolidation.ts +38 -4
  165. package/src/memory/graph/conversation-graph-memory.ts +133 -104
  166. package/src/memory/graph/extraction-job.ts +9 -4
  167. package/src/memory/graph/extraction.ts +66 -23
  168. package/src/memory/graph/graph-memory-state-store.ts +37 -0
  169. package/src/memory/graph/graph-search.ts +29 -15
  170. package/src/memory/graph/injection.ts +38 -8
  171. package/src/memory/graph/inspect.ts +12 -3
  172. package/src/memory/graph/retriever.ts +365 -262
  173. package/src/memory/graph/store.test.ts +48 -0
  174. package/src/memory/graph/store.ts +150 -11
  175. package/src/memory/graph/tool-handlers.ts +84 -209
  176. package/src/memory/graph/tools.ts +8 -52
  177. package/src/memory/graph/types.ts +24 -0
  178. package/src/memory/job-handlers/cleanup.ts +44 -1
  179. package/src/memory/jobs-store.ts +70 -60
  180. package/src/memory/jobs-worker.ts +44 -28
  181. package/src/memory/llm-request-log-store.ts +96 -12
  182. package/src/memory/memory-recall-log-store.ts +49 -5
  183. package/src/memory/migrations/203-drop-memory-items-tables.ts +33 -1
  184. package/src/memory/migrations/206-memory-graph-node-edits.ts +19 -0
  185. package/src/memory/migrations/206-scrub-corrupted-image-attachments.ts +131 -0
  186. package/src/memory/migrations/207-conversation-graph-memory-state.ts +20 -0
  187. package/src/memory/migrations/208-conversations-last-message-at.ts +35 -0
  188. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +85 -0
  189. package/src/memory/migrations/210-schedule-reuse-conversation.ts +13 -0
  190. package/src/memory/migrations/211-memory-recall-logs-query-context.ts +21 -0
  191. package/src/memory/migrations/212-llm-request-logs-created-at-index.ts +19 -0
  192. package/src/memory/migrations/index.ts +8 -0
  193. package/src/memory/migrations/registry.ts +8 -0
  194. package/src/memory/schema/conversations.ts +14 -0
  195. package/src/memory/schema/infrastructure.ts +8 -1
  196. package/src/memory/schema/memory-core.ts +0 -51
  197. package/src/memory/schema/memory-graph.ts +15 -0
  198. package/src/memory/task-memory-cleanup.ts +30 -11
  199. package/src/notifications/copy-composer.ts +86 -0
  200. package/src/notifications/decision-engine.ts +35 -0
  201. package/src/permissions/checker.ts +12 -1
  202. package/src/permissions/permission-mode-store.ts +180 -0
  203. package/src/permissions/permission-mode.ts +31 -0
  204. package/src/permissions/workspace-policy.ts +9 -0
  205. package/src/prompts/system-prompt.ts +59 -7
  206. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +100 -0
  207. package/src/prompts/templates/BOOTSTRAP.md +70 -165
  208. package/src/prompts/templates/HEARTBEAT.md +3 -1
  209. package/src/prompts/templates/SOUL.md +25 -4
  210. package/src/prompts/templates/UPDATES.md +8 -0
  211. package/src/providers/anthropic/client.ts +107 -219
  212. package/src/runtime/auth/route-policy.ts +23 -0
  213. package/src/runtime/http-server.ts +32 -2
  214. package/src/runtime/http-types.ts +12 -1
  215. package/src/runtime/migrations/vbundle-builder.ts +389 -3
  216. package/src/runtime/migrations/vbundle-importer.ts +8 -6
  217. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +378 -0
  218. package/src/runtime/routes/app-management-routes.ts +1 -11
  219. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +26 -0
  220. package/src/runtime/routes/archive-utils.ts +29 -0
  221. package/src/runtime/routes/avatar-routes.ts +2 -9
  222. package/src/runtime/routes/btw-routes.ts +14 -1
  223. package/src/runtime/routes/conversation-analysis-routes.ts +173 -0
  224. package/src/runtime/routes/conversation-management-routes.ts +1 -14
  225. package/src/runtime/routes/conversation-query-routes.ts +49 -3
  226. package/src/runtime/routes/conversation-routes.ts +264 -44
  227. package/src/runtime/routes/heartbeat-routes.ts +4 -10
  228. package/src/runtime/routes/identity-routes.ts +53 -18
  229. package/src/runtime/routes/llm-context-normalization.ts +14 -10
  230. package/src/runtime/routes/log-export-routes.ts +23 -275
  231. package/src/runtime/routes/memory-item-routes.test.ts +168 -233
  232. package/src/runtime/routes/migration-routes.ts +18 -7
  233. package/src/runtime/routes/profiler-routes.ts +350 -0
  234. package/src/runtime/routes/schedule-routes.ts +27 -12
  235. package/src/runtime/routes/settings-routes.ts +95 -8
  236. package/src/runtime/routes/subagents-routes.ts +28 -7
  237. package/src/runtime/routes/user-route-dispatcher.ts +223 -0
  238. package/src/runtime/routes/user-routes.ts +41 -0
  239. package/src/runtime/routes/workspace-routes.ts +0 -1
  240. package/src/schedule/schedule-store.ts +30 -0
  241. package/src/schedule/scheduler.ts +45 -18
  242. package/src/skills/catalog-install.ts +10 -2
  243. package/src/skills/managed-store.ts +2 -2
  244. package/src/skills/skill-memory.ts +1 -293
  245. package/src/subagent/index.ts +13 -3
  246. package/src/subagent/manager.ts +308 -29
  247. package/src/subagent/types.ts +68 -0
  248. package/src/tasks/task-runner.ts +4 -4
  249. package/src/tools/apps/executors.ts +29 -4
  250. package/src/tools/filesystem/list.ts +93 -0
  251. package/src/tools/permission-checker.ts +78 -0
  252. package/src/tools/registry.ts +4 -0
  253. package/src/tools/schedule/create.ts +3 -0
  254. package/src/tools/schedule/list.ts +1 -0
  255. package/src/tools/schedule/update.ts +6 -0
  256. package/src/tools/shared/filesystem/errors.ts +5 -0
  257. package/src/tools/shared/filesystem/file-ops-service.ts +90 -2
  258. package/src/tools/shared/filesystem/types.ts +17 -0
  259. package/src/tools/shared/shell-output.ts +31 -2
  260. package/src/tools/subagent/abort.ts +12 -2
  261. package/src/tools/subagent/message.ts +9 -2
  262. package/src/tools/subagent/notify-parent.ts +79 -0
  263. package/src/tools/subagent/read.ts +29 -8
  264. package/src/tools/subagent/resolve.ts +21 -0
  265. package/src/tools/subagent/spawn.ts +2 -0
  266. package/src/tools/subagent/status.ts +11 -1
  267. package/src/tools/system/avatar-generator.ts +3 -3
  268. package/src/tools/system/register.ts +23 -0
  269. package/src/tools/system/set-permission-mode.ts +103 -0
  270. package/src/tools/terminal/parser.ts +30 -5
  271. package/src/tools/terminal/safe-env.ts +16 -1
  272. package/src/tools/tool-manifest.ts +6 -0
  273. package/src/tools/types.ts +2 -0
  274. package/src/util/logger.ts +1 -1
  275. package/src/util/platform.ts +50 -17
  276. package/src/workspace/migrations/023-move-config-files-to-workspace.ts +2 -2
  277. package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +2 -2
  278. package/src/workspace/migrations/028-recover-conversations-from-disk-view.ts +270 -0
  279. package/src/workspace/migrations/029-seed-pkb.ts +84 -0
  280. package/src/workspace/migrations/registry.ts +4 -0
  281. package/src/workspace/top-level-renderer.ts +5 -9
  282. package/src/__tests__/cli-memory.test.ts +0 -377
  283. package/src/__tests__/clipboard.test.ts +0 -88
  284. package/src/cli/cli-memory.ts +0 -179
  285. package/src/util/clipboard.ts +0 -34
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Tests for handleListMessages tool_result merging.
3
+ *
4
+ * Verifies that tool_result blocks from user messages are merged into the
5
+ * preceding assistant message so they render with proper tool names instead
6
+ * of "Unknown" after a conversation reload.
7
+ */
8
+
9
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
10
+
11
+ mock.module("../util/logger.js", () => ({
12
+ getLogger: () =>
13
+ new Proxy({} as Record<string, unknown>, {
14
+ get: () => () => {},
15
+ }),
16
+ }));
17
+
18
+ mock.module("../config/loader.js", () => ({
19
+ getConfig: () => ({
20
+ ui: {},
21
+ model: "test",
22
+ provider: "test",
23
+ memory: { enabled: false },
24
+ rateLimit: { maxRequestsPerMinute: 0 },
25
+ }),
26
+ }));
27
+
28
+ import { addMessage, createConversation } from "../memory/conversation-crud.js";
29
+ import { getDb, initializeDb } from "../memory/db.js";
30
+ import { handleListMessages } from "../runtime/routes/conversation-routes.js";
31
+
32
+ initializeDb();
33
+
34
+ function resetTables() {
35
+ const db = getDb();
36
+ db.run("DELETE FROM message_attachments");
37
+ db.run("DELETE FROM attachments");
38
+ db.run("DELETE FROM messages");
39
+ db.run("DELETE FROM conversations");
40
+ }
41
+
42
+ function createTestUrl(conversationId: string): URL {
43
+ return new URL(
44
+ `http://localhost/v1/messages?conversationId=${conversationId}`,
45
+ );
46
+ }
47
+
48
+ interface ToolCallPayload {
49
+ name: string;
50
+ input: Record<string, unknown>;
51
+ result?: string;
52
+ isError?: boolean;
53
+ }
54
+
55
+ interface MessagePayload {
56
+ role: string;
57
+ content: string;
58
+ toolCalls?: ToolCallPayload[];
59
+ textSegments?: string[];
60
+ }
61
+
62
+ describe("handleListMessages tool_result merging", () => {
63
+ beforeEach(resetTables);
64
+
65
+ test("merges tool_result from user message into preceding assistant", async () => {
66
+ const conv = createConversation();
67
+ // User prompt
68
+ await addMessage(
69
+ conv.id,
70
+ "user",
71
+ JSON.stringify([{ type: "text", text: "run ls" }]),
72
+ );
73
+ // Assistant with tool_use
74
+ await addMessage(
75
+ conv.id,
76
+ "assistant",
77
+ JSON.stringify([
78
+ { type: "text", text: "Running command." },
79
+ { type: "tool_use", id: "tu1", name: "bash", input: { command: "ls" } },
80
+ ]),
81
+ );
82
+ // Tool result (separate user message)
83
+ await addMessage(
84
+ conv.id,
85
+ "user",
86
+ JSON.stringify([
87
+ { type: "tool_result", tool_use_id: "tu1", content: "file1.txt\nfile2.txt" },
88
+ ]),
89
+ );
90
+
91
+ const response = handleListMessages(createTestUrl(conv.id), null);
92
+ const body = (await response.json()) as { messages: MessagePayload[] };
93
+
94
+ // Should be 2 messages: user prompt + assistant (tool_result user msg suppressed)
95
+ expect(body.messages).toHaveLength(2);
96
+ expect(body.messages[0].role).toBe("user");
97
+ expect(body.messages[1].role).toBe("assistant");
98
+
99
+ // Assistant tool call should have proper name AND result
100
+ const toolCalls = body.messages[1].toolCalls;
101
+ expect(toolCalls).toBeDefined();
102
+ expect(toolCalls).toHaveLength(1);
103
+ expect(toolCalls![0].name).toBe("bash");
104
+ expect(toolCalls![0].result).toBe("file1.txt\nfile2.txt");
105
+ });
106
+
107
+ test("merges multiple tool_results into matching tool_uses", async () => {
108
+ const conv = createConversation();
109
+ await addMessage(
110
+ conv.id,
111
+ "user",
112
+ JSON.stringify([{ type: "text", text: "do stuff" }]),
113
+ );
114
+ await addMessage(
115
+ conv.id,
116
+ "assistant",
117
+ JSON.stringify([
118
+ { type: "tool_use", id: "tu1", name: "bash", input: { command: "ls" } },
119
+ { type: "text", text: "and also" },
120
+ { type: "tool_use", id: "tu2", name: "file_read", input: { path: "/tmp/a" } },
121
+ ]),
122
+ );
123
+ await addMessage(
124
+ conv.id,
125
+ "user",
126
+ JSON.stringify([
127
+ { type: "tool_result", tool_use_id: "tu1", content: "dir listing" },
128
+ { type: "tool_result", tool_use_id: "tu2", content: "file contents" },
129
+ ]),
130
+ );
131
+
132
+ const response = handleListMessages(createTestUrl(conv.id), null);
133
+ const body = (await response.json()) as { messages: MessagePayload[] };
134
+
135
+ expect(body.messages).toHaveLength(2);
136
+ const toolCalls = body.messages[1].toolCalls!;
137
+ expect(toolCalls).toHaveLength(2);
138
+ expect(toolCalls[0].name).toBe("bash");
139
+ expect(toolCalls[0].result).toBe("dir listing");
140
+ expect(toolCalls[1].name).toBe("file_read");
141
+ expect(toolCalls[1].result).toBe("file contents");
142
+ });
143
+
144
+ test("plain user message passes through unchanged", async () => {
145
+ const conv = createConversation();
146
+ await addMessage(
147
+ conv.id,
148
+ "user",
149
+ JSON.stringify([{ type: "text", text: "hello" }]),
150
+ );
151
+ await addMessage(
152
+ conv.id,
153
+ "assistant",
154
+ JSON.stringify([{ type: "text", text: "hi there" }]),
155
+ );
156
+ await addMessage(
157
+ conv.id,
158
+ "user",
159
+ JSON.stringify([{ type: "text", text: "how are you?" }]),
160
+ );
161
+
162
+ const response = handleListMessages(createTestUrl(conv.id), null);
163
+ const body = (await response.json()) as { messages: MessagePayload[] };
164
+
165
+ expect(body.messages).toHaveLength(3);
166
+ expect(body.messages[2].role).toBe("user");
167
+ expect(body.messages[2].content).toBe("how are you?");
168
+ });
169
+
170
+ test("tool_result at start of array (no preceding assistant) is preserved", async () => {
171
+ const conv = createConversation();
172
+ // Orphan tool_result with no preceding assistant (pagination boundary).
173
+ // The preceding assistant tool_use lives in the previous page — dropping
174
+ // the result would be unrecoverable, so it is kept as-is.
175
+ await addMessage(
176
+ conv.id,
177
+ "user",
178
+ JSON.stringify([
179
+ { type: "tool_result", tool_use_id: "tu_orphan", content: "stale result" },
180
+ ]),
181
+ );
182
+ await addMessage(
183
+ conv.id,
184
+ "assistant",
185
+ JSON.stringify([{ type: "text", text: "response" }]),
186
+ );
187
+
188
+ const response = handleListMessages(createTestUrl(conv.id), null);
189
+ const body = (await response.json()) as { messages: MessagePayload[] };
190
+
191
+ // Orphan tool_result is preserved (not suppressed) to avoid data loss
192
+ expect(body.messages).toHaveLength(2);
193
+ expect(body.messages[0].role).toBe("user");
194
+ // The preserved message must retain the actual tool_result payload
195
+ const orphanToolCalls = body.messages[0].toolCalls;
196
+ expect(orphanToolCalls).toBeDefined();
197
+ expect(orphanToolCalls).toHaveLength(1);
198
+ expect(orphanToolCalls![0].result).toBe("stale result");
199
+ expect(body.messages[1].role).toBe("assistant");
200
+ expect(body.messages[1].content).toBe("response");
201
+ });
202
+
203
+ test("multi-turn: each tool_result merges into correct assistant", async () => {
204
+ const conv = createConversation();
205
+ // Turn 1
206
+ await addMessage(
207
+ conv.id,
208
+ "user",
209
+ JSON.stringify([{ type: "text", text: "list files" }]),
210
+ );
211
+ await addMessage(
212
+ conv.id,
213
+ "assistant",
214
+ JSON.stringify([
215
+ { type: "tool_use", id: "tu1", name: "bash", input: { command: "ls" } },
216
+ ]),
217
+ );
218
+ await addMessage(
219
+ conv.id,
220
+ "user",
221
+ JSON.stringify([
222
+ { type: "tool_result", tool_use_id: "tu1", content: "files" },
223
+ ]),
224
+ );
225
+ // Turn 2
226
+ await addMessage(
227
+ conv.id,
228
+ "assistant",
229
+ JSON.stringify([
230
+ { type: "text", text: "Now reading:" },
231
+ { type: "tool_use", id: "tu2", name: "file_read", input: { path: "/x" } },
232
+ ]),
233
+ );
234
+ await addMessage(
235
+ conv.id,
236
+ "user",
237
+ JSON.stringify([
238
+ { type: "tool_result", tool_use_id: "tu2", content: "file data" },
239
+ ]),
240
+ );
241
+ // Turn 3: real user message
242
+ await addMessage(
243
+ conv.id,
244
+ "user",
245
+ JSON.stringify([{ type: "text", text: "thanks" }]),
246
+ );
247
+
248
+ const response = handleListMessages(createTestUrl(conv.id), null);
249
+ const body = (await response.json()) as { messages: MessagePayload[] };
250
+
251
+ // user("list files"), assistant(bash), assistant(file_read), user("thanks")
252
+ expect(body.messages).toHaveLength(4);
253
+ expect(body.messages[0].role).toBe("user");
254
+ expect(body.messages[1].role).toBe("assistant");
255
+ expect(body.messages[1].toolCalls![0].name).toBe("bash");
256
+ expect(body.messages[1].toolCalls![0].result).toBe("files");
257
+ expect(body.messages[2].role).toBe("assistant");
258
+ expect(body.messages[2].toolCalls![0].name).toBe("file_read");
259
+ expect(body.messages[2].toolCalls![0].result).toBe("file data");
260
+ expect(body.messages[3].role).toBe("user");
261
+ expect(body.messages[3].content).toBe("thanks");
262
+ });
263
+
264
+ test("tool_result with is_error propagates error status", async () => {
265
+ const conv = createConversation();
266
+ await addMessage(
267
+ conv.id,
268
+ "user",
269
+ JSON.stringify([{ type: "text", text: "do it" }]),
270
+ );
271
+ await addMessage(
272
+ conv.id,
273
+ "assistant",
274
+ JSON.stringify([
275
+ { type: "tool_use", id: "tu1", name: "bash", input: { command: "fail" } },
276
+ ]),
277
+ );
278
+ await addMessage(
279
+ conv.id,
280
+ "user",
281
+ JSON.stringify([
282
+ {
283
+ type: "tool_result",
284
+ tool_use_id: "tu1",
285
+ content: "command not found",
286
+ is_error: true,
287
+ },
288
+ ]),
289
+ );
290
+
291
+ const response = handleListMessages(createTestUrl(conv.id), null);
292
+ const body = (await response.json()) as { messages: MessagePayload[] };
293
+
294
+ expect(body.messages).toHaveLength(2);
295
+ const tc = body.messages[1].toolCalls![0];
296
+ expect(tc.name).toBe("bash");
297
+ expect(tc.result).toBe("command not found");
298
+ expect(tc.isError).toBe(true);
299
+ });
300
+ });
@@ -278,12 +278,6 @@ describe("normalizeLlmContextPayloads", () => {
278
278
  role: "user",
279
279
  text: "Find the latest changelog.",
280
280
  },
281
- {
282
- kind: "message",
283
- label: "Assistant message 2",
284
- role: "assistant",
285
- text: "Checking sources.",
286
- },
287
281
  {
288
282
  kind: "reasoning",
289
283
  label: "Assistant message 2 reasoning",
@@ -296,6 +290,12 @@ describe("normalizeLlmContextPayloads", () => {
296
290
  role: "assistant",
297
291
  text: "[redacted thinking]",
298
292
  },
293
+ {
294
+ kind: "message",
295
+ label: "Assistant message 2",
296
+ role: "assistant",
297
+ text: "Checking sources.",
298
+ },
299
299
  {
300
300
  kind: "tool_use",
301
301
  label: "Assistant message 2 tool use",
@@ -331,12 +331,6 @@ describe("normalizeLlmContextPayloads", () => {
331
331
  },
332
332
  ]);
333
333
  expect(normalized.responseSections).toEqual([
334
- {
335
- kind: "message",
336
- label: "Assistant response",
337
- role: "assistant",
338
- text: "I found the changelog.",
339
- },
340
334
  {
341
335
  kind: "reasoning",
342
336
  label: "Assistant response reasoning",
@@ -349,6 +343,12 @@ describe("normalizeLlmContextPayloads", () => {
349
343
  role: "assistant",
350
344
  text: "[redacted thinking]",
351
345
  },
346
+ {
347
+ kind: "message",
348
+ label: "Assistant response",
349
+ role: "assistant",
350
+ text: "I found the changelog.",
351
+ },
352
352
  {
353
353
  kind: "tool_use",
354
354
  label: "Assistant response tool use",
@@ -412,12 +412,6 @@ describe("normalizeLlmContextPayloads", () => {
412
412
  toolCallNames: undefined,
413
413
  });
414
414
  expect(normalized.responseSections).toEqual([
415
- {
416
- kind: "message",
417
- label: "Assistant response",
418
- role: "assistant",
419
- text: "The answer is 42.",
420
- },
421
415
  {
422
416
  kind: "reasoning",
423
417
  label: "Assistant response reasoning",
@@ -430,6 +424,12 @@ describe("normalizeLlmContextPayloads", () => {
430
424
  role: "assistant",
431
425
  text: "[redacted thinking]",
432
426
  },
427
+ {
428
+ kind: "message",
429
+ label: "Assistant response",
430
+ role: "assistant",
431
+ text: "The answer is 42.",
432
+ },
433
433
  ]);
434
434
  });
435
435
 
@@ -33,6 +33,27 @@ function dispatchLlmContext(messageId: string): Promise<Response> | Response {
33
33
  });
34
34
  }
35
35
 
36
+ function dispatchLogPayload(logId: string): Promise<Response> | Response {
37
+ const url = new URL(
38
+ `http://localhost/v1/llm-request-logs/${logId}/payload`,
39
+ );
40
+ const route = routes.find(
41
+ (r) =>
42
+ r.method === "GET" && r.endpoint === "llm-request-logs/:id/payload",
43
+ );
44
+ if (!route) {
45
+ throw new Error("No llm-request-logs payload route found");
46
+ }
47
+
48
+ return route.handler({
49
+ req: new Request(url.toString(), { method: "GET" }),
50
+ url,
51
+ server: null as never,
52
+ authContext: {} as never,
53
+ params: { id: logId },
54
+ });
55
+ }
56
+
36
57
  function clearRequestLogs(): void {
37
58
  getDb().delete(llmRequestLogs).run();
38
59
  }
@@ -189,4 +210,84 @@ describe("GET /v1/messages/:id/llm-context provider preference", () => {
189
210
  expect(body.logs).toHaveLength(1);
190
211
  expect(body.logs[0]?.summary).toEqual({ provider: "ollama" });
191
212
  });
213
+
214
+ test("returns null payloads to keep the initial response lightweight", async () => {
215
+ seedRequestLog({
216
+ id: "log-null-payload",
217
+ messageId: "msg-null-payload",
218
+ provider: "openrouter",
219
+ requestPayload: openAiRequestPayload,
220
+ responsePayload: openAiResponsePayload,
221
+ });
222
+
223
+ const response = await dispatchLlmContext("msg-null-payload");
224
+ expect(response.status).toBe(200);
225
+
226
+ const body = (await response.json()) as {
227
+ logs: Array<{
228
+ requestPayload: unknown;
229
+ responsePayload: unknown;
230
+ }>;
231
+ };
232
+
233
+ expect(body.logs).toHaveLength(1);
234
+ expect(body.logs[0]?.requestPayload).toBeNull();
235
+ expect(body.logs[0]?.responsePayload).toBeNull();
236
+ });
237
+ });
238
+
239
+ describe("GET /v1/llm-request-logs/:id/payload", () => {
240
+ test("returns parsed payloads for a valid log", async () => {
241
+ const reqPayload = JSON.stringify({ model: "gpt-4.1", messages: [] });
242
+ const resPayload = JSON.stringify({
243
+ choices: [{ message: { content: "hi" } }],
244
+ });
245
+ seedRequestLog({
246
+ id: "log-payload-ok",
247
+ messageId: "msg-payload-ok",
248
+ provider: "openai",
249
+ requestPayload: reqPayload,
250
+ responsePayload: resPayload,
251
+ });
252
+
253
+ const response = await dispatchLogPayload("log-payload-ok");
254
+ expect(response.status).toBe(200);
255
+
256
+ const body = (await response.json()) as {
257
+ id: string;
258
+ requestPayload: unknown;
259
+ responsePayload: unknown;
260
+ };
261
+
262
+ expect(body.id).toBe("log-payload-ok");
263
+ expect(body.requestPayload).toEqual(JSON.parse(reqPayload));
264
+ expect(body.responsePayload).toEqual(JSON.parse(resPayload));
265
+ });
266
+
267
+ test("returns 404 for a nonexistent log", async () => {
268
+ const response = await dispatchLogPayload("does-not-exist");
269
+ expect(response.status).toBe(404);
270
+ });
271
+
272
+ test("falls back to string values for non-JSON payloads", async () => {
273
+ seedRequestLog({
274
+ id: "log-raw-strings",
275
+ messageId: "msg-raw-strings",
276
+ provider: null,
277
+ requestPayload: "raw-request-text",
278
+ responsePayload: "raw-response-text",
279
+ });
280
+
281
+ const response = await dispatchLogPayload("log-raw-strings");
282
+ expect(response.status).toBe(200);
283
+
284
+ const body = (await response.json()) as {
285
+ id: string;
286
+ requestPayload: unknown;
287
+ responsePayload: unknown;
288
+ };
289
+
290
+ expect(body.requestPayload).toBe("raw-request-text");
291
+ expect(body.responsePayload).toBe("raw-response-text");
292
+ });
192
293
  });
@@ -301,6 +301,168 @@ describe("getRequestLogsByMessageId — turn-aware query", () => {
301
301
  expect(logs[2]?.id).toBe("log-surviving");
302
302
  });
303
303
 
304
+ test("recovers unlinked logs (messageId IS NULL) within the turn time range", () => {
305
+ // Simulate the race: logs recorded with NULL messageId, backfill hasn't run yet.
306
+ const T = 1_700_000_000_000;
307
+ const db = getDb();
308
+ const conv = createConversation("unlinked-test");
309
+
310
+ db.run(
311
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('u1-ul', ${conv.id}, 'user', '"Do the task"', ${T})`,
312
+ );
313
+ db.run(
314
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('a1-ul', ${conv.id}, 'assistant', '"Done!"', ${T + 30000})`,
315
+ );
316
+
317
+ // Unlinked log: messageId is NULL (backfill hasn't run yet)
318
+ db.insert(llmRequestLogs)
319
+ .values({
320
+ id: "log-unlinked-1",
321
+ conversationId: conv.id,
322
+ messageId: null,
323
+ provider: "anthropic",
324
+ requestPayload: '{"step":1}',
325
+ responsePayload: '{"tool":"bash"}',
326
+ createdAt: T + 5000,
327
+ })
328
+ .run();
329
+
330
+ // Linked log: already backfilled to the assistant message
331
+ db.insert(llmRequestLogs)
332
+ .values({
333
+ id: "log-linked-1",
334
+ conversationId: conv.id,
335
+ messageId: "a1-ul",
336
+ provider: "anthropic",
337
+ requestPayload: '{"step":2}',
338
+ responsePayload: '{"text":"Done!"}',
339
+ createdAt: T + 29_000,
340
+ })
341
+ .run();
342
+
343
+ const logs = getRequestLogsByMessageId("a1-ul");
344
+ expect(logs).toHaveLength(2);
345
+ expect(logs[0]?.id).toBe("log-unlinked-1");
346
+ expect(logs[1]?.id).toBe("log-linked-1");
347
+
348
+ // Verify opportunistic backfill ran: the unlinked log should now have a messageId
349
+ const backfilledLog = db
350
+ .select({ messageId: llmRequestLogs.messageId })
351
+ .from(llmRequestLogs)
352
+ .where(sql`${llmRequestLogs.id} = 'log-unlinked-1'`)
353
+ .get();
354
+ expect(backfilledLog?.messageId).toBe("a1-ul");
355
+ });
356
+
357
+ test("unlinked logs from different conversations don't bleed", () => {
358
+ const T = 1_700_000_000_000;
359
+ const db = getDb();
360
+ const convA = createConversation("conv-a");
361
+ const convB = createConversation("conv-b");
362
+
363
+ // Conversation A: user + assistant + unlinked log
364
+ db.run(
365
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('u1-a', ${convA.id}, 'user', '"Hello A"', ${T})`,
366
+ );
367
+ db.run(
368
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('a1-a', ${convA.id}, 'assistant', '"Hi A"', ${T + 10000})`,
369
+ );
370
+ db.insert(llmRequestLogs)
371
+ .values({
372
+ id: "log-conv-a",
373
+ conversationId: convA.id,
374
+ messageId: null,
375
+ provider: "anthropic",
376
+ requestPayload: '{"conv":"A"}',
377
+ responsePayload: '{"r":"A"}',
378
+ createdAt: T + 5000,
379
+ })
380
+ .run();
381
+
382
+ // Conversation B: user + assistant + unlinked log (overlapping timestamps)
383
+ db.run(
384
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('u1-b', ${convB.id}, 'user', '"Hello B"', ${T})`,
385
+ );
386
+ db.run(
387
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('a1-b', ${convB.id}, 'assistant', '"Hi B"', ${T + 10000})`,
388
+ );
389
+ db.insert(llmRequestLogs)
390
+ .values({
391
+ id: "log-conv-b",
392
+ conversationId: convB.id,
393
+ messageId: null,
394
+ provider: "anthropic",
395
+ requestPayload: '{"conv":"B"}',
396
+ responsePayload: '{"r":"B"}',
397
+ createdAt: T + 5000,
398
+ })
399
+ .run();
400
+
401
+ // Query from conv A → should only find conv A's log
402
+ const logsA = getRequestLogsByMessageId("a1-a");
403
+ expect(logsA).toHaveLength(1);
404
+ expect(logsA[0]?.id).toBe("log-conv-a");
405
+
406
+ // Query from conv B → should only find conv B's log
407
+ const logsB = getRequestLogsByMessageId("a1-b");
408
+ expect(logsB).toHaveLength(1);
409
+ expect(logsB[0]?.id).toBe("log-conv-b");
410
+ });
411
+
412
+ test("unlinked logs from different turns don't bleed", () => {
413
+ const T = 1_700_000_000_000;
414
+ const db = getDb();
415
+ const conv = createConversation("two-turn-unlinked");
416
+
417
+ // Turn 1: user → assistant (with linked log)
418
+ db.run(
419
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('u1-t', ${conv.id}, 'user', '"Turn 1"', ${T})`,
420
+ );
421
+ db.run(
422
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('a1-t', ${conv.id}, 'assistant', '"Answer 1"', ${T + 10000})`,
423
+ );
424
+ db.insert(llmRequestLogs)
425
+ .values({
426
+ id: "log-turn1-unlinked",
427
+ conversationId: conv.id,
428
+ messageId: null,
429
+ provider: "anthropic",
430
+ requestPayload: '{"turn":1}',
431
+ responsePayload: '{"r":1}',
432
+ createdAt: T + 5000,
433
+ })
434
+ .run();
435
+
436
+ // Turn 2: user → assistant (with unlinked log)
437
+ db.run(
438
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('u2-t', ${conv.id}, 'user', '"Turn 2"', ${T + 60000})`,
439
+ );
440
+ db.run(
441
+ sql`INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ('a2-t', ${conv.id}, 'assistant', '"Answer 2"', ${T + 70000})`,
442
+ );
443
+ db.insert(llmRequestLogs)
444
+ .values({
445
+ id: "log-turn2-unlinked",
446
+ conversationId: conv.id,
447
+ messageId: null,
448
+ provider: "anthropic",
449
+ requestPayload: '{"turn":2}',
450
+ responsePayload: '{"r":2}',
451
+ createdAt: T + 65000,
452
+ })
453
+ .run();
454
+
455
+ // Query turn 2 → should only find turn 2's unlinked log
456
+ const turn2Logs = getRequestLogsByMessageId("a2-t");
457
+ expect(turn2Logs).toHaveLength(1);
458
+ expect(turn2Logs[0]?.id).toBe("log-turn2-unlinked");
459
+
460
+ // Query turn 1 → should only find turn 1's unlinked log
461
+ const turn1Logs = getRequestLogsByMessageId("a1-t");
462
+ expect(turn1Logs).toHaveLength(1);
463
+ expect(turn1Logs[0]?.id).toBe("log-turn1-unlinked");
464
+ });
465
+
304
466
  test("relinkLlmRequestLogs moves logs from deleted messages to consolidated message", async () => {
305
467
  const conv = createConversation("relink-test");
306
468