@yeaft/webchat-agent 1.0.623 → 1.0.624

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.
@@ -10,6 +10,7 @@ import { attachmentMetadata, attachmentRequestHash, validateFiles } from './atta
10
10
  import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
11
11
  import { chronologicalCursor, conceptView, inspectionCursor, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
12
12
  import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
13
+ import { personPresentation } from './presentation.js';
13
14
  import { inspectCapabilities } from './capabilities.js';
14
15
  import { OUTPUT_LIMITS, outputArgs, outputMime, outputReadRequest, outputsPage, outputUrl, outputView } from './outputs.js';
15
16
 
@@ -65,7 +66,7 @@ const objectOutput = (record, data) => {
65
66
  }
66
67
  };
67
68
  const READS = new Set(['outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
68
- const WRITES = new Set(['executionControl', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
69
+ const WRITES = new Set(['executionControl', 'savePresentation', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
69
70
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
70
71
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
71
72
  const sequence = value => {
@@ -292,7 +293,7 @@ export class SqlitePersonStore {
292
293
  }
293
294
  append(episode, kind, data) {
294
295
  // Call proof/publication events are emitted only by their transactional methods.
295
- if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published'].includes(kind)) fail('INVALID_REQUEST');
296
+ if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published', 'presentation_published'].includes(kind)) fail('INVALID_REQUEST');
296
297
  const p = this.own(episode);
297
298
  const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
298
299
  if (!record || record.status !== 'running') fail('STALE');
@@ -400,6 +401,48 @@ export class SqlitePersonStore {
400
401
  state: stateView(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(messageView),
401
402
  concepts: [...new Map([...focused, ...recent].map(c => [c.id, conceptView(c)])).values()] };
402
403
  }
404
+ /** Atomic capability publication: exact started invocation + live lease, never
405
+ * publishProgress's no-open-capability path. JSON carries the canonical payload;
406
+ * text remains the searchable/model/legacy fallback. phase is delivery intent. */
407
+ savePresentation(episode, { callId, args }) {
408
+ identifier(callId);
409
+ const presentation = personPresentation(args);
410
+ const p = this.own(episode), scope = this.episodeScope(episode);
411
+ const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
412
+ const invocationHash = digest({ id: 'PresentResponse', args });
413
+ const prior = this.rows('messages', scope, " AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episode.id]);
414
+ const receipt = this.one('traces', scope,
415
+ " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'presentation_published' AND json_extract(record, '$.callId') = ?", [episode.id, callId]);
416
+ if (receipt) {
417
+ if (receipt.invocationHash !== invocationHash) fail('INVALID_REQUEST');
418
+ const message = prior.find(m => m.id === receipt.messageId);
419
+ if (!message) fail('STORAGE_UNAVAILABLE');
420
+ return messageView(message);
421
+ }
422
+ const proof = this.one('traces', scope,
423
+ " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_output' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.output.complete') = 1", [episode.id, callId]);
424
+ if (!record || record.status !== 'running' || record.openCall || !proof || record.openCapability?.callId !== callId ||
425
+ record.openCapability.invocationHash !== invocationHash) fail('STALE');
426
+ // Repeating an identical delivery in this episode adds no new visible answer.
427
+ const repeated = prior.find(m => digest(m.presentation) === digest(presentation));
428
+ if (repeated) {
429
+ this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: repeated.id, phase: presentation.phase, duplicate: true });
430
+ return messageView(repeated);
431
+ }
432
+ p.messageSeq++; p.writeSerial++; this.put('persons', p);
433
+ const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
434
+ callId, publicationHash: invocationHash, replyKind: 'presentation', role: 'assistant',
435
+ text: presentation.summary, presentation, createdAt: this.now });
436
+ this.put('messages', message, true); this.journal(scope, 'messages', message);
437
+ record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
438
+ this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: message.id, phase: presentation.phase });
439
+ return messageView(message);
440
+ }
441
+ repeatsPresentation(scope, episodeId, reply) {
442
+ return typeof reply === 'string' && this.rows('messages', scope,
443
+ " AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episodeId])
444
+ .some(message => message.text.trim() === reply.trim());
445
+ }
403
446
  /** Publish only a validated, complete call's reply. This is communication, not
404
447
  * a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
405
448
  publishProgress(episode, { callId, reply }) {
@@ -417,7 +460,7 @@ export class SqlitePersonStore {
417
460
  if (!accepted || !proposal.next || proposal.reply !== reply) fail('INVALID_REQUEST');
418
461
  // Provider latency counts too: a call started before 30s can finish after it.
419
462
  if (this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs ||
420
- prior.some(m => m.text.trim() === reply.trim())) return null;
463
+ prior.some(m => m.text.trim() === reply.trim()) || this.repeatsPresentation(scope, episode.id, reply)) return null;
421
464
  p.messageSeq++; p.writeSerial++; this.put('persons', p);
422
465
  const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
423
466
  callId, replyKind: 'progress', role: 'assistant', text: reply, createdAt: this.now });
@@ -429,7 +472,8 @@ export class SqlitePersonStore {
429
472
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {
430
473
  const p = this.own(episode), scope = this.episodeScope(episode);
431
474
  if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
432
- p.stateVersion++; p.writeSerial++; if (proposal.reply) p.messageSeq++;
475
+ const publishReply = proposal.reply && !this.repeatsPresentation(scope, episode.id, proposal.reply);
476
+ p.stateVersion++; p.writeSerial++; if (publishReply) p.messageSeq++;
433
477
  p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
434
478
  this.put('persons', p);
435
479
  const revisions = [];
@@ -452,7 +496,7 @@ export class SqlitePersonStore {
452
496
  this.put('states', state);
453
497
  this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
454
498
  episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
455
- if (proposal.reply) {
499
+ if (publishReply) {
456
500
  const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, callId, replyKind: 'final', role: 'assistant', text: proposal.reply, createdAt: this.now });
457
501
  this.put('messages', message, true); this.journal(scope, 'messages', message);
458
502
  }
@@ -0,0 +1,81 @@
1
+ /** Shared, bounded user-visible response contract for native Sessions and Persons.
2
+ * Contains no executable markup, filesystem access or network requests. Runtime
3
+ * owns identity/phase; summary remains useful without the rich client.
4
+ */
5
+ const str = (maxLength = 2000) => ({ type: 'string', maxLength });
6
+ const arr = (items, maxItems, minItems = 1) => ({ type: 'array', items, minItems, maxItems });
7
+ const obj = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
8
+ const block = (type, properties, required) => obj({ type: { type: 'string', enum: [type] }, title: str(160), ...properties }, ['type', ...required]);
9
+ export const PRESENTATION_SCHEMA = obj({
10
+ title: str(160),
11
+ summary: str(8000),
12
+ phase: { type: 'string', enum: ['progress', 'final'] },
13
+ blocks: arr({ anyOf: [
14
+ block('markdown', { text: str(12000) }, ['text']),
15
+ block('cards', { items: arr(obj({ title: str(160), text: str(2000), label: str(80) }, ['title', 'text']), 8) }, ['items']),
16
+ block('metrics', { items: arr(obj({ label: str(120), value: str(120), detail: str(240) }, ['label', 'value']), 8) }, ['items']),
17
+ block('table', { columns: arr(str(120), 8), rows: arr(arr(str(1000), 8, 1), 50) }, ['columns', 'rows']),
18
+ block('chart', { style: { type: 'string', enum: ['bar', 'line'] }, unit: str(60), series: arr(obj({ label: str(120), values: arr({ type: 'number' }, 40) }, ['label', 'values']), 4), labels: arr(str(120), 40) }, ['style', 'series', 'labels']),
19
+ block('timeline', { items: arr(obj({ label: str(120), title: str(160), text: str(2000) }, ['label', 'title', 'text']), 16) }, ['items']),
20
+ block('diagram', { nodes: arr(obj({ id: str(40), label: str(160) }, ['id', 'label']), 12), edges: arr(obj({ from: str(40), to: str(40), label: str(120) }, ['from', 'to']), 24, 0) }, ['nodes', 'edges']),
21
+ block('image', { url: str(2000), alt: str(240), caption: str(1000) }, ['url', 'alt']),
22
+ block('link', { url: str(2000), text: str(1000) }, ['url', 'text']),
23
+ block('map', { points: arr(obj({ label: str(160), latitude: { type: 'number', minimum: -90, maximum: 90 }, longitude: { type: 'number', minimum: -180, maximum: 180 }, detail: str(1000) }, ['label', 'latitude', 'longitude']), 20) }, ['points']),
24
+ ] }, 12),
25
+ }, ['summary', 'blocks']);
26
+
27
+ export const PRESENT_RESPONSE_DESCRIPTION = `Publish a rich user-visible response inside this turn: cards, metrics, comparison tables, bar/line charts, timelines, relationship diagrams, images, artifact links or coordinate maps. Optional: use normal text for simple answers. Supply a self-contained summary for copying, search, model history and older clients; do not repeat the whole rich answer afterwards. Use only verified data, mark uncertainty, and never fabricate chart values or coordinates. No HTML/JavaScript, arbitrary SVG, styling or executable content. Image/link URLs must be credential-free HTTP(S); images require the reader to load external content. Maps are coordinate sketches, not navigation maps, and do not fetch tiles. phase=progress continues work; phase=final is a delivery intent, not proof of task success. Runtime still owns persistence, cancellation and turn termination. Validation failure publishes nothing; correct the payload or answer in text.`;
28
+
29
+ function check(value, schema, path) {
30
+ if (schema.anyOf) {
31
+ const candidate = schema.anyOf.find(s => value?.type === s.properties.type.enum[0]);
32
+ if (!candidate) throw new Error(`${path}: unsupported block type`);
33
+ return check(value, candidate, path);
34
+ }
35
+ if (schema.type === 'object') {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${path}: expected object`);
37
+ for (const key of Object.keys(value)) if (!Object.hasOwn(schema.properties, key)) throw new Error(`${path}.${key}: unknown field`);
38
+ for (const key of schema.required) if (!Object.hasOwn(value, key)) throw new Error(`${path}.${key}: required`);
39
+ for (const [key, item] of Object.entries(value)) check(item, schema.properties[key], `${path}.${key}`);
40
+ } else if (schema.type === 'array') {
41
+ if (!Array.isArray(value) || value.length < schema.minItems || value.length > schema.maxItems) throw new Error(`${path}: invalid item count`);
42
+ value.forEach((item, i) => check(item, schema.items, `${path}[${i}]`));
43
+ } else if (schema.type === 'string') {
44
+ if (typeof value !== 'string' || !value.trim() || value.length > schema.maxLength) throw new Error(`${path}: expected non-empty bounded text`);
45
+ if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: unsupported value`);
46
+ } else if (schema.type === 'number') {
47
+ if (typeof value !== 'number' || !Number.isFinite(value) || Math.abs(value) > 1e12 || (schema.minimum !== undefined && value < schema.minimum) || (schema.maximum !== undefined && value > schema.maximum)) throw new Error(`${path}: invalid finite number`);
48
+ }
49
+ }
50
+
51
+ /** Return a canonical JSON-only snapshot; rejects rather than partially publishing. */
52
+ export function normalizePresentation(input) {
53
+ check(input, PRESENTATION_SCHEMA, 'response');
54
+ const encoded = JSON.stringify(input);
55
+ if (new TextEncoder().encode(encoded).length > 49152) throw new Error('response: exceeds 48 KiB');
56
+ const value = JSON.parse(encoded);
57
+ for (const b of value.blocks) {
58
+ if (b.type === 'table' && b.rows.some(row => row.length !== b.columns.length)) throw new Error('table: every row must match the columns');
59
+ if (b.type === 'chart' && b.series.some(series => series.values.length !== b.labels.length)) throw new Error('chart: values must match labels');
60
+ if (b.type === 'diagram') {
61
+ const ids = new Set(b.nodes.map(n => n.id));
62
+ if (ids.size !== b.nodes.length || b.edges.some(e => !ids.has(e.from) || !ids.has(e.to))) throw new Error('diagram: unique nodes and existing edge endpoints required');
63
+ }
64
+ if (b.type === 'image' || b.type === 'link') b.url = safePresentationUrl(b.url);
65
+ }
66
+ const canonical = { version: 1, title: value.title || '', summary: value.summary, phase: value.phase || 'final', blocks: value.blocks };
67
+ if (new TextEncoder().encode(JSON.stringify(canonical)).length > 49152) throw new Error('response: exceeds 48 KiB');
68
+ return canonical;
69
+ }
70
+
71
+ /** External URLs are metadata only. Never fetch server-side or accept credentials. */
72
+ export function safePresentationUrl(value) {
73
+ if (typeof value !== 'string' || /[\u0000-\u0020\u007f]/.test(value)) throw new Error('response: invalid URL');
74
+ let url;
75
+ try { url = new URL(value); } catch { throw new Error('response: invalid absolute URL'); }
76
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('response: unsafe URL');
77
+ // Canonical URLs are encoded, so input length alone is not a safe bound.
78
+ // Persisted snapshots must pass exactly the same limits on replay.
79
+ if (url.href.length > 2000) throw new Error('response: URL exceeds 2000 characters');
80
+ return url.href;
81
+ }
@@ -319,6 +319,12 @@ export function createStreamProjector({
319
319
  state.resultText += event.text || '';
320
320
  write({ ...base, type: 'assistant', subtype: 'text_delta', delta: { type: 'text_delta', text: event.text || '' } });
321
321
  break;
322
+ case 'response_presentation':
323
+ state.resultText += event.message?.content || '';
324
+ write({ ...base, type: 'assistant', subtype: 'response_presentation',
325
+ message: event.message, content: [{ type: 'text', text: event.message?.content || '' }],
326
+ presentation: event.message?.presentation });
327
+ break;
322
328
  case 'thinking_delta':
323
329
  write({ ...base, type: 'assistant', subtype: 'thinking_delta', delta: { type: 'thinking_delta', thinking: event.text || '' } });
324
330
  break;
@@ -10,6 +10,7 @@ export const ALWAYS_VISIBLE_TOOL_NAMES = Object.freeze([
10
10
  'EnterWorktree',
11
11
  'ExitWorktree',
12
12
  'AskUser',
13
+ 'PresentResponse',
13
14
  'DiscoverTools',
14
15
  'WebSearch',
15
16
  'WebFetch',
@@ -25,6 +25,7 @@ import exitWorktree from './exit-worktree.js';
25
25
 
26
26
  // --- P0 Core tools ---
27
27
  import askUser from './ask-user.js';
28
+ import presentResponse from './present-response.js';
28
29
  import webSearch from './web-search.js';
29
30
  import webFetch from './web-fetch.js';
30
31
  import historySearch from './history-search.js';
@@ -92,6 +93,7 @@ export const allTools = [
92
93
 
93
94
  // P0 Core
94
95
  askUser,
96
+ presentResponse,
95
97
  webSearch,
96
98
  webFetch,
97
99
  historySearch,
@@ -0,0 +1,27 @@
1
+ import { defineTool } from './types.js';
2
+ import { PRESENTATION_SCHEMA, normalizePresentation, PRESENT_RESPONSE_DESCRIPTION } from '../response-presentation.js';
3
+
4
+ export default defineTool({
5
+ name: 'PresentResponse',
6
+ description: PRESENT_RESPONSE_DESCRIPTION,
7
+ parameters: PRESENTATION_SCHEMA,
8
+ isReadOnly: () => false,
9
+ isConcurrencySafe: () => false,
10
+ cacheWithinQuery: false,
11
+ duplicateCallPolicy: () => 'allow',
12
+ errorOutput: 'json-error-envelope',
13
+ async execute(input, ctx) {
14
+ try {
15
+ // Validate the entire snapshot before invoking any host-side publication.
16
+ const presentation = normalizePresentation(input);
17
+ if (ctx?.signal?.aborted) throw new Error('Response publication was cancelled.');
18
+ if (typeof ctx?.publishResponse !== 'function') {
19
+ throw new Error('Response publication is unavailable in this runtime. Use normal text.');
20
+ }
21
+ const identity = await ctx.publishResponse(presentation);
22
+ return JSON.stringify({ accepted: true, ...identity, message: 'Response accepted for delivery. Continue work if needed; do not repeat the summary. The normal turn boundary still applies.' });
23
+ } catch (error) {
24
+ return JSON.stringify({ error: error.message });
25
+ }
26
+ },
27
+ });
@@ -1396,6 +1396,8 @@ function projectPersistedToHistoryEntry(m, { includeReflections = false } = {})
1396
1396
  entry.threadId = m.threadId || m.turnId || 'main';
1397
1397
  if (m.turnId) entry.turnId = m.turnId;
1398
1398
  if (m.imageAssetAnchor) entry.imageAssetAnchor = true;
1399
+ if (m.presentation) entry.presentation = m.presentation;
1400
+ if (m.presentationCallId) entry.presentationCallId = m.presentationCallId;
1399
1401
  if (m.responseKind === 'progress' || m.responseKind === 'result') entry.responseKind = m.responseKind;
1400
1402
  if (Number.isInteger(m.llmCallCount) && m.llmCallCount > 0) entry.llmCallCount = m.llmCallCount;
1401
1403
  for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'totalMs']) {
@@ -1541,6 +1543,8 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1541
1543
  threadId: m.threadId || m.turnId || 'main',
1542
1544
  ...(m.turnId ? { turnId: m.turnId } : {}),
1543
1545
  ...(m.imageAssetAnchor === true ? { imageAssetAnchor: true } : {}),
1546
+ ...(m.presentation ? { presentation: m.presentation } : {}),
1547
+ ...(m.presentationCallId ? { presentationCallId: m.presentationCallId } : {}),
1544
1548
  ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments, m.sessionId) } : {}),
1545
1549
  ...(m.quote ? { quote: m.quote } : {}),
1546
1550
  ...(Array.isArray(m.images) && m.images.length > 0 ? { images: m.images } : {}),
@@ -1615,6 +1619,7 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
1615
1619
  type: 'assistant',
1616
1620
  message: {
1617
1621
  id: entry.id || null,
1622
+ ...(entry.presentation ? { presentation: entry.presentation, presentationCallId: entry.presentationCallId, responseKind: entry.responseKind } : {}),
1618
1623
  content: [
1619
1624
  ...(entry.content ? [{ type: 'text', text: entry.content }] : []),
1620
1625
  ...((entry.toolCalls || []).map(toolCall => ({ type: 'tool_use', ...toolCall, history: true }))),
@@ -4156,6 +4161,14 @@ function handleEngineEvent(event, hctx) {
4156
4161
  }
4157
4162
  if ((event.type === 'text_delta' || event.type === 'thinking_delta')
4158
4163
  && (typeof event.text !== 'string' || event.text.length === 0)) return;
4164
+ // Reject stale publications before touching timers, queued text or history.
4165
+ if (event.type === 'response_presentation') {
4166
+ const message = event.message;
4167
+ if (hctx.signal?.aborted || !message?.id || !message.presentation
4168
+ || message.sessionId !== hctx.sessionId || message.turnId !== hctx.turnId
4169
+ || message.speakerVpId !== hctx.vpId || message.threadId !== (hctx.threadId || 'main')
4170
+ || hctx.presentations?.some(row => row.id === message.id)) return;
4171
+ }
4159
4172
  const terminalTurnEnd = event.type === 'turn_end' && event.terminal === true;
4160
4173
  const managesQueryTimer = terminalTurnEnd
4161
4174
  || event.type === 'aborted'
@@ -4190,6 +4203,27 @@ function handleEngineEvent(event, hctx) {
4190
4203
  maybeTransitionVpStatus(hctx, 'streaming');
4191
4204
  break;
4192
4205
 
4206
+ case 'response_presentation': {
4207
+ const message = event.message;
4208
+ if (!hctx.presentations) hctx.presentations = [];
4209
+ hctx.presentations.push({ ...message, _textPartsCount: (hctx.assistantTextParts || []).length });
4210
+ hctx.presentationTextPartsCount = (hctx.assistantTextParts || []).length;
4211
+ if (!hctx.skipPartialHistory) {
4212
+ const prompts = hctx.includeInitialPrompt ? [hctx.prompt, ...(hctx.appendedUserPrompts || [])] : (hctx.appendedUserPrompts || []);
4213
+ appendTurnToSessionHistory(hctx.sessionId, hctx.threadId || 'main', hctx.vpId,
4214
+ prompts, hctx.assistantTextParts || [], hctx.toolCallsAccum || [],
4215
+ hctx.toolResultsAccum || [], hctx.thinkingBlocksAccum || [],
4216
+ { turnId: hctx.turnId, partial: true, presentations: hctx.presentations });
4217
+ }
4218
+ sendSessionOutputFrame({
4219
+ type: 'assistant',
4220
+ message: { id: message.id, content: message.content, presentation: message.presentation,
4221
+ presentationCallId: message.presentationCallId, responseKind: 'progress' },
4222
+ ts: message.time || null,
4223
+ }, envelope);
4224
+ break;
4225
+ }
4226
+
4193
4227
  case 'thinking_delta':
4194
4228
  sendSessionEvent({ type: 'thinking_delta', text: event.text }, envelope);
4195
4229
  break;
@@ -4308,7 +4342,7 @@ function handleEngineEvent(event, hctx) {
4308
4342
  hctx.toolCallsAccum || [],
4309
4343
  hctx.toolResultsAccum || [],
4310
4344
  hctx.thinkingBlocksAccum || [],
4311
- { turnId: hctx.turnId, partial: true },
4345
+ { turnId: hctx.turnId, partial: true, presentations: hctx.presentations },
4312
4346
  );
4313
4347
  }
4314
4348
  sendSessionOutputFrame({
@@ -4376,6 +4410,22 @@ function handleEngineEvent(event, hctx) {
4376
4410
  // stop the user-query silence watchdog before that best-effort work.
4377
4411
  if (event.terminal) {
4378
4412
  (hctx.stopQueryTimer || hctx.pauseQueryTimer)?.();
4413
+ const publications = hctx.presentations || [];
4414
+ for (const row of publications) {
4415
+ row.responseKind = 'progress';
4416
+ row.stopReason = event.stopReason;
4417
+ if (event.stopReason !== 'end_turn') row.incomplete = true;
4418
+ }
4419
+ const last = publications.at(-1);
4420
+ if (event.stopReason === 'end_turn' && last?.presentation.phase === 'final'
4421
+ && hctx.presentationTextPartsCount === (hctx.assistantTextParts || []).length) last.responseKind = 'result';
4422
+ if (publications.length && !hctx.skipPartialHistory) {
4423
+ const prompts = hctx.includeInitialPrompt ? [hctx.prompt, ...(hctx.appendedUserPrompts || [])] : (hctx.appendedUserPrompts || []);
4424
+ appendTurnToSessionHistory(hctx.sessionId, hctx.threadId || 'main', hctx.vpId,
4425
+ prompts, hctx.assistantTextParts || [], hctx.toolCallsAccum || [],
4426
+ hctx.toolResultsAccum || [], hctx.thinkingBlocksAccum || [],
4427
+ { turnId: hctx.turnId, partial: true, presentations: publications });
4428
+ }
4379
4429
  }
4380
4430
  // A normal tool_use stop means "run tools, then call the adapter again",
4381
4431
  // so it must NOT end the VP's visible turn. An aborted turn is terminal
@@ -5668,6 +5718,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5668
5718
  pauseQueryTimer,
5669
5719
  touchQueryTimer: watchdog.touch,
5670
5720
  stopQueryTimer: watchdog.stop,
5721
+ signal: vpAbort.signal,
5671
5722
  sessionId,
5672
5723
  vpId,
5673
5724
  turnId,
@@ -5810,7 +5861,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5810
5861
  // block after the target response.
5811
5862
  const visiblePrompts = inboundIsInternal ? appendedUserPrompts : [prompt, ...appendedUserPrompts];
5812
5863
  const appendHistoryStart = perfNowMs();
5813
- appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, { turnId });
5864
+ appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, { turnId, presentations: handlerCtx.presentations });
5814
5865
  if (perfTraceId) {
5815
5866
  recordAgentPerfTrace(ctx.CONFIG, {
5816
5867
  traceId: perfTraceId,
@@ -5993,7 +6044,11 @@ function buildTurnHistoryEntries(threadId, vpId, prompts, assistantTextParts, to
5993
6044
  }
5994
6045
  }
5995
6046
 
5996
- const fullText = assistantTextParts.join('');
6047
+ const presentations = opts.presentations || [];
6048
+ const firstPublicationOffset = presentations[0]?._textPartsCount;
6049
+ const fullText = Number.isInteger(firstPublicationOffset)
6050
+ ? assistantTextParts.slice(0, firstPublicationOffset).join('')
6051
+ : assistantTextParts.join('');
5997
6052
  if (fullText || toolCallsAccum.length > 0) {
5998
6053
  // Stamp speakerVpId on assistant + tool rows so the in-memory
5999
6054
  // baseSnapshot filter (filterSnapshotForVp) can mirror the disk
@@ -6037,6 +6092,19 @@ function buildTurnHistoryEntries(threadId, vpId, prompts, assistantTextParts, to
6037
6092
  entries.push(markEntry(toolMsg));
6038
6093
  }
6039
6094
  }
6095
+ for (let index = 0; index < presentations.length; index += 1) {
6096
+ const record = presentations[index];
6097
+ const entry = projectPersistedToHistoryEntry(record);
6098
+ if (entry) entries.push(markEntry(entry));
6099
+ // Keep later prose after its publication in the runtime cache as well as
6100
+ // on disk. The internal offset is never projected into public history.
6101
+ if (Number.isInteger(record._textPartsCount)) {
6102
+ const end = presentations[index + 1]?._textPartsCount ?? assistantTextParts.length;
6103
+ const text = assistantTextParts.slice(record._textPartsCount, end).join('');
6104
+ if (text) entries.push(markEntry({ role: 'assistant', content: text,
6105
+ threadId: threadId || 'main', ...(vpId ? { speakerVpId: vpId } : {}) }));
6106
+ }
6107
+ }
6040
6108
  return entries;
6041
6109
  }
6042
6110