@yeaft/webchat-agent 1.0.589 → 1.0.591

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.
Binary file
@@ -17,6 +17,6 @@
17
17
  </head>
18
18
  <body>
19
19
  <div id="app"></div>
20
- <script type="module" src="app.bundle.js?v=0afa7538"></script>
20
+ <script type="module" src="app.bundle.js?v=6d70f3b7"></script>
21
21
  </body>
22
22
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.589",
3
+ "version": "1.0.591",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -77,6 +77,7 @@
77
77
  "nodemailer": "^9.0.3",
78
78
  "puppeteer-core": "24.41.0",
79
79
  "qrcode": "^1.5.4",
80
+ "quickjs-emscripten": "0.32.0",
80
81
  "speakeasy": "^2.0.0",
81
82
  "tweetnacl": "^1.0.3",
82
83
  "tweetnacl-util": "^0.15.1",
@@ -9,16 +9,17 @@
9
9
  * user message into that query's `inputStream`.
10
10
  * - Yeaft mode has many VPs taking turns inside a group, no per-VP
11
11
  * workDir, and the Engine accepts the user message via `query()`
12
- * args. So we (a) save attachments to a shared per-group folder
13
- * under the agent's CWD (so file-tools using `ctx.cwd` can read
14
- * them with relative paths) and (b) hand back the persisted-form
12
+ * args. Session uploads belong to the instance's
13
+ * `sessions/<sessionId>/attachments` directory, independent of workDir.
14
+ * Non-Session callers retain the legacy CWD-relative folder.
15
+ * We hand back the persisted-form
15
16
  * metadata AND a `promptParts` content array (image blocks +
16
17
  * synthesized [Uploaded files] suffix) for the LLM call.
17
18
  *
18
19
  * Inputs (`files`) come from the server-side resolver in
19
20
  * `client-conversation.js`: each entry is
20
21
  * `{ name, mimeType, data: <base64>, isImage }` — the `pendingFiles`
21
- * `fileId` was already consumed by the server before `forwardToAgent`.
22
+ * `fileId` is consumed only after the server successfully forwards the message.
22
23
  *
23
24
  * Output (single bundle, all named for the role each piece plays in
24
25
  * the LLM call):
@@ -40,8 +41,8 @@
40
41
  * than swallowing it in a console.warn.
41
42
  */
42
43
 
43
- import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'node:fs';
44
- import { basename, extname, join, resolve, relative, isAbsolute } from 'node:path';
44
+ import { mkdirSync, writeFileSync, lstatSync, openSync, closeSync, fstatSync, readSync, constants } from 'node:fs';
45
+ import { basename, extname, join, resolve, relative, isAbsolute, sep } from 'node:path';
45
46
  import { randomBytes } from 'node:crypto';
46
47
 
47
48
  // Same dir name Chat mode uses, so ".gitignore" rules and tool-side
@@ -89,6 +90,8 @@ function sanitizeBaseName(base) {
89
90
  * @param {Array<{name:string, mimeType:string, data:string, isImage?:boolean}>} files
90
91
  * Resolved files from server (pendingFiles → base64).
91
92
  * @param {Object} [opts]
93
+ * @param {string} [opts.yeaftDir] Instance data root for Session uploads.
94
+ * @param {string} [opts.sessionId] Session owner (required with yeaftDir).
92
95
  * @param {string} [opts.subdir] Sub-folder under TEMP_UPLOAD_DIR
93
96
  * (e.g. sessionId). Lets multiple groups co-exist without clobbering.
94
97
  * @param {string} [opts.cwd] Override base dir; defaults to process.cwd()
@@ -107,9 +110,11 @@ export function persistYeaftAttachments(files, opts = {}) {
107
110
  // from sessionId. Keep the existing strict policy here; this is NOT
108
111
  // user-visible.
109
112
  const subdir = opts.subdir ? String(opts.subdir).replace(/[^a-zA-Z0-9._-]/g, '_') : '';
110
- const uploadDir = subdir
111
- ? join(cwd, TEMP_UPLOAD_DIR, subdir)
112
- : join(cwd, TEMP_UPLOAD_DIR);
113
+ const sessionUpload = opts.yeaftDir != null || opts.sessionId != null;
114
+ const uploadDir = sessionUpload
115
+ ? sessionAttachmentRoot(opts)
116
+ : (subdir && subdir !== '.' && subdir !== '..'
117
+ ? resolve(cwd, TEMP_UPLOAD_DIR, subdir) : resolve(cwd, TEMP_UPLOAD_DIR));
113
118
 
114
119
  if (!Array.isArray(files) || files.length === 0) {
115
120
  return { promptAttachments: [], promptSuffix: '', promptParts: [], failed: [] };
@@ -120,8 +125,17 @@ export function persistYeaftAttachments(files, opts = {}) {
120
125
  const accepted = files.slice(0, MAX_FILES_PER_TURN);
121
126
  const rejectedByCount = files.slice(MAX_FILES_PER_TURN);
122
127
 
123
- if (!existsSync(uploadDir)) {
128
+ try {
129
+ if (!uploadDir) throw new Error('invalid Session attachment owner');
130
+ const root = sessionUpload ? resolve(opts.yeaftDir) : resolve(cwd);
131
+ assertNoSymlinks(root, uploadDir, true);
124
132
  mkdirSync(uploadDir, { recursive: true });
133
+ assertNoSymlinks(root, uploadDir);
134
+ } catch (error) {
135
+ return {
136
+ promptAttachments: [], promptSuffix: '', promptParts: [],
137
+ failed: files.map(file => ({ name: file?.name || '<unknown>', error: error.message })),
138
+ };
125
139
  }
126
140
 
127
141
  const promptAttachments = [];
@@ -141,17 +155,18 @@ export function persistYeaftAttachments(files, opts = {}) {
141
155
  continue;
142
156
  }
143
157
  try {
144
- const ext = extname(file.name);
145
- const base = basename(file.name, ext);
158
+ const rawExt = extname(file.name);
159
+ const ext = rawExt.replace(/[\\\/\0-\x1f\x7f]/g, '_');
160
+ const base = basename(file.name, rawExt);
146
161
  const safeBase = sanitizeBaseName(base);
147
162
  // Identity comes from random bytes — a clock is not an identity.
148
163
  // 4 bytes (2^32) is plenty for a 16-file cap.
149
164
  const suffix = randomBytes(4).toString('hex');
150
165
  const uniqueName = `${safeBase}_${suffix}${ext || ''}`;
151
166
  const absPath = join(uploadDir, uniqueName);
152
- const relPath = subdir
153
- ? join(TEMP_UPLOAD_DIR, subdir, uniqueName)
154
- : join(TEMP_UPLOAD_DIR, uniqueName);
167
+ // Absolute Session references keep file tools independent of workDir;
168
+ // readers still require this instance and Session's exact asset root.
169
+ const persistedPath = sessionUpload ? absPath : relative(resolve(cwd), absPath);
155
170
 
156
171
  const buffer = Buffer.from(file.data, 'base64');
157
172
 
@@ -166,12 +181,12 @@ export function persistYeaftAttachments(files, opts = {}) {
166
181
  }
167
182
  totalBytes += buffer.length;
168
183
 
169
- writeFileSync(absPath, buffer);
184
+ writeFileSync(absPath, buffer, { flag: 'wx', mode: 0o600 });
170
185
 
171
186
  const isImage = !!file.isImage || (file.mimeType || '').startsWith('image/');
172
187
  promptAttachments.push({
173
188
  name: file.name,
174
- path: relPath,
189
+ path: persistedPath,
175
190
  mimeType: file.mimeType || 'application/octet-stream',
176
191
  isImage,
177
192
  });
@@ -230,48 +245,177 @@ export function attachmentsForPersistence(promptAttachments) {
230
245
  }));
231
246
  }
232
247
 
248
+ /** Session IDs are identities, never sanitized into another Session's directory. */
249
+ function validSessionId(value) {
250
+ return typeof value === 'string' && /^[a-zA-Z0-9_-][a-zA-Z0-9._-]*$/.test(value);
251
+ }
252
+
253
+ function sessionAttachmentRoot({ yeaftDir, sessionId }) {
254
+ return typeof yeaftDir === 'string' && yeaftDir && validSessionId(sessionId)
255
+ ? resolve(yeaftDir, 'sessions', sessionId, 'attachments') : null;
256
+ }
257
+
258
+ function isWithin(root, path) {
259
+ const rel = relative(root, path);
260
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
261
+ }
262
+
263
+ // Check the trusted base itself and every descendant, including intermediate
264
+ // directories. O_NOFOLLOW below also guards replacement of the final file.
265
+ // This is not a sandbox against concurrently replaced ancestor directories.
266
+ function assertNoSymlinks(root, path, allowMissing = false) {
267
+ if (path !== root && !isWithin(root, path)) throw new Error('attachment path outside owner root');
268
+ let current = root;
269
+ for (const part of ['', ...relative(root, path).split(sep).filter(Boolean)]) {
270
+ if (part) current = join(current, part);
271
+ try {
272
+ if (lstatSync(current).isSymbolicLink()) throw new Error('attachment symlink is not allowed');
273
+ } catch (error) {
274
+ if (allowMissing && error.code === 'ENOENT') continue;
275
+ throw error;
276
+ }
277
+ }
278
+ }
279
+
233
280
  /**
234
- * Resolve a persisted Yeaft attachment path to an on-disk file. The persisted
235
- * path is intentionally relative (for tool use), so preview hydration must
236
- * keep it inside the upload root instead of serving arbitrary files.
237
- *
281
+ * Resolve only this Session's assets, or its CWD-relative legacy uploads.
282
+ * Absolute paths are allowed ONLY in <yeaftDir>/sessions/<sessionId>/attachments.
283
+ * No workDir search/fallback: legacy Web uploads were written under Agent CWD.
284
+ * Non-Session callers retain their original CWD-relative upload root.
238
285
  * @param {string} attachmentPath
239
- * @param {{ cwd?: string }} [opts]
286
+ * @param {{cwd?:string, legacyRoot?:string, yeaftDir?:string, sessionId?:string}} [opts]
287
+ * legacyRoot is the original Agent CWD (not workDir or the upload subdir).
240
288
  * @returns {string|null}
241
289
  */
242
290
  export function resolvePersistedAttachmentPath(attachmentPath, opts = {}) {
243
- if (!attachmentPath || typeof attachmentPath !== 'string') return null;
244
- if (isAbsolute(attachmentPath)) return null;
245
- const cwd = resolve(opts.cwd || process.cwd());
246
- const uploadRoot = resolve(cwd, TEMP_UPLOAD_DIR);
291
+ if (!attachmentPath || typeof attachmentPath !== 'string' || attachmentPath.includes('\0')) return null;
292
+ const cwd = resolve(opts.legacyRoot || opts.cwd || process.cwd());
293
+ const scoped = opts.sessionId != null || opts.yeaftDir != null;
294
+ const sessionRoot = sessionAttachmentRoot(opts);
295
+ if (scoped && !sessionRoot) return null;
247
296
  const absPath = resolve(cwd, attachmentPath);
248
- const rel = relative(uploadRoot, absPath);
249
- if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return null;
250
- return absPath;
297
+ let root;
298
+ if (isAbsolute(attachmentPath)) {
299
+ if (!sessionRoot || !isWithin(sessionRoot, absPath)) return null;
300
+ root = resolve(opts.yeaftDir);
301
+ } else {
302
+ const legacyRoot = scoped
303
+ ? resolve(cwd, TEMP_UPLOAD_DIR, opts.sessionId) : resolve(cwd, TEMP_UPLOAD_DIR);
304
+ if (!isWithin(legacyRoot, absPath)) return null;
305
+ root = cwd;
306
+ }
307
+ try {
308
+ assertNoSymlinks(root, absPath);
309
+ return absPath;
310
+ } catch {
311
+ return null;
312
+ }
251
313
  }
252
314
 
253
- /**
254
- * Read a persisted image attachment into a short-lived preview payload for the
255
- * web server. Returns null for non-images, bad paths, missing files, or files
256
- * too large to cache as previews.
257
- *
258
- * @param {{name?:string, path?:string, mimeType?:string, isImage?:boolean}} att
259
- * @param {{ cwd?: string }} [opts]
260
- * @returns {{data:string,mimeType:string,filename:string}|null}
261
- */
262
- export function persistedAttachmentPreviewPayload(att, opts = {}) {
263
- if (!att || !att.isImage || !att.path) return null;
264
- const absPath = resolvePersistedAttachmentPath(att.path, opts);
265
- if (!absPath) return null;
315
+ /** Shared bounded read for model history and browser preview. Never follows symlinks. */
316
+ function readPersistedAttachment(att, opts, maxBytes) {
317
+ const absPath = resolvePersistedAttachmentPath(att?.path, opts);
318
+ if (!absPath) throw new Error('file missing or outside this Session / 文件缺失或不属于本会话');
319
+ const fd = openSync(absPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
266
320
  try {
267
- const st = statSync(absPath);
268
- if (!st.isFile() || st.size > MAX_PREVIEW_BYTES) return null;
321
+ const stat = fstatSync(fd);
322
+ if (!stat.isFile()) throw new Error('not a regular file');
323
+ if (stat.size > maxBytes) throw new Error('image exceeds attachment size limit');
324
+ const buffer = Buffer.alloc(stat.size);
325
+ let offset = 0;
326
+ while (offset < buffer.length) {
327
+ const read = readSync(fd, buffer, offset, buffer.length - offset, null);
328
+ if (!read) throw new Error('image changed while reading');
329
+ offset += read;
330
+ }
269
331
  return {
270
- data: readFileSync(absPath).toString('base64'),
271
- mimeType: att.mimeType || 'application/octet-stream',
332
+ data: buffer.toString('base64'),
333
+ mimeType: att.mimeType || 'image/png',
272
334
  filename: att.name || basename(absPath),
335
+ bytes: buffer.length,
273
336
  };
337
+ } finally {
338
+ closeSync(fd);
339
+ }
340
+ }
341
+
342
+ /** Short-lived web preview; missing/denied/oversized images have no preview. */
343
+ export function persistedAttachmentPreviewPayload(att, opts = {}) {
344
+ if (!att?.isImage || !att.path) return null;
345
+ try {
346
+ const { bytes, ...payload } = readPersistedAttachment(att, opts, MAX_PREVIEW_BYTES);
347
+ return payload;
274
348
  } catch {
275
349
  return null;
276
350
  }
277
351
  }
352
+
353
+ /**
354
+ * Build provider content from one canonical user row, without a Web runtime.
355
+ * Call once on raw transcript rows, before history-window budgeting (including
356
+ * recalled turns). Never persist the returned base64 or unavailable notices.
357
+ * Ownership comes from the requested Session, not an attachment's metadata.
358
+ *
359
+ * @param {{role:string, sessionId?:string, content?:string|Array, attachments?:Array}} row
360
+ * @param {{yeaftDir:string, sessionId:string, legacyRoot?:string, cwd?:string}} opts
361
+ * legacyRoot is the original Agent CWD; cwd is its compatibility alias.
362
+ * @returns {Array} Standard provider content parts; the input row is unchanged.
363
+ */
364
+ export function hydratePersistedUserAttachmentContent(row, opts = {}) {
365
+ const content = Array.isArray(row?.content) ? [...row.content]
366
+ : (row?.content ? [{ type: 'text', text: String(row.content) }] : []);
367
+ if (row?.role !== 'user' || row.sessionId !== opts.sessionId || !validSessionId(opts.sessionId)) return content;
368
+ const images = Array.isArray(row.attachments) ? row.attachments.filter(att => att?.isImage) : [];
369
+ let totalBytes = 0;
370
+ for (const att of images.slice(0, MAX_FILES_PER_TURN)) {
371
+ try {
372
+ const payload = readPersistedAttachment(att, opts, MAX_TOTAL_BYTES - totalBytes);
373
+ totalBytes += payload.bytes;
374
+ content.push({ type: 'image', source: { type: 'base64', media_type: payload.mimeType, data: payload.data } });
375
+ } catch (error) {
376
+ content.push({ type: 'text', text: `[Uploaded image unavailable / 上传图片不可用: ${att.name || 'image'} — ${error.message}. Ask the user to upload it again / 请用户重新上传。]` });
377
+ }
378
+ }
379
+ if (images.length > MAX_FILES_PER_TURN) content.push({ type: 'text', text: '[Uploaded image unavailable: attachment count limit exceeded.]' });
380
+ return content;
381
+ }
382
+
383
+ /** Provider-only projection for a canonical history array; no store decoration. */
384
+ export function hydratePersistedAttachmentHistory(messages, opts = {}) {
385
+ return messages.map(row => {
386
+ if (row?.role !== 'user' || row.sessionId !== opts.sessionId || !validSessionId(opts.sessionId)
387
+ || !Array.isArray(row.attachments) || !row.attachments.some(att => att?.isImage)) return row;
388
+ return { ...row, content: hydratePersistedUserAttachmentContent(row, opts) };
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Clone selected transcript attachments into the target Session's owned root.
394
+ * Missing/denied legacy references remain unavailable (never broaden access).
395
+ * Copy errors for available files fail the fork so CRUD can roll it back.
396
+ * @param {Array} rows Selected canonical source rows, including cold history.
397
+ * @param {{yeaftDir:string, sourceSessionId:string, targetSessionId:string}} opts
398
+ * @returns {Array} New rows; callers remap message and Session identities.
399
+ */
400
+ export function copyPersistedSessionAttachments(rows, { yeaftDir, sourceSessionId, targetSessionId }) {
401
+ const sourceOptions = { yeaftDir, sessionId: sourceSessionId };
402
+ const copiedPaths = new Map();
403
+ return rows.map(row => {
404
+ if (row.sessionId !== sourceSessionId || !Array.isArray(row.attachments)) return row;
405
+ return { ...row, attachments: row.attachments.map(att => {
406
+ if (!att?.path) return att;
407
+ if (copiedPaths.has(att.path)) return { ...att, path: copiedPaths.get(att.path) };
408
+ if (!resolvePersistedAttachmentPath(att.path, sourceOptions)) return att;
409
+ const payload = readPersistedAttachment(att, sourceOptions, MAX_TOTAL_BYTES);
410
+ const bundle = persistYeaftAttachments([{ ...att, name: payload.filename, data: payload.data }], {
411
+ yeaftDir, sessionId: targetSessionId,
412
+ });
413
+ if (bundle.failed.length || bundle.promptAttachments.length !== 1) {
414
+ throw new Error(`Session attachment copy failed: ${bundle.failed[0]?.error || 'missing output'}`);
415
+ }
416
+ const path = bundle.promptAttachments[0].path;
417
+ copiedPaths.set(att.path, path);
418
+ return { ...att, path };
419
+ }) };
420
+ });
421
+ }
@@ -31,6 +31,7 @@ import {
31
31
  iterateCanonicalVisibleEntriesNewestFirst,
32
32
  normalizeLiteralSearch,
33
33
  } from './visible-entry.js';
34
+ import { copyPersistedSessionAttachments } from '../attachments.js';
34
35
  import { markConversationDirty } from './history-index-state.js';
35
36
 
36
37
  /**
@@ -2035,7 +2036,8 @@ export class ConversationStore {
2035
2036
  `m${String(firstSeq + index).padStart(4, '0')}`,
2036
2037
  ]));
2037
2038
  const remapId = id => idMap.get(id) || id;
2038
- const copies = rows.map(row => {
2039
+ const assetRows = copyPersistedSessionAttachments(rows, { yeaftDir: this.#dir, sourceSessionId, targetSessionId });
2040
+ const copies = assetRows.map(row => {
2039
2041
  const copy = {
2040
2042
  ...row,
2041
2043
  id: idMap.get(row.id),
package/yeaft/engine.js CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  } from './history-window.js';
43
43
  import { recallConversationTurns } from './conversation/history-index.js';
44
44
  import { parseSeqFromId } from './conversation/persist.js';
45
+ import { hydratePersistedAttachmentHistory } from './attachments.js';
45
46
  import { isVpForeign, readContent as readScopeContent } from './memory/store.js';
46
47
  import { ActiveMemorySet } from './memory/ams.js';
47
48
  import { cleanMemoryPromptText } from './memory/prompt-cleanup.js';
@@ -2180,7 +2181,13 @@ export class Engine {
2180
2181
  if (Number.isFinite(beforeSeq)) {
2181
2182
  const loadHistory = this.#conversationStore.loadProviderHistoryBySession
2182
2183
  || this.#conversationStore.loadRecentBySession;
2183
- const tail = await loadHistory.call(this.#conversationStore, runtimeSessionId, recentTurnCap, { beforeSeq });
2184
+ // Materialize only provider copies from this Session's controlled assets.
2185
+ // Hydrate before budgeting; never mutate/persist base64 into canonical rows.
2186
+ const attachmentOptions = { yeaftDir: this.#yeaftDir, sessionId: runtimeSessionId, legacyRoot: process.cwd() };
2187
+ const tail = hydratePersistedAttachmentHistory(
2188
+ await loadHistory.call(this.#conversationStore, runtimeSessionId, recentTurnCap, { beforeSeq }),
2189
+ attachmentOptions,
2190
+ );
2184
2191
  messages = tail.filter(m => parseSeqFromId(m.id) < beforeSeq
2185
2192
  && (m.role !== 'tool' || !queryVpId || m.speakerVpId === queryVpId))
2186
2193
  .map(m => {
@@ -2193,7 +2200,19 @@ export class Engine {
2193
2200
  const recalled = await recallConversationTurns(this.#yeaftDir, runtimeSessionId, prompt, {
2194
2201
  beforeSeq, limit: relatedTurnCap,
2195
2202
  });
2196
- relatedHistoryTurns = recalled.turns || [];
2203
+ relatedHistoryTurns = (recalled.turns || []).map(turn => ({
2204
+ ...turn,
2205
+ // Recall indexes intentionally contain text/identities, not assets.
2206
+ // Reload only the exact same-Session user anchor's references.
2207
+ messages: hydratePersistedAttachmentHistory((turn.messages || []).map(row => {
2208
+ if (row.role !== 'user' || row.sessionId !== runtimeSessionId
2209
+ || !Number.isFinite(row.seq) || row.seq >= beforeSeq) return row;
2210
+ const anchor = this.#conversationStore.loadRecentBySession(runtimeSessionId, 1, { beforeSeq: row.seq + 1 })
2211
+ .find(candidate => candidate.role === 'user' && candidate.sessionId === runtimeSessionId
2212
+ && candidate.id === row.messageId && parseSeqFromId(candidate.id) === row.seq);
2213
+ return anchor?.attachments ? { ...row, attachments: anchor.attachments } : row;
2214
+ }), attachmentOptions),
2215
+ }));
2197
2216
  historyRecallMeta = { source: 'messages', status: 'ready', ...recalled.meta };
2198
2217
  } catch (error) {
2199
2218
  // Cold/stale index degrades to recent history, never a full scan.
@@ -4279,6 +4298,7 @@ export class Engine {
4279
4298
  // break out of the outer while-loop cleanly once the current
4280
4299
  // tool batch finishes reporting.
4281
4300
  let abortedDuringTools = false;
4301
+ let batchHasUnseenImages = false;
4282
4302
  /** @type {string[]} */
4283
4303
  const pendingDupReminders = [];
4284
4304
  let terminateAfterDuplicateBatch = false;
@@ -4439,6 +4459,7 @@ export class Engine {
4439
4459
 
4440
4460
  let output;
4441
4461
  let displayImages = [];
4462
+ let modelImages = [];
4442
4463
  let isError = false;
4443
4464
  let reusedReadOnlyResult = false;
4444
4465
  let reusedReadOnlyCallId = null;
@@ -4575,6 +4596,7 @@ export class Engine {
4575
4596
  const cachedReadOnly = readOnlyToolResults.get(duplicateKey);
4576
4597
  if (!readOnlyToolReuseDisabled && cachedReadOnly && cacheableTool) {
4577
4598
  output = cachedReadOnly.output;
4599
+ modelImages = cachedReadOnly.modelImages || [];
4578
4600
  isError = Boolean(cachedReadOnly.isError);
4579
4601
  reusedReadOnlyResult = true;
4580
4602
  reusedReadOnlyCallId = cachedReadOnly.callId || null;
@@ -4619,6 +4641,13 @@ export class Engine {
4619
4641
  }
4620
4642
  displayImages = extractDisplayImages(tc.name, output);
4621
4643
  if (displayImages.length > 0) {
4644
+ // UI asset delivery and model vision are independent consumers.
4645
+ // Capture provider parts before yielding to the UI bridge; never
4646
+ // persist base64 in transcript/exec-log or textual tool output.
4647
+ modelImages = displayImages.map(image => ({
4648
+ type: 'image',
4649
+ source: { type: 'base64', media_type: image.mimeType, data: image.previewData.data },
4650
+ }));
4622
4651
  output = stripDisplayImageData(output, displayImages);
4623
4652
  }
4624
4653
  isError = toolErrorOutput === 'json-error-envelope' && isToolErrorOutput(output);
@@ -4752,6 +4781,7 @@ export class Engine {
4752
4781
  && !readOnlyToolReuseDisabled && cacheableTool) {
4753
4782
  readOnlyToolResults.set(duplicateKey, {
4754
4783
  output,
4784
+ modelImages,
4755
4785
  isError,
4756
4786
  callId: tc.id,
4757
4787
  });
@@ -4764,10 +4794,13 @@ export class Engine {
4764
4794
  toolName: tc.name,
4765
4795
  language: this.#config?.language,
4766
4796
  });
4797
+ if (modelImages.length > 0) batchHasUnseenImages = true;
4767
4798
  const toolMessage = {
4768
4799
  role: 'tool',
4769
4800
  toolCallId: tc.id,
4770
- content: contextOutput,
4801
+ content: modelImages.length > 0
4802
+ ? [{ type: 'text', text: contextOutput }, ...modelImages]
4803
+ : contextOutput,
4771
4804
  isError,
4772
4805
  };
4773
4806
  conversationMessages.push(toolMessage);
@@ -4896,7 +4929,9 @@ export class Engine {
4896
4929
  const completedToolLoops = toolLoopTurns + 1;
4897
4930
  const t1BatchDue = completedToolLoops - lastT1AtLoopCount
4898
4931
  >= TOOL_LOOP_REFLECTION_INTERVAL;
4899
- if (t1BatchDue && !toolBatchBarrier
4932
+ // Text-only reflection cannot consume visual input. Defer folding until
4933
+ // the primary provider has received this image batch at least once.
4934
+ if (t1BatchDue && !batchHasUnseenImages && !toolBatchBarrier
4900
4935
  && !abortedDuringTools && !signal?.aborted) {
4901
4936
  const t1DedupKey = `${queryNumber}:t1-loop:${completedToolLoops}`;
4902
4937
  if (this.#reflectedTurns.has(t1DedupKey)) {
@@ -59,9 +59,9 @@ import { utf8PrefixWithinBytes } from '../utf8.js';
59
59
  * @typedef {{ thinking: string, signature: string }} ThinkingBlock
60
60
  *
61
61
  * @typedef {{ role: 'system', content: string }} SystemMessage
62
- * @typedef {{ role: 'user', content: string }} UserMessage
62
+ * @typedef {{ role: 'user', content: string|Array<object> }} UserMessage
63
63
  * @typedef {{ role: 'assistant', content: string, toolCalls?: UnifiedToolCall[], thinkingBlocks?: ThinkingBlock[] }} AssistantMessage
64
- * @typedef {{ role: 'tool', toolCallId: string, content: string, isError?: boolean }} ToolMessage
64
+ * @typedef {{ role: 'tool', toolCallId: string, content: string|Array<object>, isError?: boolean }} ToolMessage
65
65
  *
66
66
  * @typedef {SystemMessage | UserMessage | AssistantMessage | ToolMessage} UnifiedMessage
67
67
  */
@@ -205,7 +205,10 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
205
205
  input.push({
206
206
  type: 'function_call_output',
207
207
  call_id: msg.toolCallId,
208
- output: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
208
+ output: Array.isArray(msg.content) && msg.content.length > 0
209
+ && msg.content.every(part => part && ['text', 'image', 'document'].includes(part.type))
210
+ ? this.#translateUserContent(msg.content)
211
+ : (typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)),
209
212
  });
210
213
  }
211
214
  }