@kuralle-syrinx/core 4.1.0 → 4.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.
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Incremental-Unit ledger (IU substrate, RFC incremental-unit-substrate)
4
+
5
+ import type { IncrementalUnit, IncrementalUnitId, IuState } from "./incremental-unit.js";
6
+
7
+ export interface IuLedger {
8
+ add(iu: IncrementalUnit): void;
9
+ commit(id: IncrementalUnitId, prefix?: { chars?: number; ms?: number }): void;
10
+ revoke(id: IncrementalUnitId): void;
11
+ get(id: IncrementalUnitId): IncrementalUnit | undefined;
12
+ latest(contextId: string, kind: IncrementalUnit["kind"]): IncrementalUnit | undefined;
13
+ clear(contextId: string): void;
14
+ }
15
+
16
+ export type IuLedgerAnomaly =
17
+ | { readonly kind: "terminal_op"; readonly op: "commit" | "revoke"; readonly id: IncrementalUnitId; readonly state: IuState }
18
+ | { readonly kind: "unknown_iu"; readonly op: "commit" | "revoke"; readonly id: IncrementalUnitId };
19
+
20
+ export class InMemoryIuLedger implements IuLedger {
21
+ private readonly byCtx = new Map<string, Map<string, IncrementalUnit>>();
22
+
23
+ constructor(
24
+ private readonly onEvent: (a: IuLedgerAnomaly) => void = () => {},
25
+ private readonly maxContexts: number = 256,
26
+ ) {}
27
+
28
+ add(iu: IncrementalUnit): void {
29
+ const ctx = iu.id.contextId;
30
+ if (!this.byCtx.has(ctx) && this.byCtx.size >= this.maxContexts) {
31
+ const oldest = this.byCtx.keys().next().value;
32
+ if (oldest !== undefined) this.byCtx.delete(oldest);
33
+ }
34
+ this.ctxMap(ctx).set(iu.id.iuId, iu);
35
+ }
36
+
37
+ commit(id: IncrementalUnitId, prefix?: { chars?: number; ms?: number }): void {
38
+ const ctxMap = this.byCtx.get(id.contextId);
39
+ if (!ctxMap) {
40
+ this.onEvent({ kind: "unknown_iu", op: "commit", id });
41
+ return;
42
+ }
43
+ const iu = ctxMap.get(id.iuId);
44
+ if (!iu) {
45
+ this.onEvent({ kind: "unknown_iu", op: "commit", id });
46
+ return;
47
+ }
48
+ if (iu.state !== "hypothesized") {
49
+ this.onEvent({ kind: "terminal_op", op: "commit", id, state: iu.state });
50
+ return;
51
+ }
52
+ iu.state = "committed";
53
+ if (prefix) {
54
+ iu.committedPrefix = prefix;
55
+ }
56
+ }
57
+
58
+ revoke(id: IncrementalUnitId): void {
59
+ const ctxMap = this.byCtx.get(id.contextId);
60
+ if (!ctxMap) {
61
+ this.onEvent({ kind: "unknown_iu", op: "revoke", id });
62
+ return;
63
+ }
64
+ const iu = ctxMap.get(id.iuId);
65
+ if (!iu) {
66
+ this.onEvent({ kind: "unknown_iu", op: "revoke", id });
67
+ return;
68
+ }
69
+ if (iu.state !== "hypothesized") {
70
+ this.onEvent({ kind: "terminal_op", op: "revoke", id, state: iu.state });
71
+ return;
72
+ }
73
+ iu.state = "revoked";
74
+ }
75
+
76
+ get(id: IncrementalUnitId): IncrementalUnit | undefined {
77
+ const ctxMap = this.byCtx.get(id.contextId);
78
+ if (!ctxMap) {
79
+ return undefined;
80
+ }
81
+ return ctxMap.get(id.iuId);
82
+ }
83
+
84
+ latest(contextId: string, kind: IncrementalUnit["kind"]): IncrementalUnit | undefined {
85
+ const ctxMap = this.byCtx.get(contextId);
86
+ if (!ctxMap) {
87
+ return undefined;
88
+ }
89
+ let found: IncrementalUnit | undefined;
90
+ for (const iu of ctxMap.values()) {
91
+ if (iu.kind === kind) {
92
+ found = iu;
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+
98
+ clear(contextId: string): void {
99
+ this.byCtx.delete(contextId);
100
+ }
101
+
102
+ private ctxMap(contextId: string): Map<string, IncrementalUnit> {
103
+ let map = this.byCtx.get(contextId);
104
+ if (!map) {
105
+ map = new Map();
106
+ this.byCtx.set(contextId, map);
107
+ }
108
+ return map;
109
+ }
110
+ }
@@ -2,7 +2,26 @@
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
4
 
5
- import { reasoningResume, reasoningSuspended } from "./packet-factories.js";
5
+ import { reasoningResume, reasoningSuspended, sttPartial } from "./packet-factories.js";
6
+
7
+ describe("sttPartial", () => {
8
+ it("returns a stt.partial packet with optional wordTimings", () => {
9
+ const timings = [{ word: "hello", startMs: 100, endMs: 250, confidence: 0.9 }];
10
+ expect(sttPartial("ctx-1", 1000, "hello world", timings)).toEqual({
11
+ kind: "stt.partial",
12
+ contextId: "ctx-1",
13
+ timestampMs: 1000,
14
+ text: "hello world",
15
+ wordTimings: timings,
16
+ });
17
+ expect(sttPartial("ctx-1", 1000, "hello")).toEqual({
18
+ kind: "stt.partial",
19
+ contextId: "ctx-1",
20
+ timestampMs: 1000,
21
+ text: "hello",
22
+ });
23
+ });
24
+ });
6
25
 
7
26
  describe("reasoningSuspended", () => {
8
27
  it("returns a reasoning.suspended packet with the expected shape", () => {
@@ -7,6 +7,7 @@
7
7
  // definition and call sites need no `as` assertion — illegal packet shapes are
8
8
  // unrepresentable at construction instead of asserted-valid by a cast (CR-05).
9
9
 
10
+ import type { WordTiming } from "./interaction-policy.js";
10
11
  import { ErrorCategory } from "./packets.js";
11
12
  import type {
12
13
  ConversationMetricPacket,
@@ -21,7 +22,9 @@ import type {
21
22
  SpeechToTextAudioPacket,
22
23
  EndOfSpeechAudioPacket,
23
24
  EndOfSpeechPacket,
25
+ SttPartialPacket,
24
26
  SttResultPacket,
27
+ FinalizeSttPacket,
25
28
  UserInputPacket,
26
29
  TextToSpeechTextPacket,
27
30
  TextToSpeechDonePacket,
@@ -39,6 +42,7 @@ import type {
39
42
  StartIdleTimeoutPacket,
40
43
  StopIdleTimeoutPacket,
41
44
  ModeSwitchRequestedPacket,
45
+ InteractionBackchannelPacket,
42
46
  } from "./packets.js";
43
47
 
44
48
  const DTMF_DIGITS = new Set<DtmfDigit>(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"]);
@@ -115,6 +119,17 @@ export function sttAudio(contextId: string, timestampMs: number, audio: Uint8Arr
115
119
  return { kind: "stt.audio", contextId, timestampMs, audio };
116
120
  }
117
121
 
122
+ export function sttPartial(
123
+ contextId: string,
124
+ timestampMs: number,
125
+ text: string,
126
+ wordTimings?: readonly WordTiming[],
127
+ ): SttPartialPacket {
128
+ return wordTimings
129
+ ? { kind: "stt.partial", contextId, timestampMs, text, wordTimings }
130
+ : { kind: "stt.partial", contextId, timestampMs, text };
131
+ }
132
+
118
133
  export function eosAudio(contextId: string, timestampMs: number, audio: Uint8Array): EndOfSpeechAudioPacket {
119
134
  return { kind: "eos.audio", contextId, timestampMs, audio };
120
135
  }
@@ -128,6 +143,10 @@ export function eosTurnComplete(
128
143
  return { kind: "eos.turn_complete", contextId, timestampMs, text, transcripts };
129
144
  }
130
145
 
146
+ export function finalizeStt(contextId: string, timestampMs: number): FinalizeSttPacket {
147
+ return { kind: "stt.finalize", contextId, timestampMs };
148
+ }
149
+
131
150
  export function userInput(
132
151
  contextId: string,
133
152
  timestampMs: number,
@@ -241,3 +260,11 @@ export function modeSwitchRequested(
241
260
  ): ModeSwitchRequestedPacket {
242
261
  return { kind: "mode.switch_requested", contextId, timestampMs, mode };
243
262
  }
263
+
264
+ export function interactionBackchannel(
265
+ contextId: string,
266
+ timestampMs: number,
267
+ cue: string,
268
+ ): InteractionBackchannelPacket {
269
+ return { kind: "interaction.backchannel", contextId, timestampMs, cue };
270
+ }
package/src/packets.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  // Errors: SttError, TtsError, LlmError
10
10
  // Lifecycle: InitStepCompleted, InitFailed, InitCompleted
11
11
 
12
+ import type { WordTiming } from "./interaction-policy.js";
13
+
12
14
  // =============================================================================
13
15
  // Base Types
14
16
  // =============================================================================
@@ -44,7 +46,7 @@ export enum ErrorCategory {
44
46
 
45
47
  export interface VoiceErrorPacket extends VoicePacket {
46
48
  /** Which component emitted the error. */
47
- readonly component: "stt" | "tts" | "vad" | "eos" | "denoiser" | "llm" | "bridge" | "pipeline";
49
+ readonly component: "stt" | "tts" | "vad" | "eos" | "denoiser" | "llm" | "bridge" | "pipeline" | "iu_ledger";
48
50
  /** Machine-readable error category. */
49
51
  readonly category: ErrorCategory;
50
52
  /** Original error. May contain provider-specific details. */
@@ -120,8 +122,10 @@ export interface AudioFormat {
120
122
 
121
123
  export interface UserAudioReceivedPacket extends VoicePacket {
122
124
  readonly kind: "user.audio_received";
123
- /** Raw PCM audio (16-bit, mono, 16kHz). */
125
+ /** Raw PCM audio (16-bit, mono). Rate given by `sampleRateHz` (defaults to 16kHz). */
124
126
  readonly audio: Uint8Array;
127
+ /** Sample rate of `audio` in Hz. Omitted means 16000 (the legacy default). */
128
+ readonly sampleRateHz?: number;
125
129
  }
126
130
 
127
131
  export interface UserTextReceivedPacket extends VoicePacket {
@@ -176,6 +180,12 @@ export interface SttInterimPacket extends VoicePacket {
176
180
  readonly text: string;
177
181
  }
178
182
 
183
+ export interface SttPartialPacket extends VoicePacket {
184
+ readonly kind: "stt.partial";
185
+ readonly text: string;
186
+ readonly wordTimings?: readonly WordTiming[];
187
+ }
188
+
179
189
  export interface SttResultPacket extends VoicePacket {
180
190
  readonly kind: "stt.result";
181
191
  readonly text: string;
@@ -286,7 +296,7 @@ export interface LlmResponseDonePacket extends VoicePacket {
286
296
 
287
297
  export interface LlmErrorPacket extends VoicePacket, VoiceErrorPacket {
288
298
  readonly kind: "llm.error";
289
- readonly component: "llm" | "bridge";
299
+ readonly component: "llm" | "bridge" | "iu_ledger";
290
300
  }
291
301
 
292
302
  export interface LlmToolCallPacket extends VoicePacket {
@@ -460,6 +470,16 @@ export interface RecordAssistantAudioTruncatePacket extends VoicePacket {
460
470
 
461
471
  export type RecordAssistantAudioPacket = RecordAssistantAudioDataPacket | RecordAssistantAudioTruncatePacket;
462
472
 
473
+ // =============================================================================
474
+ // Interaction Policy Packets
475
+ // =============================================================================
476
+
477
+ /** Pre-cached backchannel cue id rendered by the outbound mixer (IP-C3). */
478
+ export interface InteractionBackchannelPacket extends VoicePacket {
479
+ readonly kind: "interaction.backchannel";
480
+ readonly cue: string;
481
+ }
482
+
463
483
  // =============================================================================
464
484
  // Behavior Packets
465
485
  // =============================================================================
@@ -560,6 +580,7 @@ export type InputPacket =
560
580
  | VadSpeechActivityPacket
561
581
  | SpeechToTextAudioPacket
562
582
  | SttInterimPacket
583
+ | SttPartialPacket
563
584
  | SttResultPacket
564
585
  | FinalizeSttPacket
565
586
  | SttErrorPacket
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import type { InteractionObservation, InteractionPolicy } from "../interaction-policy.js";
5
+ import { DeferInteractionPolicy } from "./defer.js";
6
+
7
+ const OBSERVATIONS: InteractionObservation[] = [
8
+ {
9
+ kind: "vad_speech_started",
10
+ contextId: "user",
11
+ timestampMs: 1000,
12
+ confidence: 0.99,
13
+ interruptedContextId: "assistant-turn",
14
+ },
15
+ {
16
+ kind: "vad_speech_activity",
17
+ contextId: "user",
18
+ timestampMs: 1300,
19
+ },
20
+ {
21
+ kind: "vad_speech_ended",
22
+ contextId: "user",
23
+ timestampMs: 2000,
24
+ hasActiveTts: true,
25
+ },
26
+ {
27
+ kind: "vad_barge_in_audio",
28
+ contextId: "user",
29
+ timestampMs: 1500,
30
+ audio: new Uint8Array([1, 2, 3]),
31
+ },
32
+ {
33
+ kind: "stt_partial",
34
+ contextId: "user",
35
+ timestampMs: 1600,
36
+ text: "hello",
37
+ confidence: 0.9,
38
+ interruptedContextId: "assistant-turn",
39
+ },
40
+ {
41
+ kind: "stt_final",
42
+ contextId: "user",
43
+ timestampMs: 2000,
44
+ text: "hello there",
45
+ confidence: 0.95,
46
+ interruptedContextId: "assistant-turn",
47
+ },
48
+ {
49
+ kind: "audio_frame",
50
+ contextId: "user",
51
+ timestampMs: 1700,
52
+ audio: new Int16Array([1, 2, 3]),
53
+ },
54
+ {
55
+ kind: "playout_tick",
56
+ contextId: "assistant-turn",
57
+ timestampMs: 1800,
58
+ playedOutMs: 500,
59
+ ttsActive: true,
60
+ },
61
+ {
62
+ kind: "delegate_state",
63
+ contextId: "turn-1",
64
+ timestampMs: 1900,
65
+ delegateInFlight: true,
66
+ },
67
+ ];
68
+
69
+ describe("DeferInteractionPolicy", () => {
70
+ it("implements InteractionPolicy", () => {
71
+ const policy: InteractionPolicy = new DeferInteractionPolicy();
72
+ expect(policy).toBeInstanceOf(DeferInteractionPolicy);
73
+ });
74
+
75
+ it("observe returns [] for every observation kind", () => {
76
+ const policy = new DeferInteractionPolicy();
77
+ for (const obs of OBSERVATIONS) {
78
+ expect(policy.observe(obs)).toEqual([]);
79
+ }
80
+ });
81
+
82
+ it("reset is a no-op", () => {
83
+ const policy = new DeferInteractionPolicy();
84
+ expect(() => policy.reset("any-context")).not.toThrow();
85
+ });
86
+ });
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { InteractionDecision, InteractionObservation, InteractionPolicy } from "../interaction-policy.js";
4
+
5
+ /**
6
+ * The front owns full-duplex interaction (turn-taking, barge-in, backchannels). This policy takes no
7
+ * action — it observes only, so the coordinator drives nothing and the front's own decisions (surfaced
8
+ * by its adapter/bridge as interrupt.detected / eos.turn_complete) stand. RFC InteractionPolicy REQ-4.
9
+ */
10
+ export class DeferInteractionPolicy implements InteractionPolicy {
11
+ observe(_obs: InteractionObservation): readonly InteractionDecision[] {
12
+ return [];
13
+ }
14
+ reset(_contextId: string): void {}
15
+ }
@@ -0,0 +1,269 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import { PipelineBusImpl } from "../pipeline-bus.js";
5
+ import { RuleBasedInteractionPolicy } from "./rule-based.js";
6
+ import { PrimarySpeakerGate } from "../primary-speaker-gate.js";
7
+ import { TtsPlayoutClock } from "../tts-playout-clock.js";
8
+
9
+ async function createPolicy(minInterruptionMs = 280) {
10
+ const bus = new PipelineBusImpl();
11
+ void bus.start();
12
+ const ttsPlayout = new TtsPlayoutClock();
13
+ const policy = new RuleBasedInteractionPolicy({
14
+ bus,
15
+ primarySpeakerGate: new PrimarySpeakerGate(),
16
+ ttsPlayout,
17
+ minInterruptionMs,
18
+ });
19
+ return { bus, ttsPlayout, policy };
20
+ }
21
+
22
+ function metricNames(bus: PipelineBusImpl): string[] {
23
+ const names: string[] = [];
24
+ bus.on("metric.conversation", (pkt) => {
25
+ names.push((pkt as unknown as { name: string }).name);
26
+ });
27
+ return names;
28
+ }
29
+
30
+ async function drainBus(): Promise<void> {
31
+ await new Promise((resolve) => setTimeout(resolve, 0));
32
+ }
33
+
34
+ describe("RuleBasedInteractionPolicy", () => {
35
+ it("parity vector: sustained speech commits interrupt at activity frame", async () => {
36
+ const { ttsPlayout, policy } = await createPolicy(280);
37
+ ttsPlayout.noteAudio("assistant-turn", 100, 1000);
38
+
39
+ const t0 = 2000;
40
+ expect(
41
+ policy.observe({
42
+ kind: "vad_speech_started",
43
+ contextId: "user",
44
+ timestampMs: t0,
45
+ confidence: 0.99,
46
+ interruptedContextId: "assistant-turn",
47
+ }),
48
+ ).toEqual([]);
49
+
50
+ expect(
51
+ policy.observe({
52
+ kind: "vad_speech_activity",
53
+ contextId: "user",
54
+ timestampMs: t0 + 300,
55
+ }),
56
+ ).toEqual([{ kind: "interrupt", interruptedContextId: "assistant-turn" }]);
57
+ });
58
+
59
+ it("suppresses short speech blip without interrupt decision", async () => {
60
+ const { bus, ttsPlayout, policy } = await createPolicy(280);
61
+ const metrics = metricNames(bus);
62
+ ttsPlayout.noteAudio("assistant-turn", 100, 1000);
63
+
64
+ const t0 = 3000;
65
+ policy.observe({
66
+ kind: "vad_speech_started",
67
+ contextId: "user",
68
+ timestampMs: t0,
69
+ confidence: 0.99,
70
+ interruptedContextId: "assistant-turn",
71
+ });
72
+ policy.observe({
73
+ kind: "vad_speech_activity",
74
+ contextId: "user",
75
+ timestampMs: t0 + 90,
76
+ });
77
+ const decisions = policy.observe({
78
+ kind: "vad_speech_ended",
79
+ contextId: "user",
80
+ timestampMs: t0 + 130,
81
+ hasActiveTts: true,
82
+ });
83
+ await drainBus();
84
+
85
+ expect(decisions).toEqual([]);
86
+ expect(metrics).toContain("interrupt.suppressed_short_speech");
87
+ });
88
+
89
+ it("suppresses backchannel interim without interrupt decision", async () => {
90
+ const { bus, ttsPlayout, policy } = await createPolicy(280);
91
+ const metrics = metricNames(bus);
92
+ ttsPlayout.noteAudio("assistant-turn", 100, 1000);
93
+
94
+ policy.observe({
95
+ kind: "vad_speech_started",
96
+ contextId: "user",
97
+ timestampMs: 2000,
98
+ confidence: 0.99,
99
+ interruptedContextId: "assistant-turn",
100
+ });
101
+ policy.observe({
102
+ kind: "stt_partial",
103
+ contextId: "user",
104
+ timestampMs: 2050,
105
+ text: "uh huh",
106
+ });
107
+ const decisions = policy.observe({
108
+ kind: "vad_speech_activity",
109
+ contextId: "user",
110
+ timestampMs: 2300,
111
+ });
112
+ await drainBus();
113
+
114
+ expect(decisions).toEqual([]);
115
+ expect(metrics).toContain("interrupt.suppressed_backchannel");
116
+ });
117
+
118
+ it("IP-C3: emits no backchannel on VAD/user-pause observations", async () => {
119
+ const { policy } = await createPolicy();
120
+
121
+ expect(
122
+ policy.observe({
123
+ kind: "vad_speech_ended",
124
+ contextId: "user",
125
+ timestampMs: 5000,
126
+ hasActiveTts: false,
127
+ }),
128
+ ).toEqual([]);
129
+ expect(
130
+ policy.observe({
131
+ kind: "vad_speech_activity",
132
+ contextId: "user",
133
+ timestampMs: 5100,
134
+ }),
135
+ ).toEqual([]);
136
+ });
137
+
138
+ it("IP-C3: emits exactly one cue on started → delayed and clears on complete", async () => {
139
+ const { policy } = await createPolicy();
140
+
141
+ policy.observe({
142
+ kind: "delegate_state",
143
+ contextId: "turn-1",
144
+ timestampMs: 1000,
145
+ toolCallPhase: "started",
146
+ });
147
+ expect(
148
+ policy.observe({
149
+ kind: "delegate_state",
150
+ contextId: "turn-1",
151
+ timestampMs: 3000,
152
+ toolCallPhase: "delayed",
153
+ }),
154
+ ).toEqual([{ kind: "backchannel", cue: "mm_hmm" }]);
155
+ expect(
156
+ policy.observe({
157
+ kind: "delegate_state",
158
+ contextId: "turn-1",
159
+ timestampMs: 3200,
160
+ toolCallPhase: "delayed",
161
+ }),
162
+ ).toEqual([]);
163
+ policy.observe({
164
+ kind: "delegate_state",
165
+ contextId: "turn-1",
166
+ timestampMs: 4000,
167
+ toolCallPhase: "complete",
168
+ });
169
+ policy.observe({
170
+ kind: "delegate_state",
171
+ contextId: "turn-1",
172
+ timestampMs: 5000,
173
+ toolCallPhase: "started",
174
+ });
175
+ expect(
176
+ policy.observe({
177
+ kind: "delegate_state",
178
+ contextId: "turn-1",
179
+ timestampMs: 7000,
180
+ toolCallPhase: "delayed",
181
+ }),
182
+ ).toEqual([{ kind: "backchannel", cue: "mm_hmm" }]);
183
+ });
184
+
185
+ it("IP-C3: suppresses backchannel when TTS is active or the user is speaking", async () => {
186
+ const { ttsPlayout, policy } = await createPolicy();
187
+ ttsPlayout.noteAudio("assistant-turn", 500, 1000);
188
+
189
+ policy.observe({
190
+ kind: "delegate_state",
191
+ contextId: "turn-1",
192
+ timestampMs: 1000,
193
+ toolCallPhase: "started",
194
+ });
195
+ expect(
196
+ policy.observe({
197
+ kind: "delegate_state",
198
+ contextId: "turn-1",
199
+ timestampMs: 3000,
200
+ toolCallPhase: "delayed",
201
+ }),
202
+ ).toEqual([]);
203
+
204
+ ttsPlayout.release("assistant-turn");
205
+ policy.observe({
206
+ kind: "delegate_state",
207
+ contextId: "turn-2",
208
+ timestampMs: 4000,
209
+ toolCallPhase: "started",
210
+ });
211
+ policy.observe({
212
+ kind: "vad_speech_started",
213
+ contextId: "user",
214
+ timestampMs: 4100,
215
+ confidence: 0.9,
216
+ interruptedContextId: "",
217
+ });
218
+ expect(
219
+ policy.observe({
220
+ kind: "delegate_state",
221
+ contextId: "turn-2",
222
+ timestampMs: 6000,
223
+ toolCallPhase: "delayed",
224
+ }),
225
+ ).toEqual([]);
226
+ });
227
+
228
+ it("IP-C3: reset clears delegate-gap backchannel state", async () => {
229
+ const { policy } = await createPolicy();
230
+
231
+ policy.observe({
232
+ kind: "delegate_state",
233
+ contextId: "turn-1",
234
+ timestampMs: 1000,
235
+ toolCallPhase: "started",
236
+ });
237
+ policy.reset("turn-1");
238
+ expect(
239
+ policy.observe({
240
+ kind: "delegate_state",
241
+ contextId: "turn-1",
242
+ timestampMs: 3000,
243
+ toolCallPhase: "delayed",
244
+ }),
245
+ ).toEqual([]);
246
+ });
247
+
248
+ it("reset clears pending state", async () => {
249
+ const { ttsPlayout, policy } = await createPolicy(280);
250
+ ttsPlayout.noteAudio("assistant-turn", 100, 1000);
251
+
252
+ const t0 = 4000;
253
+ policy.observe({
254
+ kind: "vad_speech_started",
255
+ contextId: "user",
256
+ timestampMs: t0,
257
+ confidence: 0.99,
258
+ interruptedContextId: "assistant-turn",
259
+ });
260
+ policy.reset("user");
261
+
262
+ const decisions = policy.observe({
263
+ kind: "vad_speech_activity",
264
+ contextId: "user",
265
+ timestampMs: t0 + 300,
266
+ });
267
+ expect(decisions).toEqual([]);
268
+ });
269
+ });