@modelprofile.com/flexharness 9.10.0 → 10.1.0
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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +2 -0
- package/dist_ts/classes.flexharness.js +101 -7
- package/dist_ts/classes.stores.js +11 -119
- package/dist_ts/errors.d.ts +7 -2
- package/dist_ts/errors.js +1 -1
- package/dist_ts/interfaces.d.ts +1 -0
- package/dist_ts/utils.harnessoptions.js +2 -1
- package/dist_ts/utils.storerules.d.ts +28 -0
- package/dist_ts/utils.storerules.js +107 -0
- package/dist_ts_chat_harness/classes.transcript.d.ts +88 -0
- package/dist_ts_chat_harness/classes.transcript.js +268 -0
- package/dist_ts_chat_harness/functions.intents.d.ts +62 -0
- package/dist_ts_chat_harness/functions.intents.js +96 -0
- package/dist_ts_chat_harness/functions.mapping.d.ts +33 -0
- package/dist_ts_chat_harness/functions.mapping.js +220 -0
- package/dist_ts_chat_harness/index.d.ts +3 -0
- package/dist_ts_chat_harness/index.js +4 -0
- package/dist_ts_chat_harness/plugins.d.ts +1 -0
- package/dist_ts_chat_harness/plugins.js +2 -0
- package/dist_ts_kvm/classes.browserkvm.js +3 -3
- package/dist_ts_kvm/plugins.d.ts +1 -1
- package/dist_ts_kvm/plugins.js +2 -2
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +31 -0
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +312 -0
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +110 -0
- package/dist_ts_stores_nosqldb/classes.recordstore.js +437 -0
- package/dist_ts_stores_nosqldb/index.d.ts +2 -0
- package/dist_ts_stores_nosqldb/index.js +3 -0
- package/dist_ts_stores_nosqldb/plugins.d.ts +7 -0
- package/dist_ts_stores_nosqldb/plugins.js +10 -0
- package/package.json +27 -7
- package/readme.md +14 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +102 -7
- package/ts/classes.stores.ts +28 -172
- package/ts/errors.ts +8 -2
- package/ts/interfaces.ts +1 -0
- package/ts/readme.md +14 -6
- package/ts/utils.harnessoptions.ts +1 -0
- package/ts/utils.storerules.ts +177 -0
- package/ts_chat_harness/classes.transcript.ts +365 -0
- package/ts_chat_harness/functions.intents.ts +169 -0
- package/ts_chat_harness/functions.mapping.ts +267 -0
- package/ts_chat_harness/index.ts +33 -0
- package/ts_chat_harness/plugins.ts +18 -0
- package/ts_chat_harness/readme.md +114 -0
- package/ts_kvm/classes.browserkvm.ts +2 -2
- package/ts_kvm/plugins.ts +1 -1
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +465 -0
- package/ts_stores_nosqldb/classes.recordstore.ts +560 -0
- package/ts_stores_nosqldb/index.ts +9 -0
- package/ts_stores_nosqldb/plugins.ts +38 -0
- package/ts_stores_nosqldb/readme.md +53 -0
package/ts/readme.md
CHANGED
|
@@ -61,10 +61,12 @@ or provider SDKs.
|
|
|
61
61
|
| `/chat` | Portable streaming `ChatSession`, history and usage | None |
|
|
62
62
|
| `/chat/cli` | Ink/React terminal chat | `ink ink-text-input react`; TypeScript: `@types/react` |
|
|
63
63
|
| `/chat/web` | Lit `flexchat-window`, `flexchat-message`, `flexchat-input` | `lit` |
|
|
64
|
+
| `/chat/harness` | `FlexHarnessTranscript` and intents for the `@design.estate/dees-catalog` harness components, browser-safe | `@design.estate/dees-catalog` (types only) |
|
|
64
65
|
| `/kvm` | Browser control, terminal framing and OCR observation | `puppeteer` |
|
|
65
66
|
| `/documents` | PDF and image documents, `extractTextFromPdf` | `@push.rocks/smartpdf`; for image documents `sharp` |
|
|
66
67
|
| `/mcp` | MCP clients and AI SDK tool conversion | `@modelcontextprotocol/sdk` |
|
|
67
68
|
| `/migration` | Existing versioned harness migrations | None |
|
|
69
|
+
| `/stores/nosqldb` | `NoSqlFlexHarnessStores`: cross-process CAS stores on `@lossless.org/client/nosqldb` | `@lossless.org/client` |
|
|
68
70
|
|
|
69
71
|
The integrations in the last column use optional peer dependencies. Install them
|
|
70
72
|
only for the entrypoints your project imports, for example:
|
|
@@ -403,26 +405,29 @@ Normal Flex session cleanup always waits in-flight local project operations, the
|
|
|
403
405
|
|
|
404
406
|
`InMemoryFlexProjectManagementStore` is the standalone in-memory implementation. `InMemoryFlexHarnessStores` and `JsonFileFlexHarnessStores` include `projectManagement` as a required bundle member. `assertFlexProjectManagementSnapshot()` validates live records, `assertFlexProjectManagementTombstone()` validates tombstones, and `assertFlexProjectManagementRecord()` validates the union. `createEmptyFlexProjectManagementSnapshot(sessionGenerationId, sessionGenerationSequence)` returns a revision-0 live state for the supplied current generation.
|
|
405
407
|
|
|
406
|
-
##
|
|
408
|
+
## Subagents
|
|
407
409
|
|
|
408
|
-
`subagents` enables
|
|
410
|
+
`subagents` enables the harness-owned `delegate` and `delegate_result` tools when at least one definition exists and the session depth is below `maxSubagentDepth`. A tool provider cannot replace either enabled built-in. Delegation is asynchronous by default: `delegate` returns `{ taskId, status: 'running', runId }` after durable child admission, so the parent can continue independent work. `delegate_result({ taskId, waitMs? })` reads live status and, once settled, bounded final text and model identity; its optional wait is limited to 240 seconds and observes parent cancellation. Set `foreground: true` to await the final answer in `delegate` itself. Children remain owned by the parent turn: Stop cancels its exact child runs, and the parent joins them before releasing authority and finalizing. A child result arriving while inference is still active enters the parent's next model context as untrusted task data.
|
|
409
411
|
|
|
410
412
|
The model calls it with this exact input shape:
|
|
411
413
|
|
|
414
|
+
When upgrading from 9.x, callers that require the previous synchronous delegate result must pass `foreground: true`. Tool providers must also leave the newly reserved `delegate_result` name available when subagents are enabled.
|
|
415
|
+
|
|
412
416
|
```typescript
|
|
413
417
|
interface IDelegateInput {
|
|
414
418
|
description: string;
|
|
415
419
|
prompt: string;
|
|
416
420
|
subagentType: string;
|
|
417
421
|
taskId?: string;
|
|
422
|
+
foreground?: boolean;
|
|
418
423
|
}
|
|
419
424
|
```
|
|
420
425
|
|
|
421
426
|
Before creating or resuming a child, FlexHarness requests permission on the parent run with `kind: 'subagent.start'`, the parent `toolCallId`, and separately bounded harness-owned agent/task metadata. Its metadata includes `childSessionId`, the exact ID reserved for this call: FlexHarness derives it deterministically when `taskId` is omitted and copies the supplied candidate when `taskId` is present. A resume also retains that candidate as `taskId`. This block is not truncated by `toolOutputLimits`. The reserved ID binds permission handling before child creation but does not prove that the child exists or is owned: applications must treat the later delegated admission context as authoritative. The controller answers through the normal permission APIs. This request has no `rememberKey`, so `always` is invalid; controllers use `once` or `reject`.
|
|
422
427
|
|
|
423
|
-
Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` remains limited to `sessionId`, `sessionGenerationId`, and `title`. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the
|
|
428
|
+
Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` remains limited to `sessionId`, `sessionGenerationId`, and `title`. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
|
|
424
429
|
|
|
425
|
-
The parent tool part receives `childSessionId`
|
|
430
|
+
The parent tool part receives `childSessionId` as soon as the child is acquired, so a host can offer an authorized live transcript. A foreground call also adds the resolved child model to its part and returns bounded JSON:
|
|
426
431
|
|
|
427
432
|
```typescript
|
|
428
433
|
{
|
|
@@ -687,7 +692,7 @@ At most 128 custom commands may be registered. Every registration must be a plai
|
|
|
687
692
|
|
|
688
693
|
`/undo` and `/redo`, plus `undoSession()` and `redoSession()`, move a durable history cursor. The direct methods return `{ revertedRunId }` and `{ restoredRunId }`; the slash forms return `{ type: 'operation', name: 'undo' | 'redo' }`. Each committed cursor move emits one `session.history.changed` event with `direction`, `runId`, and the selected session identity. A committed branch emits the same event with `direction: 'branch'` and no `runId`, so controllers should refresh the complete selected session. Capture finalization, cleanup, and metadata-only changes do not emit this event.
|
|
689
694
|
|
|
690
|
-
A completed root-session turn defines an operation-group boundary. Failed and cancelled turns after it belong to that group; leading failed or cancelled turns belong to the first completed group. No completed boundary means there is nothing to undo. Undo applies selected segments in reverse order and redo applies them in forward order. Without `turnReversionProvider`, only transcript and future model context move. Hidden messages disappear from `getMessages()`, message pages, exact message lookup, and future model context. Starting a new prompt, template, handler, or compaction from an undone position commits a branch: hidden messages and segments are removed durably and cannot be redone. Successful event archival also commits hidden redo history; a missing compaction or failed archive leaves it intact.
|
|
695
|
+
A completed root-session turn defines an operation-group boundary. Failed and cancelled turns after it belong to that group; leading failed or cancelled turns belong to the first completed group. No completed boundary means there is nothing to undo. Undo applies selected segments in reverse order and redo applies them in forward order. Without `turnReversionProvider`, only transcript and future model context move. Hidden messages disappear from `getMessages()`, message pages, exact message lookup, and future model context. Starting a new prompt, template, handler, or compaction from an undone position commits a branch: hidden messages and segments are removed durably and cannot be redone. Successful event archival also commits hidden redo history; a missing compaction or failed archive leaves it intact. Automatic budget compaction archives covered events after generation finalization and advances the same undo horizon; set `contextCompactionBytes: false` to disable that automatic policy.
|
|
691
696
|
|
|
692
697
|
Two horizons bound undo. Retention pruning removes the oldest complete visible units when `reversionLimits` is exceeded. Explicit event archival marks covered turns context-unavailable and prunes complete prefixes that can no longer be rebuilt; FlexHarness never crosses that archive horizon. Manual compaction without archival retains the original events and remains undoable. Schema-1 projection history and sessions migrated from `2.x` have no reversion segments, so historical turns are not retroactively undoable; newly written turns are tracked normally.
|
|
693
698
|
|
|
@@ -790,7 +795,7 @@ The protocol is durable and inspectable:
|
|
|
790
795
|
|
|
791
796
|
1. FlexHarness persists a `preparing` capture intent before calling `prepare()`, before model or tool execution. The provider must establish exclusive capture ownership for that `storageKey` and retain it until `release()` succeeds.
|
|
792
797
|
2. `inspectCapture()` returns `missing`, `prepared`, `finalized`, or `unknown`. A finalized V1 result includes `reference`; a finalized V2 result includes the complete tagged `outcome`. `missing` is safe only while the durable state is still `preparing`; a missing prepared or finalizing capture, an `unknown` result, or an inspection failure fences the namespace.
|
|
793
|
-
3. `finalize()` closes the capture and returns its JSON-safe V1 reference or complete V2 outcome. FlexHarness may call it during normal finalization or recovery after `inspectCapture()` reports `prepared`. Failed and cancelled root turns are captured too. A root capture spans
|
|
798
|
+
3. `finalize()` closes the capture and returns its JSON-safe V1 reference or complete V2 outcome. FlexHarness may call it during normal finalization or recovery after `inspectCapture()` reports `prepared`. Failed and cancelled root turns are captured too. A root capture spans its admitted subagent effects, including asynchronous children joined before finalization, although child transcript records remain separate. A failed parent cancels its outstanding children before joining them.
|
|
794
799
|
4. Before undo or redo, FlexHarness persists an apply write-ahead record. `inspectApply()` must report the exact durable outcome for the supplied `operationId`: `not-applied` means no effect occurred, `applied` means the complete effect occurred, and `unknown` means the provider cannot prove either result. FlexHarness calls `apply()` only for `not-applied`; after an apply error it inspects again, and an `unknown` result or inspection failure fences the namespace. Progress is persisted after each segment, and the transcript cursor moves only after the complete unit succeeds. Known zero-progress failures leave the cursor unchanged and can be retried; partial progress retains the write-ahead record and resumes after restart. Caller cancellation is honored until the first workspace segment makes progress; recovery then continues with fresh bounded maintenance signals until the unit and cursor commit. Providers must treat `operationId` idempotently.
|
|
795
800
|
5. `release()` relinquishes the capture after no-change or nonrevertible V2 finalization, branch commitment, retention or archive pruning, session deletion, or other durable removal. It must be idempotent: an unacknowledged release remains persisted and is retried before FlexHarness discards the reference.
|
|
796
801
|
|
|
@@ -960,6 +965,7 @@ A `contextCompactor` states which model to compact with from the model the compa
|
|
|
960
965
|
| `reason` | Compaction | Model members |
|
|
961
966
|
| --- | --- | --- |
|
|
962
967
|
| `context-overflow` | A run's context overflowed, including a delegated run's | `resolvedModel`: the `IFlexResolvedModel` the model resolver returned for that run, frozen; a delegated run's overflow carries the delegated run's own model |
|
|
968
|
+
| `context-budget` | Settled history exceeds `contextCompactionBytes` before a generation's first inference (256 KiB by default with a compactor) | `resolvedModel`: the exact model resolved for that run, including its delegated model |
|
|
963
969
|
| `manual` | `/compact` or `compactSession()` | `modelHint`: the `modelHint` of the `/compact` execution options or of `compactSession(scopeId, sessionId, { modelHint })`, when one was passed |
|
|
964
970
|
| `retention` | Event retention | none |
|
|
965
971
|
|
|
@@ -1064,6 +1070,8 @@ After every harness using a `JsonFileFlexHarnessStores` instance has been dispos
|
|
|
1064
1070
|
|
|
1065
1071
|
The JSON stores are explicitly not cross-process safe. When several processes can access the same storage namespace, every core `IFlexHarnessStores` domain and `stores.projectManagement` must use database-backed or equivalent cross-process CAS. Process-local CAS for the core stores or for PM alone is insufficient: session generation creation, cleanup tombstones, PM replacement, and stale-writer rejection must all retain their respective atomic preconditions across processes.
|
|
1066
1072
|
|
|
1073
|
+
`NoSqlFlexHarnessStores` from `/stores/nosqldb` is that database-backed bundle for every domain. Pass it an initialized `SmartdataDb` and `await stores.prepare()` at startup, before the first harness uses it; the subpath readme describes chunked snapshots and failure reporting.
|
|
1074
|
+
|
|
1067
1075
|
Direct store operations and non-run session mutations surface conflicts as `FlexHarnessStoreConflictError` or the corresponding AgentSession store conflict. Malformed, wrong-schema, or non-JSON snapshots are surfaced as `FlexHarnessStoreFormatError`. A write or deletion that changed its target but cannot confirm parent-directory durability surfaces `FlexHarnessStoreCommitUncertainError` with the affected path, operation, and cause. Run persistence failures cross the external error boundary and therefore become `FlexHarnessExternalError`. FlexHarness does not merge conflicts.
|
|
1068
1076
|
|
|
1069
1077
|
Each domain serializes its own mutations. A successful run first persists a hidden completed projection, then finalizes the canonical Agent generation as `accepted`, then promotes the hidden projection publicly. Recovery uses the canonical generation outcome to promote an accepted stage or publish a failed/cancelled projection. Failed and cancelled generations remain auditable but never enter future model context.
|
|
@@ -380,6 +380,7 @@ export function normalizeAgentSessionPolicy<TScope>(
|
|
|
380
380
|
return {
|
|
381
381
|
...(policy.contextBuilder === undefined ? {} : { contextBuilder: policy.contextBuilder }),
|
|
382
382
|
...(policy.contextCompactor === undefined ? {} : { contextCompactor: policy.contextCompactor }),
|
|
383
|
+
...(policy.contextCompactionBytes === undefined ? {} : { contextCompactionBytes: policy.contextCompactionBytes }),
|
|
383
384
|
...(policy.eventRetention === undefined ? {} : { eventRetention: policy.eventRetention }),
|
|
384
385
|
...(policy.changeListenerTimeoutMs === undefined
|
|
385
386
|
? {}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
FlexHarnessStoreConflictError,
|
|
4
|
+
FlexHarnessStoreFormatError,
|
|
5
|
+
FlexHarnessValidationError,
|
|
6
|
+
} from './errors.js';
|
|
7
|
+
import type {
|
|
8
|
+
IFlexPermissionSnapshot,
|
|
9
|
+
IFlexProjectManagementSnapshot,
|
|
10
|
+
IFlexProjectManagementTombstone,
|
|
11
|
+
IFlexProjectManagementWriteContext,
|
|
12
|
+
IFlexScopeSnapshot,
|
|
13
|
+
TFlexProjectionSnapshot,
|
|
14
|
+
TFlexProjectManagementRecord,
|
|
15
|
+
} from './interfaces.js';
|
|
16
|
+
import { assertJsonSerializable, cloneSerializable } from './utils.json.js';
|
|
17
|
+
import { assertExactKeys, assertRecord } from './utils.storecodecs.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Store-independent admission rules every `IFlexHarnessStores` implementation applies: argument
|
|
21
|
+
* validation, the next-revision rule, exact session keys, and the project-management generation
|
|
22
|
+
* fence. Backends own only how they read, compare and write.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type TSnapshot =
|
|
26
|
+
| IFlexScopeSnapshot
|
|
27
|
+
| TFlexProjectionSnapshot
|
|
28
|
+
| IFlexPermissionSnapshot
|
|
29
|
+
| TFlexProjectManagementRecord;
|
|
30
|
+
type TSnapshotValidator<TSnapshotValue extends TSnapshot> = (
|
|
31
|
+
value: unknown,
|
|
32
|
+
) => asserts value is TSnapshotValue;
|
|
33
|
+
|
|
34
|
+
export function validateIdentifier(value: string, name: string): void {
|
|
35
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
36
|
+
throw new FlexHarnessValidationError(`${name} must be a non-empty string.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateExpectedRevision(expectedRevision: number): void {
|
|
41
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
42
|
+
throw new FlexHarnessValidationError('expectedRevision must be a non-negative integer.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateProjectManagementWriteContext(
|
|
47
|
+
writeContext: IFlexProjectManagementWriteContext,
|
|
48
|
+
): void {
|
|
49
|
+
assertJsonSerializable(writeContext, '$writeContext');
|
|
50
|
+
assertRecord(writeContext, 'writeContext');
|
|
51
|
+
assertExactKeys(writeContext, ['actor', 'runId', 'agent', 'toolCallId'], 'writeContext');
|
|
52
|
+
if (writeContext.actor !== 'agent' && writeContext.actor !== 'application') {
|
|
53
|
+
throw new FlexHarnessValidationError('writeContext.actor must be agent or application.');
|
|
54
|
+
}
|
|
55
|
+
for (const key of ['runId', 'agent', 'toolCallId'] as const) {
|
|
56
|
+
if (writeContext[key] !== undefined) validateIdentifier(writeContext[key], `writeContext.${key}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateSnapshotSave<TSnapshotValue extends TSnapshot>(
|
|
61
|
+
snapshot: TSnapshotValue,
|
|
62
|
+
expectedRevision: number,
|
|
63
|
+
validator: TSnapshotValidator<TSnapshotValue>,
|
|
64
|
+
): TSnapshotValue {
|
|
65
|
+
validator(snapshot);
|
|
66
|
+
validateExpectedRevision(expectedRevision);
|
|
67
|
+
if (snapshot.revision !== expectedRevision + 1) {
|
|
68
|
+
throw new FlexHarnessStoreFormatError(
|
|
69
|
+
`Snapshot revision ${snapshot.revision} must equal expected revision ${expectedRevision} plus one.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return cloneSerializable(snapshot);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function providerKey(storageKey: string, sessionId: string): string {
|
|
76
|
+
validateIdentifier(storageKey, 'storageKey');
|
|
77
|
+
validateIdentifier(sessionId, 'sessionId');
|
|
78
|
+
return JSON.stringify([storageKey, sessionId]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function assertProjectionSession(snapshot: TFlexProjectionSnapshot, sessionId: string): void {
|
|
82
|
+
for (const message of snapshot.messages) {
|
|
83
|
+
if (message.sessionId !== sessionId) {
|
|
84
|
+
throw new FlexHarnessStoreFormatError(
|
|
85
|
+
`Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const terminal of snapshot.stagedTerminals) {
|
|
90
|
+
if (
|
|
91
|
+
terminal.userMessage.sessionId !== sessionId
|
|
92
|
+
|| terminal.assistantMessage.sessionId !== sessionId
|
|
93
|
+
) {
|
|
94
|
+
throw new FlexHarnessStoreFormatError(
|
|
95
|
+
`Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sameProjectManagementGeneration(
|
|
102
|
+
left: TFlexProjectManagementRecord,
|
|
103
|
+
right: TFlexProjectManagementRecord,
|
|
104
|
+
): boolean {
|
|
105
|
+
return left.sessionGenerationId === right.sessionGenerationId
|
|
106
|
+
&& left.sessionGenerationSequence === right.sessionGenerationSequence;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function canInitializeProjectManagementGeneration(
|
|
110
|
+
current: TFlexProjectManagementRecord,
|
|
111
|
+
next: TFlexProjectManagementRecord,
|
|
112
|
+
expectedRevision: number,
|
|
113
|
+
): boolean {
|
|
114
|
+
return 'deletedAt' in current
|
|
115
|
+
&& expectedRevision === 0
|
|
116
|
+
&& next.sessionGenerationId !== current.sessionGenerationId
|
|
117
|
+
&& next.sessionGenerationSequence > current.sessionGenerationSequence;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Admits a live project-management save over the stored record: a CAS save within the stored
|
|
122
|
+
* generation, or a newer generation that replaces a tombstone at expected revision 0.
|
|
123
|
+
*/
|
|
124
|
+
export function assertProjectManagementSaveAdmitted(
|
|
125
|
+
key: string,
|
|
126
|
+
current: TFlexProjectManagementRecord | undefined,
|
|
127
|
+
snapshot: IFlexProjectManagementSnapshot,
|
|
128
|
+
expectedRevision: number,
|
|
129
|
+
): void {
|
|
130
|
+
const actualRevision = current?.revision ?? 0;
|
|
131
|
+
if (current && !sameProjectManagementGeneration(current, snapshot)) {
|
|
132
|
+
if (!canInitializeProjectManagementGeneration(current, snapshot, expectedRevision)) {
|
|
133
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
134
|
+
}
|
|
135
|
+
} else if ((current && 'deletedAt' in current) || actualRevision !== expectedRevision) {
|
|
136
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Admits a project-management tombstone over the stored record. Returns false when the same
|
|
142
|
+
* generation is already tombstoned, which makes a repeated cleanup idempotent.
|
|
143
|
+
*/
|
|
144
|
+
export function isProjectManagementTombstoneAdmitted(
|
|
145
|
+
key: string,
|
|
146
|
+
current: TFlexProjectManagementRecord | undefined,
|
|
147
|
+
tombstone: IFlexProjectManagementTombstone,
|
|
148
|
+
expectedRevision: number,
|
|
149
|
+
): boolean {
|
|
150
|
+
const actualRevision = current?.revision ?? 0;
|
|
151
|
+
if (current && sameProjectManagementGeneration(current, tombstone)) {
|
|
152
|
+
if ('deletedAt' in current) return false;
|
|
153
|
+
if (actualRevision !== expectedRevision) {
|
|
154
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
155
|
+
}
|
|
156
|
+
} else if (current) {
|
|
157
|
+
if (!canInitializeProjectManagementGeneration(current, tombstone, expectedRevision)) {
|
|
158
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
159
|
+
}
|
|
160
|
+
} else if (actualRevision !== expectedRevision) {
|
|
161
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An archive is written once; repeating it with the same events is a no-op. */
|
|
167
|
+
export function assertSameArchiveEvents(
|
|
168
|
+
existing: plugins.IAgentEventArchiveV2,
|
|
169
|
+
next: plugins.IAgentEventArchiveV2,
|
|
170
|
+
): void {
|
|
171
|
+
if (
|
|
172
|
+
JSON.stringify(existing.events.map((event) => event.id))
|
|
173
|
+
!== JSON.stringify(next.events.map((event) => event.id))
|
|
174
|
+
) {
|
|
175
|
+
throw new Error(`Archive "${next.archiveId}" already exists with different events.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type * as plugins from './plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
IFlexMessage,
|
|
4
|
+
IFlexPermissionRequest,
|
|
5
|
+
IFlexProjectStateResult,
|
|
6
|
+
IFlexSession,
|
|
7
|
+
TFlexHarnessEvent,
|
|
8
|
+
TFlexMessagePart,
|
|
9
|
+
} from '../ts/interfaces.js';
|
|
10
|
+
import {
|
|
11
|
+
flexMessageToHarnessRows,
|
|
12
|
+
flexPartRowId,
|
|
13
|
+
flexPartTimingKey,
|
|
14
|
+
flexPermissionToHarness,
|
|
15
|
+
flexProjectToHarnessScratchpad,
|
|
16
|
+
flexProjectToHarnessTodos,
|
|
17
|
+
flexSessionToHarnessStatus,
|
|
18
|
+
flexToolPartToHarness,
|
|
19
|
+
flexUsageToHarness,
|
|
20
|
+
type IFlexPartTiming,
|
|
21
|
+
} from './functions.mapping.js';
|
|
22
|
+
|
|
23
|
+
export interface IFlexHarnessTranscriptOptions {
|
|
24
|
+
scopeId: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What `load()` reads: the session's authoritative state, as the harness APIs return it. */
|
|
29
|
+
export interface IFlexHarnessTranscriptSnapshot {
|
|
30
|
+
/** `getMessages()` of the session. */
|
|
31
|
+
messages: readonly IFlexMessage[];
|
|
32
|
+
/** `listPendingPermissions()` of the session. */
|
|
33
|
+
permissions?: readonly IFlexPermissionRequest[];
|
|
34
|
+
/** `getSession()`; states whether a run is active or failed. */
|
|
35
|
+
session?: IFlexSession;
|
|
36
|
+
/** `getProjectState()`, when project management is enabled. */
|
|
37
|
+
project?: IFlexProjectStateResult;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The inputs of `dees-harness-chat`, as plain data the transcript does not share. */
|
|
41
|
+
export interface IFlexHarnessTranscriptView {
|
|
42
|
+
messages: plugins.IHarnessMessage[];
|
|
43
|
+
permissions: plugins.IHarnessPermissionRequest[];
|
|
44
|
+
/** FlexHarness asks no questions; always empty. */
|
|
45
|
+
questions: plugins.IHarnessQuestionRequest[];
|
|
46
|
+
status: plugins.IHarnessStatus;
|
|
47
|
+
usage: plugins.IHarnessUsage;
|
|
48
|
+
todos: plugins.IHarnessTodoItem[];
|
|
49
|
+
scratchpad?: plugins.IHarnessScratchpad;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where an event stands in its harness instance's event stream, as the host received it. */
|
|
53
|
+
export interface IFlexHarnessEventDelivery {
|
|
54
|
+
/**
|
|
55
|
+
* The `sequence` of the event of the same harness instance the host received just before this
|
|
56
|
+
* one, of any session; `undefined` for the first event after `load()`. The host forwards every
|
|
57
|
+
* event of the instance in order, and calls `load()` after a gap or a reconnect.
|
|
58
|
+
*/
|
|
59
|
+
precedingSequence: number | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What one event changed. Apply `deltas` with `applyDelta()` in order; when `messages` is true,
|
|
64
|
+
* assign `transcript.view().messages` instead, which already holds the deltas. `reload` means
|
|
65
|
+
* the transcript can no longer follow the session incrementally: read the session again and
|
|
66
|
+
* pass it to `load()`.
|
|
67
|
+
*/
|
|
68
|
+
export interface IFlexHarnessTranscriptChange {
|
|
69
|
+
messages: boolean;
|
|
70
|
+
deltas: plugins.THarnessStreamDelta[];
|
|
71
|
+
permissions: boolean;
|
|
72
|
+
status: boolean;
|
|
73
|
+
usage: boolean;
|
|
74
|
+
reload: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const utf8 = new TextEncoder();
|
|
78
|
+
|
|
79
|
+
/** UTF-8 bytes `delta` adds to `text`, counted the way FlexHarness counts them. */
|
|
80
|
+
function appendedUtf8Bytes(text: string, delta: string): number {
|
|
81
|
+
const trailing = text.charCodeAt(text.length - 1);
|
|
82
|
+
const leading = delta.charCodeAt(0);
|
|
83
|
+
const joinsSurrogatePair = trailing >= 0xd800 && trailing <= 0xdbff
|
|
84
|
+
&& leading >= 0xdc00 && leading <= 0xdfff;
|
|
85
|
+
return utf8.encode(delta).length - (joinsSurrogatePair ? 2 : 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sumUsage(
|
|
89
|
+
total: plugins.IHarnessUsage,
|
|
90
|
+
usage: plugins.IHarnessUsage,
|
|
91
|
+
): plugins.IHarnessUsage {
|
|
92
|
+
const sum = (key: keyof plugins.IHarnessUsage) => (total[key] ?? 0) + (usage[key] ?? 0);
|
|
93
|
+
return {
|
|
94
|
+
inputTokens: sum('inputTokens'),
|
|
95
|
+
outputTokens: sum('outputTokens'),
|
|
96
|
+
totalTokens: sum('totalTokens'),
|
|
97
|
+
cacheReadTokens: sum('cacheReadTokens'),
|
|
98
|
+
cacheWriteTokens: sum('cacheWriteTokens'),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const noChange = (): IFlexHarnessTranscriptChange => ({
|
|
103
|
+
messages: false,
|
|
104
|
+
deltas: [],
|
|
105
|
+
permissions: false,
|
|
106
|
+
status: false,
|
|
107
|
+
usage: false,
|
|
108
|
+
reload: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Follows one FlexHarness session and keeps the `dees-harness-chat` inputs for it. It is a pure
|
|
113
|
+
* reducer over the session's public messages and events: it imports no runtime code, so it runs
|
|
114
|
+
* in the browser beside the components as well as on the server beside the harness.
|
|
115
|
+
*/
|
|
116
|
+
export class FlexHarnessTranscript {
|
|
117
|
+
public readonly scopeId: string;
|
|
118
|
+
public readonly sessionId: string;
|
|
119
|
+
private flexMessages: IFlexMessage[] = [];
|
|
120
|
+
private readonly timings = new Map<string, IFlexPartTiming>();
|
|
121
|
+
private readonly partBytes = new Map<string, number>();
|
|
122
|
+
private readonly permissionRows = new Map<string, plugins.IHarnessPermissionRequest>();
|
|
123
|
+
private status: plugins.IHarnessStatus = { type: 'idle' };
|
|
124
|
+
private usage: plugins.IHarnessUsage = {};
|
|
125
|
+
private todos: plugins.IHarnessTodoItem[] = [];
|
|
126
|
+
private scratchpad?: plugins.IHarnessScratchpad;
|
|
127
|
+
private runningQueueId?: string;
|
|
128
|
+
|
|
129
|
+
constructor(options: IFlexHarnessTranscriptOptions) {
|
|
130
|
+
this.scopeId = options.scopeId;
|
|
131
|
+
this.sessionId = options.sessionId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The queue entry whose run is running; a steer joins it. */
|
|
135
|
+
public get activeQueueId(): string | undefined {
|
|
136
|
+
return this.runningQueueId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Replaces everything with the session's authoritative state. */
|
|
140
|
+
public load(snapshot: IFlexHarnessTranscriptSnapshot): void {
|
|
141
|
+
this.flexMessages = [];
|
|
142
|
+
this.todos = [];
|
|
143
|
+
this.scratchpad = undefined;
|
|
144
|
+
this.runningQueueId = undefined;
|
|
145
|
+
this.timings.clear();
|
|
146
|
+
this.partBytes.clear();
|
|
147
|
+
for (const message of snapshot.messages) this.upsertMessage(message);
|
|
148
|
+
this.permissionRows.clear();
|
|
149
|
+
for (const request of snapshot.permissions ?? []) {
|
|
150
|
+
this.permissionRows.set(request.permissionId, flexPermissionToHarness(request));
|
|
151
|
+
}
|
|
152
|
+
this.status = snapshot.session ? flexSessionToHarnessStatus(snapshot.session) : { type: 'idle' };
|
|
153
|
+
this.usage = this.flexMessages.reduce<plugins.IHarnessUsage>(
|
|
154
|
+
(total, message) => message.usage ? sumUsage(total, flexUsageToHarness(message.usage)) : total,
|
|
155
|
+
{},
|
|
156
|
+
);
|
|
157
|
+
if (snapshot.project) this.applyProjectState(snapshot.project);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Takes in the session's task list and scratchpad after `getProjectState()`. */
|
|
161
|
+
public applyProjectState(project: IFlexProjectStateResult): void {
|
|
162
|
+
this.todos = flexProjectToHarnessTodos(project);
|
|
163
|
+
this.scratchpad = flexProjectToHarnessScratchpad(this.sessionId, project);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A copy of the component inputs; the transcript keeps its own objects. */
|
|
167
|
+
public view(): IFlexHarnessTranscriptView {
|
|
168
|
+
return structuredClone({
|
|
169
|
+
messages: this.flexMessages.flatMap((message, index) =>
|
|
170
|
+
flexMessageToHarnessRows(message, index, this.timings)),
|
|
171
|
+
permissions: [...this.permissionRows.values()],
|
|
172
|
+
questions: [],
|
|
173
|
+
status: this.status,
|
|
174
|
+
usage: this.usage,
|
|
175
|
+
todos: this.todos,
|
|
176
|
+
...(this.scratchpad === undefined ? {} : { scratchpad: this.scratchpad }),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Takes one event of the harness instance. An event that does not directly follow
|
|
182
|
+
* `delivery.precedingSequence` means events were lost, possibly of this session, so the change
|
|
183
|
+
* asks for a reload.
|
|
184
|
+
*/
|
|
185
|
+
public applyEvent(
|
|
186
|
+
event: TFlexHarnessEvent,
|
|
187
|
+
delivery: IFlexHarnessEventDelivery,
|
|
188
|
+
): IFlexHarnessTranscriptChange {
|
|
189
|
+
const change = noChange();
|
|
190
|
+
if (
|
|
191
|
+
delivery.precedingSequence !== undefined
|
|
192
|
+
&& event.sequence !== delivery.precedingSequence + 1
|
|
193
|
+
) {
|
|
194
|
+
change.reload = true;
|
|
195
|
+
return change;
|
|
196
|
+
}
|
|
197
|
+
if (event.scopeId !== this.scopeId || event.sessionId !== this.sessionId) return change;
|
|
198
|
+
const timestamp = Date.parse(event.timestamp);
|
|
199
|
+
switch (event.type) {
|
|
200
|
+
case 'session.created':
|
|
201
|
+
case 'session.updated':
|
|
202
|
+
this.setStatus(change, flexSessionToHarnessStatus(event.session));
|
|
203
|
+
break;
|
|
204
|
+
case 'session.deleted':
|
|
205
|
+
this.load({ messages: [] });
|
|
206
|
+
change.messages = change.permissions = change.status = change.usage = true;
|
|
207
|
+
break;
|
|
208
|
+
case 'run.started':
|
|
209
|
+
this.setStatus(change, { type: 'busy' });
|
|
210
|
+
break;
|
|
211
|
+
case 'message.created':
|
|
212
|
+
case 'message.updated':
|
|
213
|
+
this.upsertMessage(event.message);
|
|
214
|
+
change.messages = true;
|
|
215
|
+
break;
|
|
216
|
+
case 'part.started':
|
|
217
|
+
this.timings.set(flexPartTimingKey(event.messageId, event.partId), { startedAt: timestamp });
|
|
218
|
+
this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
|
|
219
|
+
break;
|
|
220
|
+
case 'part.updated':
|
|
221
|
+
this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
|
|
222
|
+
break;
|
|
223
|
+
case 'part.completed': {
|
|
224
|
+
const key = flexPartTimingKey(event.messageId, event.partId);
|
|
225
|
+
this.timings.set(key, { ...this.timings.get(key), endedAt: timestamp });
|
|
226
|
+
this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'part.delta':
|
|
230
|
+
this.applyPartDelta(change, event);
|
|
231
|
+
break;
|
|
232
|
+
case 'permission.requested':
|
|
233
|
+
this.permissionRows.set(event.request.permissionId, flexPermissionToHarness(event.request));
|
|
234
|
+
change.permissions = true;
|
|
235
|
+
break;
|
|
236
|
+
case 'permission.resolved': {
|
|
237
|
+
const row = this.permissionRows.get(event.request.permissionId);
|
|
238
|
+
if (!row) break;
|
|
239
|
+
row.response = event.decision;
|
|
240
|
+
row.respondedAt = timestamp;
|
|
241
|
+
change.permissions = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case 'run.finished':
|
|
245
|
+
this.upsertMessage(event.message);
|
|
246
|
+
change.messages = true;
|
|
247
|
+
if (event.message.usage) {
|
|
248
|
+
this.usage = sumUsage(this.usage, flexUsageToHarness(event.message.usage));
|
|
249
|
+
change.usage = true;
|
|
250
|
+
}
|
|
251
|
+
this.setStatus(change, event.status === 'failed'
|
|
252
|
+
? { type: 'error', ...(event.error ? { message: event.error.message } : {}) }
|
|
253
|
+
: { type: 'idle' });
|
|
254
|
+
break;
|
|
255
|
+
case 'prompt.started':
|
|
256
|
+
case 'prompt.running':
|
|
257
|
+
this.runningQueueId = event.queueId;
|
|
258
|
+
break;
|
|
259
|
+
case 'prompt.finished':
|
|
260
|
+
if (this.runningQueueId === event.queueId) this.runningQueueId = undefined;
|
|
261
|
+
break;
|
|
262
|
+
case 'session.history.changed':
|
|
263
|
+
change.reload = true;
|
|
264
|
+
break;
|
|
265
|
+
default:
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
return change;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private setStatus(change: IFlexHarnessTranscriptChange, status: plugins.IHarnessStatus): void {
|
|
272
|
+
if (status.type === this.status.type && status.message === this.status.message) return;
|
|
273
|
+
this.status = status;
|
|
274
|
+
change.status = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private upsertMessage(message: IFlexMessage): void {
|
|
278
|
+
const index = this.flexMessages.findIndex((entry) => entry.messageId === message.messageId);
|
|
279
|
+
const copy = structuredClone(message);
|
|
280
|
+
if (index === -1) this.flexMessages.push(copy);
|
|
281
|
+
else this.flexMessages[index] = copy;
|
|
282
|
+
for (const part of copy.parts) {
|
|
283
|
+
if (part.type === 'text' || part.type === 'reasoning') {
|
|
284
|
+
this.partBytes.set(flexPartTimingKey(copy.messageId, part.partId), utf8.encode(part.text).length);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private applyPartSnapshot(
|
|
290
|
+
change: IFlexHarnessTranscriptChange,
|
|
291
|
+
messageId: string,
|
|
292
|
+
partIndex: number,
|
|
293
|
+
part: TFlexMessagePart,
|
|
294
|
+
): void {
|
|
295
|
+
const message = this.flexMessages.find((entry) => entry.messageId === messageId);
|
|
296
|
+
if (!message || partIndex > message.parts.length) {
|
|
297
|
+
change.reload = true;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const previous = message.parts[partIndex];
|
|
301
|
+
message.parts[partIndex] = structuredClone(part);
|
|
302
|
+
const key = flexPartTimingKey(messageId, part.partId);
|
|
303
|
+
if (part.type === 'text' || part.type === 'reasoning') {
|
|
304
|
+
this.partBytes.set(key, utf8.encode(part.text).length);
|
|
305
|
+
}
|
|
306
|
+
const rowId = flexPartRowId(messageId, part.partId);
|
|
307
|
+
if (previous?.partId !== part.partId || previous.type !== part.type) {
|
|
308
|
+
change.messages = true;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (part.type === 'tool') {
|
|
312
|
+
change.deltas.push({
|
|
313
|
+
type: 'tool-update',
|
|
314
|
+
messageId: rowId,
|
|
315
|
+
tool: flexToolPartToHarness(part, this.timings.get(key)),
|
|
316
|
+
});
|
|
317
|
+
} else if (part.type === 'text' && previous.type === 'text') {
|
|
318
|
+
if (previous.text !== part.text) change.messages = true;
|
|
319
|
+
else if (message.status !== 'streaming' || partIndex !== message.parts.length - 1) {
|
|
320
|
+
change.deltas.push({ type: 'message-end', messageId: rowId });
|
|
321
|
+
}
|
|
322
|
+
} else if (part.type === 'reasoning' && previous.type === 'reasoning') {
|
|
323
|
+
if (previous.text !== part.text) change.messages = true;
|
|
324
|
+
else if (part.status !== 'running') {
|
|
325
|
+
change.deltas.push({
|
|
326
|
+
type: 'reasoning-end',
|
|
327
|
+
messageId: rowId,
|
|
328
|
+
partId: part.partId,
|
|
329
|
+
endedAt: this.timings.get(key)?.endedAt,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
change.messages = true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private applyPartDelta(
|
|
338
|
+
change: IFlexHarnessTranscriptChange,
|
|
339
|
+
event: Extract<TFlexHarnessEvent, { type: 'part.delta' }>,
|
|
340
|
+
): void {
|
|
341
|
+
const message = this.flexMessages.find((entry) => entry.messageId === event.messageId);
|
|
342
|
+
const part = message?.parts[event.partIndex];
|
|
343
|
+
const key = flexPartTimingKey(event.messageId, event.partId);
|
|
344
|
+
if (
|
|
345
|
+
!part
|
|
346
|
+
|| part.partId !== event.partId
|
|
347
|
+
|| part.type !== event.partType
|
|
348
|
+
|| this.partBytes.get(key) !== event.baseTextUtf8Bytes
|
|
349
|
+
) {
|
|
350
|
+
change.reload = true;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const bytes = event.baseTextUtf8Bytes + appendedUtf8Bytes(part.text, event.delta);
|
|
354
|
+
if (bytes !== event.textUtf8Bytes) {
|
|
355
|
+
change.reload = true;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
part.text += event.delta;
|
|
359
|
+
this.partBytes.set(key, bytes);
|
|
360
|
+
const rowId = flexPartRowId(event.messageId, event.partId);
|
|
361
|
+
change.deltas.push(event.partType === 'text'
|
|
362
|
+
? { type: 'text', messageId: rowId, delta: event.delta }
|
|
363
|
+
: { type: 'reasoning', messageId: rowId, partId: event.partId, delta: event.delta });
|
|
364
|
+
}
|
|
365
|
+
}
|