@stigmer/runner 3.2.2 → 3.2.3

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 (80) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
  3. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  4. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  5. package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
  6. package/dist/activities/execute-cursor/approval-state.js +13 -1
  7. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +38 -0
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.js +28 -12
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
  13. package/dist/activities/execute-cursor/message-translator.js +78 -2
  14. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  15. package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
  16. package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
  17. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  18. package/dist/activities/execute-cursor/turn-boundary.js +15 -2
  19. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  20. package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
  21. package/dist/activities/execute-deep-agent/hitl.js +48 -1
  22. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/index.js +10 -1
  24. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
  26. package/dist/activities/execute-deep-agent/setup.js +12 -1
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
  29. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/status-builder.js +6 -0
  31. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
  33. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  34. package/dist/activities/workflow-event-activities.js +9 -0
  35. package/dist/activities/workflow-event-activities.js.map +1 -1
  36. package/dist/middleware/approval-gate.d.ts +20 -0
  37. package/dist/middleware/approval-gate.js +16 -1
  38. package/dist/middleware/approval-gate.js.map +1 -1
  39. package/dist/shared/approval-policy.d.ts +24 -1
  40. package/dist/shared/approval-policy.js +35 -1
  41. package/dist/shared/approval-policy.js.map +1 -1
  42. package/dist/shared/with-timeout.d.ts +10 -0
  43. package/dist/shared/with-timeout.js +14 -1
  44. package/dist/shared/with-timeout.js.map +1 -1
  45. package/dist/workflow-engine/tasks/human-input.js +1 -0
  46. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  47. package/dist/workflow-engine/types.d.ts +22 -0
  48. package/dist/workflow-engine/types.js.map +1 -1
  49. package/dist/workflows/human-input-orchestrator.d.ts +2 -1
  50. package/dist/workflows/human-input-orchestrator.js +2 -1
  51. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  52. package/package.json +2 -2
  53. package/src/activities/__tests__/workflow-event-activities.test.ts +29 -0
  54. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
  55. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
  56. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
  57. package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
  58. package/src/activities/execute-cursor/approval-state.ts +32 -1
  59. package/src/activities/execute-cursor/hook-script.ts +40 -0
  60. package/src/activities/execute-cursor/index.ts +27 -14
  61. package/src/activities/execute-cursor/message-translator.ts +82 -1
  62. package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
  63. package/src/activities/execute-cursor/turn-boundary.ts +23 -0
  64. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
  65. package/src/activities/execute-deep-agent/hitl.ts +51 -0
  66. package/src/activities/execute-deep-agent/index.ts +11 -1
  67. package/src/activities/execute-deep-agent/setup.ts +27 -0
  68. package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
  69. package/src/activities/execute-deep-agent/status-builder.ts +7 -0
  70. package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
  71. package/src/activities/workflow-event-activities.ts +9 -0
  72. package/src/middleware/__tests__/approval-gate.test.ts +95 -0
  73. package/src/middleware/approval-gate.ts +37 -0
  74. package/src/shared/__tests__/with-timeout.test.ts +16 -1
  75. package/src/shared/approval-policy.ts +41 -2
  76. package/src/shared/with-timeout.ts +15 -1
  77. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
  78. package/src/workflow-engine/tasks/human-input.ts +1 -0
  79. package/src/workflow-engine/types.ts +23 -0
  80. package/src/workflows/human-input-orchestrator.ts +2 -1
@@ -34,6 +34,7 @@ import {
34
34
  ArtifactCreatedPayloadSchema,
35
35
  HumanInputOutcomeInfoSchema,
36
36
  } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
37
+ import { ApiResourceAuditActorSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/status_pb";
37
38
  import { WorkflowExecutionStatusSchema, WorkflowTaskSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
38
39
  import {
39
40
  WorkflowExecutionUpdateStatusInputSchema,
@@ -309,6 +310,14 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
309
310
  case: "approvalResolved",
310
311
  value: create(ApprovalResolvedPayloadSchema, {
311
312
  resolvedBy: desc.resolvedBy,
313
+ resolvedByActor: desc.resolvedByActor
314
+ ? create(ApiResourceAuditActorSchema, {
315
+ id: desc.resolvedByActor.id,
316
+ displayName: desc.resolvedByActor.display_name ?? "",
317
+ email: desc.resolvedByActor.email ?? "",
318
+ avatar: desc.resolvedByActor.avatar ?? "",
319
+ })
320
+ : undefined,
312
321
  comment: desc.comment,
313
322
  waitDurationMs: BigInt(desc.waitDurationMs),
314
323
  }),
@@ -784,4 +784,99 @@ describe("ApprovalGateMiddleware", () => {
784
784
  expect(result).toBeInstanceOf(ToolMessage);
785
785
  expect((result as ToolMessage).content).toContain("unknown action");
786
786
  });
787
+
788
+ describe("unattended approval mode (DD-014)", () => {
789
+ const gatedMcpPolicies = new Map<string, MergedToolPolicy>([
790
+ ["srv/gated_tool", {
791
+ toolName: "gated_tool",
792
+ mcpServerSlug: "srv",
793
+ requiresApproval: true,
794
+ approvalMessage: "Run gated_tool",
795
+ source: "classifier_default",
796
+ }],
797
+ ]);
798
+
799
+ it("skips a gated MCP tool without interrupting and records it in the registry", async () => {
800
+ const unattendedSkips = new Set<string>();
801
+ const mw = createApprovalGateMiddleware(makeConfig({
802
+ policies: gatedMcpPolicies,
803
+ toolServerMap: new Map([["gated_tool", "srv"]]),
804
+ unattended: true,
805
+ unattendedSkips,
806
+ }));
807
+
808
+ const handler = vi.fn(passthrough);
809
+ const result = await mw.wrapToolCall!(
810
+ makeRequest({ name: "gated_tool", args: { target: "prod" } }),
811
+ handler,
812
+ );
813
+
814
+ // Gateway invariant: the side effect never ran, and no interrupt was
815
+ // raised — the turn continues instead of parking WAITING_FOR_APPROVAL.
816
+ expect(handler).not.toHaveBeenCalled();
817
+ expect(mockedInterrupt).not.toHaveBeenCalled();
818
+ expect((result as ToolMessage).content).toContain("skipped automatically");
819
+ expect(unattendedSkips.has("call_abc123")).toBe(true);
820
+ });
821
+
822
+ it("skips a gated built-in (shell) the same way", async () => {
823
+ const unattendedSkips = new Set<string>();
824
+ const mw = createApprovalGateMiddleware(makeConfig({
825
+ unattended: true,
826
+ unattendedSkips,
827
+ }));
828
+
829
+ const handler = vi.fn(passthrough);
830
+ const result = await mw.wrapToolCall!(
831
+ makeRequest({ name: "shell", args: { command: "rm -rf /" } }),
832
+ handler,
833
+ );
834
+
835
+ expect(handler).not.toHaveBeenCalled();
836
+ expect(mockedInterrupt).not.toHaveBeenCalled();
837
+ expect((result as ToolMessage).content).toContain("skipped automatically");
838
+ expect(unattendedSkips.has("call_abc123")).toBe(true);
839
+ });
840
+
841
+ it("still runs tools the policy chain auto-approved (un-gating stays the operator lever)", async () => {
842
+ // The operator's tool_approval_overrides path: an un-gated tool is
843
+ // absent from the policy map, so unattended mode never touches it.
844
+ const unattendedSkips = new Set<string>();
845
+ const mw = createApprovalGateMiddleware(makeConfig({
846
+ toolServerMap: new Map([["book_appointment", "clinic"]]),
847
+ unattended: true,
848
+ unattendedSkips,
849
+ }));
850
+
851
+ const result = await mw.wrapToolCall!(
852
+ makeRequest({ name: "book_appointment", args: { slot: "mon-10" } }),
853
+ passthrough,
854
+ );
855
+
856
+ expect((result as ToolMessage).content).toBe("tool result");
857
+ expect(unattendedSkips.size).toBe(0);
858
+ expect(mockedInterrupt).not.toHaveBeenCalled();
859
+ });
860
+
861
+ it("never emits an execution receipt for an unattended skip (no side effect)", async () => {
862
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
863
+ try {
864
+ const mw = createApprovalGateMiddleware(makeConfig({
865
+ policies: gatedMcpPolicies,
866
+ toolServerMap: new Map([["gated_tool", "srv"]]),
867
+ unattended: true,
868
+ unattendedSkips: new Set<string>(),
869
+ }));
870
+
871
+ await mw.wrapToolCall!(makeRequest({ name: "gated_tool" }), passthrough);
872
+
873
+ const receipts = logSpy.mock.calls
874
+ .map((c) => String(c[0]))
875
+ .filter((line) => line.includes("[hitl-gateway] receipt"));
876
+ expect(receipts).toHaveLength(0);
877
+ } finally {
878
+ logSpy.mockRestore();
879
+ }
880
+ });
881
+ });
787
882
  });
@@ -51,6 +51,7 @@ import {
51
51
  type PolicySource,
52
52
  POLICY_ENGINE_VERSION,
53
53
  resolveApprovalMessage,
54
+ unattendedSkipMessage,
54
55
  } from "../shared/approval-policy.js";
55
56
  import { toolApprovalCategory, type ToolApprovalCategory } from "../shared/tool-kind.js";
56
57
  import { extractFilePath } from "../shared/file-tools.js";
@@ -121,6 +122,26 @@ export interface ApprovalGateConfig {
121
122
  * secret; the CONTENT never leaves the workspace). Absent ⇒ nothing recorded.
122
123
  */
123
124
  readonly recordBlockedSecret?: (rawPath: string) => void;
125
+ /**
126
+ * Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
127
+ * the creating surface — a messaging channel, a guest share — has no
128
+ * approver, so a gated tool is resolved as an automatic SKIP (the model is
129
+ * told to adapt) instead of `interrupt()`. The execution never enters
130
+ * WAITING_FOR_APPROVAL. What is gated is unchanged — only the resolution
131
+ * differs; an operator un-gates a specific tool for the agent via
132
+ * tool_approval_overrides, not by weakening this mode.
133
+ */
134
+ readonly unattended?: boolean;
135
+ /**
136
+ * Registry of tool-call ids this gate auto-skipped under {@link unattended}
137
+ * — the gate is the single WRITER; `reconcileUnattendedSkips` (hitl.ts) is
138
+ * the reader that folds each id into a terminal TOOL_CALL_SKIPPED row with
139
+ * UNATTENDED_SKIP provenance after the stream. In-process, per-execution
140
+ * state keyed by the framework's own tool-call id (direct identity, no
141
+ * matching); inherited verbatim by sub-agent gates so their skips land in
142
+ * the same registry.
143
+ */
144
+ readonly unattendedSkips?: Set<string>;
124
145
  }
125
146
 
126
147
  interface ApprovalDecision {
@@ -249,6 +270,22 @@ export function createApprovalGateMiddleware(
249
270
  return await handler(request);
250
271
  }
251
272
 
273
+ // Unattended surfaces (channels, guest shares) have no approver, so a
274
+ // gate that would interrupt() here resolves as an automatic SKIP: the
275
+ // tool does NOT run (the gateway invariant holds — no side effect
276
+ // without a backing authorization), the model is told to adapt in plain
277
+ // language, and the turn continues to normal completion instead of
278
+ // parking in WAITING_FOR_APPROVAL forever. The registry entry lets the
279
+ // post-stream reconciler stamp the terminal SKIPPED row + provenance.
280
+ if (config.unattended) {
281
+ config.unattendedSkips?.add(toolCall.id);
282
+ return new ToolMessage({
283
+ content: unattendedSkipMessage(toolName),
284
+ tool_call_id: toolCall.id,
285
+ name: toolName,
286
+ });
287
+ }
288
+
252
289
  const approvalRequest = {
253
290
  tool_call_id: toolCall.id,
254
291
  tool_name: toolName,
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { withTimeout } from "../with-timeout.js";
2
+ import { withTimeout, TimeoutError } from "../with-timeout.js";
3
3
 
4
4
  describe("withTimeout", () => {
5
5
  it("resolves with the function's result when it completes in time", async () => {
@@ -14,6 +14,21 @@ describe("withTimeout", () => {
14
14
  ).rejects.toThrow("Cursor agent create timed out after 20ms");
15
15
  });
16
16
 
17
+ it("rejects with a TimeoutError so callers can react to expiry by type", async () => {
18
+ const hang = () => new Promise<never>(() => {});
19
+ await expect(withTimeout(20, "expired", hang)).rejects.toBeInstanceOf(TimeoutError);
20
+ });
21
+
22
+ it("does not wrap the function's own rejection in a TimeoutError", async () => {
23
+ const boom = new Error("underlying failure");
24
+ let caught: unknown;
25
+ await withTimeout(1_000, "should not fire", () => Promise.reject(boom)).catch((err) => {
26
+ caught = err;
27
+ });
28
+ expect(caught).toBe(boom);
29
+ expect(caught).not.toBeInstanceOf(TimeoutError);
30
+ });
31
+
17
32
  it("evaluates a lazy message only on expiry", async () => {
18
33
  let evaluated = 0;
19
34
  const message = () => {
@@ -18,7 +18,7 @@
18
18
  import type { ToolApprovalPolicy } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
19
19
  import type { ToolApprovalOverride } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
20
20
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
21
- import { ApprovalAction, ApprovalPolicySource } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
21
+ import { ApprovalAction, ApprovalMode, ApprovalPolicySource } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
22
22
  import { toolApprovalCategory, type ToolApprovalCategory } from "./tool-kind.js";
23
23
  import type { ResolvedMcpServer } from "./mcp-resolver.js";
24
24
 
@@ -147,6 +147,42 @@ export function deriveActiveLeases(execution: AgentExecution): ActiveLeases {
147
147
  };
148
148
  }
149
149
 
150
+ /**
151
+ * Whether this execution runs in UNATTENDED approval mode
152
+ * (ExecutionConfig.approval_mode): the creating surface — a messaging
153
+ * channel, a guest share — has no approver, so a gated tool is resolved as
154
+ * an automatic skip (the model is told to adapt) instead of pausing the
155
+ * execution for a decision that can never arrive.
156
+ *
157
+ * The mode changes HOW a gate resolves, never WHAT is gated: the four-level
158
+ * policy merge below is identical in both modes. Both harnesses read the
159
+ * mode through this one helper (the native gate skips instead of
160
+ * interrupting; the Cursor hook records a non-pausing "unattended" denial),
161
+ * so the surfaces can never diverge on what "unattended" means.
162
+ */
163
+ export function isUnattendedApprovalMode(execution: AgentExecution): boolean {
164
+ return execution.spec?.executionConfig?.approvalMode === ApprovalMode.UNATTENDED;
165
+ }
166
+
167
+ /**
168
+ * The tool-result text for an unattended auto-skip — ONE definition for both
169
+ * harnesses (the native gate returns it as the skip ToolMessage; the Cursor
170
+ * turn boundary backfills it onto stamped SKIPPED rows), so the transcript
171
+ * reads identically wherever the skip happened. Deliberately instructs
172
+ * plain-language adaptation with NO tool/approval vocabulary reaching the end
173
+ * user (the channel/guest anti-leak posture).
174
+ */
175
+ export function unattendedSkipMessage(toolName: string): string {
176
+ return (
177
+ `Tool '${toolName}' requires an approval that is not available in ` +
178
+ `this conversation, so it was skipped automatically. Do not retry ` +
179
+ `it or attempt a workaround. Adapt your plan, and explain to the ` +
180
+ `user in plain language what you could not do and what they can ` +
181
+ `do instead — never mention tools, approvals, or platform ` +
182
+ `mechanics.`
183
+ );
184
+ }
185
+
150
186
  /**
151
187
  * Provenance of a gate decision: which policy layer (or decision point) is
152
188
  * responsible for the final requires-approval verdict.
@@ -170,7 +206,8 @@ export type PolicySource =
170
206
  | "approval_lease" // Layer 4: a run-lifetime scoped lease cleared this action
171
207
  | "builtin_category" // Non-MCP built-in gated by the shared tool taxonomy
172
208
  | "file_capture" // Capture mode: a git-tracked built-in file edit flows, reviewed post-hoc via the file_review ledger (not gated; audit-only on the shadow receipt)
173
- | "annotation_destructive_tighten"; // Layer 1 sub-case: connect-time destructiveHint tightener force-gated this MCP tool
209
+ | "annotation_destructive_tighten" // Layer 1 sub-case: connect-time destructiveHint tightener force-gated this MCP tool
210
+ | "unattended_skip"; // Layer 4 resolution: unattended approval mode auto-skipped this gated call (no approver on the creating surface)
174
211
 
175
212
  /**
176
213
  * Monotonic identifier of the policy-engine logic that produced a decision,
@@ -205,6 +242,8 @@ export function toProtoPolicySource(source: PolicySource | undefined): ApprovalP
205
242
  return ApprovalPolicySource.BUILTIN_CATEGORY;
206
243
  case "annotation_destructive_tighten":
207
244
  return ApprovalPolicySource.ANNOTATION_DESTRUCTIVE_TIGHTEN;
245
+ case "unattended_skip":
246
+ return ApprovalPolicySource.UNATTENDED_SKIP;
208
247
  case "file_capture":
209
248
  // Capture-mode flow is never persisted on a gated tool call (the file tool
210
249
  // is not gated — it has no WAITING_APPROVAL row); it exists only on the
@@ -15,6 +15,20 @@
15
15
  * `AbortSignal.timeout` and pass the signal to the callee instead.
16
16
  */
17
17
 
18
+ /**
19
+ * Thrown when the bound expires before `fn` settles. A distinct class so
20
+ * callers can react to expiry specifically (`err instanceof TimeoutError`)
21
+ * instead of sniffing message text — e.g. the agent-resolution transport
22
+ * recovery retries on expiry but must let deterministic failures (auth,
23
+ * validation) propagate untouched.
24
+ */
25
+ export class TimeoutError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "TimeoutError";
29
+ }
30
+ }
31
+
18
32
  export async function withTimeout<T>(
19
33
  ms: number,
20
34
  timeoutMessage: string | (() => string),
@@ -23,7 +37,7 @@ export async function withTimeout<T>(
23
37
  return new Promise<T>((resolve, reject) => {
24
38
  const timer = setTimeout(() => {
25
39
  const message = typeof timeoutMessage === "function" ? timeoutMessage() : timeoutMessage;
26
- reject(new Error(message));
40
+ reject(new TimeoutError(message));
27
41
  }, ms);
28
42
 
29
43
  fn()
@@ -317,10 +317,50 @@ describe("executeHumanInputTask", () => {
317
317
  expect(resolved.taskName).toBe("gate");
318
318
  expect(resolved.outcome).toBe("reject");
319
319
  expect(resolved.resolvedBy).toBe("carol");
320
+ expect(resolved.resolvedByActor).toBeUndefined();
320
321
  expect(resolved.autoResolved).toBe(false);
321
322
  expect(resolved.waitDurationMs).toBeGreaterThanOrEqual(0);
322
323
  });
323
324
 
325
+ it("carries the reviewer_actor display snapshot into approval_resolved and the task output", async () => {
326
+ const emitted: WorkflowEventDescriptor[][] = [];
327
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
328
+ const reviewerActor = {
329
+ id: "ida_01abc",
330
+ display_name: "Ada Lovelace",
331
+ email: "ada@example.com",
332
+ avatar: "https://example.com/ada.png",
333
+ };
334
+ const awaitFn: AwaitHumanInputFn = async () => ({
335
+ outcome: "approve",
336
+ reviewer: "ida_01abc",
337
+ reviewer_actor: reviewerActor,
338
+ responded_at: "2026-05-20T10:00:00Z",
339
+ });
340
+
341
+ const taskDef: HumanInputTaskDef = {
342
+ kind: "human_input",
343
+ humanInput: { prompt: "Confirm?" },
344
+ };
345
+
346
+ const state = createState();
347
+ await executeHumanInputTask(taskDef, "gate", state, makeCtx(awaitFn, emitFn));
348
+
349
+ const resolved = emitted[1][0];
350
+ if (resolved.type !== "approval_resolved") throw new Error("unexpected");
351
+ expect(resolved.resolvedBy).toBe("ida_01abc");
352
+ expect(resolved.resolvedByActor).toEqual(reviewerActor);
353
+
354
+ // The whole signal payload IS the task output — the actor lands there
355
+ // for downstream tasks (e.g. notification templates) without copying.
356
+ expect(state.data.gate).toEqual({
357
+ outcome: "approve",
358
+ reviewer: "ida_01abc",
359
+ reviewer_actor: reviewerActor,
360
+ responded_at: "2026-05-20T10:00:00Z",
361
+ });
362
+ });
363
+
324
364
  it("does not error when emitEvents is undefined (backward compat)", async () => {
325
365
  const awaitFn: AwaitHumanInputFn = async () => ({ outcome: "approve" });
326
366
 
@@ -109,6 +109,7 @@ export async function executeHumanInputTask(
109
109
  occurredAt: new Date().toISOString(),
110
110
  outcome: result.outcome,
111
111
  resolvedBy: result.reviewer ?? "",
112
+ resolvedByActor: result.reviewer_actor,
112
113
  comment: "",
113
114
  waitDurationMs: Date.now() - approvalRequestedAt,
114
115
  autoResolved: result.auto_resolved ?? false,
@@ -563,13 +563,33 @@ export interface HumanInputExecutionConfig {
563
563
 
564
564
  export interface HumanInputResult {
565
565
  readonly outcome: string;
566
+ /** Canonical reviewer identity (identity-account ID). Empty/absent when unattributed. */
566
567
  readonly reviewer?: string;
568
+ /**
569
+ * Display snapshot of the reviewer, stamped server-side by the control
570
+ * plane at decision time. Rides the signal opaquely into the task output
571
+ * (for downstream tasks, e.g. notifications) and the approval_resolved
572
+ * event (for UIs). Absent when no attribution exists — never synthesized.
573
+ */
574
+ readonly reviewer_actor?: HumanInputReviewerActor;
567
575
  readonly responded_at?: string;
568
576
  readonly form_data?: Record<string, unknown>;
569
577
  readonly auto_resolved?: boolean;
570
578
  readonly reason?: string;
571
579
  }
572
580
 
581
+ /** Write-time snapshot of the reviewer's display identity. */
582
+ export interface HumanInputReviewerActor {
583
+ /** Canonical identity (matches HumanInputResult.reviewer). */
584
+ readonly id: string;
585
+ /** Human-readable name; empty when unknown. */
586
+ readonly display_name?: string;
587
+ /** Email address; empty when unknown. */
588
+ readonly email?: string;
589
+ /** Avatar URL; empty when unknown. */
590
+ readonly avatar?: string;
591
+ }
592
+
573
593
  export interface HumanInputConfig {
574
594
  readonly prompt: string;
575
595
  readonly outcomes?: HumanInputOutcome[];
@@ -843,7 +863,10 @@ export interface ApprovalRequestedEvent extends EventBase {
843
863
  export interface ApprovalResolvedEvent extends EventBase {
844
864
  readonly type: "approval_resolved";
845
865
  readonly outcome: string;
866
+ /** Canonical reviewer identity. Empty when unattributed. */
846
867
  readonly resolvedBy: string;
868
+ /** Display snapshot of the reviewer from the signal payload, if stamped. */
869
+ readonly resolvedByActor?: HumanInputReviewerActor;
847
870
  readonly comment: string;
848
871
  readonly waitDurationMs: number;
849
872
  readonly autoResolved: boolean;
@@ -8,7 +8,8 @@
8
8
  * - "approve": returns auto-approved output
9
9
  * - "deny": returns auto-denied output
10
10
  *
11
- * Signal payload shape: { outcome, form_data?, reviewer, responded_at }
11
+ * Signal payload shape:
12
+ * { outcome, form_data?, reviewer, reviewer_actor?, responded_at }
12
13
  *
13
14
  * TEMPORAL SANDBOX: This file runs inside the deterministic workflow isolate.
14
15
  */