@pikku/core 0.12.20 → 0.12.22

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 (141) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/dev/hot-reload.js +20 -6
  3. package/dist/errors/error-handler.d.ts +1 -1
  4. package/dist/errors/error-handler.js +2 -2
  5. package/dist/function/functions.types.d.ts +3 -2
  6. package/dist/handle-error.js +3 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +2 -2
  9. package/dist/middleware/index.d.ts +2 -2
  10. package/dist/middleware/index.js +2 -2
  11. package/dist/middleware/telemetry.d.ts +2 -2
  12. package/dist/middleware/telemetry.js +2 -2
  13. package/dist/middleware-runner.d.ts +15 -27
  14. package/dist/middleware-runner.js +25 -30
  15. package/dist/permissions.d.ts +15 -22
  16. package/dist/permissions.js +30 -25
  17. package/dist/pikku-request.js +1 -1
  18. package/dist/pikku-state.js +2 -3
  19. package/dist/services/ai-agent-runner-service.d.ts +1 -0
  20. package/dist/services/in-memory-queue-service.js +1 -1
  21. package/dist/services/in-memory-workflow-service.d.ts +15 -13
  22. package/dist/services/in-memory-workflow-service.js +31 -13
  23. package/dist/services/local-content.js +8 -1
  24. package/dist/services/user-session-service.d.ts +1 -1
  25. package/dist/testing/service-tests.js +24 -0
  26. package/dist/types/core.types.d.ts +4 -0
  27. package/dist/types/state.types.d.ts +2 -18
  28. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
  29. package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
  30. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
  31. package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
  32. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
  33. package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
  34. package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
  35. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
  36. package/dist/wirings/channel/channel-handler.js +1 -1
  37. package/dist/wirings/channel/channel-store.d.ts +13 -0
  38. package/dist/wirings/channel/channel.types.d.ts +3 -0
  39. package/dist/wirings/channel/local/local-channel-runner.js +23 -5
  40. package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
  41. package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
  42. package/dist/wirings/cli/cli-runner.js +24 -5
  43. package/dist/wirings/cli/command-parser.js +19 -4
  44. package/dist/wirings/http/http-runner.js +72 -36
  45. package/dist/wirings/http/http.types.d.ts +1 -0
  46. package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
  47. package/dist/wirings/http/web-request.js +32 -0
  48. package/dist/wirings/rpc/rpc-runner.js +13 -3
  49. package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
  50. package/dist/wirings/workflow/graph/graph-node.js +4 -0
  51. package/dist/wirings/workflow/graph/graph-runner.js +12 -10
  52. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
  53. package/dist/wirings/workflow/index.d.ts +1 -1
  54. package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
  55. package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
  56. package/dist/wirings/workflow/workflow.types.d.ts +34 -0
  57. package/package.json +1 -1
  58. package/run-tests.sh +4 -1
  59. package/src/dev/hot-reload.test.ts +62 -11
  60. package/src/dev/hot-reload.ts +28 -7
  61. package/src/errors/error-handler.ts +8 -2
  62. package/src/errors/error.test.ts +2 -0
  63. package/src/function/function-runner.test.ts +500 -10
  64. package/src/function/functions.types.ts +3 -2
  65. package/src/handle-error.test.ts +1 -1
  66. package/src/handle-error.ts +4 -1
  67. package/src/index.ts +12 -2
  68. package/src/middleware/index.ts +11 -2
  69. package/src/middleware/telemetry.ts +2 -2
  70. package/src/middleware-runner.test.ts +16 -16
  71. package/src/middleware-runner.ts +42 -30
  72. package/src/permissions.test.ts +27 -24
  73. package/src/permissions.ts +41 -25
  74. package/src/pikku-request.test.ts +35 -0
  75. package/src/pikku-request.ts +1 -1
  76. package/src/pikku-state.ts +2 -3
  77. package/src/run-tests-script.test.ts +18 -0
  78. package/src/services/ai-agent-runner-service.ts +1 -0
  79. package/src/services/in-memory-queue-service.ts +1 -1
  80. package/src/services/in-memory-session-store.ts +1 -2
  81. package/src/services/in-memory-workflow-service.test.ts +33 -11
  82. package/src/services/in-memory-workflow-service.ts +49 -13
  83. package/src/services/local-content.test.ts +54 -0
  84. package/src/services/local-content.ts +12 -1
  85. package/src/services/typed-credential-service.ts +3 -3
  86. package/src/services/typed-secret-service.ts +3 -3
  87. package/src/services/typed-variables-service.ts +3 -3
  88. package/src/services/user-session-service.ts +4 -4
  89. package/src/testing/service-tests.ts +30 -0
  90. package/src/types/core.types.ts +6 -2
  91. package/src/types/state.types.ts +2 -13
  92. package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
  93. package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
  94. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
  95. package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
  96. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
  97. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
  98. package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
  99. package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
  100. package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
  101. package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
  102. package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
  103. package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
  104. package/src/wirings/channel/channel-handler.test.ts +272 -0
  105. package/src/wirings/channel/channel-handler.ts +1 -1
  106. package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
  107. package/src/wirings/channel/channel-store.ts +19 -0
  108. package/src/wirings/channel/channel.types.ts +4 -0
  109. package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
  110. package/src/wirings/channel/local/local-channel-runner.ts +41 -5
  111. package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
  112. package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
  113. package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
  114. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
  115. package/src/wirings/cli/cli-runner.test.ts +255 -2
  116. package/src/wirings/cli/cli-runner.ts +31 -10
  117. package/src/wirings/cli/cli.types.ts +4 -8
  118. package/src/wirings/cli/command-parser.test.ts +83 -0
  119. package/src/wirings/cli/command-parser.ts +23 -4
  120. package/src/wirings/http/http-runner.test.ts +296 -1
  121. package/src/wirings/http/http-runner.ts +87 -57
  122. package/src/wirings/http/http.types.ts +11 -26
  123. package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
  124. package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
  125. package/src/wirings/http/web-request.test.ts +115 -0
  126. package/src/wirings/http/web-request.ts +43 -5
  127. package/src/wirings/mcp/mcp-runner.test.ts +367 -0
  128. package/src/wirings/queue/queue.types.ts +2 -5
  129. package/src/wirings/rpc/rpc-runner.test.ts +511 -0
  130. package/src/wirings/rpc/rpc-runner.ts +15 -3
  131. package/src/wirings/rpc/wire-addon.test.ts +57 -0
  132. package/src/wirings/workflow/graph/graph-node.ts +12 -0
  133. package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
  134. package/src/wirings/workflow/graph/graph-runner.ts +28 -10
  135. package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
  136. package/src/wirings/workflow/index.ts +1 -0
  137. package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
  138. package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
  139. package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
  140. package/src/wirings/workflow/workflow.types.ts +68 -5
  141. package/tsconfig.tsbuildinfo +1 -1
@@ -2,8 +2,12 @@ import { beforeEach, describe, test } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
 
4
4
  import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
- import { runAIAgent } from './ai-agent-runner.js'
6
- import type { CoreAIAgent, PikkuAIMiddlewareHooks } from './ai-agent.types.js'
5
+ import { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js'
6
+ import type {
7
+ AgentRunState,
8
+ CoreAIAgent,
9
+ PikkuAIMiddlewareHooks,
10
+ } from './ai-agent.types.js'
7
11
  import type { AIAgentStepResult } from '../../services/ai-agent-runner-service.js'
8
12
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
9
13
 
@@ -16,7 +20,7 @@ const addTestAgent = (agentName: string) => {
16
20
  name: agentName,
17
21
  description: 'test agent',
18
22
  instructions: 'be helpful',
19
- model: 'test-model',
23
+ model: 'test/test-model',
20
24
  }
21
25
 
22
26
  pikkuState(null, 'agent', 'agentsMeta')[agentName] = {
@@ -26,9 +30,6 @@ const addTestAgent = (agentName: string) => {
26
30
  workingMemorySchema: null,
27
31
  }
28
32
  pikkuState(null, 'agent', 'agents').set(agentName, agent)
29
- pikkuState(null, 'models', 'config', {
30
- models: { 'test-model': 'test/test-model' },
31
- })
32
33
  }
33
34
 
34
35
  const makeStepResult = (
@@ -43,6 +44,34 @@ const makeStepResult = (
43
44
  })
44
45
 
45
46
  describe('runAIAgent', () => {
47
+ test('throws when aiRunState service is missing', async () => {
48
+ addTestAgent('missing-run-state-agent')
49
+
50
+ pikkuState(null, 'package', 'singletonServices', {
51
+ logger: {
52
+ info: () => {},
53
+ warn: () => {},
54
+ error: () => {},
55
+ debug: () => {},
56
+ },
57
+ aiAgentRunner: {
58
+ run: async () => makeStepResult({ text: 'unused' }),
59
+ },
60
+ } as any)
61
+
62
+ await assert.rejects(
63
+ () =>
64
+ runAIAgent(
65
+ 'missing-run-state-agent',
66
+ { message: 'hello', threadId: 't', resourceId: 'r' },
67
+ {}
68
+ ),
69
+ {
70
+ message: 'AIRunStateService not available in singletonServices',
71
+ }
72
+ )
73
+ })
74
+
46
75
  test('throws AIProviderNotConfiguredError when aiAgentRunner is missing', async () => {
47
76
  addTestAgent('no-provider-agent')
48
77
 
@@ -350,4 +379,594 @@ describe('runAIAgent', () => {
350
379
  expectedError
351
380
  )
352
381
  })
382
+
383
+ test('returns suspended run when required RPC tool is missing', async () => {
384
+ addTestAgent('missing-rpc-agent')
385
+ pikkuState(null, 'agent', 'agentsMeta')['missing-rpc-agent'].tools = [
386
+ 'deployMissing',
387
+ ]
388
+
389
+ const createdRuns: any[] = []
390
+ const mockServices = {
391
+ logger: {
392
+ info: () => {},
393
+ warn: () => {},
394
+ error: () => {},
395
+ debug: () => {},
396
+ },
397
+ aiAgentRunner: {
398
+ run: async () => {
399
+ throw new Error('should not run')
400
+ },
401
+ },
402
+ aiRunState: {
403
+ createRun: async (run: any) => {
404
+ createdRuns.push(run)
405
+ return 'run-missing-rpc'
406
+ },
407
+ updateRun: async () => {},
408
+ },
409
+ } as any
410
+
411
+ pikkuState(null, 'package', 'singletonServices', mockServices)
412
+
413
+ const result = await runAIAgent(
414
+ 'missing-rpc-agent',
415
+ { message: 'deploy', threadId: 'thread-1', resourceId: 'resource-1' },
416
+ {}
417
+ )
418
+
419
+ assert.equal(result.runId, 'run-missing-rpc')
420
+ assert.equal(result.text, '')
421
+ assert.deepEqual(result.steps, [])
422
+ assert.deepEqual(result.usage, { inputTokens: 0, outputTokens: 0 })
423
+ assert.deepEqual(createdRuns, [
424
+ {
425
+ agentName: 'missing-rpc-agent',
426
+ threadId: 'thread-1',
427
+ resourceId: 'resource-1',
428
+ status: 'suspended',
429
+ suspendReason: 'rpc-missing',
430
+ missingRpcs: ['deployMissing'],
431
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test/test-model' },
432
+ createdAt: createdRuns[0].createdAt,
433
+ updatedAt: createdRuns[0].updatedAt,
434
+ },
435
+ ])
436
+ })
437
+
438
+ test('modifyInput and modifyOutput middleware transform the run payload', async () => {
439
+ addTestAgent('middleware-agent')
440
+
441
+ const seenRunnerParams: any[] = []
442
+ const inputMessages = [
443
+ { id: 'extra', role: 'system', content: 'extra', createdAt: new Date() },
444
+ ] as any
445
+ const middleware: PikkuAIMiddlewareHooks = {
446
+ modifyInput: async (_services, ctx) => ({
447
+ instructions: `${ctx.instructions}\n\nextra rules`,
448
+ messages: [...ctx.messages, ...inputMessages],
449
+ }),
450
+ modifyOutput: async (_services, ctx) => ({
451
+ text: `${ctx.text} [postprocessed]`,
452
+ messages: [...ctx.messages],
453
+ }),
454
+ }
455
+ const agent = pikkuState(null, 'agent', 'agents').get('middleware-agent')!
456
+ agent.aiMiddleware = [middleware] as any
457
+ pikkuState(null, 'agent', 'agents').set('middleware-agent', agent)
458
+
459
+ const mockServices = {
460
+ logger: {
461
+ info: () => {},
462
+ warn: () => {},
463
+ error: () => {},
464
+ debug: () => {},
465
+ },
466
+ aiAgentRunner: {
467
+ run: async (params: any): Promise<AIAgentStepResult> => {
468
+ seenRunnerParams.push({
469
+ instructions: params.instructions,
470
+ messages: params.messages,
471
+ })
472
+ return makeStepResult({
473
+ text: 'Final answer',
474
+ object: { ok: true },
475
+ usage: { inputTokens: 11, outputTokens: 7 },
476
+ } as any)
477
+ },
478
+ },
479
+ aiRunState: {
480
+ createRun: async () => 'run-middleware',
481
+ updateRun: async () => {},
482
+ },
483
+ } as any
484
+
485
+ pikkuState(null, 'package', 'singletonServices', mockServices)
486
+
487
+ const result = await runAIAgent(
488
+ 'middleware-agent',
489
+ { message: 'hello', threadId: 'thread-mw', resourceId: 'resource-mw' },
490
+ {}
491
+ )
492
+
493
+ assert.equal(seenRunnerParams.length, 1)
494
+ assert.match(seenRunnerParams[0].instructions, /extra rules/)
495
+ assert.equal(seenRunnerParams[0].messages.at(-1).content, 'extra')
496
+ assert.equal(result.text, 'Final answer [postprocessed]')
497
+ assert.deepEqual(result.object, { ok: true })
498
+ })
499
+
500
+ test('prepareStep can stop execution before calling the runner', async () => {
501
+ addTestAgent('prepare-stop-agent')
502
+
503
+ const agent = pikkuState(null, 'agent', 'agents').get('prepare-stop-agent')!
504
+ agent.prepareStep = ({ stop }) => {
505
+ stop()
506
+ }
507
+ pikkuState(null, 'agent', 'agents').set('prepare-stop-agent', agent)
508
+
509
+ let runnerCalls = 0
510
+ const updates: any[] = []
511
+ const mockServices = {
512
+ logger: {
513
+ info: () => {},
514
+ warn: () => {},
515
+ error: () => {},
516
+ debug: () => {},
517
+ },
518
+ aiAgentRunner: {
519
+ run: async (): Promise<AIAgentStepResult> => {
520
+ runnerCalls++
521
+ return makeStepResult({ text: 'should not happen' })
522
+ },
523
+ },
524
+ aiRunState: {
525
+ createRun: async () => 'run-stop',
526
+ updateRun: async (_runId: string, patch: any) => {
527
+ updates.push(patch)
528
+ },
529
+ },
530
+ } as any
531
+
532
+ pikkuState(null, 'package', 'singletonServices', mockServices)
533
+
534
+ const result = await runAIAgent(
535
+ 'prepare-stop-agent',
536
+ {
537
+ message: 'hello',
538
+ threadId: 'thread-stop',
539
+ resourceId: 'resource-stop',
540
+ },
541
+ {}
542
+ )
543
+
544
+ assert.equal(runnerCalls, 0)
545
+ assert.equal(result.text, '')
546
+ assert.deepEqual(result.steps, [])
547
+ assert.deepEqual(updates, [
548
+ {
549
+ status: 'completed',
550
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test/test-model' },
551
+ },
552
+ ])
553
+ })
554
+
555
+ test('suspends with approval details when a tool call requires approval', async () => {
556
+ addTestAgent('approval-agent')
557
+ pikkuState(null, 'agent', 'agentsMeta')['approval-agent'].tools = ['deploy']
558
+ pikkuState(null, 'rpc', 'meta').deploy = 'deploy'
559
+ pikkuState(null, 'function', 'meta').deploy = {
560
+ description: 'Deploy',
561
+ approvalRequired: true,
562
+ inputSchemaName: 'DeployInput',
563
+ }
564
+ pikkuState(null, 'misc', 'schemas').set('DeployInput', {
565
+ type: 'object',
566
+ properties: { env: { type: 'string' } },
567
+ })
568
+ pikkuState(null, 'function', 'functions').set('deploy', {
569
+ approvalDescription: async () => {
570
+ throw new Error('description failed')
571
+ },
572
+ })
573
+
574
+ const updates: any[] = []
575
+ const savedMessages: any[] = []
576
+ const mockServices = {
577
+ logger: {
578
+ info: () => {},
579
+ warn: () => {},
580
+ error: () => {},
581
+ debug: () => {},
582
+ },
583
+ aiAgentRunner: {
584
+ run: async (): Promise<AIAgentStepResult> =>
585
+ makeStepResult({
586
+ text: 'Need approval',
587
+ toolCalls: [
588
+ {
589
+ toolCallId: 'tc-approve',
590
+ toolName: 'deploy',
591
+ args: { env: 'prod' },
592
+ },
593
+ ],
594
+ usage: { inputTokens: 4, outputTokens: 2 },
595
+ finishReason: 'tool-calls',
596
+ }),
597
+ },
598
+ aiRunState: {
599
+ createRun: async () => 'run-approval',
600
+ updateRun: async (_runId: string, patch: any) => {
601
+ updates.push(patch)
602
+ },
603
+ },
604
+ aiStorage: {
605
+ createThread: async () => {},
606
+ getMessages: async () => [],
607
+ saveMessages: async (_threadId: string, messages: any[]) => {
608
+ savedMessages.push(messages)
609
+ },
610
+ },
611
+ } as any
612
+
613
+ pikkuState(null, 'package', 'singletonServices', mockServices)
614
+
615
+ const result = await runAIAgent(
616
+ 'approval-agent',
617
+ { message: 'deploy', threadId: 'thread-a', resourceId: 'resource-a' },
618
+ {}
619
+ )
620
+
621
+ assert.equal(result.status, 'suspended')
622
+ assert.equal(result.text, 'Need approval')
623
+ assert.deepEqual(result.pendingApprovals, [
624
+ {
625
+ toolCallId: 'tc-approve',
626
+ toolName: 'deploy',
627
+ args: { env: 'prod' },
628
+ reason: undefined,
629
+ runId: 'run-approval',
630
+ },
631
+ ])
632
+ assert.deepEqual(updates, [
633
+ {
634
+ status: 'suspended',
635
+ suspendReason: 'approval',
636
+ pendingApprovals: [
637
+ {
638
+ type: 'tool-call',
639
+ toolCallId: 'tc-approve',
640
+ toolName: 'deploy',
641
+ args: { env: 'prod' },
642
+ },
643
+ ],
644
+ usage: { inputTokens: 4, outputTokens: 2, model: 'test/test-model' },
645
+ },
646
+ ])
647
+ assert.equal(savedMessages.length, 2)
648
+ assert.equal(savedMessages[0][0].role, 'user')
649
+ assert.equal(savedMessages[0][1].role, 'assistant')
650
+ assert.equal(savedMessages[1][0].role, 'assistant')
651
+ })
652
+ })
653
+
654
+ describe('resumeAIAgentSync', () => {
655
+ test('throws for missing services, missing runs, wrong status, and missing runner', async () => {
656
+ pikkuState(null, 'package', 'singletonServices', {} as any)
657
+ await assert.rejects(() => resumeAIAgentSync('run-x', [], {}), {
658
+ message: 'AIRunStateService not available in singletonServices',
659
+ })
660
+
661
+ pikkuState(null, 'package', 'singletonServices', {
662
+ aiRunState: {
663
+ getRun: async () => null,
664
+ },
665
+ } as any)
666
+ await assert.rejects(() => resumeAIAgentSync('run-x', [], {}), {
667
+ message: 'No run found for runId run-x',
668
+ })
669
+
670
+ addTestAgent('resume-guard-agent')
671
+ pikkuState(null, 'package', 'singletonServices', {
672
+ aiRunState: {
673
+ getRun: async () => ({
674
+ runId: 'run-y',
675
+ agentName: 'resume-guard-agent',
676
+ threadId: 't',
677
+ resourceId: 'r',
678
+ status: 'completed',
679
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test/test-model' },
680
+ createdAt: new Date(),
681
+ updatedAt: new Date(),
682
+ }),
683
+ },
684
+ } as any)
685
+ await assert.rejects(() => resumeAIAgentSync('run-y', [], {}), {
686
+ message: 'Run run-y is not suspended (status: completed)',
687
+ })
688
+
689
+ pikkuState(null, 'package', 'singletonServices', {
690
+ aiRunState: {
691
+ getRun: async () => ({
692
+ runId: 'run-z',
693
+ agentName: 'resume-guard-agent',
694
+ threadId: 't',
695
+ resourceId: 'r',
696
+ status: 'suspended',
697
+ pendingApprovals: [],
698
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test/test-model' },
699
+ createdAt: new Date(),
700
+ updatedAt: new Date(),
701
+ }),
702
+ resolveApproval: async () => {},
703
+ updateRun: async () => {},
704
+ },
705
+ } as any)
706
+ await assert.rejects(
707
+ () => resumeAIAgentSync('run-z', [], {}),
708
+ (error: unknown) => error instanceof AIProviderNotConfiguredError
709
+ )
710
+ })
711
+
712
+ test('replays approved tool calls, strips nulls, and continues the run', async () => {
713
+ addTestAgent('resume-agent')
714
+ pikkuState(null, 'agent', 'agentsMeta')['resume-agent'].tools = ['deploy']
715
+ pikkuState(null, 'rpc', 'meta').deploy = 'deploy'
716
+ pikkuState(null, 'function', 'meta').deploy = {
717
+ description: 'Deploy',
718
+ inputSchemaName: 'DeployInput',
719
+ }
720
+ pikkuState(null, 'misc', 'schemas').set('DeployInput', {
721
+ type: 'object',
722
+ properties: { env: { type: 'string' }, optional: { type: 'string' } },
723
+ })
724
+
725
+ let executedInput: any
726
+ pikkuState(null, 'function', 'functions').set('deploy', {
727
+ func: async (_services: any, input: any) => {
728
+ executedInput = input
729
+ return { deployed: true }
730
+ },
731
+ })
732
+
733
+ const savedMessages: any[] = []
734
+ const updates: any[] = []
735
+ const resolveCalls: any[] = []
736
+ const run: AgentRunState = {
737
+ runId: 'run-resume',
738
+ agentName: 'resume-agent',
739
+ threadId: 'thread-resume',
740
+ resourceId: 'resource-resume',
741
+ status: 'suspended',
742
+ suspendReason: 'approval',
743
+ pendingApprovals: [
744
+ {
745
+ type: 'tool-call',
746
+ toolCallId: 'tc-1',
747
+ toolName: 'deploy',
748
+ args: JSON.stringify({ env: 'prod', optional: null }),
749
+ },
750
+ ],
751
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test/test-model' },
752
+ createdAt: new Date(),
753
+ updatedAt: new Date(),
754
+ }
755
+
756
+ const mockServices = {
757
+ logger: {
758
+ info: () => {},
759
+ warn: () => {},
760
+ error: () => {},
761
+ debug: () => {},
762
+ },
763
+ aiAgentRunner: {
764
+ run: async (): Promise<AIAgentStepResult> =>
765
+ makeStepResult({
766
+ text: 'Resumed answer',
767
+ usage: { inputTokens: 5, outputTokens: 3 },
768
+ }),
769
+ },
770
+ aiRunState: {
771
+ getRun: async () => run,
772
+ createRun: async () => 'unused',
773
+ updateRun: async (_runId: string, patch: any) => {
774
+ updates.push(patch)
775
+ },
776
+ resolveApproval: async (toolCallId: string, status: string) => {
777
+ resolveCalls.push({ toolCallId, status })
778
+ },
779
+ },
780
+ aiStorage: {
781
+ saveMessages: async (threadId: string, messages: any[]) => {
782
+ savedMessages.push({ threadId, messages })
783
+ },
784
+ getMessages: async () => [],
785
+ },
786
+ } as any
787
+
788
+ pikkuState(null, 'package', 'singletonServices', mockServices)
789
+
790
+ const result = await resumeAIAgentSync(
791
+ 'run-resume',
792
+ [{ toolCallId: 'tc-1', approved: true }],
793
+ {}
794
+ )
795
+
796
+ assert.deepEqual(resolveCalls, [{ toolCallId: 'tc-1', status: 'approved' }])
797
+ assert.deepEqual(executedInput, { env: 'prod' })
798
+ assert.equal(savedMessages.length, 2)
799
+ assert.equal(savedMessages[0].messages[0].role, 'tool')
800
+ assert.equal(result.text, 'Resumed answer')
801
+ assert.deepEqual(updates, [
802
+ { status: 'running' },
803
+ {
804
+ status: 'completed',
805
+ usage: { inputTokens: 5, outputTokens: 3, model: 'test/test-model' },
806
+ },
807
+ ])
808
+ })
809
+
810
+ test('records denied approvals and rejects agent mismatches', async () => {
811
+ addTestAgent('resume-agent')
812
+
813
+ const baseRun: AgentRunState = {
814
+ runId: 'run-deny',
815
+ agentName: 'resume-agent',
816
+ threadId: 'thread-deny',
817
+ resourceId: 'resource-deny',
818
+ status: 'suspended',
819
+ suspendReason: 'approval',
820
+ pendingApprovals: [
821
+ {
822
+ type: 'tool-call',
823
+ toolCallId: 'tc-2',
824
+ toolName: 'deploy',
825
+ args: { env: 'prod' },
826
+ },
827
+ ],
828
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test/test-model' },
829
+ createdAt: new Date(),
830
+ updatedAt: new Date(),
831
+ }
832
+
833
+ const resolveCalls: any[] = []
834
+ const savedMessages: any[] = []
835
+ const mockServices = {
836
+ logger: {
837
+ info: () => {},
838
+ warn: () => {},
839
+ error: () => {},
840
+ debug: () => {},
841
+ },
842
+ aiAgentRunner: {
843
+ run: async (): Promise<AIAgentStepResult> =>
844
+ makeStepResult({
845
+ text: 'Declined',
846
+ usage: { inputTokens: 1, outputTokens: 1 },
847
+ }),
848
+ },
849
+ aiRunState: {
850
+ getRun: async () => baseRun,
851
+ createRun: async () => 'unused',
852
+ updateRun: async () => {},
853
+ resolveApproval: async (toolCallId: string, status: string) => {
854
+ resolveCalls.push({ toolCallId, status })
855
+ },
856
+ },
857
+ aiStorage: {
858
+ saveMessages: async (_threadId: string, messages: any[]) => {
859
+ savedMessages.push(messages)
860
+ },
861
+ getMessages: async () => [],
862
+ },
863
+ } as any
864
+
865
+ pikkuState(null, 'package', 'singletonServices', mockServices)
866
+
867
+ const denied = await resumeAIAgentSync(
868
+ 'run-deny',
869
+ [{ toolCallId: 'tc-2', approved: false }],
870
+ {}
871
+ )
872
+
873
+ assert.deepEqual(resolveCalls, [{ toolCallId: 'tc-2', status: 'denied' }])
874
+ assert.match(
875
+ savedMessages[0][0].toolResults[0].result,
876
+ /explicitly declined/
877
+ )
878
+ assert.equal(denied.text, 'Declined')
879
+
880
+ await assert.rejects(
881
+ () =>
882
+ resumeAIAgentSync(
883
+ 'run-deny',
884
+ [{ toolCallId: 'tc-2', approved: false }],
885
+ {},
886
+ 'other-agent'
887
+ ),
888
+ {
889
+ message:
890
+ "Run run-deny belongs to agent 'resume-agent', not 'other-agent'",
891
+ }
892
+ )
893
+ })
894
+
895
+ test('captures tool execution errors during resume and persists them as tool results', async () => {
896
+ addTestAgent('resume-error-agent')
897
+ pikkuState(null, 'agent', 'agentsMeta')['resume-error-agent'].tools = [
898
+ 'deploy',
899
+ ]
900
+ pikkuState(null, 'rpc', 'meta').deploy = 'deploy'
901
+ pikkuState(null, 'function', 'meta').deploy = {
902
+ description: 'Deploy',
903
+ inputSchemaName: 'DeployInput',
904
+ }
905
+ pikkuState(null, 'misc', 'schemas').set('DeployInput', {
906
+ type: 'object',
907
+ properties: { env: { type: 'string' } },
908
+ })
909
+ pikkuState(null, 'function', 'functions').set('deploy', {
910
+ func: async () => {
911
+ throw new Error('tool exploded')
912
+ },
913
+ })
914
+
915
+ const savedMessages: any[] = []
916
+ const run: AgentRunState = {
917
+ runId: 'run-error',
918
+ agentName: 'resume-error-agent',
919
+ threadId: 'thread-error',
920
+ resourceId: 'resource-error',
921
+ status: 'suspended',
922
+ suspendReason: 'approval',
923
+ pendingApprovals: [
924
+ {
925
+ type: 'tool-call',
926
+ toolCallId: 'tc-error',
927
+ toolName: 'deploy',
928
+ args: { env: 'prod' },
929
+ },
930
+ ],
931
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test/test-model' },
932
+ createdAt: new Date(),
933
+ updatedAt: new Date(),
934
+ }
935
+
936
+ pikkuState(null, 'package', 'singletonServices', {
937
+ logger: {
938
+ info: () => {},
939
+ warn: () => {},
940
+ error: () => {},
941
+ debug: () => {},
942
+ },
943
+ aiAgentRunner: {
944
+ run: async (): Promise<AIAgentStepResult> =>
945
+ makeStepResult({
946
+ text: 'Recovered',
947
+ usage: { inputTokens: 1, outputTokens: 1 },
948
+ }),
949
+ },
950
+ aiRunState: {
951
+ getRun: async () => run,
952
+ resolveApproval: async () => {},
953
+ updateRun: async () => {},
954
+ },
955
+ aiStorage: {
956
+ saveMessages: async (_threadId: string, messages: any[]) => {
957
+ savedMessages.push(messages)
958
+ },
959
+ getMessages: async () => [],
960
+ },
961
+ } as any)
962
+
963
+ const result = await resumeAIAgentSync(
964
+ 'run-error',
965
+ [{ toolCallId: 'tc-error', approved: true }],
966
+ {}
967
+ )
968
+
969
+ assert.match(savedMessages[0][0].toolResults[0].result, /tool exploded/)
970
+ assert.equal(result.text, 'Recovered')
971
+ })
353
972
  })