@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.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +223 -123
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/workbench/file-ops.js +34 -8
- package/yeaft/cli-session-runner.js +1 -0
- package/yeaft/cli.js +4 -0
- package/yeaft/conversation/persist.js +39 -18
- package/yeaft/engine.js +82 -5
- package/yeaft/person/capabilities.js +13 -4
- package/yeaft/person/inspection.js +5 -0
- package/yeaft/person/presentation.js +32 -0
- package/yeaft/person/runtime.js +15 -4
- package/yeaft/person/sqlite-repository.js +1 -0
- package/yeaft/person/sqlite-store.js +49 -5
- package/yeaft/response-presentation.js +81 -0
- package/yeaft/stdio-protocol.js +6 -0
- package/yeaft/tools/activation.js +1 -0
- package/yeaft/tools/index.js +2 -0
- package/yeaft/tools/present-response.js +27 -0
- package/yeaft/web-bridge.js +71 -3
|
@@ -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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/yeaft/stdio-protocol.js
CHANGED
|
@@ -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;
|
package/yeaft/tools/index.js
CHANGED
|
@@ -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
|
+
});
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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
|
|
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
|
|