@aiworker/web 1.22.2 → 1.23.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 (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -0,0 +1,127 @@
1
+ const TURN_LIMIT = 3;
2
+ const PAGE_SIZE = 50;
3
+ const MAX_PAGES = 8;
4
+ const USER_CHAR_LIMIT = 8_000;
5
+ const ANSWER_CHAR_LIMIT = 16_000;
6
+
7
+ // Mirrors the persisted context contract owned by UI lib/messages/contextParts.ts.
8
+ // Read only the fields needed for model context; malformed attached text fails
9
+ // this generation instead of silently dropping the user's comment.
10
+ const QUOTE_FIELDS = new Map([
11
+ ['code-comment', 'code'], ['file-quote', 'quote'], ['chat-quote', 'quote'],
12
+ ['browser-annotation', 'prompt'], ['pr-comment', 'body'], ['pr-check', 'output'],
13
+ ['terminal', 'output'],
14
+ ]);
15
+ const LINK_KINDS = new Set(['github-issue', 'github-pr', 'linear-issue']);
16
+
17
+ export function excerpt(text, limit) {
18
+ if (text.length <= limit) return text;
19
+ const marker = '\n[Content omitted]\n';
20
+ if (limit <= marker.length) return text.slice(0, Math.max(0, limit));
21
+ const head = Math.ceil((limit - marker.length) / 2);
22
+ const tail = limit - marker.length - head;
23
+ return text.slice(0, head) + marker + (tail > 0 ? text.slice(-tail) : '');
24
+ }
25
+
26
+ function attachedText(part) {
27
+ const context = part.metadata?.aiworkerContext;
28
+ if (QUOTE_FIELDS.has(context?.kind)) {
29
+ const source = (context[QUOTE_FIELDS.get(context.kind)] ?? '').trim();
30
+ const authored = (context.text ?? '').trim();
31
+ const label = excerpt((context.fileLabel ?? context.label ?? context.pageUrl ?? context.terminalLabel ?? '').trim(), 300);
32
+ const location = Number.isInteger(context.startLine) ? `, lines ${context.startLine}-${context.endLine ?? context.startLine}` : '';
33
+ const quote = excerpt(source, 4_000).split('\n').map((line) => `> ${line}`).join('\n');
34
+ return { text: `Attached ${context.kind}${label ? ` (${label}${location})` : ''}:\n${quote}\n\nUser comment:\n${excerpt(authored, USER_CHAR_LIMIT)}`, authored };
35
+ }
36
+ if (LINK_KINDS.has(context?.kind)) return { text: excerpt(part.text, USER_CHAR_LIMIT), authored: '' };
37
+ const comment = part.metadata?.opencodeComment;
38
+ if (comment) {
39
+ const authored = comment.comment.trim();
40
+ const source = (comment.preview ?? '').trim();
41
+ const label = excerpt((comment.path ?? '').trim(), 300);
42
+ return { text: `Attached file context (${label}):\n${excerpt(source, 4_000)}\n\nUser comment:\n${excerpt(authored, USER_CHAR_LIMIT)}`, authored };
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** SDK message records are chronological; tool payloads never enter this view. */
48
+ function readMessage({ info, parts }) {
49
+ const blocks = [];
50
+ const authored = [];
51
+ for (const part of parts) {
52
+ if (part.type !== 'text' || part.ignored) continue;
53
+ const attached = info.role === 'user' ? attachedText(part) : null;
54
+ if (attached) {
55
+ blocks.push(attached.text);
56
+ authored.push(attached.authored);
57
+ } else if (!part.synthetic) {
58
+ blocks.push(part.text);
59
+ if (info.role === 'user') {
60
+ // Legacy terminal selections are source material, not a language sample.
61
+ authored.push(part.text.replace(/\n*<terminal_context>\n[\s\S]*?\n<\/terminal_context>\s*$/, ''));
62
+ }
63
+ }
64
+ }
65
+ return {
66
+ id: info.id, role: info.role, parentID: info.parentID,
67
+ providerID: info.providerID, modelID: info.modelID,
68
+ complete: info.role === 'assistant' && info.finish === 'stop' && Boolean(info.time?.completed) && !info.error && !info.summary,
69
+ summary: Boolean(info.summary),
70
+ text: excerpt(blocks.join('\n\n').trim(), info.role === 'user' ? USER_CHAR_LIMIT : ANSWER_CHAR_LIMIT),
71
+ authored: excerpt(authored.filter(Boolean).join('\n\n').trim(), USER_CHAR_LIMIT),
72
+ };
73
+ }
74
+
75
+ function collectTurns(messages) {
76
+ const turns = [];
77
+ let active = null;
78
+ let parents = new Set();
79
+ for (const message of messages) {
80
+ if (message.role === 'user' && message.text) {
81
+ active = { user: message, assistant: null, complete: false };
82
+ parents = new Set([message.id]);
83
+ turns.push(active);
84
+ } else if (active && message.role === 'user') {
85
+ parents.add(message.id);
86
+ } else if (active && message.role === 'assistant' && parents.has(message.parentID) && !message.summary) {
87
+ // Compaction's synthetic continuation users belong to the same human
88
+ // turn. Parent IDs alone would lose its eventual final answer.
89
+ if (message.text) active.assistant = message;
90
+ active.complete = message.complete && Boolean(message.text);
91
+ }
92
+ }
93
+ return turns.slice(-TURN_LIMIT);
94
+ }
95
+
96
+ /** Failure is thrown; null means no eligible final answer within bounded history. */
97
+ export async function loadAssistContext({ readPage, signal }) {
98
+ let messages = [];
99
+ let before;
100
+ const cursors = new Set();
101
+ const ids = new Set();
102
+ for (let pageNumber = 0; pageNumber < MAX_PAGES; pageNumber++) {
103
+ signal.throwIfAborted();
104
+ const page = await readPage({ limit: PAGE_SIZE, before });
105
+ signal.throwIfAborted();
106
+ if (!Array.isArray(page.data)) throw new Error('Session message page is unavailable');
107
+ const older = [];
108
+ for (const record of page.data) {
109
+ if (ids.has(record.info.id)) continue;
110
+ ids.add(record.info.id);
111
+ older.push(readMessage(record));
112
+ }
113
+ messages = older.concat(messages);
114
+ const last = messages.at(-1);
115
+ if (!last?.complete || !last.text) return null;
116
+ const turns = collectTurns(messages);
117
+ const cursor = page.response.headers.get('x-next-cursor');
118
+ if (turns.length === TURN_LIMIT || !cursor || pageNumber === MAX_PAGES - 1) {
119
+ if (!turns.at(-1)?.complete || turns.at(-1).assistant.id !== last.id) return null;
120
+ return { turns, last };
121
+ }
122
+ if (cursors.has(cursor)) throw new Error('Session message pagination made no progress');
123
+ cursors.add(cursor);
124
+ before = cursor;
125
+ }
126
+ return null;
127
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { excerpt, loadAssistContext } from './context.js';
3
+ import { buildAssistPrompt } from './prompt.js';
4
+
5
+ const record = (id, role, text, extra = {}, parts = []) => ({
6
+ info: { id, role, parentID: 'user', finish: 'stop', time: { completed: 1 }, providerID: 'provider', modelID: 'model', ...extra },
7
+ parts: [...(text ? [{ type: 'text', text }] : []), ...parts],
8
+ });
9
+ const pair = (id) => [record(`u${id}`, 'user', `request ${id}`), record(`a${id}`, 'assistant', `answer ${id}`, { parentID: `u${id}` })];
10
+ const page = (data, cursor = '') => ({ data, response: { headers: new Headers({ 'x-next-cursor': cursor }) } });
11
+ const load = (readPage) => loadAssistContext({ readPage, signal: new AbortController().signal });
12
+
13
+ describe('session assist context', () => {
14
+ it('stops paging at three human turns and excludes tools and injected instructions', async () => {
15
+ let calls = 0;
16
+ const records = [...pair(1), ...pair(2), ...pair(3), ...pair(4)];
17
+ records.at(-1).parts.push({ type: 'tool', state: { output: 'TOOL_PAYLOAD' } }, { type: 'text', synthetic: true, text: 'INJECTED_PROMPT' });
18
+ const context = await load(async ({ limit, before }) => {
19
+ calls++;
20
+ expect(limit).toBe(50);
21
+ expect(before).toBeUndefined();
22
+ return page(records, 'more');
23
+ });
24
+ expect(calls).toBe(1);
25
+ expect(context.turns.map((t) => t.user.id)).toEqual(['u2', 'u3', 'u4']);
26
+ expect(context.last.text).toBe('answer 4');
27
+ expect(JSON.stringify(context)).not.toContain('TOOL_PAYLOAD');
28
+ expect(JSON.stringify(context)).not.toContain('INJECTED_PROMPT');
29
+ });
30
+
31
+ it('finds the real user across a page boundary and compaction continuations', async () => {
32
+ const summary = record('summary', 'assistant', 'INTERNAL SUMMARY', { summary: true });
33
+ const continuation = record('continuation', 'user', '', {}, [{ type: 'text', synthetic: true, text: 'Continue' }]);
34
+ const final = record('final', 'assistant', 'All requested work done', { parentID: 'continuation' });
35
+ const context = await load(async ({ before }) => before
36
+ ? page([...pair(1), ...pair(2), record('human', 'user', 'Finish the requested work')])
37
+ : page([summary, continuation, final], 'older'));
38
+ expect(context.last.id).toBe('final');
39
+ expect(context.turns.at(-1).user.id).toBe('human');
40
+ expect(context.turns.at(-1).assistant.id).toBe('final');
41
+ expect(JSON.stringify(context)).not.toContain('INTERNAL SUMMARY');
42
+ });
43
+
44
+ it('retains interrupted requests as progress, not as completed turns', async () => {
45
+ const context = await load(async () => page([
46
+ record('u1', 'user', 'Implement both fixes'),
47
+ record('a1', 'assistant', 'First fix done', { finish: 'tool-calls', parentID: 'u1' }),
48
+ ...pair(2),
49
+ ]));
50
+ expect(context.turns[0].complete).toBe(false);
51
+ expect(context.turns[0].assistant.text).toBe('First fix done');
52
+ expect(context.turns[1].complete).toBe(true);
53
+ });
54
+
55
+ it('keeps synthetic annotations and separates author language from quoted material', async () => {
56
+ const user = record('u', 'user', '', {}, [{
57
+ type: 'text', synthetic: true, text: 'Serialized attachment',
58
+ metadata: { aiworkerContext: { kind: 'chat-quote', quote: 'English quoted source', text: 'Виправ саме це' } },
59
+ }]);
60
+ const context = await load(async () => page([user, record('a', 'assistant', 'Done', { parentID: 'u' })]));
61
+ expect(context.turns[0].user.text).toContain('> English quoted source');
62
+ expect(context.turns[0].user.text).toContain('User comment:\nВиправ саме це');
63
+ expect(context.turns[0].user.authored).toBe('Виправ саме це');
64
+ });
65
+
66
+ it('retains the comment after a large quote and the conclusion after a long answer', async () => {
67
+ const user = record('u', 'user', '', {}, [{ type: 'text', synthetic: true, text: 'attachment', metadata: {
68
+ aiworkerContext: { kind: 'browser-annotation', prompt: 'x'.repeat(30_000), text: 'USER_REQUEST_END' },
69
+ } }]);
70
+ const context = await load(async () => page([user, record('a', 'assistant', `START${'x'.repeat(30_000)}CONCLUSION`, { parentID: 'u' })]));
71
+ expect(context.turns[0].user.text).toContain('USER_REQUEST_END');
72
+ expect(context.last.text).toMatch(/^START/);
73
+ expect(context.last.text).toMatch(/CONCLUSION$/);
74
+ expect(context.last.text.length).toBeLessThanOrEqual(16_000);
75
+ });
76
+
77
+ it('fails explicitly on malformed annotation text instead of losing the comment', async () => {
78
+ const user = record('u', 'user', '', {}, [{ type: 'text', synthetic: true, text: 'attachment', metadata: {
79
+ aiworkerContext: { kind: 'chat-quote', quote: 'source', text: 42 },
80
+ } }]);
81
+ await expect(load(async () => page([user, record('a', 'assistant', 'done')]))).rejects.toThrow();
82
+ });
83
+
84
+ it('does not treat a failed or repeated page as complete history', async () => {
85
+ await expect(load(async () => ({ data: undefined }))).rejects.toThrow('unavailable');
86
+ await expect(load(async () => page(pair(1), 'repeated'))).rejects.toThrow('no progress');
87
+ let calls = 0;
88
+ await expect(load(async () => {
89
+ if (++calls === 2) throw new Error('offline');
90
+ return page(pair(1), 'next');
91
+ })).rejects.toThrow('offline');
92
+ });
93
+
94
+ it('bounds retrieval and refuses to invent a user for an orphaned answer', async () => {
95
+ let calls = 0;
96
+ const context = await load(async () => page([record(`a${++calls}`, 'assistant', 'answer')], `cursor${calls}`));
97
+ expect(calls).toBe(8);
98
+ expect(context).toBeNull();
99
+ });
100
+
101
+ it('skips unfinished, failed, summary, and user tails', async () => {
102
+ for (const tail of [
103
+ record('tail', 'user', 'New request'),
104
+ record('tail', 'assistant', 'Working', { finish: 'tool-calls' }),
105
+ record('tail', 'assistant', 'Failed', { error: { name: 'error' } }),
106
+ record('tail', 'assistant', 'Summary', { summary: true }),
107
+ ]) expect(await load(async () => page([...pair(1), tail]))).toBeNull();
108
+ });
109
+
110
+ it('keeps both sides of the newest exchange within a small model budget', async () => {
111
+ const context = await load(async () => page([
112
+ ...pair(1), ...pair(2), record('latest', 'user', `REQUEST_START${'u'.repeat(8000)}REQUEST_END`),
113
+ record('answer', 'assistant', `ANSWER_START${'a'.repeat(16000)}ANSWER_END`, { parentID: 'latest' }),
114
+ ]));
115
+ for (const budget of [1_000, 4_000, 14_000, 32_000, 1_000_000]) {
116
+ const prompt = buildAssistPrompt(context.turns, { recap: true, suggestion: true }, budget);
117
+ expect(prompt.text.length).toBeLessThanOrEqual(Math.min(budget, 32_000));
118
+ for (const marker of ['REQUEST_START', 'REQUEST_END', 'ANSWER_START', 'ANSWER_END']) expect(prompt.text).toContain(marker);
119
+ }
120
+ expect(buildAssistPrompt(context.turns, { recap: true }, 500)).toBeNull();
121
+ expect(excerpt('long text', 0)).toBe('');
122
+ expect(excerpt('long text', 2)).toBe('lo');
123
+ for (let limit = 0; limit < 60; limit++) expect(excerpt('x'.repeat(100), limit).length).toBeLessThanOrEqual(limit);
124
+ });
125
+
126
+ it('does not assign an older parent\'s late answer to a newer user request', async () => {
127
+ expect(await load(async () => page([
128
+ ...pair(1), record('new-user', 'user', 'A different request'),
129
+ record('late-answer', 'assistant', 'Old work done', { parentID: 'u1' }),
130
+ ]))).toBeNull();
131
+ });
132
+ });
@@ -0,0 +1,54 @@
1
+ import { excerpt } from './context.js';
2
+
3
+ const MAX_PROMPT_CHARS = 32_000;
4
+
5
+ export function buildAssistSystemPrompt({ recap, suggestion }) {
6
+ return [
7
+ `Return exactly one JSON object with string fields: ${[recap ? '"recap"' : '', suggestion ? '"suggestion"' : ''].filter(Boolean).join(', ')}.`,
8
+ recap ? 'recap is a reminder of the actual work accomplished or conclusion reached in the recent conversation. In at most 20 words, name the concrete behavior or subject and its current result. The reader wants to remember WHAT changed or was learned.' : '',
9
+ recap ? 'Write the recap for the person using the app, not for someone reviewing its code. Name the feature and the concrete difference the user will notice. Replace generic "implemented and tested" summaries with what now works differently or what was learned. File paths, internal module names, test counts, and lists of checks belong in the recap only when they are the subject of the user\'s request. Preserve a specific finding or limitation when it changes the meaning of the result.' : '',
10
+ recap ? 'For a closing exchange such as a commit, push, acknowledgment, or thank-you, summarize the substantive work from the preceding answers. Commit bookkeeping, authorship, branch names, and hashes are secondary and usually omitted. A recap saying only that optimizations or changes were committed does not serve this purpose.' : '',
11
+ recap ? 'Use the latest state of that work. Distinguish recommendations from actions already performed, and implementations from verified deployments. Retain the reported conclusion without recalculating detailed lists. Earlier unrelated topics are not part of the recap.' : '',
12
+ suggestion ? 'suggestion is optional. Return "" when the current request is satisfied or continuing requires a user decision. Otherwise return one concise message the user could send to continue specific unfinished work they requested.' : '',
13
+ suggestion ? 'suggestion: return an empty string if the latest request has been satisfied, the next move requires the user\'s decision, or the context does not establish unfinished requested work. Finishing is a normal outcome.' : '',
14
+ suggestion ? 'Otherwise write one concise, specific next message the user can send unchanged to continue the unfinished request. Use the user\'s voice addressing the agent.' : '',
15
+ suggestion ? 'A request to analyze, explain, or recommend is satisfied by that analysis, explanation, or recommendation unless the user also asked for execution. An optional offer, an implementation plan, a caveat about untested platforms, or uncommitted work is not permission for a new task. Do not revive old requests after the user changes topic.' : '',
16
+ 'Language: all requested fields follow the latest user-authored communication, including the user\'s comments on quotes. Ignore the language of the quoted material, code, logs, assistant responses, and these instructions. For a language-neutral acknowledgment use recent user-authored communication. Keep technical names unchanged where useful.',
17
+ recap && suggestion ? 'Keep recap and suggestion independent: recap may carry earlier substantive work forward, while suggestion must be justified by the current request, not by that earlier work.' : '',
18
+ suggestion ? 'Only suggest work the coding agent can perform in the session. If the next action belongs to the user, such as checking their phone, choosing a design, or approving a change, return "". A suggestion is a message sent TO the agent, never a reminder addressed to the user.' : '',
19
+ ].filter(Boolean).join('\n');
20
+ }
21
+
22
+ function renderTurn(turn, index, userText = turn.user.text, answerText = turn.assistant?.text ?? '') {
23
+ return [
24
+ `Turn ${index + 1}${turn.complete ? '' : ' (interrupted before a final response)'}`,
25
+ 'User message with attached context:', userText,
26
+ turn.complete ? 'Assistant final response:' : 'Assistant progress before interruption:', answerText,
27
+ ].join('\n');
28
+ }
29
+
30
+ export function buildAssistPrompt(turns, targets, charBudget) {
31
+ const budget = Math.min(MAX_PROMPT_CHARS, Math.floor(charBudget));
32
+ if (!Number.isFinite(budget) || budget < 1_000 || !turns.length) return null;
33
+ const languageBudget = Math.min(3_600, Math.floor(budget / 5));
34
+ const language = excerpt(turns.map((turn) => excerpt(turn.user.authored, 1_200)).filter(Boolean).join('\n'), languageBudget);
35
+ const header = 'Recent conversation turns, oldest first. Older turns may be omitted.\n\n';
36
+ const requested = [targets.recap ? 'a reminder of the recent substantive work in recap' : '', targets.suggestion ? 'an optional current next step in suggestion' : ''].filter(Boolean).join(', and ');
37
+ const footer = `\n\n--- End of conversation evidence ---\n\nRecent user-authored communication, excluding attached quotes, oldest first:\n\n${language}\n\nReturn ${requested}.`;
38
+ const available = budget - header.length - footer.length;
39
+ const kept = turns.slice();
40
+ let body = kept.map((turn, i) => renderTurn(turn, i)).join('\n\n---\n\n');
41
+ while (body.length > available && kept.length > 1) {
42
+ kept.shift();
43
+ body = kept.map((turn, i) => renderTurn(turn, i)).join('\n\n---\n\n');
44
+ }
45
+ if (body.length > available) {
46
+ const turn = kept[0];
47
+ const textBudget = available - renderTurn(turn, 0, '', '').length;
48
+ if (textBudget < 128) return null;
49
+ const answer = turn.assistant?.text ?? '';
50
+ const userBudget = Math.min(turn.user.text.length, Math.max(Math.floor(textBudget / 3), textBudget - answer.length));
51
+ body = renderTurn(turn, 0, excerpt(turn.user.text, userBudget), excerpt(answer, textBudget - userBudget));
52
+ }
53
+ return { text: header + body + footer, language };
54
+ }