@modelprofile.com/flexharness 10.1.0 → 10.2.1

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/interfaces.ts CHANGED
@@ -513,6 +513,25 @@ export interface IFlexMessagePage {
513
513
  nextCursor?: string;
514
514
  }
515
515
 
516
+ /**
517
+ * A session as the events up to `sequence` describe it, read in one step. Replaying the events of
518
+ * the same harness instance after `sequence` onto it gives the session's later states.
519
+ */
520
+ export interface IFlexSessionView {
521
+ /** Current metadata. Its status can be ahead of `sequence`: status changes emit no event. */
522
+ session: IFlexSession;
523
+ /** The visible messages, including what a running turn has streamed so far. */
524
+ messages: IFlexMessage[];
525
+ /**
526
+ * The session's pending permission requests, oldest first. A request its run leaves unanswered,
527
+ * because the run was stopped or failed, leaves this list without an event of its own; the run's
528
+ * `run.finished` follows.
529
+ */
530
+ pendingPermissions: IFlexPermissionRequest[];
531
+ /** The `sequence` of the instance's last event this view includes; 0 before the first event. */
532
+ sequence: number;
533
+ }
534
+
516
535
  export interface IFlexResolvedScope<TScope> {
517
536
  storageKey: string;
518
537
  scope: TScope;
package/ts/readme.md CHANGED
@@ -789,14 +789,14 @@ for (const group of info.groups) {
789
789
 
790
790
  The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. It never includes capture IDs or provider references.
791
791
 
792
- `workspaceSnapshots` is application-owned. Every context contains `scopeId`, `scope`, `storageKey`, `sessionId`, the required canonical `sessionGenerationId` and `sessionGenerationSequence`, `runId`, deterministic `captureId`, and an `AbortSignal`. Apply contexts additionally contain the normalized `reference`, deterministic per-segment `operationId`, and `direction`; release contexts contain the reference. Deleted-session recovery retains the same generation from the durable tombstone, so providers can reject callbacks from an older same-ID generation.
792
+ `workspaceSnapshots` is application-owned. Every context contains `scopeId`, `scope`, `storageKey`, `sessionId`, the required canonical `sessionGenerationId` and `sessionGenerationSequence`, `runId`, deterministic `captureId`, and an `AbortSignal`. Apply contexts additionally contain the normalized `reference`, per-segment `operationId`, and `direction`; release contexts contain the reference. Deleted-session recovery retains the same generation from the durable tombstone, so providers can reject callbacks from an older same-ID generation.
793
793
 
794
794
  The protocol is durable and inspectable:
795
795
 
796
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.
797
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.
798
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.
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.
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. Each `operationId` is unique to one apply of one segment: a later undo or redo gets a new ID even when it moves the same turn between the same cursors, such as undoing a turn again after a redo. Retries of that apply, in-process or after a restart, reuse its persisted ID, so providers must treat `operationId` idempotently and may record applied IDs to answer `inspectApply()`.
800
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.
801
801
 
802
802
  Under `workspace-required`, a group is a barrier when any segment is pending, nonrevertible, or legacy transcript-only history. It is a candidate when at least one segment is revertible and none is a barrier; otherwise it is no-change. Only candidates can be traversed. No-change groups after a candidate travel with that candidate until the next candidate or barrier. Leading no-change groups remain visible. A retained barrier blocks older groups, while later candidates remain undoable. A mixed revertible/nonrevertible group is a barrier.
@@ -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.