@openchambery/web 1.18.4-beta.9 → 1.18.4

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 (64) hide show
  1. package/dist/assets/{AssistantView-CbCe7NJG.js → AssistantView-DM2dKfoR.js} +1 -1
  2. package/dist/assets/{ChatContainer-CSHDg3oS.js → ChatContainer-BVPeKR-K.js} +42 -42
  3. package/dist/assets/{DiagramView-DUciKofo.js → DiagramView-Cmf-1RrR.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-BjAOpViW.js → MarkdownRendererImpl-TCIXUwWr.js} +1 -1
  5. package/dist/assets/{MobileDetailNavigation-EXZlPK6E.js → MobileDetailNavigation-BVwAAhoU.js} +1 -1
  6. package/dist/assets/{MobileShareBridge-CevrYlt3.js → MobileShareBridge-2J_Tfuji.js} +1 -1
  7. package/dist/assets/MobileSurface-CDiflo1b.js +2 -0
  8. package/dist/assets/{MultiRunWindow-CNWuOY4y.js → MultiRunWindow-UffGnrG2.js} +1 -1
  9. package/dist/assets/{OnboardingScreen-BhSzVmYo.js → OnboardingScreen-Cx79VlTN.js} +1 -1
  10. package/dist/assets/{SettingsView-YyQ-C-qq.js → SettingsView-Da1ATyc8.js} +6 -6
  11. package/dist/assets/{SettingsWindow-uUezVVwi.js → SettingsWindow-nVdVW9KM.js} +1 -1
  12. package/dist/assets/{TerminalView-DPto-DoO.js → TerminalView-BTY7HznA.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-CgPYRmDX.js → ToolOutputDialog-BJfWelnd.js} +1 -1
  14. package/dist/assets/appThemeRegistry-BQGUOtTV.js +2 -0
  15. package/dist/assets/{clipboard-DhOMUCwT.js → clipboard-BfgFef1i.js} +1 -1
  16. package/dist/assets/{context-menu-BPxTMlbo.js → context-menu-BwxsmiaI.js} +1 -1
  17. package/dist/assets/{dialog-BoAAoL5Z.js → dialog-GR7KoP1o.js} +1 -1
  18. package/dist/assets/{es-BXQb1dPR.js → es-Bi4m8Mm6.js} +1 -1
  19. package/dist/assets/{fr-BTyDIvxw.js → fr-B5UdepyS.js} +1 -1
  20. package/dist/assets/{gitApi-BktvSPdR.js → gitApi-BfOQScwC.js} +3 -3
  21. package/dist/assets/insertionBoundaries-Gk_rVlpX.js +1 -0
  22. package/dist/assets/{ja-fcQgtBFy.js → ja-KtFuRjvm.js} +1 -1
  23. package/dist/assets/{ko-CgoVt1eb.js → ko-CWpFVSCH.js} +1 -1
  24. package/dist/assets/{main-UJICKUVV.js → main-7rfc5WXp.js} +15 -15
  25. package/dist/assets/{main-DOGudNxM.js → main-CTzdOaGn.js} +2 -2
  26. package/dist/assets/{miniChat-C465ME90.js → miniChat-CAbceO-F.js} +2 -2
  27. package/dist/assets/{mobile-BHRw1m5c.js → mobile-BX6ehEeM.js} +2 -2
  28. package/dist/assets/multirun-C2J5Yp8V.js +4 -0
  29. package/dist/assets/{pl-DVDnk2fT.js → pl-30X8TD5i.js} +1 -1
  30. package/dist/assets/projectMeta-mqvgqR8a.js +3 -0
  31. package/dist/assets/{pt-BR-ofV_Qcd_.js → pt-BR-Cm-Klf0E.js} +1 -1
  32. package/dist/assets/{radio-CS9DITGQ.js → radio-nnW-6V1r.js} +1 -1
  33. package/dist/assets/renderElectronMiniChatApp-C2Ya6FdB.js +2 -0
  34. package/dist/assets/renderMobileApp-BgDGLev_.js +23 -0
  35. package/dist/assets/{runtimeConfig-Dofg9S27.js → runtimeConfig-CCrS3B1v.js} +1 -1
  36. package/dist/assets/runtimeEndpointReset-C1NE1NgZ.js +147 -0
  37. package/dist/assets/{sortable-tabs-strip-N3j5So4j.js → sortable-tabs-strip-BYmWSdS9.js} +1 -1
  38. package/dist/assets/{store-Hr-xvtDJ.js → store-Z1MxUaP4.js} +2 -2
  39. package/dist/assets/{uk-Dzob0yMx.js → uk-Cgc5wXrs.js} +1 -1
  40. package/dist/assets/useAppFontEffects-BoCsqVCV.js +7 -0
  41. package/dist/assets/{useDesktopWindowControlsLayout-CGYSjr_T.js → useDesktopWindowControlsLayout-C7JocspE.js} +1 -1
  42. package/dist/assets/{useEffectiveDirectory-BtkZPQZ2.js → useEffectiveDirectory-DMWATHLS.js} +1 -1
  43. package/dist/assets/{useMobileNavigationStore-BNnnW08z.js → useMobileNavigationStore-CLiqmN1d.js} +15 -15
  44. package/dist/assets/useSessionAutoCleanup-BPKBXJg4.js +1 -0
  45. package/dist/assets/{useWorkerHighlightedLines-Bo9meAHp.js → useWorkerHighlightedLines-NddS5-lr.js} +1 -1
  46. package/dist/assets/{zh-CN-Sndj2nhS.js → zh-CN-BdRwDJuj.js} +1 -1
  47. package/dist/assets/{zh-TW-S24a8EMD.js → zh-TW-C3LubKzZ.js} +1 -1
  48. package/dist/index.html +2 -2
  49. package/dist/mini-chat.html +2 -2
  50. package/dist/mobile.html +2 -2
  51. package/package.json +1 -1
  52. package/server/lib/session-assist/DOCUMENTATION.md +4 -2
  53. package/server/lib/session-assist/runtime.js +67 -30
  54. package/server/lib/session-assist/runtime.test.js +114 -0
  55. package/dist/assets/MobileSurface-cYYd428z.js +0 -2
  56. package/dist/assets/appThemeRegistry-CPVFQqR9.js +0 -2
  57. package/dist/assets/insertionBoundaries-Cqk1aLwK.js +0 -1
  58. package/dist/assets/multirun-BCvIqAk-.js +0 -4
  59. package/dist/assets/projectMeta-Wxow1DQz.js +0 -3
  60. package/dist/assets/renderElectronMiniChatApp-DAIgdBk9.js +0 -2
  61. package/dist/assets/renderMobileApp-BXOtgQHy.js +0 -23
  62. package/dist/assets/runtimeEndpointReset-Cc3yypUQ.js +0 -147
  63. package/dist/assets/useAppFontEffects-BpT_sNyZ.js +0 -7
  64. package/dist/assets/useSessionAutoCleanup-C5V-GCiC.js +0 -1
package/dist/index.html CHANGED
@@ -552,7 +552,7 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-DOGudNxM.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-CTzdOaGn.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -569,7 +569,7 @@
569
569
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-KDUc5mh2.js">
570
570
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
571
571
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
572
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Dofg9S27.js">
572
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-CCrS3B1v.js">
573
573
  <link rel="stylesheet" crossorigin href="/assets/src-Dbdlm5x1.css">
574
574
  </head>
575
575
  <body class="h-full bg-background text-foreground">
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-C465ME90.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-CAbceO-F.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -91,7 +91,7 @@
91
91
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-KDUc5mh2.js">
92
92
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
93
93
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
94
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Dofg9S27.js">
94
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-CCrS3B1v.js">
95
95
  <link rel="stylesheet" crossorigin href="/assets/src-Dbdlm5x1.css">
96
96
  </head>
97
97
  <body class="h-full bg-background text-foreground">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-BHRw1m5c.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-BX6ehEeM.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -80,7 +80,7 @@
80
80
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-KDUc5mh2.js">
81
81
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
82
82
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
83
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Dofg9S27.js">
83
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-CCrS3B1v.js">
84
84
  <link rel="stylesheet" crossorigin href="/assets/src-Dbdlm5x1.css">
85
85
  </head>
86
86
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.18.4-beta.9",
3
+ "version": "1.18.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -14,8 +14,10 @@ under `metadata.openchamber.assist`.
14
14
  `retry` status or a user `message.updated` clears it (the "1 minute of
15
15
  quiet" rule).
16
16
  3. On fire: fetch the session (skip sub-agent sessions with `parentID`),
17
- take the LAST exchange only the final assistant reply plus the user
18
- message it answered (assistant `parentID` user id) and call
17
+ then build a trimmed excerpt via `buildAssistTranscript`: the LAST text
18
+ block of the last assistant message (earlier blocks, reasoning, and tool
19
+ output are excluded) plus the latest 3 real user messages (synthetic
20
+ hidden-instruction parts never count). The excerpt is sent to
19
21
  `generateSmallModelText` with the
20
22
  session's own provider/model taken from the last assistant message — so
21
23
  the utility call spends the same subscription as the conversation.
@@ -33,6 +33,8 @@ const isSessionRecapEnabled = () => {
33
33
 
34
34
  const IDLE_QUIET_MS = 60_000;
35
35
  const TRANSCRIPT_MESSAGE_LIMIT = 12;
36
+ /** Latest real user messages fed to the recap prompt. */
37
+ const TRANSCRIPT_USER_MESSAGE_LIMIT = 3;
36
38
  const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
37
39
  const RECAP_CHAR_LIMIT = 320;
38
40
  const FETCH_TIMEOUT_MS = 5_000;
@@ -92,13 +94,63 @@ const extractUserMessage = (payload) => {
92
94
  };
93
95
  };
94
96
 
95
- const messagePartsToText = (message) => {
97
+ /** Real (non-synthetic) text parts of a message — hidden instructions excluded. */
98
+ const realTextParts = (message) => {
96
99
  const parts = Array.isArray(message?.parts) ? message.parts : [];
97
100
  return parts
98
- .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
99
- .filter(Boolean)
100
- .join('\n')
101
- .slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
101
+ .filter((part) => part?.type === 'text'
102
+ && typeof part.text === 'string'
103
+ && part.text.trim().length > 0
104
+ && part.synthetic !== true)
105
+ .map((part) => part.text);
106
+ };
107
+
108
+ /** Body text of a message: only its LAST text block, char-clamped. */
109
+ const lastMessageText = (message) => {
110
+ const texts = realTextParts(message);
111
+ return texts.length > 0 ? texts[texts.length - 1].slice(0, TRANSCRIPT_PART_CHAR_LIMIT) : '';
112
+ };
113
+
114
+ /**
115
+ * Build the recap transcript from a bounded recent-message window.
116
+ * - Assistant: only the LAST text block of the last assistant message —
117
+ * earlier blocks, reasoning, and tool output are token waste for a
118
+ * one-line recap.
119
+ * - User: the latest real user messages (default 3), chronological.
120
+ */
121
+ export const buildAssistTranscript = (
122
+ messages,
123
+ { userLimit = TRANSCRIPT_USER_MESSAGE_LIMIT } = {},
124
+ ) => {
125
+ if (!Array.isArray(messages) || messages.length === 0) {
126
+ return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
127
+ }
128
+ let lastAssistant = null;
129
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
130
+ if (messages[i]?.info?.role === 'assistant') {
131
+ lastAssistant = messages[i];
132
+ break;
133
+ }
134
+ }
135
+ const lastAssistantInfo = lastAssistant?.info ?? null;
136
+ if (!lastAssistantInfo?.id) {
137
+ return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
138
+ }
139
+
140
+ const userTexts = [];
141
+ for (let i = messages.length - 1; i >= 0 && userTexts.length < userLimit; i -= 1) {
142
+ const message = messages[i];
143
+ if (message?.info?.role !== 'user') continue;
144
+ const text = lastMessageText(message);
145
+ if (!text) continue;
146
+ userTexts.unshift(text);
147
+ }
148
+ const assistantText = lastMessageText(lastAssistant);
149
+ const transcript = [
150
+ ...userTexts.map((text) => `User:\n${text}`),
151
+ assistantText ? `Assistant:\n${assistantText}` : '',
152
+ ].filter(Boolean).join('\n\n');
153
+ return { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText };
102
154
  };
103
155
 
104
156
  export const createSessionAssistRuntime = ({
@@ -169,36 +221,21 @@ export const createSessionAssistRuntime = ({
169
221
  return;
170
222
  }
171
223
 
172
- let lastAssistant = null;
173
- for (let i = messages.length - 1; i >= 0; i -= 1) {
174
- const info = messages[i]?.info;
175
- if (info?.role === 'assistant') {
176
- lastAssistant = messages[i];
177
- break;
178
- }
179
- }
180
- const lastAssistantInfo = lastAssistant?.info;
224
+ // Trimmed extraction for a one-line recap: the last assistant text block
225
+ // plus the latest real user messages. Everything else (earlier blocks,
226
+ // reasoning, tool output, older turns) is token waste.
227
+ const { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText } = buildAssistTranscript(messages);
181
228
  if (!lastAssistantInfo?.id) return;
182
-
183
- // Only the last exchange: the assistant reply plus the user message it
184
- // answered (assistant info.parentID → user info.id). Everything else is
185
- // token waste for a one-line recap.
186
- const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
187
- ? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
188
- : null;
189
- const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
190
- const assistantText = messagePartsToText(lastAssistant);
191
- const transcript = [
192
- userText ? `User:\n${userText}` : '',
193
- assistantText ? `Assistant:\n${assistantText}` : '',
194
- ].filter(Boolean).join('\n\n');
195
229
  if (!transcript) return;
196
230
 
197
231
  const { generateSmallModelText } = await getSmallModelService();
198
232
  // Instruct the language by example, not by description — account-side
199
233
  // personalization (e.g. the ChatGPT backend knowing the user's locale)
200
234
  // otherwise leaks a different language into the output.
201
- const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim();
235
+ const languageSample = (userTexts[userTexts.length - 1] || assistantText)
236
+ .slice(0, 200)
237
+ .replace(/\s+/g, ' ')
238
+ .trim();
202
239
  let generated;
203
240
  try {
204
241
  generated = await generateSmallModelText({
@@ -206,7 +243,7 @@ export const createSessionAssistRuntime = ({
206
243
  // session's own provider unless the user explicitly picked a small
207
244
  // model (settings override / opencode config).
208
245
  restrictToPreferredProvider: true,
209
- prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap in the SAME language as this sample from the conversation: "${languageSample}"`,
246
+ prompt: `The latest conversation excerpt:\n\n${transcript}\n\nWrite recap in the SAME language as this sample from the conversation: "${languageSample}"`,
210
247
  system: ASSIST_SYSTEM_PROMPT,
211
248
  directory,
212
249
  preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
@@ -228,7 +265,7 @@ export const createSessionAssistRuntime = ({
228
265
  // no Cyrillic/CJK at all, the output must not either.
229
266
  const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
230
267
  const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
231
- const inputText = `${userText}\n${assistantText}`;
268
+ const inputText = `${userTexts.join('\n')}\n${assistantText}`;
232
269
  const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
233
270
  || (hasCjk(text) && !hasCjk(inputText));
234
271
  if (recap && scriptMismatch(recap)) {
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { buildAssistTranscript } from './runtime.js';
3
+
4
+ const textPart = (text, { synthetic = false } = {}) => ({
5
+ type: 'text',
6
+ text,
7
+ ...(synthetic ? { synthetic: true } : {}),
8
+ });
9
+
10
+ const userMessage = (id, parts) => ({ info: { id, role: 'user' }, parts });
11
+ const assistantMessage = (id, parts) => ({ info: { id, role: 'assistant' }, parts });
12
+
13
+ describe('buildAssistTranscript', () => {
14
+ test('assistant keeps only the LAST text block of the last assistant message', () => {
15
+ const result = buildAssistTranscript([
16
+ userMessage('u1', [textPart('first user message')]),
17
+ assistantMessage('a1', [textPart('old assistant reply')]),
18
+ userMessage('u2', [textPart('second user message')]),
19
+ assistantMessage('a2', [
20
+ textPart('intro before tools'),
21
+ { type: 'tool', tool: 'bash' },
22
+ textPart('final body text'),
23
+ ]),
24
+ ]);
25
+ expect(result.assistantInfo.id).toBe('a2');
26
+ expect(result.assistantText).toBe('final body text');
27
+ expect(result.transcript).toBe(
28
+ 'User:\nfirst user message\n\nUser:\nsecond user message\n\nAssistant:\nfinal body text',
29
+ );
30
+ });
31
+
32
+ test('reasoning parts never enter the transcript', () => {
33
+ const result = buildAssistTranscript([
34
+ userMessage('u1', [textPart('请帮我看看')]),
35
+ assistantMessage('a1', [
36
+ { type: 'reasoning', text: 'thinking about the problem' },
37
+ textPart('最终答复'),
38
+ ]),
39
+ ]);
40
+ expect(result.transcript).toBe('User:\n请帮我看看\n\nAssistant:\n最终答复');
41
+ expect(result.transcript).not.toContain('thinking');
42
+ });
43
+
44
+ test('user side keeps only the latest 3 real user messages, chronologically', () => {
45
+ const result = buildAssistTranscript([
46
+ userMessage('u1', [textPart('question 1')]),
47
+ assistantMessage('a1', [textPart('answer 1')]),
48
+ userMessage('u2', [textPart('question 2')]),
49
+ assistantMessage('a2', [textPart('answer 2')]),
50
+ userMessage('u3', [textPart('question 3')]),
51
+ userMessage('u4', [textPart('question 4')]),
52
+ assistantMessage('a3', [textPart('answer 3')]),
53
+ ]);
54
+ expect(result.userTexts).toEqual(['question 2', 'question 3', 'question 4']);
55
+ expect(result.transcript).toBe(
56
+ 'User:\nquestion 2\n\nUser:\nquestion 3\n\nUser:\nquestion 4\n\nAssistant:\nanswer 3',
57
+ );
58
+ });
59
+
60
+ test('synthetic-only user messages do not count toward the user window', () => {
61
+ const result = buildAssistTranscript([
62
+ userMessage('u1', [textPart('visible request')]),
63
+ userMessage('u2', [textPart('hidden instruction', { synthetic: true })]),
64
+ userMessage('u3', [textPart('real follow-up')]),
65
+ assistantMessage('a1', [textPart('reply body')]),
66
+ ]);
67
+ expect(result.userTexts).toEqual(['visible request', 'real follow-up']);
68
+ expect(result.transcript).not.toContain('hidden instruction');
69
+ });
70
+
71
+ test('synthetic instruction part on a real user message is dropped', () => {
72
+ const result = buildAssistTranscript([
73
+ userMessage('u1', [
74
+ textPart('visible request'),
75
+ textPart('/summary hidden instructions', { synthetic: true }),
76
+ ]),
77
+ assistantMessage('a1', [textPart('reply body')]),
78
+ ]);
79
+ expect(result.userTexts).toEqual(['visible request']);
80
+ expect(result.transcript).not.toContain('/summary hidden instructions');
81
+ });
82
+
83
+ test('fewer than 3 user messages keeps all of them', () => {
84
+ const result = buildAssistTranscript([
85
+ userMessage('u1', [textPart('only question')]),
86
+ assistantMessage('a1', [textPart('only answer')]),
87
+ ]);
88
+ expect(result.userTexts).toEqual(['only question']);
89
+ expect(result.transcript).toBe('User:\nonly question\n\nAssistant:\nonly answer');
90
+ });
91
+
92
+ test('no assistant message yields an empty transcript', () => {
93
+ const result = buildAssistTranscript([
94
+ userMessage('u1', [textPart('unanswered question')]),
95
+ ]);
96
+ expect(result.transcript).toBe('');
97
+ expect(result.assistantInfo).toBeNull();
98
+ expect(result.userTexts).toEqual([]);
99
+ });
100
+
101
+ test('assistant with no text parts still summarizes the user side', () => {
102
+ const result = buildAssistTranscript([
103
+ userMessage('u1', [textPart('question')]),
104
+ assistantMessage('a1', [{ type: 'tool', tool: 'bash' }]),
105
+ ]);
106
+ expect(result.assistantInfo.id).toBe('a1');
107
+ expect(result.transcript).toBe('User:\nquestion');
108
+ });
109
+
110
+ test('empty input yields an empty transcript', () => {
111
+ expect(buildAssistTranscript([]).transcript).toBe('');
112
+ expect(buildAssistTranscript(null).transcript).toBe('');
113
+ });
114
+ });