@nanobpm/urban 0.92.1 → 0.93.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.
@@ -3,6 +3,19 @@
3
3
  // written against these interfaces and MUST NOT import `node:*` or reference `Deno`.
4
4
  // The adapters/ directory supplies concrete implementations.
5
5
 
6
+ // The engine-native AgentHistory read shapes ({@link AgentHistoryRecord}) reuse the
7
+ // Camunda `AgentHistoryRecordValue`-parity types the transcript store already models
8
+ // (roles, content blocks, per-turn metrics, tool calls) rather than forking a second
9
+ // projection of the same conversation grammar (No Drift Surfaces). This is a type-only
10
+ // import — fully erased at runtime — so `core/` stays free of any concrete host/engine
11
+ // module load, exactly as the rule above requires.
12
+ import type {
13
+ TranscriptContentBlock,
14
+ TranscriptToolCall,
15
+ TranscriptTurnMetrics,
16
+ TranscriptTurnRole,
17
+ } from "@nanobpm/agentic/transcript";
18
+
6
19
  /** A minimal HTTP request as seen by a mounted surface/trigger handler. */
7
20
  export interface HttpRequest {
8
21
  method: string;
@@ -603,6 +616,100 @@ export interface JobSummary {
603
616
  readonly deadline?: string;
604
617
  }
605
618
 
619
+ /**
620
+ * Aggregated metrics for an agent instance across all of its model calls — the
621
+ * `AgentInstanceMetrics` the engine rolls up on the instance itself (distinct from the
622
+ * per-turn {@link TranscriptTurnMetrics} carried on an {@link AgentHistoryRecord}). Total
623
+ * `inputTokens`/`outputTokens` consumed, and the count of `modelCalls`/`toolCalls` made.
624
+ */
625
+ export interface AgentInstanceMetrics {
626
+ readonly inputTokens: number;
627
+ readonly outputTokens: number;
628
+ readonly modelCalls: number;
629
+ readonly toolCalls: number;
630
+ }
631
+
632
+ /**
633
+ * A single engine-native agent instance as {@link EngineClient.searchAgentInstances} /
634
+ * {@link EngineClient.getAgentInstance} report it — the projection of the engine's
635
+ * `AgentInstanceResult`. Carries the `agentInstanceKey` a caller passes to
636
+ * {@link EngineClient.searchAgentInstanceHistory}, the lifecycle `status` (a bare string —
637
+ * the engine's `AgentInstanceStatusEnum` is broad: `INITIALIZING`/`THINKING`/`TOOL_CALLING`/
638
+ * `IDLE`/`COMPLETED`/…), the owning `processInstanceKey`, and best-effort diagnostics
639
+ * (`elementId`, the owning `elementInstanceKeys`, the process-definition linkage, aggregated
640
+ * `metrics`, and the creation/update/completion timestamps). Optional fields are omitted when
641
+ * the engine does not report them.
642
+ */
643
+ export interface AgentInstanceSummary {
644
+ readonly agentInstanceKey: string;
645
+ readonly status: string;
646
+ readonly processInstanceKey: string;
647
+ readonly elementId?: string;
648
+ readonly elementInstanceKeys?: readonly string[];
649
+ readonly rootProcessInstanceKey?: string;
650
+ readonly processDefinitionKey?: string;
651
+ readonly processDefinitionId?: string;
652
+ readonly metrics?: AgentInstanceMetrics;
653
+ readonly creationDate?: string;
654
+ readonly lastUpdatedDate?: string;
655
+ readonly completionDate?: string;
656
+ }
657
+
658
+ /**
659
+ * A single agent-instance history item as {@link EngineClient.searchAgentInstanceHistory}
660
+ * reports it — the projection of the engine's `AgentInstanceHistoryItemResult`, i.e. one
661
+ * Camunda `AgentHistoryRecordValue`. Its conversation grammar (`role`, `content` blocks,
662
+ * `toolCalls`, per-turn `metrics`) reuses the `@nanobpm/agentic/transcript` parity types the
663
+ * transcript store already models, so the engine-read seam and the transcript store project
664
+ * the same shape (No Drift Surfaces). `historyItemKey` is the stable, creation-ordered
665
+ * identity; `loopIteration` is the agent-loop counter (one LLM call + its tool dispatches +
666
+ * their results share an iteration); `commitStatus` is the engine's `COMMITTED`/`PENDING`/
667
+ * `DISCARDED` flag (a bare string). `metrics` is present only when the engine recorded per-call
668
+ * metrics for the item; `elementInstanceKey`/`jobKey`/`producedAt` are best-effort diagnostics,
669
+ * omitted when unreported.
670
+ */
671
+ export interface AgentHistoryRecord {
672
+ readonly historyItemKey: string;
673
+ readonly agentInstanceKey: string;
674
+ readonly loopIteration: number;
675
+ readonly role: TranscriptTurnRole;
676
+ readonly content: readonly TranscriptContentBlock[];
677
+ readonly toolCalls: readonly TranscriptToolCall[];
678
+ readonly metrics?: TranscriptTurnMetrics;
679
+ readonly commitStatus: string;
680
+ readonly elementInstanceKey?: string;
681
+ readonly jobKey?: string;
682
+ readonly producedAt?: string;
683
+ }
684
+
685
+ /** Selectors for {@link EngineClient.searchAgentInstances}. Every field is optional; an
686
+ * omitted/blank selector is not applied (No Drift Surfaces — the same presence rule the key
687
+ * selectors elsewhere in this seam use). */
688
+ export interface AgentInstanceFilter {
689
+ /** Only agent instances owned by this process instance. */
690
+ readonly processInstanceKey?: string;
691
+ /** Only agent instances belonging to this *root* process-instance hierarchy. */
692
+ readonly rootProcessInstanceKey?: string;
693
+ /** Only agent instances in this lifecycle status (the engine's `AgentInstanceStatusEnum`). */
694
+ readonly status?: string;
695
+ /** Only agent instances owned by this BPMN element (the AI-agent task / ad-hoc sub-process). */
696
+ readonly elementId?: string;
697
+ }
698
+
699
+ /** Selectors for {@link EngineClient.searchAgentInstanceHistory} (the `agentInstanceKey` is a
700
+ * required positional argument, not a filter field). Every field is optional. Note the engine
701
+ * defaults an unfiltered history search to `COMMITTED` items only. */
702
+ export interface AgentHistoryFilter {
703
+ /** Only history items with this conversation role. Note that `TranscriptTurnRole` is the wider
704
+ * transcript union: only the engine-side subset `"USER" | "ASSISTANT" | "TOOL_RESULT"` is
705
+ * applied as a filter — `"CONFIGURATION"`/`"UNSPECIFIED"` are ignored (not forwarded). */
706
+ readonly role?: TranscriptTurnRole;
707
+ /** Only history items produced in this agent-loop iteration. */
708
+ readonly loopIteration?: number;
709
+ /** Only history items produced under this element instance. */
710
+ readonly elementInstanceKey?: string;
711
+ }
712
+
606
713
  /**
607
714
  * EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
608
715
  * it against a live engine; tests implement it in-memory. Core modules depend only on this.
@@ -772,6 +879,42 @@ export interface EngineClient {
772
879
  * caller distinguishes "have it" from "don't" without a try/catch. A zero-wait read.
773
880
  */
774
881
  getProcessDefinitionXml(processDefinitionKey: string): Promise<string | null>;
882
+ /**
883
+ * Search engine-native agent instances (`POST /agent-instances/search`), optionally narrowed
884
+ * by owning `processInstanceKey`/`rootProcessInstanceKey`, lifecycle `status`, or `elementId`.
885
+ * The read entry point onto the engine's AgentInstance/AgentHistory model: an
886
+ * {@link AgentInstanceSummary} carries the `agentInstanceKey` a caller passes to
887
+ * {@link searchAgentInstanceHistory} to read that instance's conversation. An eventually
888
+ * consistent (zero-wait) read; each malformed row (no `agentInstanceKey`/`processInstanceKey`)
889
+ * is dropped rather than surfaced.
890
+ */
891
+ searchAgentInstances(filter?: AgentInstanceFilter): Promise<AgentInstanceSummary[]>;
892
+ /**
893
+ * Search one agent instance's conversation history
894
+ * (`POST /agent-instances/{agentInstanceKey}/history/search`) — the ordered
895
+ * {@link AgentHistoryRecord}s (turns) the engine retains for `agentInstanceKey`, optionally
896
+ * narrowed by `role`/`loopIteration`/`elementInstanceKey`. This is the production read path
897
+ * onto engine AgentHistory the historical-transcript + metrics consumer builds on
898
+ * (nanobpm/nano-workforce#747). Each record's conversation grammar reuses the
899
+ * `@nanobpm/agentic/transcript` parity shapes (roles, content blocks, per-turn metrics, tool
900
+ * calls). Note the engine defaults an unfiltered search to `COMMITTED` items only. A blank
901
+ * `agentInstanceKey` addresses no instance, so it returns the empty list rather than issuing a
902
+ * request with an empty path segment. An eventually consistent (zero-wait) read; malformed rows
903
+ * are dropped.
904
+ */
905
+ searchAgentInstanceHistory(
906
+ agentInstanceKey: string,
907
+ filter?: AgentHistoryFilter,
908
+ ): Promise<AgentHistoryRecord[]>;
909
+ /**
910
+ * Fetch a single agent instance by its key (`GET /agent-instances/{agentInstanceKey}`), or
911
+ * `null` when it cannot be resolved — the key is blank, no such agent instance exists (a 404),
912
+ * or the fetch otherwise fails. A read is treated as absence rather than propagating (mirroring
913
+ * {@link getForm}/{@link getElementInstance}/{@link getProcessDefinitionXml}), so a caller
914
+ * distinguishes "have it" from "don't" without a try/catch. A zero-wait read returning the same
915
+ * {@link AgentInstanceSummary} shape as {@link searchAgentInstances}.
916
+ */
917
+ getAgentInstance(agentInstanceKey: string): Promise<AgentInstanceSummary | null>;
775
918
  /**
776
919
  * Resolve an open incident by key (`POST /v2/incidents/{incidentKey}/resolution`), unblocking
777
920
  * the parked token: a job incident returns the job to the activatable pool (so it must have
@@ -839,6 +982,9 @@ export const ENGINE_CLIENT_METHODS = [
839
982
  "searchVariables",
840
983
  "searchJobs",
841
984
  "getProcessDefinitionXml",
985
+ "searchAgentInstances",
986
+ "searchAgentInstanceHistory",
987
+ "getAgentInstance",
842
988
  "resolveIncident",
843
989
  "updateJobRetries",
844
990
  "setVariables",
@@ -40,6 +40,9 @@ const engine: EngineClient = {
40
40
  searchVariables: async () => [],
41
41
  searchJobs: async () => [],
42
42
  getProcessDefinitionXml: async () => null,
43
+ searchAgentInstances: async () => [],
44
+ searchAgentInstanceHistory: async () => [],
45
+ getAgentInstance: async () => null,
43
46
  resolveIncident: async () => {},
44
47
  updateJobRetries: async () => {},
45
48
  setVariables: async () => {},
@@ -25,6 +25,9 @@ const fakeEngine: EngineClient = {
25
25
  searchVariables: async () => [],
26
26
  searchJobs: async () => [],
27
27
  getProcessDefinitionXml: async () => null,
28
+ searchAgentInstances: async () => [],
29
+ searchAgentInstanceHistory: async () => [],
30
+ getAgentInstance: async () => null,
28
31
  resolveIncident: async () => {},
29
32
  updateJobRetries: async () => {},
30
33
  setVariables: async () => {},
@@ -38,6 +38,9 @@ const engine: EngineClient = {
38
38
  searchVariables: async () => [],
39
39
  searchJobs: async () => [],
40
40
  getProcessDefinitionXml: async () => null,
41
+ searchAgentInstances: async () => [],
42
+ searchAgentInstanceHistory: async () => [],
43
+ getAgentInstance: async () => null,
41
44
  resolveIncident: async () => {},
42
45
  updateJobRetries: async () => {},
43
46
  setVariables: async () => {},
@@ -92,6 +92,9 @@ function cancelEngine(opts: {
92
92
  searchVariables: notUsed("searchVariables"),
93
93
  searchJobs: notUsed("searchJobs"),
94
94
  getProcessDefinitionXml: notUsed("getProcessDefinitionXml"),
95
+ searchAgentInstances: notUsed("searchAgentInstances"),
96
+ searchAgentInstanceHistory: notUsed("searchAgentInstanceHistory"),
97
+ getAgentInstance: notUsed("getAgentInstance"),
95
98
  resolveIncident: notUsed("resolveIncident"),
96
99
  updateJobRetries: notUsed("updateJobRetries"),
97
100
  setVariables: notUsed("setVariables"),
@@ -28,6 +28,9 @@ class MiniEngine implements EngineClient {
28
28
  async searchVariables(): Promise<never[]> { return []; }
29
29
  async searchJobs(): Promise<never[]> { return []; }
30
30
  async getProcessDefinitionXml(): Promise<null> { return null; }
31
+ async searchAgentInstances(): Promise<never[]> { return []; }
32
+ async searchAgentInstanceHistory(): Promise<never[]> { return []; }
33
+ async getAgentInstance(): Promise<null> { return null; }
31
34
  async resolveIncident(): Promise<void> {}
32
35
  async updateJobRetries(): Promise<void> {}
33
36
  async setVariables(): Promise<void> {}
@@ -27,6 +27,9 @@ const noEngine: EngineClient = {
27
27
  searchVariables: async () => [],
28
28
  searchJobs: async () => [],
29
29
  getProcessDefinitionXml: async () => null,
30
+ searchAgentInstances: async () => [],
31
+ searchAgentInstanceHistory: async () => [],
32
+ getAgentInstance: async () => null,
30
33
  resolveIncident: async () => {},
31
34
  updateJobRetries: async () => {},
32
35
  setVariables: async () => {},
@@ -97,6 +97,9 @@ function fakeEngine(
97
97
  searchVariables: notUsed("searchVariables"),
98
98
  searchJobs: notUsed("searchJobs"),
99
99
  getProcessDefinitionXml: notUsed("getProcessDefinitionXml"),
100
+ searchAgentInstances: notUsed("searchAgentInstances"),
101
+ searchAgentInstanceHistory: notUsed("searchAgentInstanceHistory"),
102
+ getAgentInstance: notUsed("getAgentInstance"),
100
103
  resolveIncident: notUsed("resolveIncident"),
101
104
  updateJobRetries: notUsed("updateJobRetries"),
102
105
  setVariables: notUsed("setVariables"),
@@ -93,6 +93,9 @@ function fakeEngine(overrides: Partial<EngineClient> = {}): EngineClient {
93
93
  searchVariables: async () => [],
94
94
  searchJobs: async () => [],
95
95
  getProcessDefinitionXml: async () => null,
96
+ searchAgentInstances: async () => [],
97
+ searchAgentInstanceHistory: async () => [],
98
+ getAgentInstance: async () => null,
96
99
  resolveIncident: async () => {},
97
100
  updateJobRetries: async () => {},
98
101
  setVariables: async () => {},
@@ -646,6 +649,9 @@ test("urban_debug_search_variables / search_jobs / get_process_definition_xml ar
646
649
  seenXmlKeys.push(key);
647
650
  return key === "pd-1" ? "<bpmn:definitions/>" : null;
648
651
  },
652
+ searchAgentInstances: async () => [],
653
+ searchAgentInstanceHistory: async () => [],
654
+ getAgentInstance: async () => null,
649
655
  });
650
656
  const { router } = buildHarness({ engine });
651
657
  const session = await connect(router);
@@ -55,6 +55,9 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
55
55
  async searchVariables() { return []; },
56
56
  async searchJobs() { return []; },
57
57
  async getProcessDefinitionXml() { return null; },
58
+ async searchAgentInstances() { return []; },
59
+ async searchAgentInstanceHistory() { return []; },
60
+ async getAgentInstance() { return null; },
58
61
  async resolveIncident() {},
59
62
  async updateJobRetries() {},
60
63
  async setVariables() {},
@@ -41,6 +41,9 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
41
41
  searchVariables: async () => [],
42
42
  searchJobs: async () => [],
43
43
  getProcessDefinitionXml: async () => null,
44
+ searchAgentInstances: async () => [],
45
+ searchAgentInstanceHistory: async () => [],
46
+ getAgentInstance: async () => null,
44
47
  resolveIncident: async () => {},
45
48
  updateJobRetries: async () => {},
46
49
  setVariables: async () => {},
@@ -67,6 +70,9 @@ const fakeEngine: EngineClient = {
67
70
  searchVariables: async () => [],
68
71
  searchJobs: async () => [],
69
72
  getProcessDefinitionXml: async () => null,
73
+ searchAgentInstances: async () => [],
74
+ searchAgentInstanceHistory: async () => [],
75
+ getAgentInstance: async () => null,
70
76
  resolveIncident: async () => {},
71
77
  updateJobRetries: async () => {},
72
78
  setVariables: async () => {},
@@ -42,6 +42,9 @@ class MiniEngine implements EngineClient {
42
42
  async searchVariables(): Promise<never[]> { return []; }
43
43
  async searchJobs(): Promise<never[]> { return []; }
44
44
  async getProcessDefinitionXml(): Promise<null> { return null; }
45
+ async searchAgentInstances(): Promise<never[]> { return []; }
46
+ async searchAgentInstanceHistory(): Promise<never[]> { return []; }
47
+ async getAgentInstance(): Promise<null> { return null; }
45
48
  async resolveIncident(): Promise<void> {}
46
49
  async updateJobRetries(): Promise<void> {}
47
50
  async setVariables(): Promise<void> {}
@@ -39,6 +39,9 @@ class MiniEngine implements EngineClient {
39
39
  async searchVariables(): Promise<never[]> { return []; }
40
40
  async searchJobs(): Promise<never[]> { return []; }
41
41
  async getProcessDefinitionXml(): Promise<null> { return null; }
42
+ async searchAgentInstances(): Promise<never[]> { return []; }
43
+ async searchAgentInstanceHistory(): Promise<never[]> { return []; }
44
+ async getAgentInstance(): Promise<null> { return null; }
42
45
  async resolveIncident(): Promise<void> {}
43
46
  async updateJobRetries(): Promise<void> {}
44
47
  async setVariables(): Promise<void> {}
@@ -53,6 +53,15 @@ class NoopEngine implements EngineClient {
53
53
  async getProcessDefinitionXml() {
54
54
  return null;
55
55
  }
56
+ async searchAgentInstances() {
57
+ return [];
58
+ }
59
+ async searchAgentInstanceHistory() {
60
+ return [];
61
+ }
62
+ async getAgentInstance() {
63
+ return null;
64
+ }
56
65
  async resolveIncident() {}
57
66
  async updateJobRetries() {}
58
67
  async setVariables() {}
@@ -74,6 +74,15 @@ class FakeEngine implements EngineClient {
74
74
  async getProcessDefinitionXml() {
75
75
  return null;
76
76
  }
77
+ async searchAgentInstances() {
78
+ return [];
79
+ }
80
+ async searchAgentInstanceHistory() {
81
+ return [];
82
+ }
83
+ async getAgentInstance() {
84
+ return null;
85
+ }
77
86
  async resolveIncident() {}
78
87
  async updateJobRetries() {}
79
88
  async setVariables() {}
@@ -109,6 +109,18 @@ function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient &
109
109
  calls.push("getProcessDefinitionXml");
110
110
  return "";
111
111
  },
112
+ async searchAgentInstances() {
113
+ calls.push("searchAgentInstances");
114
+ return { items: [] };
115
+ },
116
+ async searchAgentInstanceHistory() {
117
+ calls.push("searchAgentInstanceHistory");
118
+ return { items: [] };
119
+ },
120
+ async getAgentInstance() {
121
+ calls.push("getAgentInstance");
122
+ return {};
123
+ },
112
124
  async resolveIncident(input) {
113
125
  calls.push("resolveIncident");
114
126
  return { ...input };
@@ -1408,3 +1420,200 @@ test("publishMessage threads lineage so message-started instances inherit it", a
1408
1420
  }
1409
1421
  assert.deepEqual(readLineage(client.msgVars), { rootRequestKey: "root-9", causedByInstanceKey: "pi-9" });
1410
1422
  });
1423
+
1424
+ // ── Engine-native AgentInstance / AgentHistory read seam (nanobpm/nano-ide#563) ──
1425
+ // The read path onto engine AgentInstance/AgentHistory that unblocks the historical-transcript
1426
+ // consumer (nanobpm/nano-workforce#747). These pin the request-mapping + response-mapping
1427
+ // contract of the three `EngineClient` methods against the fake nano-sdk client, mirroring the
1428
+ // other search-seam tests above (so the same scenarios would hold against a live engine).
1429
+
1430
+ test("searchAgentInstances normalizes selectors, reads zero-wait, and maps rows", async () => {
1431
+ let seenInput: { filter?: Record<string, unknown> } | undefined;
1432
+ let seenConsistency: unknown;
1433
+ const client = fakeSdkClient({
1434
+ searchAgentInstances: async (input, consistency) => {
1435
+ seenInput = input;
1436
+ seenConsistency = consistency;
1437
+ return {
1438
+ items: [
1439
+ {
1440
+ agentInstanceKey: " ai-1 ", // padded → trimmed
1441
+ status: "THINKING",
1442
+ processInstanceKey: 10,
1443
+ elementId: "agent",
1444
+ rootProcessInstanceKey: 30,
1445
+ processDefinitionKey: "pd-1",
1446
+ processDefinitionId: "proc",
1447
+ elementInstanceKeys: ["ei-1", "", 42],
1448
+ metrics: { inputTokens: 5, outputTokens: 7, modelCalls: 2, toolCalls: 1 },
1449
+ creationDate: "2024-01-01T00:00:00Z",
1450
+ lastUpdatedDate: "2024-01-02T00:00:00Z",
1451
+ completionDate: null,
1452
+ },
1453
+ { status: "IDLE", processInstanceKey: "11" }, // keyless → skipped
1454
+ { agentInstanceKey: "ai-2" }, // no processInstanceKey → skipped
1455
+ ],
1456
+ };
1457
+ },
1458
+ });
1459
+ const engine = new SdkEngineClient(client);
1460
+ const out = await engine.searchAgentInstances({
1461
+ processInstanceKey: " 10 ", // padded → trimmed
1462
+ rootProcessInstanceKey: " ", // whitespace-only → dropped
1463
+ status: "THINKING",
1464
+ elementId: "agent",
1465
+ });
1466
+ assert.deepEqual(seenInput, {
1467
+ filter: { processInstanceKey: "10", status: "THINKING", elementId: "agent" },
1468
+ });
1469
+ assert.deepEqual(seenConsistency, { consistency: { waitUpToMs: 0 } });
1470
+ assert.deepEqual(out, [
1471
+ {
1472
+ agentInstanceKey: "ai-1",
1473
+ status: "THINKING",
1474
+ processInstanceKey: "10",
1475
+ elementId: "agent",
1476
+ elementInstanceKeys: ["ei-1", "42"],
1477
+ rootProcessInstanceKey: "30",
1478
+ processDefinitionKey: "pd-1",
1479
+ processDefinitionId: "proc",
1480
+ metrics: { inputTokens: 5, outputTokens: 7, modelCalls: 2, toolCalls: 1 },
1481
+ creationDate: "2024-01-01T00:00:00Z",
1482
+ lastUpdatedDate: "2024-01-02T00:00:00Z",
1483
+ },
1484
+ ]);
1485
+ });
1486
+
1487
+ test("searchAgentInstanceHistory keys the path, maps the transcript grammar, and short-circuits a blank key", async () => {
1488
+ let seenInput: { agentInstanceKey?: string; filter?: Record<string, unknown> } | undefined;
1489
+ const client = fakeSdkClient({
1490
+ searchAgentInstanceHistory: async (input) => {
1491
+ seenInput = input;
1492
+ return {
1493
+ items: [
1494
+ {
1495
+ historyItemKey: "h-1",
1496
+ agentInstanceKey: "ai-1",
1497
+ loopIteration: 3,
1498
+ role: "ASSISTANT",
1499
+ content: [
1500
+ { contentType: "TEXT", text: "hi" },
1501
+ { contentType: "OBJECT", object: { a: 1 } },
1502
+ { contentType: "MYSTERY" }, // unknown → UNSPECIFIED
1503
+ ],
1504
+ toolCalls: [
1505
+ { toolCallId: "t-1", toolName: "search", elementId: "tool", arguments: { q: "x" } },
1506
+ { toolName: "no-id", arguments: null }, // no toolCallId → dropped
1507
+ ],
1508
+ metrics: { inputTokens: 11, outputTokens: null, durationMs: 42 },
1509
+ commitStatus: "COMMITTED",
1510
+ elementInstanceKey: "ei-9",
1511
+ jobKey: "job-9",
1512
+ producedAt: "2024-01-01T00:00:00Z",
1513
+ },
1514
+ { agentInstanceKey: "ai-1", role: "USER" }, // no historyItemKey → skipped
1515
+ ],
1516
+ };
1517
+ },
1518
+ });
1519
+ const engine = new SdkEngineClient(client);
1520
+ const out = await engine.searchAgentInstanceHistory(" ai-1 ", {
1521
+ role: "ASSISTANT",
1522
+ loopIteration: 3,
1523
+ elementInstanceKey: " ei-9 ",
1524
+ });
1525
+ assert.deepEqual(seenInput, {
1526
+ agentInstanceKey: "ai-1",
1527
+ filter: { role: "ASSISTANT", loopIteration: 3, elementInstanceKey: "ei-9" },
1528
+ });
1529
+ assert.deepEqual(out, [
1530
+ {
1531
+ historyItemKey: "h-1",
1532
+ agentInstanceKey: "ai-1",
1533
+ loopIteration: 3,
1534
+ role: "ASSISTANT",
1535
+ content: [
1536
+ { contentType: "TEXT", text: "hi" },
1537
+ { contentType: "OBJECT", object: { a: 1 } },
1538
+ { contentType: "UNSPECIFIED" },
1539
+ ],
1540
+ toolCalls: [{ toolCallId: "t-1", toolName: "search", elementId: "tool", arguments: { q: "x" } }],
1541
+ metrics: {
1542
+ inputTokens: 11,
1543
+ outputTokens: 0,
1544
+ reasoningTokenCount: 0,
1545
+ cacheCreationTokenCount: 0,
1546
+ cacheReadTokenCount: 0,
1547
+ durationMs: 42,
1548
+ },
1549
+ commitStatus: "COMMITTED",
1550
+ elementInstanceKey: "ei-9",
1551
+ jobKey: "job-9",
1552
+ producedAt: "2024-01-01T00:00:00Z",
1553
+ },
1554
+ ]);
1555
+
1556
+ // A blank key addresses no instance → empty list, no request issued.
1557
+ seenInput = undefined;
1558
+ const blank = await engine.searchAgentInstanceHistory(" ");
1559
+ assert.deepEqual(blank, []);
1560
+ assert.equal(seenInput, undefined, "no history search is issued for a blank key");
1561
+ });
1562
+
1563
+ test("searchAgentInstanceHistory drops a transcript role the engine filter does not accept", async () => {
1564
+ // `TranscriptTurnRole` is the wider transcript union; only `USER`/`ASSISTANT`/`TOOL_RESULT` are
1565
+ // valid engine history filters. An unsupported role (e.g. `CONFIGURATION`/`UNSPECIFIED`) must not
1566
+ // be forwarded, else the engine 4xxes on the request. Guards the fix against reintroduction.
1567
+ let seenInput: { agentInstanceKey?: string; filter?: Record<string, unknown> } | undefined;
1568
+ const client = fakeSdkClient({
1569
+ searchAgentInstanceHistory: async (input) => {
1570
+ seenInput = input;
1571
+ return { items: [] };
1572
+ },
1573
+ });
1574
+ const engine = new SdkEngineClient(client);
1575
+
1576
+ await engine.searchAgentInstanceHistory("ai-1", { role: "CONFIGURATION", loopIteration: 2 });
1577
+ assert.deepEqual(
1578
+ seenInput,
1579
+ { agentInstanceKey: "ai-1", filter: { loopIteration: 2 } },
1580
+ "an unsupported role is not forwarded as an engine filter",
1581
+ );
1582
+
1583
+ await engine.searchAgentInstanceHistory("ai-1", { role: "UNSPECIFIED" });
1584
+ assert.deepEqual(
1585
+ seenInput,
1586
+ { agentInstanceKey: "ai-1", filter: {} },
1587
+ "UNSPECIFIED is dropped, leaving no role filter",
1588
+ );
1589
+
1590
+ await engine.searchAgentInstanceHistory("ai-1", { role: "TOOL_RESULT" });
1591
+ assert.deepEqual(
1592
+ seenInput,
1593
+ { agentInstanceKey: "ai-1", filter: { role: "TOOL_RESULT" } },
1594
+ "a supported engine role is forwarded",
1595
+ );
1596
+ });
1597
+
1598
+ test("getAgentInstance reads-as-absence on a blank key and on a fetch error", async () => {
1599
+ let calls = 0;
1600
+ const client = fakeSdkClient({
1601
+ getAgentInstance: async (input) => {
1602
+ calls++;
1603
+ if (input.agentInstanceKey === "boom") throw new Error("404");
1604
+ return { agentInstanceKey: input.agentInstanceKey, status: "COMPLETED", processInstanceKey: "10" };
1605
+ },
1606
+ });
1607
+ const engine = new SdkEngineClient(client);
1608
+
1609
+ assert.equal(await engine.getAgentInstance(" "), null, "blank key → null without a fetch");
1610
+ assert.equal(calls, 0);
1611
+
1612
+ assert.equal(await engine.getAgentInstance("boom"), null, "a failed fetch is absence, not a throw");
1613
+
1614
+ assert.deepEqual(await engine.getAgentInstance(" ai-7 "), {
1615
+ agentInstanceKey: "ai-7",
1616
+ status: "COMPLETED",
1617
+ processInstanceKey: "10",
1618
+ });
1619
+ });