@prur/dsh-chat-service 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -47,9 +47,10 @@ import { join } from 'node:path';
47
47
  import z from '@deepseek-ai/schemastery';
48
48
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
49
49
  import { ChatStore, ChatStorageError } from './store.js';
50
- import { ChatTurnRunner, nextSeq } from './runner.js';
50
+ import { ChatTurnRunner } from './runner.js';
51
51
  import { ChatEventBus, ChatEventsGateway } from './events.js';
52
- import { deriveTitle } from './engine.js';
52
+ import { deriveTitle, regenerateTarget } from './engine.js';
53
+ import { admitEncodedImages } from '@deepseek-ai/dsh-attachment';
53
54
  /** Default page size for `chat/history`. */
54
55
  const DEFAULT_HISTORY_PAGE = 50;
55
56
  /** Upper bound on one history page. */
@@ -92,6 +93,11 @@ let ChatService = (() => {
92
93
  let _delete_decorators;
93
94
  let _history_decorators;
94
95
  let _send_decorators;
96
+ let _archive_decorators;
97
+ let _deleteMessage_decorators;
98
+ let _regenerate_decorators;
99
+ let _capabilities_decorators;
100
+ let _attachment_decorators;
95
101
  let _cancel_decorators;
96
102
  let _selectModel_decorators;
97
103
  let _update_decorators;
@@ -104,6 +110,11 @@ let ChatService = (() => {
104
110
  _delete_decorators = [Remote('delete')];
105
111
  _history_decorators = [Remote('history')];
106
112
  _send_decorators = [Remote('send')];
113
+ _archive_decorators = [Remote('archive')];
114
+ _deleteMessage_decorators = [Remote('deleteMessage')];
115
+ _regenerate_decorators = [Remote('regenerate')];
116
+ _capabilities_decorators = [Remote('capabilities')];
117
+ _attachment_decorators = [Remote('attachment')];
107
118
  _cancel_decorators = [Remote('cancel')];
108
119
  _selectModel_decorators = [Remote('selectModel')];
109
120
  _update_decorators = [Remote('update')];
@@ -113,6 +124,11 @@ let ChatService = (() => {
113
124
  __esDecorate(this, null, _delete_decorators, { kind: "method", name: "delete", static: false, private: false, access: { has: obj => "delete" in obj, get: obj => obj.delete }, metadata: _metadata }, null, _instanceExtraInitializers);
114
125
  __esDecorate(this, null, _history_decorators, { kind: "method", name: "history", static: false, private: false, access: { has: obj => "history" in obj, get: obj => obj.history }, metadata: _metadata }, null, _instanceExtraInitializers);
115
126
  __esDecorate(this, null, _send_decorators, { kind: "method", name: "send", static: false, private: false, access: { has: obj => "send" in obj, get: obj => obj.send }, metadata: _metadata }, null, _instanceExtraInitializers);
127
+ __esDecorate(this, null, _archive_decorators, { kind: "method", name: "archive", static: false, private: false, access: { has: obj => "archive" in obj, get: obj => obj.archive }, metadata: _metadata }, null, _instanceExtraInitializers);
128
+ __esDecorate(this, null, _deleteMessage_decorators, { kind: "method", name: "deleteMessage", static: false, private: false, access: { has: obj => "deleteMessage" in obj, get: obj => obj.deleteMessage }, metadata: _metadata }, null, _instanceExtraInitializers);
129
+ __esDecorate(this, null, _regenerate_decorators, { kind: "method", name: "regenerate", static: false, private: false, access: { has: obj => "regenerate" in obj, get: obj => obj.regenerate }, metadata: _metadata }, null, _instanceExtraInitializers);
130
+ __esDecorate(this, null, _capabilities_decorators, { kind: "method", name: "capabilities", static: false, private: false, access: { has: obj => "capabilities" in obj, get: obj => obj.capabilities }, metadata: _metadata }, null, _instanceExtraInitializers);
131
+ __esDecorate(this, null, _attachment_decorators, { kind: "method", name: "attachment", static: false, private: false, access: { has: obj => "attachment" in obj, get: obj => obj.attachment }, metadata: _metadata }, null, _instanceExtraInitializers);
116
132
  __esDecorate(this, null, _cancel_decorators, { kind: "method", name: "cancel", static: false, private: false, access: { has: obj => "cancel" in obj, get: obj => obj.cancel }, metadata: _metadata }, null, _instanceExtraInitializers);
117
133
  __esDecorate(this, null, _selectModel_decorators, { kind: "method", name: "selectModel", static: false, private: false, access: { has: obj => "selectModel" in obj, get: obj => obj.selectModel }, metadata: _metadata }, null, _instanceExtraInitializers);
118
134
  __esDecorate(this, null, _update_decorators, { kind: "method", name: "update", static: false, private: false, access: { has: obj => "update" in obj, get: obj => obj.update }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -144,6 +160,9 @@ let ChatService = (() => {
144
160
  /** Re-run queued sends and settle half-finished conversations on startup. */
145
161
  async recover() {
146
162
  await this.store.ensure();
163
+ // Legacy records without seqHighWater get normalized once (mock the
164
+ // monotonic identity for regenerations and future allocations).
165
+ await this.store.normalizeSeqHighWaters();
147
166
  const records = await this.store.list();
148
167
  for (const record of records) {
149
168
  if (record.running) {
@@ -168,6 +187,7 @@ let ChatService = (() => {
168
187
  messageCount: (await this.store.readMessages(record.conversationId)).length,
169
188
  // Read-only: never create a runner just to answer a list query.
170
189
  running: record.running || (this.runners.get(record.conversationId)?.isRunning() ?? false),
190
+ archived: record.archived === true,
171
191
  })));
172
192
  return { ok: true, value: { items } };
173
193
  }
@@ -192,14 +212,16 @@ let ChatService = (() => {
192
212
  systemPrompt: null,
193
213
  queue: [],
194
214
  running: false,
215
+ seqHighWater: 0,
195
216
  };
196
217
  await this.store.put(record);
197
218
  return { ok: true, value: { conversationId: record.conversationId } };
198
219
  }
199
220
  /** Rename a conversation. */
200
221
  async rename(conversationId, title) {
201
- const record = await this.store.require(conversationId);
202
- await this.store.put({ ...record, title, updatedAt: Date.now() });
222
+ // Atomic record mutation: concurrent archive/select must not restore a
223
+ // stale renamed title (review P2).
224
+ await this.store.mutateRecord(conversationId, record => ({ ...record, title, updatedAt: Date.now() }));
203
225
  return { ok: true, value: {} };
204
226
  }
205
227
  /** Delete a conversation and its message log; idempotent. */
@@ -220,35 +242,220 @@ let ChatService = (() => {
220
242
  /**
221
243
  * Send one user turn into a conversation: the user message persists and is
222
244
  * anchored through `chat/message`, then its turn joins the FIFO queue.
245
+ * Image parts are admitted against the deployment attachment limits and
246
+ * promoted to durable references on the persisted message (design §3).
223
247
  */
224
248
  async send(conversationId, content) {
225
249
  const record = await this.store.require(conversationId);
226
- const blocks = validateTextBlocks(content);
227
- if (blocks.length === 0) {
228
- throw new ChatServiceError('invalid-request', 'chat: send needs at least one text block');
250
+ const parts = validateContentParts(content);
251
+ if (parts.length === 0) {
252
+ throw new ChatServiceError('invalid-request', 'chat: send needs at least one content part');
253
+ }
254
+ const imageParts = parts.filter(part => part.type === 'image');
255
+ const attachments = this.ctx.get('attachments');
256
+ if (imageParts.length > 0) {
257
+ if (attachments === undefined) {
258
+ throw new ChatServiceError('image-unsupported', 'chat: host attachment service is unavailable');
259
+ }
260
+ let info;
261
+ try {
262
+ info = await this.resolveModelInfo(record.provider, record.model);
263
+ }
264
+ catch {
265
+ // 未知/不可达:fail-closed(与 sessions 准入一致——拒绝发图并报错呈现)。
266
+ }
267
+ if (!this.imageInputAllowed(info)) {
268
+ throw new ChatServiceError('image-unsupported', `chat: model "${record.model}" does not support image input`);
269
+ }
270
+ }
271
+ const seq = await this.store.reserveTurnSeq(conversationId);
272
+ let blocks = [];
273
+ if (imageParts.length > 0 && attachments !== undefined) {
274
+ // Image parts are admitted in order; the durable refs replace the upload
275
+ // bytes on the persisted message (cheap to page back through chat/attachment).
276
+ const refs = await admitEncodedImages(attachments, imageParts.map(image => ({
277
+ mediaType: image.mediaType,
278
+ data: image.data,
279
+ ...image.name === undefined ? {} : { name: image.name },
280
+ })));
281
+ let next = 0;
282
+ for (const part of parts) {
283
+ if (part.type === 'text') {
284
+ blocks.push({ type: 'text', text: part.text });
285
+ }
286
+ else {
287
+ const ref = refs[next];
288
+ next += 1;
289
+ blocks.push({ type: 'image', attachment: toChatImageRef(ref) });
290
+ }
291
+ }
292
+ }
293
+ else {
294
+ blocks = parts
295
+ .filter((part) => part.type === 'text')
296
+ .map(part => ({ type: 'text', text: part.text }));
229
297
  }
230
- const history = await this.store.readMessages(conversationId);
231
298
  const message = {
232
- seq: nextSeq(history),
299
+ seq,
233
300
  role: 'user',
234
301
  blocks,
235
302
  createdAt: Date.now(),
236
303
  };
237
304
  await this.store.appendMessage(conversationId, message);
238
305
  this.emitMessage(conversationId, message);
239
- const title = record.title ?? deriveTitle(blocks.map(block => block.text).join(''));
306
+ const derived = deriveTitle(parts.filter((part) => part.type === 'text').map(part => part.text).join(''));
307
+ // 标题落锁内决定:send 早先快照允许并发 rename 抢在前头,派生标题若在
308
+ // 快照上求值会覆盖用户命名——在 mutate 回调里以锁内当前记录为准。
240
309
  // Bind the queued send to its message seq: a queued turn's context must
241
- // not include LATER queued user messages (boundary in runTurn).
310
+ // not include LATER queued user messages (boundary in runTurn). The queue
311
+ // append is an atomic read-modify-write — a concurrent send must never
312
+ // lose this entry to an outdated snapshot (store.mutateRecord).
242
313
  const pending = { seq: message.seq, content: blocks, createdAt: Date.now() };
243
- await this.store.put({
314
+ await this.store.mutateRecord(conversationId, record => ({
244
315
  ...record,
245
- title,
316
+ title: record.title ?? derived,
246
317
  updatedAt: Date.now(),
247
318
  queue: [...record.queue, pending],
319
+ }));
320
+ this.runnerFor(conversationId).enqueue(pending);
321
+ return { ok: true, value: { accepted: true } };
322
+ }
323
+ /**
324
+ * Toggle a conversation's archive flag (M4). Archived conversations keep
325
+ * their messages and settings; the client hides them from the active view.
326
+ */
327
+ async archive(conversationId, archived) {
328
+ await this.store.require(conversationId);
329
+ await this.store.mutateRecord(conversationId, record => ({
330
+ ...record,
331
+ archived,
332
+ updatedAt: Date.now(),
333
+ }));
334
+ return { ok: true, value: {} };
335
+ }
336
+ /**
337
+ * Delete a single message (design §2.3): the host rewrites the message
338
+ * file, so subsequent context assembly excludes it. Queued sends bound to
339
+ * the deleted seq are dropped too; an in-flight turn keeps its assembled
340
+ * context and settles normally. Idempotent: absent seq reports removed:
341
+ * false without error.
342
+ */
343
+ async deleteMessage(conversationId, seq) {
344
+ await this.store.require(conversationId);
345
+ const result = await this.store.deleteMessage(conversationId, seq);
346
+ // Both queue mirrors must stay in sync: the runner's in-memory queue and
347
+ // the persisted index queue (a queued turn for a deleted user message
348
+ // would otherwise run as an orphan response — review 🔴).
349
+ this.runners.get(conversationId)?.removeQueued(seq);
350
+ if (result.removed) {
351
+ await this.store.mutateRecord(conversationId, record => ({
352
+ ...record,
353
+ queue: record.queue.filter(pending => pending.seq !== seq),
354
+ updatedAt: Date.now(),
355
+ }));
356
+ }
357
+ return { ok: true, value: { removed: result.removed } };
358
+ }
359
+ /**
360
+ * Regenerate the last assistant message (design §3): remove it, then rerun
361
+ * the last user turn with the same context boundary. Rejected while a turn
362
+ * is running or when there is no assistant message to regenerate.
363
+ */
364
+ async regenerate(conversationId) {
365
+ const record = await this.store.require(conversationId);
366
+ const runner = this.runners.get(conversationId);
367
+ // A running turn or an existing queue means the conversation's tail is
368
+ // not stable: regenerating would reorder the timeline under the client
369
+ // (review 🟡#11). The client only offers regenerate on the last settled
370
+ // assistant message and never while running.
371
+ if (record.running || record.queue.length > 0 || (runner?.isRunning() ?? false)) {
372
+ throw new ChatServiceError('invalid-request', 'chat: a turn is running or queued');
373
+ }
374
+ // One serialized state transition (review P1): tail check + deletion +
375
+ // queue insert under the store write lock—competing regenerates/sends see
376
+ // the previous op's outcome instead of a stale view.
377
+ const pending = await this.store.mutateConversation(conversationId, async (record, history) => {
378
+ const target = regenerateTarget(history);
379
+ if (target === undefined || target.assistant === undefined) {
380
+ throw new ChatServiceError('invalid-request', 'chat: no assistant message to regenerate');
381
+ }
382
+ if (target.user === undefined) {
383
+ throw new ChatServiceError('invalid-request', 'chat: no user message to regenerate against');
384
+ }
385
+ // The user message whose turn is regenerated must be the newest user
386
+ // message; otherwise an unanswered user message exists above it and the
387
+ // regenerated answer would land before it (timeline confusion).
388
+ const newerUser = history.some(message => message.role === 'user' && message.seq > target.assistant.seq);
389
+ if (newerUser) {
390
+ throw new ChatServiceError('invalid-request', 'chat: a newer user message exists');
391
+ }
392
+ const pending = { seq: target.user.seq, content: target.user.blocks, createdAt: Date.now() };
393
+ return {
394
+ nextRecord: {
395
+ ...record,
396
+ queue: [...record.queue, pending],
397
+ updatedAt: Date.now(),
398
+ },
399
+ nextMessages: history.filter(message => message.seq !== target.assistant.seq),
400
+ result: pending,
401
+ };
248
402
  });
249
403
  this.runnerFor(conversationId).enqueue(pending);
250
404
  return { ok: true, value: { accepted: true } };
251
405
  }
406
+ /**
407
+ * Model capability probe for the conversation's current selection
408
+ * (adapter metadata; design §4.6): the client preflights image sending
409
+ * instead of discovering a refusal after the bytes were picked.
410
+ */
411
+ async capabilities(conversationId) {
412
+ const record = await this.store.require(conversationId);
413
+ let info;
414
+ try {
415
+ info = await this.resolveModelInfo(record.provider, record.model);
416
+ }
417
+ catch {
418
+ // 探测失败不抛:预检以保守结论(不支持/未知窗口)呈现,客户端可重试。
419
+ }
420
+ const imageInput = info !== undefined
421
+ && this.imageInputAllowed(info)
422
+ && this.ctx.get('attachments') !== undefined;
423
+ return {
424
+ ok: true,
425
+ value: {
426
+ provider: record.provider,
427
+ model: record.model,
428
+ imageInput,
429
+ contextWindow: info?.context?.contextWindow ?? null,
430
+ },
431
+ };
432
+ }
433
+ /**
434
+ * Read one image's bytes after proving the conversation's message log
435
+ * references its attachment id (mirror of `session.attachment`'s
436
+ * authorization discipline; design §3 读图).
437
+ */
438
+ async attachment(conversationId, attachmentId) {
439
+ await this.store.require(conversationId);
440
+ const attachments = this.ctx.get('attachments');
441
+ if (attachments === undefined) {
442
+ throw new ChatServiceError('image-unsupported', 'chat: host attachment service is unavailable');
443
+ }
444
+ const messages = await this.store.readMessages(conversationId);
445
+ const ref = findImageRef(messages, attachmentId);
446
+ if (ref === undefined) {
447
+ throw new ChatServiceError('invalid-request', 'chat: conversation does not reference this attachment');
448
+ }
449
+ try {
450
+ const stored = await attachments.readImage(toAttachmentRef(ref));
451
+ return { ok: true, value: { attachment: toChatImageRef(stored.ref), data: Buffer.from(stored.data).toString('base64') } };
452
+ }
453
+ catch (error) {
454
+ // AttachmentError 携带稳定 code(INVALID_ATTACHMENT_REF 等);typert 网关
455
+ // 按消息投射——统一包装为 ChatServiceError 保 code 稳定(评审 ⚪#8)。
456
+ throw new ChatServiceError('invalid-request', `chat: cannot read attachment: ${error.message}`);
457
+ }
458
+ }
252
459
  /** Abort the in-flight turn (frozen partial is persisted); idempotent. */
253
460
  async cancel(conversationId) {
254
461
  await this.store.require(conversationId);
@@ -260,39 +467,50 @@ let ChatService = (() => {
260
467
  if (provider.length === 0 || model.length === 0) {
261
468
  throw new ChatServiceError('invalid-request', 'chat: provider and model must be non-empty');
262
469
  }
263
- const record = await this.store.require(conversationId);
264
- await this.store.put({ ...record, provider, model, updatedAt: Date.now() });
470
+ await this.store.mutateRecord(conversationId, record => ({ ...record, provider, model, updatedAt: Date.now() }));
265
471
  return { ok: true, value: { provider, model } };
266
472
  }
267
473
  /** Update conversation settings; `null` in the patch unsets a field. */
268
474
  async update(conversationId, patch) {
269
- const record = await this.store.require(conversationId);
270
- const systemPrompt = patch.systemPrompt === undefined ? record.systemPrompt : patch.systemPrompt;
271
- if (systemPrompt !== null && systemPrompt.length > MAX_SYSTEM_PROMPT_CHARS) {
272
- throw new ChatServiceError('invalid-request', `chat: system prompt exceeds ${MAX_SYSTEM_PROMPT_CHARS} characters`);
273
- }
274
- const temperature = patch.temperature === undefined ? record.temperature : patch.temperature;
275
- if (temperature !== null && (!Number.isFinite(temperature) || temperature < 0 || temperature > 2)) {
276
- throw new ChatServiceError('invalid-request', 'chat: temperature must be within 0..2');
277
- }
278
- const contextMessages = patch.contextMessages === undefined ? record.contextMessages : patch.contextMessages;
279
- if (contextMessages !== null && (!Number.isInteger(contextMessages) || contextMessages <= 0)) {
280
- throw new ChatServiceError('invalid-request', 'chat: contextMessages must be a positive integer');
281
- }
282
- const updatedAt = Date.now();
283
- const next = {
284
- ...record,
285
- systemPrompt,
286
- temperature,
287
- contextMessages,
288
- updatedAt,
289
- };
290
- await this.store.put(next);
475
+ // 整个合并在写锁内完成(读当前值 + 校验 + 写回):先前 require 快照与
476
+ // mutate 分离的读改写会让两个并发 update 互相覆盖(与 rename/archive
477
+ // P2 同类);校验抛错发生在锁内,写文件前终止,不留半更新。
478
+ const next = await this.store.mutateRecord(conversationId, record => {
479
+ const systemPrompt = patch.systemPrompt === undefined ? record.systemPrompt : patch.systemPrompt;
480
+ if (systemPrompt !== null && systemPrompt.length > MAX_SYSTEM_PROMPT_CHARS) {
481
+ throw new ChatServiceError('invalid-request', `chat: system prompt exceeds ${MAX_SYSTEM_PROMPT_CHARS} characters`);
482
+ }
483
+ const temperature = patch.temperature === undefined ? record.temperature : patch.temperature;
484
+ if (temperature !== null && (!Number.isFinite(temperature) || temperature < 0 || temperature > 2)) {
485
+ throw new ChatServiceError('invalid-request', 'chat: temperature must be within 0..2');
486
+ }
487
+ const contextMessages = patch.contextMessages === undefined ? record.contextMessages : patch.contextMessages;
488
+ if (contextMessages !== null && (!Number.isInteger(contextMessages) || contextMessages <= 0)) {
489
+ throw new ChatServiceError('invalid-request', 'chat: contextMessages must be a positive integer');
490
+ }
491
+ return { ...record, systemPrompt, temperature, contextMessages, updatedAt: Date.now() };
492
+ });
291
493
  return { ok: true, value: settingsView(next) };
292
494
  }
293
495
  defaultSelection() {
294
496
  return this.ctx.get('agentDefaultModel')?.currentSelection();
295
497
  }
498
+ /** Adapter metadata for the conversation's model (structural llm seam). */
499
+ async resolveModelInfo(provider, model) {
500
+ return this.ctx.llm.resolveModelInfo(provider, model);
501
+ }
502
+ /**
503
+ * Whether the model accepts image input. Unknown capability is a refusal
504
+ * (fail-closed, mirror of the session image admission preflight outcome:
505
+ * an unresolvable adapter must not silently accept an image request).
506
+ */
507
+ imageInputAllowed(info) {
508
+ // Fail-closed: only an explicit 'image' modality is treated as capable;
509
+ // unknown (absent) capability is a refusal (review P2).
510
+ return info !== undefined
511
+ && info.inputModalities !== undefined
512
+ && info.inputModalities.includes('image');
513
+ }
296
514
  runnerFor(conversationId) {
297
515
  let runner = this.runners.get(conversationId);
298
516
  if (runner === undefined) {
@@ -324,18 +542,22 @@ let ChatService = (() => {
324
542
  }
325
543
  /** Persist turn-start bookkeeping: shift the queued send and mark running. */
326
544
  async turnStart(conversationId) {
327
- const record = await this.store.require(conversationId);
328
- await this.store.put({
545
+ // Atomic read-modify-write: a concurrent chat/send must not lose its
546
+ // queue append to this outdated snapshot (store.mutateRecord).
547
+ await this.store.mutateRecord(conversationId, record => ({
329
548
  ...record,
330
549
  running: true,
331
550
  queue: record.queue.slice(1),
332
551
  updatedAt: Date.now(),
333
- });
552
+ }));
334
553
  }
335
554
  /** Persist turn-end bookkeeping: release the running flag. */
336
555
  async turnEnd(conversationId) {
337
- const record = await this.store.require(conversationId);
338
- await this.store.put({ ...record, running: false, updatedAt: Date.now() });
556
+ await this.store.mutateRecord(conversationId, record => ({
557
+ ...record,
558
+ running: false,
559
+ updatedAt: Date.now(),
560
+ }));
339
561
  }
340
562
  emitMessage(conversationId, message) {
341
563
  this.bus.publish('chat/message', [conversationId, message.seq, message]);
@@ -351,16 +573,73 @@ function clampPage(maxMessages) {
351
573
  return DEFAULT_HISTORY_PAGE;
352
574
  return Math.min(Math.max(Math.trunc(maxMessages), 1), MAX_HISTORY_PAGE);
353
575
  }
354
- /** Accept only text blocks on the v1 wire; anything else is rejected up front. */
355
- function validateTextBlocks(content) {
356
- const blocks = [];
357
- for (const block of content) {
358
- if (typeof block !== 'object' || block === null || block.type !== 'text' || typeof block.text !== 'string') {
359
- throw new ChatServiceError('unsupported-block', 'chat: v1 accepts text blocks only');
360
- }
361
- blocks.push({ type: 'text', text: block.text });
576
+ /** Accepted raster media types (mirror of the image admission enum). */
577
+ const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
578
+ /** Accept text and image content parts; anything else is rejected up front. */
579
+ function validateContentParts(content) {
580
+ const parts = [];
581
+ for (const part of content) {
582
+ if (typeof part !== 'object' || part === null) {
583
+ throw new ChatServiceError('unsupported-block', 'chat: content parts must be objects');
584
+ }
585
+ if (part.type === 'text') {
586
+ if (typeof part.text !== 'string') {
587
+ throw new ChatServiceError('unsupported-block', 'chat: text part needs a string text');
588
+ }
589
+ parts.push({ type: 'text', text: part.text });
590
+ }
591
+ else if (part.type === 'image') {
592
+ if (typeof part.mediaType !== 'string' || typeof part.data !== 'string' || part.data.length === 0) {
593
+ throw new ChatServiceError('unsupported-block', 'chat: image part needs mediaType and data');
594
+ }
595
+ if (!IMAGE_MEDIA_TYPES.includes(part.mediaType)) {
596
+ throw new ChatServiceError('unsupported-block', `chat: unsupported image media type: ${part.mediaType}`);
597
+ }
598
+ parts.push({
599
+ type: 'image',
600
+ mediaType: part.mediaType,
601
+ data: part.data,
602
+ ...part.name === undefined ? {} : { name: part.name },
603
+ });
604
+ }
605
+ else {
606
+ throw new ChatServiceError('unsupported-block', 'chat: unknown content part type');
607
+ }
608
+ }
609
+ return parts;
610
+ }
611
+ /** Map a durable harness image ref onto the chat wire ref (subset shape). */
612
+ function toChatImageRef(ref) {
613
+ return {
614
+ attachmentId: ref.attachmentId,
615
+ mediaType: ref.mediaType,
616
+ bytes: ref.bytes,
617
+ width: ref.width,
618
+ height: ref.height,
619
+ ...ref.name === undefined ? {} : { name: ref.name },
620
+ };
621
+ }
622
+ /** Map a chat wire ref back onto the harness ref shape for store reads. */
623
+ function toAttachmentRef(ref) {
624
+ return {
625
+ attachmentId: ref.attachmentId,
626
+ mediaType: ref.mediaType,
627
+ bytes: ref.bytes,
628
+ width: ref.width,
629
+ height: ref.height,
630
+ ...ref.name === undefined ? {} : { name: ref.name },
631
+ };
632
+ }
633
+ /** Find the chat message's image ref by attachment id (authorization walk). */
634
+ function findImageRef(messages, attachmentId) {
635
+ for (const message of messages) {
636
+ for (const block of message.blocks) {
637
+ if (block.type === 'image' && block.attachment.attachmentId === attachmentId) {
638
+ return block.attachment;
639
+ }
640
+ }
362
641
  }
363
- return blocks;
642
+ return undefined;
364
643
  }
365
644
  function settingsView(record) {
366
645
  return {
package/lib/runner.d.ts CHANGED
@@ -40,6 +40,7 @@ export interface LlmLike {
40
40
  readonly context?: {
41
41
  readonly contextWindow: number;
42
42
  };
43
+ readonly inputModalities?: readonly string[];
43
44
  }>;
44
45
  }
45
46
  /** Event sink for the forwarded chat triplet (host allowlist). */
@@ -59,12 +60,12 @@ export interface TurnRecord {
59
60
  /** Storage interface the runner needs (subset of {@link ChatStore}). */
60
61
  export interface RunnerStore {
61
62
  readMessages(conversationId: string): Promise<ChatMessage[]>;
63
+ /** Allocate the next monotonic seq (durable high-water; never reused). */
64
+ reserveTurnSeq(conversationId: string): Promise<number>;
62
65
  appendMessage(conversationId: string, message: ChatMessage): Promise<{
63
66
  trimmed: boolean;
64
67
  }>;
65
68
  }
66
- /** Next monotonic message sequence for a conversation's persisted history. */
67
- export declare function nextSeq(messages: readonly ChatMessage[]): number;
68
69
  /**
69
70
  * Serialized per-conversation turn machine: sends enqueue; the queue drains
70
71
  * one turn at a time; cancel aborts only the in-flight turn.
@@ -92,6 +93,16 @@ export declare class ChatTurnRunner {
92
93
  enqueue(send: PendingSend): void;
93
94
  /** Abort the in-flight turn; returns whether one was running. */
94
95
  cancel(): boolean;
96
+ /**
97
+ * Remove one still-queued send (mirror of the index queue filter in
98
+ * `chat/deleteMessage`): the runner's in-memory queue and the persisted
99
+ * index queue must never drift, or a deleted user message would still be
100
+ * answered with an orphan assistant turn. In-flight sends are never
101
+ * touched (their turn settles; deletion of their context boundary is
102
+ * handled by the runTurn existence check).
103
+ * @returns whether a queued send with `seq` was removed.
104
+ */
105
+ removeQueued(seq: number): boolean;
95
106
  /** Stop queue advances; an in-flight turn still settles before the runner is dropped. */
96
107
  dispose(): void;
97
108
  private drain;
package/lib/runner.js CHANGED
@@ -20,15 +20,6 @@ function mapFinishKind(kind) {
20
20
  default: return 'completed';
21
21
  }
22
22
  }
23
- /** Next monotonic message sequence for a conversation's persisted history. */
24
- export function nextSeq(messages) {
25
- let last = 0;
26
- for (const message of messages) {
27
- if (message.seq > last)
28
- last = message.seq;
29
- }
30
- return last + 1;
31
- }
32
23
  /**
33
24
  * Serialized per-conversation turn machine: sends enqueue; the queue drains
34
25
  * one turn at a time; cancel aborts only the in-flight turn.
@@ -71,6 +62,22 @@ export class ChatTurnRunner {
71
62
  this.controller.abort();
72
63
  return true;
73
64
  }
65
+ /**
66
+ * Remove one still-queued send (mirror of the index queue filter in
67
+ * `chat/deleteMessage`): the runner's in-memory queue and the persisted
68
+ * index queue must never drift, or a deleted user message would still be
69
+ * answered with an orphan assistant turn. In-flight sends are never
70
+ * touched (their turn settles; deletion of their context boundary is
71
+ * handled by the runTurn existence check).
72
+ * @returns whether a queued send with `seq` was removed.
73
+ */
74
+ removeQueued(seq) {
75
+ const index = this.queue.findIndex(send => send.seq === seq);
76
+ if (index < 0)
77
+ return false;
78
+ this.queue.splice(index, 1);
79
+ return true;
80
+ }
74
81
  /** Stop queue advances; an in-flight turn still settles before the runner is dropped. */
75
82
  dispose() {
76
83
  this.disposed = true;
@@ -105,14 +112,20 @@ export class ChatTurnRunner {
105
112
  async runTurn(send) {
106
113
  const record = await this.deps.record();
107
114
  const all = await this.deps.store.readMessages(this.conversationId);
115
+ // The send's user message was deleted while this send waited in the
116
+ // queue: the turn has no prompt to answer — skip it silently (the index
117
+ // queue mirror is already filtered; this guards the in-memory queue).
118
+ if (!all.some(message => message.seq === send.seq && message.role === 'user')) {
119
+ return;
120
+ }
108
121
  // Context boundary: this turn answers only its own user message. Later
109
122
  // (queued) user messages stay out of the prompt until their own turn.
110
- const history = typeof send.seq === 'number'
111
- ? all.filter(message => message.seq <= send.seq)
112
- : all;
123
+ const history = all.filter(message => message.seq <= send.seq);
113
124
  const window = await this.resolveWindow(record);
114
125
  const assembled = assembleContext(history, record.contextMessages, window);
115
- const seq = nextSeq(history);
126
+ // Durable monotonic seq identity: reserved (never reused) even when this
127
+ // turn later aborts — the high-water mark only ever advances.
128
+ const seq = await this.deps.store.reserveTurnSeq(this.conversationId);
116
129
  const controller = new AbortController();
117
130
  this.controller = controller;
118
131
  this.emitStatus({ running: true, turn: seq });
@@ -180,7 +193,7 @@ export class ChatTurnRunner {
180
193
  ? [{ type: 'text', text }]
181
194
  : [];
182
195
  // An empty abort persists nothing (nothing was frozen to settle).
183
- if (this.disposed || (finishReason === 'aborted' && text.length === 0)) {
196
+ if (finishReason === 'aborted' && text.length === 0) {
184
197
  this.emitStatus({ running: false, turn: seq, reason: 'aborted' });
185
198
  return;
186
199
  }