@nauticana/sail 1.1.20 → 1.1.21

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/README.md CHANGED
@@ -646,7 +646,7 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
646
646
  | `sail-prompt-section-editor` | One section's layers in server order. Read-only layers render as text; editable ones edit `instruction`, `output`, `merge_mode`, `sealed`. A sealed layer locks every layer below it. Effective text is shown as received, never merged client-side; after a save, replace the whole draft with the response. |
647
647
  | `sail-release-sections` | Frozen sections of a release with the layer (`source`) that decided each. |
648
648
  | `sail-turn-event-list` | Renders typed turn events in frame order; proposal, result, effect, and approval of one call meet by `call_id`. Effect status `unknown` is its own state. Unknown kinds are ignored; `extension` events render only through `[extensionTemplate]`. |
649
- | `TurnStream` | Per-turn stream state: `accept(frame)` drops frames already rendered, `cursor()` is the resume point, `markReplayExpired()` + `acceptStoredResult(frame)` handle an expired replay window, `markCancelRequested()` holds `cancelling` until the final frame. |
649
+ | `TurnStream` | Per-turn stream state: `accept(frame)` accepts Scout's zero-based frames and drops frames already rendered, `cursor()` is the next sequence wanted for inclusive replay, a suspension frame (`approval_pending`, even when marked final) sets `awaiting_approval` without advancing the cursor so the resumed frame at the same sequence is accepted, `markReplayExpired()` + `acceptStoredResult(frame)` handle an expired replay window, `markCancelRequested()` holds `cancelling` until the final frame. |
650
650
  | `sail-turn-stream-view` | `TurnStream` + event list + status + cancel button (`cancelRequested`). |
651
651
  | `sail-decision-timeline` | Read-only decision chain of one request, grouped by category. |
652
652
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.20",
3
+ "version": "1.1.21",
4
4
  "description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
@@ -12,22 +12,29 @@ const terminalStatuses: ReadonlySet<TurnStreamStatus> = new Set(['completed', 'c
12
12
  export class TurnStream {
13
13
  private readonly frames = signal<TurnReplyFrame[]>([]);
14
14
  private readonly state = signal<TurnStreamStatus>('idle');
15
- private readonly lastSequence = signal(0);
15
+ private readonly lastSequence = signal(-1);
16
+ private suspendedAt: number | null = null;
16
17
 
17
18
  readonly status = this.state.asReadonly();
18
- /** Last sequence rendered; a reconnect resumes after it. */
19
- readonly cursor = this.lastSequence.asReadonly();
19
+ /** Next zero-based sequence wanted by Scout's inclusive replay cursor. */
20
+ readonly cursor = computed(() => this.lastSequence() + 1);
20
21
  readonly events = computed<TurnEvent[]>(() => this.frames().flatMap((frame) => frame.events));
21
- readonly finalFrame = computed(() => this.frames().find((frame) => frame.final));
22
+ readonly finalFrame = computed(() => this.frames().find((frame) => frame.final && !isSuspension(frame)));
22
23
  readonly isTerminal = computed(() => terminalStatuses.has(this.state()));
23
24
 
24
25
  constructor(readonly requestId: string) {}
25
26
 
26
- /** Returns false for a frame of another turn, one already rendered, or one after the final. */
27
+ /**
28
+ * Returns false for a frame of another turn, one already rendered, or one after the final.
29
+ * A suspension frame does not advance the cursor: the resumed turn reuses its sequence.
30
+ */
27
31
  accept(frame: TurnReplyFrame): boolean {
28
32
  if (frame.request_id !== this.requestId || this.isTerminal()) return false;
29
33
  if (frame.sequence <= this.lastSequence()) return false;
30
- this.lastSequence.set(frame.sequence);
34
+ const suspension = isSuspension(frame);
35
+ if (suspension && this.suspendedAt === frame.sequence) return false;
36
+ this.suspendedAt = suspension ? frame.sequence : null;
37
+ if (!suspension) this.lastSequence.set(frame.sequence);
31
38
  this.append(frame);
32
39
  return true;
33
40
  }
@@ -58,13 +65,16 @@ export class TurnStream {
58
65
  }
59
66
 
60
67
  private statusAfter(frame: TurnReplyFrame): TurnStreamStatus {
61
- if (frame.final) {
68
+ if (frame.final && !isSuspension(frame)) {
62
69
  if (frame.error_code === TURN_ERROR_CANCELED) return 'canceled';
63
70
  return frame.error_code ? 'failed' : 'completed';
64
71
  }
65
72
  if (this.state() === 'cancelling') return 'cancelling';
66
- // approval_pending ends the delivery, not the turn: resumed frames follow on the same stream.
67
- const last = frame.events[frame.events.length - 1];
68
- return last?.kind === 'approval_pending' ? 'awaiting_approval' : 'streaming';
73
+ return isSuspension(frame) ? 'awaiting_approval' : 'streaming';
69
74
  }
70
75
  }
76
+
77
+ /** approval_pending ends the delivery, not the turn, even on a frame marked final with an error code. */
78
+ function isSuspension(frame: TurnReplyFrame): boolean {
79
+ return frame.events[frame.events.length - 1]?.kind === 'approval_pending';
80
+ }