@nifrajs/coding-agent 3.2.0 → 3.5.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.
Files changed (49) hide show
  1. package/dist/context.js +29 -1
  2. package/dist/context.js.map +1 -1
  3. package/dist/diff.js +38 -2
  4. package/dist/diff.js.map +1 -1
  5. package/dist/host.d.ts.map +1 -1
  6. package/dist/host.js +217 -24
  7. package/dist/host.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/isolated.js +37 -2
  13. package/dist/isolated.js.map +1 -1
  14. package/dist/native.d.ts +17 -0
  15. package/dist/native.d.ts.map +1 -1
  16. package/dist/native.js +265 -41
  17. package/dist/native.js.map +1 -1
  18. package/dist/replay.d.ts +4 -0
  19. package/dist/replay.d.ts.map +1 -1
  20. package/dist/replay.js +189 -24
  21. package/dist/replay.js.map +1 -1
  22. package/dist/rpc.d.ts +10 -0
  23. package/dist/rpc.d.ts.map +1 -1
  24. package/dist/rpc.js +208 -29
  25. package/dist/rpc.js.map +1 -1
  26. package/dist/sessions.d.ts +14 -2
  27. package/dist/sessions.d.ts.map +1 -1
  28. package/dist/sessions.js +253 -59
  29. package/dist/sessions.js.map +1 -1
  30. package/dist/verification.d.ts +23 -0
  31. package/dist/verification.d.ts.map +1 -1
  32. package/dist/verification.js +113 -1
  33. package/dist/verification.js.map +1 -1
  34. package/dist/workflows.d.ts +1 -0
  35. package/dist/workflows.d.ts.map +1 -1
  36. package/dist/workflows.js +43 -21
  37. package/dist/workflows.js.map +1 -1
  38. package/package.json +6 -5
  39. package/src/context.ts +32 -1
  40. package/src/diff.ts +40 -2
  41. package/src/host.ts +219 -24
  42. package/src/index.ts +5 -0
  43. package/src/isolated.ts +39 -2
  44. package/src/native.ts +408 -42
  45. package/src/replay.ts +206 -25
  46. package/src/rpc.ts +261 -33
  47. package/src/sessions.ts +271 -56
  48. package/src/verification.ts +140 -1
  49. package/src/workflows.ts +37 -25
package/src/native.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  agentError,
14
14
  type CreateSessionInput,
15
15
  createAgentEventStream,
16
+ isAgentEvent,
16
17
  type ReloadResult,
17
18
  type SendMessageInput,
18
19
  } from "@nifrajs/agent-protocol"
@@ -102,6 +103,12 @@ function parseNativeModelResponse(value: unknown): NativeModelResponse {
102
103
  export interface NativeApprovalPort {
103
104
  request(input: {
104
105
  readonly sessionId: string
106
+ /** Stable, bounded identifier for this approval boundary. */
107
+ readonly approvalId: string
108
+ readonly turnId: string
109
+ readonly callId: string
110
+ readonly action: string
111
+ readonly capability: string
105
112
  readonly tool: NativeTool
106
113
  readonly input: unknown
107
114
  readonly signal: AbortSignal
@@ -112,11 +119,39 @@ export interface NifraBackendOptions {
112
119
  readonly model: NativeModelPort
113
120
  readonly tools?: readonly NativeTool[]
114
121
  readonly approval?: NativeApprovalPort
122
+ /** Maximum time a protocol-visible native approval may remain unresolved. */
123
+ readonly approvalTimeoutMs?: number
115
124
  readonly maxSteps?: number
116
125
  readonly maxMessageChars?: number
117
126
  readonly now?: () => number
118
127
  }
119
128
 
129
+ type NativeApprovalFailureCode =
130
+ | "APPROVAL_DENIED"
131
+ | "APPROVAL_CANCELLED"
132
+ | "APPROVAL_TIMEOUT"
133
+ | "APPROVAL_CLOSED"
134
+ | "APPROVAL_FAILED"
135
+
136
+ interface NativeApprovalResolution {
137
+ readonly approved: boolean
138
+ readonly reason?: string
139
+ readonly errorCode?: NativeApprovalFailureCode
140
+ }
141
+
142
+ interface NativePendingApproval {
143
+ readonly id: string
144
+ readonly sessionId: string
145
+ readonly turnId: string
146
+ readonly callId: string
147
+ readonly action: string
148
+ readonly capability: string
149
+ readonly resolve: (resolution: NativeApprovalResolution) => void
150
+ readonly timer: ReturnType<typeof setTimeout>
151
+ readonly onAbort: () => void
152
+ readonly signal: AbortSignal
153
+ }
154
+
120
155
  interface NativeSession {
121
156
  readonly id: string
122
157
  readonly cwd: string
@@ -124,9 +159,12 @@ interface NativeSession {
124
159
  controller: AbortController
125
160
  snapshot: AgentSessionSnapshot
126
161
  active: AgentEventStream | undefined
162
+ turnId: string | undefined
163
+ turnAbortCleanup: (() => void) | undefined
127
164
  seq: number
128
165
  extensionRevision: number
129
166
  closed: boolean
167
+ cancelled: boolean
130
168
  }
131
169
 
132
170
  /**
@@ -144,6 +182,7 @@ export class NifraBackend implements AgentBackend {
144
182
  private readonly options: Required<Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars">> &
145
183
  NifraBackendOptions
146
184
  private readonly sessions = new Map<string, NativeSession>()
185
+ private readonly pendingApprovals = new Map<string, NativePendingApproval>()
147
186
 
148
187
  constructor(options: NifraBackendOptions) {
149
188
  this.options = Object.freeze({
@@ -160,9 +199,30 @@ export class NifraBackend implements AgentBackend {
160
199
  throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
161
200
  if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
162
201
  throw new RangeError("nifra backend: maxMessageChars must be at least 256")
202
+ if (
203
+ this.options.approvalTimeoutMs !== undefined &&
204
+ (!Number.isSafeInteger(this.options.approvalTimeoutMs) ||
205
+ this.options.approvalTimeoutMs < 1 ||
206
+ this.options.approvalTimeoutMs > 24 * 60 * 60_000)
207
+ )
208
+ throw new RangeError("nifra backend: approvalTimeoutMs must be between 1ms and 24h")
163
209
  }
164
210
 
165
211
  async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
212
+ if (
213
+ typeof input.cwd !== "string" ||
214
+ input.cwd.length === 0 ||
215
+ input.cwd.length > 4_096 ||
216
+ input.cwd.includes("\0")
217
+ )
218
+ throw new TypeError("nifra backend: cwd must be a bounded non-empty path")
219
+ if (
220
+ input.capabilities !== undefined &&
221
+ (!Array.isArray(input.capabilities) ||
222
+ input.capabilities.length > 256 ||
223
+ !input.capabilities.every((value) => boundedApprovalToken(value)))
224
+ )
225
+ throw new TypeError("nifra backend: capabilities must be bounded tokens")
166
226
  const id = input.sessionId ?? crypto.randomUUID()
167
227
  if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(id))
168
228
  throw new TypeError("nifra backend: sessionId must be a bounded token")
@@ -186,9 +246,12 @@ export class NifraBackend implements AgentBackend {
186
246
  controller: new AbortController(),
187
247
  snapshot,
188
248
  active: undefined,
249
+ turnId: undefined,
250
+ turnAbortCleanup: undefined,
189
251
  seq: 0,
190
252
  extensionRevision: 0,
191
253
  closed: false,
254
+ cancelled: false,
192
255
  }
193
256
  this.sessions.set(id, session)
194
257
  return snapshot
@@ -205,31 +268,41 @@ export class NifraBackend implements AgentBackend {
205
268
  )
206
269
  const stream = createAgentEventStream()
207
270
  session.active = stream
271
+ session.cancelled = false
208
272
  session.controller = new AbortController()
209
273
  const signal = session.controller.signal
274
+ const turnId = crypto.randomUUID()
275
+ session.turnId = turnId
210
276
  if (input.signal !== undefined) {
211
277
  const externalSignal = input.signal
212
- if (externalSignal.aborted) session.controller.abort(externalSignal.reason)
213
- else
214
- externalSignal.addEventListener(
215
- "abort",
216
- () => session.controller.abort(externalSignal.reason),
217
- { once: true },
278
+ const onAbort = (): void => {
279
+ // Capture the turn identity. A request signal must never abort a later turn that reused
280
+ // this session after the original stream settled.
281
+ if (
282
+ session.active !== stream ||
283
+ session.turnId !== turnId ||
284
+ session.controller.signal !== signal
218
285
  )
286
+ return
287
+ void this.cancelTurn(session, stream, turnId, "cancelled")
288
+ }
289
+ if (externalSignal.aborted) onAbort()
290
+ else {
291
+ externalSignal.addEventListener("abort", onAbort, { once: true })
292
+ session.turnAbortCleanup = () => externalSignal.removeEventListener("abort", onAbort)
293
+ // Abort can race the registration between the check above and addEventListener.
294
+ if (externalSignal.aborted) onAbort()
295
+ }
219
296
  }
220
- void this.run(session, input.message, stream, signal)
297
+ void this.run(session, input.message, stream, signal, turnId)
221
298
  return stream
222
299
  }
223
300
 
224
301
  async cancel(sessionId: string, reason = "cancelled"): Promise<void> {
225
302
  const session = this.requireSession(sessionId)
226
303
  if (session.closed) return
227
- const stream = session.active
228
- session.controller.abort(reason)
229
- this.update(session, "stopped")
230
- this.emit(session, { type: "session.stopped", reason })
231
- stream?.complete()
232
- session.active = undefined
304
+ if (session.active === undefined) return
305
+ await this.cancelTurn(session, session.active, session.turnId, reason)
233
306
  }
234
307
 
235
308
  async snapshot(sessionId: string): Promise<AgentSessionSnapshot> {
@@ -256,13 +329,37 @@ export class NifraBackend implements AgentBackend {
256
329
  return { revision, loaded: [], disabled: [], rolledBack: false }
257
330
  }
258
331
 
332
+ async resolveApproval(
333
+ sessionId: string,
334
+ approvalId: string,
335
+ approved: boolean,
336
+ reason?: string,
337
+ ): Promise<boolean | undefined> {
338
+ const session = this.requireSession(sessionId)
339
+ if (session.closed || session.cancelled) return undefined
340
+ const pending = this.pendingApprovals.get(approvalId)
341
+ if (pending === undefined || pending.sessionId !== sessionId) return undefined
342
+ const resolution = this.settleApproval(
343
+ session,
344
+ approvalId,
345
+ approved === true,
346
+ approved === true ? reason : (reason ?? "approval denied"),
347
+ approved === true ? undefined : "APPROVAL_DENIED",
348
+ )
349
+ return resolution?.approved
350
+ }
351
+
259
352
  async close(sessionId: string): Promise<void> {
260
353
  const session = this.requireSession(sessionId)
261
354
  if (session.closed) return
262
355
  session.closed = true
356
+ session.cancelled = true
357
+ session.turnAbortCleanup?.()
358
+ session.turnAbortCleanup = undefined
263
359
  session.controller.abort("closed")
264
360
  session.active?.complete()
265
361
  session.active = undefined
362
+ session.turnId = undefined
266
363
  this.sessions.delete(sessionId)
267
364
  }
268
365
 
@@ -271,14 +368,18 @@ export class NifraBackend implements AgentBackend {
271
368
  message: string,
272
369
  stream: AgentEventStream,
273
370
  signal: AbortSignal,
371
+ turnId: string,
274
372
  ): Promise<void> {
275
- const turnId = crypto.randomUUID()
373
+ if (!this.isCurrentTurn(session, stream, turnId, signal)) {
374
+ stream.complete()
375
+ return
376
+ }
276
377
  this.update(session, "running", turnId)
277
378
  this.emit(session, { type: "turn.started", turnId, prompt: message })
278
379
  session.messages.push({ role: "user", text: message })
279
380
  try {
280
381
  for (let step = 0; step < this.options.maxSteps; step++) {
281
- if (signal.aborted) throw new Error("native turn cancelled")
382
+ this.assertCurrentTurn(session, stream, turnId, signal)
282
383
  const raw = this.options.model.complete({
283
384
  sessionId: session.id,
284
385
  cwd: session.cwd,
@@ -292,10 +393,12 @@ export class NifraBackend implements AgentBackend {
292
393
  ),
293
394
  signal,
294
395
  })
295
- const response = await this.consumeModel(raw, session, turnId, signal)
396
+ const response = await this.consumeModel(raw, session, stream, turnId, signal)
397
+ this.assertCurrentTurn(session, stream, turnId, signal)
296
398
  if (response.type === "text") {
297
- session.messages.push({ role: "assistant", text: response.text })
298
- this.emit(session, { type: "assistant.message", turnId, text: response.text })
399
+ const text = boundedText(response.text, this.options.maxMessageChars)
400
+ session.messages.push({ role: "assistant", text })
401
+ this.emit(session, { type: "assistant.message", turnId, text })
299
402
  this.finish(session, stream)
300
403
  return
301
404
  }
@@ -313,6 +416,7 @@ export class NifraBackend implements AgentBackend {
313
416
  continue
314
417
  }
315
418
  const callId = crypto.randomUUID()
419
+ const execute = tool.execute
316
420
  this.emit(session, {
317
421
  type: "tool.started",
318
422
  turnId,
@@ -324,30 +428,34 @@ export class NifraBackend implements AgentBackend {
324
428
  typeof tool.requiresApproval === "function"
325
429
  ? await tool.requiresApproval(response.input)
326
430
  : tool.requiresApproval === true
431
+ this.assertCurrentTurn(session, stream, turnId, signal)
327
432
  if (needsApproval) {
328
- if (
329
- this.options.approval === undefined ||
330
- !(await this.options.approval.request({
331
- sessionId: session.id,
332
- tool,
333
- input: response.input,
334
- signal,
335
- }))
336
- ) {
433
+ const resolution = await this.requestApproval(
434
+ session,
435
+ turnId,
436
+ callId,
437
+ tool,
438
+ response.input,
439
+ signal,
440
+ )
441
+ this.assertCurrentTurn(session, stream, turnId, signal)
442
+ if (!resolution.approved) {
443
+ const code = resolution.errorCode ?? "APPROVAL_DENIED"
337
444
  this.emit(session, {
338
445
  type: "tool.completed",
339
446
  turnId,
340
447
  callId,
341
448
  name: tool.name,
342
449
  ok: false,
343
- error: agentError("APPROVAL_DENIED", `approval denied for ${tool.name}`),
450
+ error: agentError(code, boundedApprovalMessage(code, tool.name, resolution.reason)),
344
451
  })
345
- session.messages.push({ role: "tool", name: tool.name, text: "approval denied" })
452
+ session.messages.push({ role: "tool", name: tool.name, text: code })
346
453
  continue
347
454
  }
348
455
  }
349
456
  try {
350
- const output = await tool.execute(response.input, { cwd: session.cwd, signal })
457
+ const output = await execute(response.input, { cwd: session.cwd, signal })
458
+ this.assertCurrentTurn(session, stream, turnId, signal)
351
459
  const text = boundedText(output)
352
460
  session.messages.push({ role: "tool", name: tool.name, text })
353
461
  this.emit(session, {
@@ -359,7 +467,11 @@ export class NifraBackend implements AgentBackend {
359
467
  output: text,
360
468
  })
361
469
  } catch (error) {
362
- const message = error instanceof Error ? error.message : String(error)
470
+ if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal)) throw error
471
+ const message = boundedText(
472
+ error instanceof Error ? error.message : error,
473
+ this.options.maxMessageChars,
474
+ )
363
475
  session.messages.push({ role: "tool", name: tool.name, text: message })
364
476
  this.emit(session, {
365
477
  type: "tool.completed",
@@ -373,46 +485,255 @@ export class NifraBackend implements AgentBackend {
373
485
  }
374
486
  throw new Error("native turn exceeded maxSteps")
375
487
  } catch (error) {
376
- if (session.closed) return
377
- this.update(session, signal.aborted ? "stopped" : "failed")
488
+ if (!this.isCurrentTurn(session, stream, turnId, signal)) {
489
+ stream.complete()
490
+ return
491
+ }
492
+ if (signal.aborted || session.cancelled) {
493
+ await this.cancelTurn(session, stream, turnId, "cancelled")
494
+ return
495
+ }
496
+ this.update(session, "failed")
378
497
  this.emit(session, {
379
498
  type: "session.failed",
380
499
  error: agentError(
381
- signal.aborted ? "TURN_CANCELLED" : "NATIVE_TURN_FAILED",
382
- error instanceof Error ? error.message : String(error),
500
+ "NATIVE_TURN_FAILED",
501
+ boundedText(error instanceof Error ? error.message : error, 4_096),
383
502
  ),
384
503
  recoverable: true,
385
504
  })
386
505
  stream.complete()
387
- session.active = undefined
506
+ this.clearTurn(session, stream, turnId)
388
507
  }
389
508
  }
390
509
 
391
510
  private async consumeModel(
392
511
  raw: ReturnType<NativeModelPort["complete"]>,
393
512
  session: NativeSession,
513
+ stream: AgentEventStream,
394
514
  turnId: string,
395
515
  signal: AbortSignal,
396
516
  ): Promise<NativeModelResponse> {
397
517
  if (isAsyncIterable<NativeModelChunk>(raw)) {
398
518
  let response: NativeModelResponse | undefined
399
519
  for await (const chunk of raw) {
400
- if (signal.aborted) throw new Error("native turn cancelled")
520
+ this.assertCurrentTurn(session, stream, turnId, signal)
401
521
  if (chunk.type === "text_delta")
402
522
  this.emit(session, { type: "assistant.delta", turnId, text: chunk.text })
403
523
  else response = chunk.response
404
524
  }
405
525
  if (response === undefined) throw new Error("native model stream ended without a response")
526
+ this.assertCurrentTurn(session, stream, turnId, signal)
406
527
  return response
407
528
  }
408
- return await raw
529
+ const response = await raw
530
+ this.assertCurrentTurn(session, stream, turnId, signal)
531
+ return response
409
532
  }
410
533
 
411
534
  private finish(session: NativeSession, stream: AgentEventStream): void {
412
535
  this.update(session, "idle")
413
536
  this.emit(session, { type: "session.completed", snapshot: session.snapshot })
414
537
  stream.complete()
415
- session.active = undefined
538
+ this.clearTurn(session, stream, session.turnId)
539
+ }
540
+
541
+ private requestApproval(
542
+ session: NativeSession,
543
+ turnId: string,
544
+ callId: string,
545
+ tool: NativeTool,
546
+ input: unknown,
547
+ signal: AbortSignal,
548
+ ): Promise<NativeApprovalResolution> {
549
+ const approvalId = `nifra-approval-${crypto.randomUUID()}`
550
+ const action = boundedApprovalText(tool.name, 512)
551
+ const capability = boundedApprovalText(tool.capabilities?.[0] ?? `tool:${tool.name}`, 128)
552
+ let pending!: NativePendingApproval
553
+ const promise = new Promise<NativeApprovalResolution>((resolve) => {
554
+ const onAbort = (): void => {
555
+ this.settleApproval(
556
+ session,
557
+ approvalId,
558
+ false,
559
+ session.closed ? "approval closed" : "approval cancelled",
560
+ session.closed ? "APPROVAL_CLOSED" : "APPROVAL_CANCELLED",
561
+ )
562
+ }
563
+ const timer = setTimeout(
564
+ () => {
565
+ this.settleApproval(session, approvalId, false, "approval timed out", "APPROVAL_TIMEOUT")
566
+ },
567
+ this.options.approvalTimeoutMs ?? 5 * 60_000,
568
+ )
569
+ ;(timer as unknown as { unref?: () => void }).unref?.()
570
+ pending = {
571
+ id: approvalId,
572
+ sessionId: session.id,
573
+ turnId,
574
+ callId,
575
+ action,
576
+ capability,
577
+ resolve,
578
+ timer,
579
+ onAbort,
580
+ signal,
581
+ }
582
+ this.pendingApprovals.set(approvalId, pending)
583
+ signal.addEventListener("abort", onAbort, { once: true })
584
+ })
585
+
586
+ this.emit(session, {
587
+ type: "approval.required",
588
+ turnId,
589
+ approvalId,
590
+ action,
591
+ capability,
592
+ })
593
+
594
+ if (signal.aborted) pending.onAbort()
595
+ else if (this.options.approval !== undefined) {
596
+ try {
597
+ void Promise.resolve(
598
+ this.options.approval.request({
599
+ sessionId: session.id,
600
+ approvalId,
601
+ turnId,
602
+ callId,
603
+ action,
604
+ capability,
605
+ tool,
606
+ input,
607
+ signal,
608
+ }),
609
+ ).then(
610
+ (approved) =>
611
+ this.settleApproval(
612
+ session,
613
+ approvalId,
614
+ approved === true,
615
+ approved === true ? undefined : "approval denied",
616
+ approved === true ? undefined : "APPROVAL_DENIED",
617
+ ),
618
+ () =>
619
+ this.settleApproval(
620
+ session,
621
+ approvalId,
622
+ false,
623
+ "approval callback failed",
624
+ "APPROVAL_FAILED",
625
+ ),
626
+ )
627
+ } catch {
628
+ this.settleApproval(
629
+ session,
630
+ approvalId,
631
+ false,
632
+ "approval callback failed",
633
+ "APPROVAL_FAILED",
634
+ )
635
+ }
636
+ }
637
+ return promise
638
+ }
639
+
640
+ private settleApproval(
641
+ session: NativeSession,
642
+ approvalId: string,
643
+ approved: boolean,
644
+ reason: string | undefined,
645
+ errorCode: NativeApprovalFailureCode | undefined,
646
+ ): NativeApprovalResolution | undefined {
647
+ const pending = this.pendingApprovals.get(approvalId)
648
+ if (pending === undefined || pending.sessionId !== session.id) return undefined
649
+ clearTimeout(pending.timer)
650
+ pending.signal.removeEventListener("abort", pending.onAbort)
651
+ this.pendingApprovals.delete(approvalId)
652
+ const boundedReason = reason === undefined ? undefined : boundedApprovalText(reason, 512)
653
+ const resolution: NativeApprovalResolution = Object.freeze({
654
+ approved,
655
+ ...(boundedReason === undefined ? {} : { reason: boundedReason }),
656
+ ...(errorCode === undefined ? {} : { errorCode }),
657
+ })
658
+ this.emit(session, {
659
+ type: "approval.resolved",
660
+ turnId: pending.turnId,
661
+ approvalId,
662
+ approved,
663
+ ...(boundedReason === undefined ? {} : { reason: boundedReason }),
664
+ })
665
+ pending.resolve(resolution)
666
+ return resolution
667
+ }
668
+
669
+ private async cancelTurn(
670
+ session: NativeSession,
671
+ expectedStream: AgentEventStream | undefined,
672
+ expectedTurnId: string | undefined,
673
+ reason: string,
674
+ ): Promise<void> {
675
+ if (
676
+ expectedStream !== undefined &&
677
+ (session.active !== expectedStream || session.turnId !== expectedTurnId)
678
+ )
679
+ return
680
+ const stream = session.active
681
+ const turnId = session.turnId
682
+ const stopReason = boundedText(reason, 512) || "cancelled"
683
+ session.cancelled = true
684
+ this.clearTurnSignal(session)
685
+ try {
686
+ session.controller.abort(stopReason)
687
+ } catch {
688
+ // AbortController.abort is normally infallible; lifecycle cleanup must still complete if a
689
+ // host supplies an unusual controller implementation.
690
+ }
691
+ this.update(session, "stopped")
692
+ this.emit(session, { type: "session.stopped", reason: stopReason })
693
+ stream?.complete()
694
+ this.clearTurn(session, stream, turnId)
695
+ }
696
+
697
+ private clearTurnSignal(session: NativeSession): void {
698
+ session.turnAbortCleanup?.()
699
+ session.turnAbortCleanup = undefined
700
+ }
701
+
702
+ private clearTurn(
703
+ session: NativeSession,
704
+ stream: AgentEventStream | undefined,
705
+ turnId: string | undefined,
706
+ ): void {
707
+ if (stream !== undefined && session.active !== stream) return
708
+ if (turnId !== undefined && session.turnId !== turnId) return
709
+ this.clearTurnSignal(session)
710
+ if (stream === undefined || session.active === stream) session.active = undefined
711
+ if (turnId === undefined || session.turnId === turnId) session.turnId = undefined
712
+ }
713
+
714
+ private isCurrentTurn(
715
+ session: NativeSession,
716
+ stream: AgentEventStream,
717
+ turnId: string,
718
+ signal: AbortSignal,
719
+ ): boolean {
720
+ return (
721
+ !session.closed &&
722
+ !session.cancelled &&
723
+ session.active === stream &&
724
+ session.turnId === turnId &&
725
+ session.controller.signal === signal
726
+ )
727
+ }
728
+
729
+ private assertCurrentTurn(
730
+ session: NativeSession,
731
+ stream: AgentEventStream,
732
+ turnId: string,
733
+ signal: AbortSignal,
734
+ ): void {
735
+ if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal))
736
+ throw new Error("native turn cancelled")
416
737
  }
417
738
 
418
739
  private emit(
@@ -426,6 +747,8 @@ export class NifraBackend implements AgentBackend {
426
747
  at: this.now(),
427
748
  ...payload,
428
749
  }) as AgentEvent
750
+ if (!isAgentEvent(event))
751
+ throw new Error("nifra backend emitted an event outside protocol bounds")
429
752
  session.snapshot = Object.freeze({
430
753
  ...session.snapshot,
431
754
  lastSeq: event.seq,
@@ -441,7 +764,9 @@ export class NifraBackend implements AgentBackend {
441
764
  ): void {
442
765
  const next = { ...session.snapshot, status, updatedAt: this.now(), lastSeq: session.seq }
443
766
  session.snapshot = Object.freeze(
444
- status === "running" && turnId !== undefined ? { ...next, activeTurnId: turnId } : next,
767
+ status === "running" && turnId !== undefined
768
+ ? { ...next, activeTurnId: turnId }
769
+ : removeActiveTurn(next),
445
770
  )
446
771
  if (session.active !== undefined)
447
772
  this.emit(session, { type: "session.updated", snapshot: session.snapshot })
@@ -462,17 +787,58 @@ function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
462
787
  return value !== null && typeof value === "object" && Symbol.asyncIterator in value
463
788
  }
464
789
 
465
- function boundedText(value: unknown): string {
790
+ function boundedText(value: unknown, maxChars = 64 * 1024): string {
466
791
  try {
467
792
  const text = typeof value === "string" ? value : JSON.stringify(value)
468
- return text.length > 64 * 1024 ? `${text.slice(0, 64 * 1024)}…[truncated]` : text
793
+ if (typeof text !== "string") return "[unserializable tool output]"
794
+ const suffix = "…[truncated]"
795
+ return text.length > maxChars
796
+ ? `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`
797
+ : text
469
798
  } catch {
470
799
  return "[unserializable tool output]"
471
800
  }
472
801
  }
473
802
 
803
+ function boundedApprovalToken(value: unknown): value is string {
804
+ return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
805
+ }
806
+
807
+ function boundedApprovalText(value: string, maxChars: number): string {
808
+ const text = value.trim()
809
+ if (text.length === 0) return "native tool"
810
+ return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text
811
+ }
812
+
813
+ function boundedApprovalMessage(
814
+ code: NativeApprovalFailureCode,
815
+ toolName: string,
816
+ reason: string | undefined,
817
+ ): string {
818
+ const base =
819
+ code === "APPROVAL_DENIED"
820
+ ? "approval denied"
821
+ : code === "APPROVAL_TIMEOUT"
822
+ ? "approval timed out"
823
+ : code === "APPROVAL_CANCELLED"
824
+ ? "approval cancelled"
825
+ : code === "APPROVAL_CLOSED"
826
+ ? "approval closed"
827
+ : "approval failed"
828
+ const name = boundedApprovalText(toolName, 128)
829
+ const suffix = reason === undefined || reason === "approval denied" ? "" : `: ${reason}`
830
+ return boundedApprovalText(`${base} for ${name}${suffix}`, 512)
831
+ }
832
+
474
833
  function failedStream(error: unknown): AgentEventStream {
475
834
  const stream = createAgentEventStream()
476
835
  stream.fail(error)
477
836
  return stream
478
837
  }
838
+
839
+ function removeActiveTurn(
840
+ snapshot: Omit<AgentSessionSnapshot, "activeTurnId"> & { readonly activeTurnId?: string },
841
+ ): Omit<AgentSessionSnapshot, "activeTurnId"> {
842
+ const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
843
+ return withoutActiveTurn
844
+ }