@modelprofile.com/flexharness 10.1.0 → 10.2.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/ts/readme.md CHANGED
@@ -1034,6 +1034,8 @@ unsubscribe();
1034
1034
 
1035
1035
  Events are discriminated, deeply immutable values with one global sequence within each `FlexHarness` instance. Every `IFlexEventBase` carries the exact source `sessionGenerationId` and `sessionGenerationSequence`; `session.deleted` retains the deleted generation after live metadata is removed. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits `prompt.queued` and exactly one `prompt.finished`. Every accepted steer emits `prompt.steer.accepted`, and `prompt.steer.applied`, after the `message.*` events of its user message, once the run took it in; `run.finished` lists it in `unappliedSteerIds` unless it is in the model context of later turns. Durable promotion additionally emits `prompt.started`, and actual model preparation emits `prompt.running`; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede `prompt.finished`. Every callback-backed streamed text part emits exactly one `part.completed` event before the corresponding `run.finished` event. Every `part.started`, `part.delta`, `part.updated`, and `part.completed` event carries zero-based `messageIndex` and `partIndex` coordinates from the session's authoritative message and part sequences.
1036
1036
 
1037
+ `getSessionView(scopeId, sessionId)` reads a session's metadata, visible messages and pending permission requests in one step, together with the instance's event `sequence` they include. The messages include what a running turn has streamed so far, so a consumer that applies every event of the instance with a greater `sequence` follows the session exactly from the view on; events up to `sequence` are already in it. The read waits while a queued write or a change that emits events for the session is in progress; a session that keeps changing through 64 consecutive waits is refused with `FlexHarnessSessionBusyError`, and the read can be repeated. Session metadata is current: status changes such as `waiting_permission` emit no event. A permission request its run leaves unanswered leaves `pendingPermissions` without an event of its own; the run's `run.finished` follows.
1038
+
1037
1039
  `part.started`, `part.updated`, and `part.completed` are snapshot events with a complete immutable `part`. A newly streamed text part emits `part.started` with empty text before its first delta; reasoning parts also start empty. `part.delta` is a separate delta-only event: it has no cumulative `part`, and carries `partType`, the required exact source `delta`, and `baseTextUtf8Bytes`/`textUtf8Bytes` for the cumulative text before and after that delta. The counters remain correct when a UTF-16 surrogate pair is split across callbacks. Exact deltas are never truncated, including a single delta above the 96 KiB message-transfer text limit. `part.updated` remains a cumulative replacement snapshot for running tool output or metadata, not a text delta. `session.history.changed` carries `direction: 'undo' | 'redo' | 'branch'`; `runId` is present for undo and redo. Events contain public IDs, exact delta payloads, and public snapshots only; they do not expose prompt payloads, the resolved scope object, storage key, model object, provider options, or raw storage key.
1038
1040
 
1039
1041
  Part events narrow through the exported `TFlexPartEvent` union. `IFlexPartEventBase` contains their shared coordinates, `IFlexPartSnapshotEvent` owns snapshot events and their complete `part`, and `IFlexPartDeltaEvent` owns exact delta-only events and their UTF-8 counters.
@@ -25,14 +25,23 @@ export interface IFlexHarnessTranscriptOptions {
25
25
  sessionId: string;
26
26
  }
27
27
 
28
- /** What `load()` reads: the session's authoritative state, as the harness APIs return it. */
28
+ /**
29
+ * What `load()` reads: the session's authoritative state, as the harness APIs return it. Take it
30
+ * from `getSessionView()`, whose `sequence` ties the state to the event stream.
31
+ */
29
32
  export interface IFlexHarnessTranscriptSnapshot {
30
- /** `getMessages()` of the session. */
33
+ /** `messages` of `getSessionView()`, or `getMessages()` of the session. */
31
34
  messages: readonly IFlexMessage[];
32
- /** `listPendingPermissions()` of the session. */
35
+ /** `pendingPermissions` of `getSessionView()`, or `listPendingPermissions()` of the session. */
33
36
  permissions?: readonly IFlexPermissionRequest[];
34
- /** `getSession()`; states whether a run is active or failed. */
37
+ /** `session` of `getSessionView()`, or `getSession()`; states whether a run is active or failed. */
35
38
  session?: IFlexSession;
39
+ /**
40
+ * `sequence` of `getSessionView()`: the last event of the instance the state includes. With it,
41
+ * the transcript skips the events the state already holds and proves that none after it was
42
+ * lost; without it, the first event after `load()` is taken unchecked.
43
+ */
44
+ sequence?: number;
36
45
  /** `getProjectState()`, when project management is enabled. */
37
46
  project?: IFlexProjectStateResult;
38
47
  }
@@ -120,6 +129,10 @@ export class FlexHarnessTranscript {
120
129
  private readonly timings = new Map<string, IFlexPartTiming>();
121
130
  private readonly partBytes = new Map<string, number>();
122
131
  private readonly permissionRows = new Map<string, plugins.IHarnessPermissionRequest>();
132
+ /** The run of every unanswered permission row; a run that finishes leaves them unanswerable. */
133
+ private readonly permissionRuns = new Map<string, string>();
134
+ /** The last event of the instance the transcript holds, once a view tied it to the stream. */
135
+ private followedSequence?: number;
123
136
  private status: plugins.IHarnessStatus = { type: 'idle' };
124
137
  private usage: plugins.IHarnessUsage = {};
125
138
  private todos: plugins.IHarnessTodoItem[] = [];
@@ -146,9 +159,9 @@ export class FlexHarnessTranscript {
146
159
  this.partBytes.clear();
147
160
  for (const message of snapshot.messages) this.upsertMessage(message);
148
161
  this.permissionRows.clear();
149
- for (const request of snapshot.permissions ?? []) {
150
- this.permissionRows.set(request.permissionId, flexPermissionToHarness(request));
151
- }
162
+ this.permissionRuns.clear();
163
+ for (const request of snapshot.permissions ?? []) this.addPermission(request);
164
+ this.followedSequence = snapshot.sequence;
152
165
  this.status = snapshot.session ? flexSessionToHarnessStatus(snapshot.session) : { type: 'idle' };
153
166
  this.usage = this.flexMessages.reduce<plugins.IHarnessUsage>(
154
167
  (total, message) => message.usage ? sumUsage(total, flexUsageToHarness(message.usage)) : total,
@@ -179,15 +192,23 @@ export class FlexHarnessTranscript {
179
192
 
180
193
  /**
181
194
  * 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.
195
+ * `delivery.precedingSequence`, or after a load with `sequence` the last event the transcript
196
+ * holds, means events were lost, possibly of this session, so the change asks for a reload. An
197
+ * event the loaded state already includes changes nothing.
184
198
  */
185
199
  public applyEvent(
186
200
  event: TFlexHarnessEvent,
187
201
  delivery: IFlexHarnessEventDelivery,
188
202
  ): IFlexHarnessTranscriptChange {
189
203
  const change = noChange();
190
- if (
204
+ if (this.followedSequence !== undefined) {
205
+ if (event.sequence <= this.followedSequence) return change;
206
+ if (event.sequence !== this.followedSequence + 1) {
207
+ change.reload = true;
208
+ return change;
209
+ }
210
+ this.followedSequence = event.sequence;
211
+ } else if (
191
212
  delivery.precedingSequence !== undefined
192
213
  && event.sequence !== delivery.precedingSequence + 1
193
214
  ) {
@@ -202,7 +223,7 @@ export class FlexHarnessTranscript {
202
223
  this.setStatus(change, flexSessionToHarnessStatus(event.session));
203
224
  break;
204
225
  case 'session.deleted':
205
- this.load({ messages: [] });
226
+ this.load({ messages: [], sequence: this.followedSequence });
206
227
  change.messages = change.permissions = change.status = change.usage = true;
207
228
  break;
208
229
  case 'run.started':
@@ -230,7 +251,7 @@ export class FlexHarnessTranscript {
230
251
  this.applyPartDelta(change, event);
231
252
  break;
232
253
  case 'permission.requested':
233
- this.permissionRows.set(event.request.permissionId, flexPermissionToHarness(event.request));
254
+ this.addPermission(event.request);
234
255
  change.permissions = true;
235
256
  break;
236
257
  case 'permission.resolved': {
@@ -238,12 +259,20 @@ export class FlexHarnessTranscript {
238
259
  if (!row) break;
239
260
  row.response = event.decision;
240
261
  row.respondedAt = timestamp;
262
+ this.permissionRuns.delete(event.request.permissionId);
241
263
  change.permissions = true;
242
264
  break;
243
265
  }
244
266
  case 'run.finished':
245
267
  this.upsertMessage(event.message);
246
268
  change.messages = true;
269
+ // A request the run left unanswered can no longer be answered, and gets no event of its own.
270
+ for (const [permissionId, runId] of [...this.permissionRuns]) {
271
+ if (runId !== event.runId) continue;
272
+ this.permissionRows.delete(permissionId);
273
+ this.permissionRuns.delete(permissionId);
274
+ change.permissions = true;
275
+ }
247
276
  if (event.message.usage) {
248
277
  this.usage = sumUsage(this.usage, flexUsageToHarness(event.message.usage));
249
278
  change.usage = true;
@@ -268,6 +297,11 @@ export class FlexHarnessTranscript {
268
297
  return change;
269
298
  }
270
299
 
300
+ private addPermission(request: IFlexPermissionRequest): void {
301
+ this.permissionRows.set(request.permissionId, flexPermissionToHarness(request));
302
+ this.permissionRuns.set(request.permissionId, request.runId);
303
+ }
304
+
271
305
  private setStatus(change: IFlexHarnessTranscriptChange, status: plugins.IHarnessStatus): void {
272
306
  if (status.type === this.status.type && status.message === this.status.message) return;
273
307
  this.status = status;
@@ -35,15 +35,18 @@ function apply(event: TFlexHarnessEvent): void {
35
35
 
36
36
  async function reloadSession(): Promise<void> {
37
37
  buffered = [];
38
+ const view = await harness.getSessionView(scopeId, sessionId);
38
39
  transcript.load({
39
- messages: await harness.getMessages(scopeId, sessionId),
40
- permissions: await harness.listPendingPermissions(scopeId, sessionId),
41
- session: await harness.getSession(scopeId, sessionId),
40
+ messages: view.messages,
41
+ permissions: view.pendingPermissions,
42
+ session: view.session,
43
+ sequence: view.sequence,
42
44
  });
43
45
  precedingSequence = undefined;
44
46
  Object.assign(chat, transcript.view());
45
47
  const pending = buffered;
46
48
  buffered = undefined;
49
+ // Buffered events the view already includes change nothing.
47
50
  for (const event of pending) {
48
51
  if (buffered) break; // a reported gap started another reload
49
52
  apply(event);
@@ -63,14 +66,14 @@ chat.addEventListener('harness-abort', () => send(flexHarnessAbortIntent(target)
63
66
 
64
67
  ## Transcript
65
68
 
66
- `FlexHarnessTranscript` follows one session. `load()` takes its authoritative state from `getMessages()`, `listPendingPermissions()`, `getSession()` and, with project management, `getProjectState()`. `applyEvent()` takes every harness event, ignores those of other sessions, and returns what changed.
69
+ `FlexHarnessTranscript` follows one session. `load()` takes its authoritative state from `getSessionView()` and, with project management, `getProjectState()`. `applyEvent()` takes every harness event, ignores those of other sessions, and returns what changed.
67
70
 
68
- The host forwards every event of the harness instance, of every session, in order, and passes the `sequence` of the event it received before as `precedingSequence` (`undefined` for the first event after `load()`). Event sequences are global to the instance, so only this proves that no event of the session was lost: an event that does not directly follow its predecessor asks for a reload. After a gap or a reconnect, the host calls `load()` and starts again with `precedingSequence: undefined`. A snapshot cannot yet be tied to an event sequence: the session reads do not state which event they include, so the first event after `load()` is taken unchecked, and a buffered event the snapshot already holds can repeat it (a repeated text delta then fails its byte check and asks for another reload). `view()` returns a copy of the component inputs: `messages`, `permissions`, `questions`, `status`, `usage`, `todos` and `scratchpad`. The transcript keeps its own objects, so the deltas the components apply to theirs never apply twice.
71
+ The host forwards every event of the harness instance, of every session, in order, and passes the `sequence` of the event it received before as `precedingSequence` (`undefined` for the first event after `load()`). Event sequences are global to the instance, so only this proves that no event of the session was lost: an event that does not directly follow its predecessor asks for a reload. After a gap or a reconnect, the host calls `load()` and starts again with `precedingSequence: undefined`. `getSessionView()` states the `sequence` of the last event its state includes; passed to `load()`, it lets the transcript skip the events the state already holds and prove that the first event after it follows. A snapshot from `getMessages()`, `listPendingPermissions()` and `getSession()` carries no sequence, so the first event after such a `load()` is taken unchecked. `view()` returns a copy of the component inputs: `messages`, `permissions`, `questions`, `status`, `usage`, `todos` and `scratchpad`. The transcript keeps its own objects, so the deltas the components apply to theirs never apply twice.
69
72
 
70
73
  - A user message is one row. An assistant message has one row per part, in part order: a text row, a reasoning row, or a tool row, so every streamed delta addresses exactly one row. The run's usage and error attach to its last text or reasoning row. Rows carry `order` from the message and part positions.
71
74
  - `change.deltas` stream text, reasoning, tool updates and part ends into the rows the components already hold. When `change.messages` is true, rows were added or replaced: assign `view().messages` instead.
72
75
  - `change.reload` means the transcript can no longer follow incrementally: events were lost, the history moved (`session.history.changed` after undo, redo or a branch), or a delta does not continue the text the transcript holds, which the delta's UTF-8 byte counters prove. Read the session again and call `load()`.
73
- - Permission rows stay in the transcript once answered, showing the decision.
76
+ - Permission rows stay in the transcript once answered, showing the decision. A request its run leaves unanswered, because the run was stopped or failed, is removed when the run finishes.
74
77
  - FlexHarness emits no event for project state. After a run, or after a project tool's row completes, read `getProjectState()` and pass it to `applyProjectState()` for the task list and the scratchpad.
75
78
  - `questions` is always empty: FlexHarness has no question requests.
76
79
  - Messages the session has not taken in yet (a queued prompt, a steer) carry no text in FlexHarness events. The host that sent them shows them as pending rows.