@stigmer/runner 3.2.0 → 3.2.2

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 (95) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-transform.js +12 -2
  3. package/dist/activities/call-transform.js.map +1 -1
  4. package/dist/activities/call-validate.js +20 -2
  5. package/dist/activities/call-validate.js.map +1 -1
  6. package/dist/activities/discover-mcp-server.js +6 -20
  7. package/dist/activities/discover-mcp-server.js.map +1 -1
  8. package/dist/activities/execute-cursor/cost-guard.d.ts +41 -0
  9. package/dist/activities/execute-cursor/cost-guard.js +50 -0
  10. package/dist/activities/execute-cursor/cost-guard.js.map +1 -0
  11. package/dist/activities/execute-cursor/index.d.ts +12 -0
  12. package/dist/activities/execute-cursor/index.js +83 -12
  13. package/dist/activities/execute-cursor/index.js.map +1 -1
  14. package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
  15. package/dist/activities/execute-cursor/prompt-builder.js +21 -0
  16. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  17. package/dist/activities/execute-cursor/turn-stream.d.ts +10 -1
  18. package/dist/activities/execute-cursor/turn-stream.js +42 -9
  19. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  20. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +19 -0
  21. package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
  22. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/setup.js +4 -0
  24. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  25. package/dist/activities/workflow-event-activities.js +2 -0
  26. package/dist/activities/workflow-event-activities.js.map +1 -1
  27. package/dist/config.d.ts +17 -0
  28. package/dist/config.js +14 -0
  29. package/dist/config.js.map +1 -1
  30. package/dist/runner-manager.d.ts +2 -0
  31. package/dist/runner-manager.js +2 -1
  32. package/dist/runner-manager.js.map +1 -1
  33. package/dist/runner.d.ts +2 -0
  34. package/dist/runner.js +2 -1
  35. package/dist/runner.js.map +1 -1
  36. package/dist/shared/context-bridge.d.ts +30 -0
  37. package/dist/shared/context-bridge.js +45 -0
  38. package/dist/shared/context-bridge.js.map +1 -0
  39. package/dist/shared/sender-identity.d.ts +50 -0
  40. package/dist/shared/sender-identity.js +69 -0
  41. package/dist/shared/sender-identity.js.map +1 -0
  42. package/dist/shared/with-timeout.d.ts +17 -0
  43. package/dist/shared/with-timeout.js +34 -0
  44. package/dist/shared/with-timeout.js.map +1 -0
  45. package/dist/workflow-engine/do-executor.d.ts +7 -0
  46. package/dist/workflow-engine/do-executor.js +59 -1
  47. package/dist/workflow-engine/do-executor.js.map +1 -1
  48. package/dist/workflow-engine/tasks/call-function.js +68 -1
  49. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  50. package/dist/workflow-engine/types.d.ts +26 -0
  51. package/dist/workflow-engine/types.js.map +1 -1
  52. package/dist/workflows/engine-core.js +5 -1
  53. package/dist/workflows/engine-core.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/__tests__/config.test.ts +8 -0
  56. package/src/activities/__tests__/call-validate.test.ts +137 -0
  57. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  58. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  59. package/src/activities/__tests__/workflow-event-activities.test.ts +60 -0
  60. package/src/activities/call-transform.ts +20 -2
  61. package/src/activities/call-validate.ts +27 -2
  62. package/src/activities/discover-mcp-server.ts +7 -28
  63. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -0
  64. package/src/activities/execute-cursor/__tests__/cost-guard.test.ts +64 -0
  65. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +118 -0
  66. package/src/activities/execute-cursor/cost-guard.ts +56 -0
  67. package/src/activities/execute-cursor/index.ts +101 -15
  68. package/src/activities/execute-cursor/prompt-builder.ts +44 -0
  69. package/src/activities/execute-cursor/turn-stream.ts +61 -10
  70. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  71. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  72. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  73. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  74. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
  75. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  76. package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
  77. package/src/activities/execute-deep-agent/setup.ts +4 -0
  78. package/src/activities/workflow-event-activities.ts +2 -0
  79. package/src/config.ts +23 -0
  80. package/src/runner-manager.ts +6 -1
  81. package/src/runner.ts +6 -1
  82. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  83. package/src/shared/__tests__/context-bridge.test.ts +51 -0
  84. package/src/shared/__tests__/sender-identity.test.ts +92 -0
  85. package/src/shared/__tests__/with-timeout.test.ts +45 -0
  86. package/src/shared/context-bridge.ts +51 -0
  87. package/src/shared/sender-identity.ts +85 -0
  88. package/src/shared/with-timeout.ts +39 -0
  89. package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
  90. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
  91. package/src/workflow-engine/do-executor.ts +65 -1
  92. package/src/workflow-engine/tasks/call-function.ts +84 -3
  93. package/src/workflow-engine/types.ts +27 -0
  94. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
  95. package/src/workflows/engine-core.ts +5 -1
@@ -10,6 +10,11 @@ import { relative } from "node:path";
10
10
  import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
11
11
  import type { ProvisionResult, GitMetadata } from "../../shared/workspace/types.js";
12
12
  import { SourceType } from "../../shared/workspace/types.js";
13
+ import { formatContextBridgeText } from "../../shared/context-bridge.js";
14
+ import {
15
+ formatSenderIdentityText,
16
+ type SenderIdentity,
17
+ } from "../../shared/sender-identity.js";
13
18
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
14
19
  import {
15
20
  buildImplementPlanDirective,
@@ -106,6 +111,24 @@ export interface PromptBuilderInput {
106
111
  * message itself is just a short label ("Build from plan").
107
112
  */
108
113
  buildFromPlan?: boolean;
114
+ /**
115
+ * Rollover context bridge (cloud DD-013): a digest of the previous
116
+ * session's conversation, read from `SessionSpec.metadata`. Injected on
117
+ * EVERY turn by design: the native system prompt is rebuilt per
118
+ * invocation (never checkpointed with the message history), so a
119
+ * first-turn-only injection would vanish from turn 2 onward. The bridge
120
+ * is standing session context, like skills.
121
+ */
122
+ contextBridge?: string;
123
+ /**
124
+ * Channel sender identity (attribution, not authorization): the
125
+ * provider-verified identifier of the person on the channel, read from
126
+ * `SessionSpec.metadata`. Injected on EVERY turn like the bridge — the
127
+ * native system prompt is rebuilt per invocation, and the sender is
128
+ * constant for the session's lifetime (channel sessions are keyed
129
+ * per-sender).
130
+ */
131
+ senderIdentity?: SenderIdentity;
109
132
  }
110
133
 
111
134
  export interface InjectedFile {
@@ -147,6 +170,18 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
147
170
  prompt += buildInjectedFilesSection(input.injectedFiles);
148
171
  }
149
172
 
173
+ if (input.senderIdentity) {
174
+ prompt +=
175
+ "\n\n## Conversation sender\n\n" +
176
+ formatSenderIdentityText(input.senderIdentity);
177
+ }
178
+
179
+ if (input.contextBridge) {
180
+ prompt +=
181
+ "\n\n## Previous conversation context\n\n" +
182
+ formatContextBridgeText(input.contextBridge);
183
+ }
184
+
150
185
  prompt += RESPONSE_RULES;
151
186
  prompt += SUB_AGENT_RULES;
152
187
 
@@ -23,6 +23,8 @@ import type { DynamicStructuredTool } from "@langchain/core/tools";
23
23
  import type { Config } from "../../config.js";
24
24
  import type { StigmerClient } from "../../client/stigmer-client.js";
25
25
  import { createCheckpointer } from "../../shared/checkpointer/factory.js";
26
+ import { readContextBridge } from "../../shared/context-bridge.js";
27
+ import { readSenderIdentity } from "../../shared/sender-identity.js";
26
28
  import { connectMcpServers, type McpConnectionResult } from "../../shared/mcp-manager.js";
27
29
  import { resolveMcpServers } from "../../shared/mcp-resolver.js";
28
30
  import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
@@ -386,6 +388,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
386
388
  injectedFiles,
387
389
  interactionMode: execution.spec!.executionConfig?.interactionMode,
388
390
  buildFromPlan: execution.spec!.executionConfig?.buildFromPlan,
391
+ contextBridge: readContextBridge(session.spec!.metadata),
392
+ senderIdentity: readSenderIdentity(session.spec!.metadata),
389
393
  });
390
394
 
391
395
  // Step 9: Construct the LLM model. Resolution to the provider API id
@@ -221,6 +221,7 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
221
221
  case: "taskStarted",
222
222
  value: create(TaskStartedPayloadSchema, {
223
223
  taskKind: TASK_KIND_MAP[desc.taskKind] ?? 0,
224
+ inputSummary: toJsonObject(desc.inputSummary),
224
225
  attemptNumber: desc.attemptNumber,
225
226
  }),
226
227
  };
@@ -233,6 +234,7 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
233
234
  value: create(TaskCompletedPayloadSchema, {
234
235
  taskKind: TASK_KIND_MAP[desc.taskKind] ?? 0,
235
236
  durationMs: BigInt(desc.durationMs),
237
+ outputSummary: toJsonObject(desc.outputSummary),
236
238
  costMicros: BigInt(desc.costMicros),
237
239
  tokensUsed: BigInt(desc.tokensUsed),
238
240
  }),
package/src/config.ts CHANGED
@@ -37,6 +37,17 @@ import { homedir, tmpdir } from "node:os";
37
37
  */
38
38
  export const DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS = 180_000;
39
39
 
40
+ /**
41
+ * Default bound for Cursor Agent.create/Agent.resume (ms). These SDK calls
42
+ * have no timeout of their own; a degraded transport (dead proxy connection,
43
+ * stale HTTP/2 session) hangs them forever — which surfaces as an opaque
44
+ * Temporal heartbeat timeout minutes later instead of an actionable error.
45
+ * 120s comfortably covers a slow create (token exchange + workspace
46
+ * registration) while failing fast enough to diagnose. Single source of truth
47
+ * for env-loaded and options-mapped config (mirrors the stall timeout above).
48
+ */
49
+ export const DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS = 120_000;
50
+
40
51
  // Re-exported so the runner/manager options mappers default the lock-wait
41
52
  // bound from the same constant the lock module owns (no drift across the
42
53
  // three construction sites — mirrors DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS).
@@ -89,6 +100,13 @@ export interface Config {
89
100
  * heartbeatTimeout (process liveness) and the 30s keep-alive heartbeat.
90
101
  */
91
102
  readonly cursorStreamStallTimeoutMs: number;
103
+ /**
104
+ * Bound (milliseconds) for Cursor Agent.create/Agent.resume. On expiry the
105
+ * execution fails fast with an explicit transport diagnosis instead of
106
+ * hanging into Temporal's heartbeat timeout. See
107
+ * {@link DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS} for why this exists.
108
+ */
109
+ readonly agentResolveTimeoutMs: number;
92
110
  /**
93
111
  * Max wait (milliseconds) for the per-workspace turn lock before failing
94
112
  * the execution with a "workspace is in use by another session" error.
@@ -174,6 +192,10 @@ export function loadConfig(): Config {
174
192
  ? parseInt(process.env.WORKSPACE_LOCK_TIMEOUT_MS, 10)
175
193
  : DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS;
176
194
 
195
+ const agentResolveTimeoutMs = process.env.CURSOR_AGENT_RESOLVE_TIMEOUT_MS
196
+ ? parseInt(process.env.CURSOR_AGENT_RESOLVE_TIMEOUT_MS, 10)
197
+ : DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS;
198
+
177
199
  return {
178
200
  taskQueue,
179
201
  temporalAddress,
@@ -191,6 +213,7 @@ export function loadConfig(): Config {
191
213
  checkpointerProxyEndpoint,
192
214
  primaryModel,
193
215
  cursorStreamStallTimeoutMs,
216
+ agentResolveTimeoutMs,
194
217
  workspaceLockTimeoutMs,
195
218
  };
196
219
  }
@@ -24,7 +24,7 @@ import {
24
24
  } from "@temporalio/worker";
25
25
  import type { PayloadCodec } from "@temporalio/common";
26
26
  import type { Config } from "./config.js";
27
- import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
27
+ import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
28
28
  import type { WorkerActivities } from "./worker.js";
29
29
  import { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
30
30
  import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
@@ -91,6 +91,9 @@ export interface RunnerManagerOptions {
91
91
  /** No-progress bound for the Cursor harness stream (ms). @default 180000 */
92
92
  readonly cursorStreamStallTimeoutMs?: number;
93
93
 
94
+ /** Bound for Cursor Agent.create/resume (ms). @default 120000 */
95
+ readonly agentResolveTimeoutMs?: number;
96
+
94
97
  /** Max wait for the per-workspace turn lock (ms). @default 900000 */
95
98
  readonly workspaceLockTimeoutMs?: number;
96
99
 
@@ -598,6 +601,8 @@ export function mapManagerOptionsToConfig(
598
601
  primaryModel: options.primaryModel ?? "gpt-4.1",
599
602
  cursorStreamStallTimeoutMs:
600
603
  options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
604
+ agentResolveTimeoutMs:
605
+ options.agentResolveTimeoutMs ?? DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS,
601
606
  workspaceLockTimeoutMs:
602
607
  options.workspaceLockTimeoutMs ?? DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
603
608
  };
package/src/runner.ts CHANGED
@@ -17,7 +17,7 @@ import { join } from "node:path";
17
17
  import { homedir, tmpdir } from "node:os";
18
18
  import type { PayloadCodec } from "@temporalio/common";
19
19
  import type { Config } from "./config.js";
20
- import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
20
+ import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
21
21
  import type { WorkerActivities } from "./worker.js";
22
22
  import { resolveRunnerBootstrap } from "./bootstrap.js";
23
23
 
@@ -69,6 +69,9 @@ export interface StigmerRunnerOptions {
69
69
  /** No-progress bound for the Cursor harness stream (ms). @default 180000 */
70
70
  readonly cursorStreamStallTimeoutMs?: number;
71
71
 
72
+ /** Bound for Cursor Agent.create/resume (ms). @default 120000 */
73
+ readonly agentResolveTimeoutMs?: number;
74
+
72
75
  /** Max wait for the per-workspace turn lock (ms). @default 900000 */
73
76
  readonly workspaceLockTimeoutMs?: number;
74
77
 
@@ -282,6 +285,8 @@ export function mapOptionsToConfig(options: StigmerRunnerOptions): Config {
282
285
  primaryModel: options.primaryModel ?? "gpt-4.1",
283
286
  cursorStreamStallTimeoutMs:
284
287
  options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
288
+ agentResolveTimeoutMs:
289
+ options.agentResolveTimeoutMs ?? DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS,
285
290
  workspaceLockTimeoutMs:
286
291
  options.workspaceLockTimeoutMs ?? DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
287
292
  };
@@ -430,6 +430,7 @@ describe("loadArtifactStorageConfig", () => {
430
430
  checkpointerProxyEndpoint: null,
431
431
  primaryModel: "gpt-4.1",
432
432
  cursorStreamStallTimeoutMs: 180000,
433
+ agentResolveTimeoutMs: 120000,
433
434
  workspaceLockTimeoutMs: 900000,
434
435
  };
435
436
 
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Unit tests for the rollover context bridge (cloud DD-013): the pinned
3
+ * cross-repo metadata key, the read semantics, and the shared framing.
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ CONTEXT_BRIDGE_METADATA_KEY,
9
+ formatContextBridgeText,
10
+ readContextBridge,
11
+ } from "../context-bridge.js";
12
+
13
+ describe("CONTEXT_BRIDGE_METADATA_KEY", () => {
14
+ it("is pinned verbatim to the cloud broker's constant (mirror guard)", () => {
15
+ // Pinned to ChannelRuntimeConstants.CONTEXT_BRIDGE_METADATA_KEY in
16
+ // stigmer-cloud. Changing either side alone silently disables the
17
+ // bridge (a plain context reset); change BOTH together.
18
+ expect(CONTEXT_BRIDGE_METADATA_KEY).toBe("stigmer.ai/context-bridge");
19
+ });
20
+ });
21
+
22
+ describe("readContextBridge", () => {
23
+ it("reads the digest from the session spec metadata map", () => {
24
+ expect(
25
+ readContextBridge({ [CONTEXT_BRIDGE_METADATA_KEY]: "User: hi\nAssistant: hello" }),
26
+ ).toBe("User: hi\nAssistant: hello");
27
+ });
28
+
29
+ it("returns undefined for an absent map", () => {
30
+ expect(readContextBridge(undefined)).toBeUndefined();
31
+ });
32
+
33
+ it("returns undefined when the key is absent", () => {
34
+ expect(readContextBridge({ other: "value" })).toBeUndefined();
35
+ });
36
+
37
+ it("returns undefined for a blank value — a blank bridge is no bridge", () => {
38
+ expect(readContextBridge({ [CONTEXT_BRIDGE_METADATA_KEY]: " " })).toBeUndefined();
39
+ });
40
+ });
41
+
42
+ describe("formatContextBridgeText", () => {
43
+ it("frames the digest as already-known background", () => {
44
+ const framed = formatContextBridgeText("Subject: Orders\nUser: hi\nAssistant: hello");
45
+
46
+ expect(framed).toContain("previous conversation");
47
+ expect(framed).toContain("Do not repeat it back");
48
+ expect(framed).toContain("Subject: Orders");
49
+ expect(framed.endsWith("Assistant: hello")).toBe(true);
50
+ });
51
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Unit tests for the channel sender identity: the pinned cross-repo
3
+ * metadata keys, the read semantics, and the shared framing.
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ SENDER_IDENTITY_METADATA_KEY,
9
+ SENDER_KIND_METADATA_KEY,
10
+ formatSenderIdentityText,
11
+ readSenderIdentity,
12
+ } from "../sender-identity.js";
13
+
14
+ describe("sender identity metadata keys", () => {
15
+ it("are pinned verbatim to the cloud broker's constants (mirror guard)", () => {
16
+ // Pinned to ChannelRuntimeConstants.SENDER_IDENTITY_METADATA_KEY /
17
+ // SENDER_KIND_METADATA_KEY in stigmer-cloud. Changing either side alone
18
+ // silently blinds the agent to the sender; change BOTH together.
19
+ expect(SENDER_IDENTITY_METADATA_KEY).toBe("stigmer.ai/channel-sender-identity");
20
+ expect(SENDER_KIND_METADATA_KEY).toBe("stigmer.ai/channel-sender-kind");
21
+ });
22
+ });
23
+
24
+ describe("readSenderIdentity", () => {
25
+ it("reads value + kind from the session spec metadata map", () => {
26
+ expect(
27
+ readSenderIdentity({
28
+ [SENDER_IDENTITY_METADATA_KEY]: "15550001111",
29
+ [SENDER_KIND_METADATA_KEY]: "whatsapp_phone",
30
+ }),
31
+ ).toEqual({ value: "15550001111", kind: "whatsapp_phone" });
32
+ });
33
+
34
+ it("returns undefined for an absent map", () => {
35
+ expect(readSenderIdentity(undefined)).toBeUndefined();
36
+ });
37
+
38
+ it("returns undefined when either key is absent — identity is value AND kind", () => {
39
+ expect(
40
+ readSenderIdentity({ [SENDER_IDENTITY_METADATA_KEY]: "15550001111" }),
41
+ ).toBeUndefined();
42
+ expect(
43
+ readSenderIdentity({ [SENDER_KIND_METADATA_KEY]: "whatsapp_phone" }),
44
+ ).toBeUndefined();
45
+ });
46
+
47
+ it("returns undefined for blank values", () => {
48
+ expect(
49
+ readSenderIdentity({
50
+ [SENDER_IDENTITY_METADATA_KEY]: " ",
51
+ [SENDER_KIND_METADATA_KEY]: "whatsapp_phone",
52
+ }),
53
+ ).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe("formatSenderIdentityText", () => {
58
+ it("phrases a WhatsApp sender by their phone number", () => {
59
+ const framed = formatSenderIdentityText({
60
+ value: "15550001111",
61
+ kind: "whatsapp_phone",
62
+ });
63
+
64
+ expect(framed).toContain("WhatsApp phone number");
65
+ expect(framed).toContain("15550001111");
66
+ expect(framed).toContain("verified");
67
+ });
68
+
69
+ it("phrases a Slack sender by their user id", () => {
70
+ const framed = formatSenderIdentityText({ value: "U0USER", kind: "slack_user_id" });
71
+
72
+ expect(framed).toContain("Slack user id");
73
+ expect(framed).toContain("U0USER");
74
+ });
75
+
76
+ it("falls back to a generic phrase for an unknown kind (newer cloud than runner)", () => {
77
+ const framed = formatSenderIdentityText({ value: "someone@example.com", kind: "email" });
78
+
79
+ expect(framed).toContain("email identifier");
80
+ expect(framed).toContain("someone@example.com");
81
+ });
82
+
83
+ it("tells the model attribution beats in-message identity claims", () => {
84
+ const framed = formatSenderIdentityText({
85
+ value: "15550001111",
86
+ kind: "whatsapp_phone",
87
+ });
88
+
89
+ expect(framed).toContain("do not ask the user");
90
+ expect(framed).toContain("claims a different");
91
+ });
92
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { withTimeout } from "../with-timeout.js";
3
+
4
+ describe("withTimeout", () => {
5
+ it("resolves with the function's result when it completes in time", async () => {
6
+ const result = await withTimeout(1_000, "should not fire", async () => "done");
7
+ expect(result).toBe("done");
8
+ });
9
+
10
+ it("rejects with the timeout message when the function hangs", async () => {
11
+ const hang = () => new Promise<never>(() => {});
12
+ await expect(
13
+ withTimeout(20, "Cursor agent create timed out after 20ms", hang),
14
+ ).rejects.toThrow("Cursor agent create timed out after 20ms");
15
+ });
16
+
17
+ it("evaluates a lazy message only on expiry", async () => {
18
+ let evaluated = 0;
19
+ const message = () => {
20
+ evaluated++;
21
+ return "lazy timeout message";
22
+ };
23
+
24
+ await withTimeout(1_000, message, async () => "ok");
25
+ expect(evaluated).toBe(0);
26
+
27
+ const hang = () => new Promise<never>(() => {});
28
+ await expect(withTimeout(20, message, hang)).rejects.toThrow("lazy timeout message");
29
+ expect(evaluated).toBe(1);
30
+ });
31
+
32
+ it("propagates the function's own rejection unchanged", async () => {
33
+ const boom = new Error("underlying failure");
34
+ await expect(
35
+ withTimeout(1_000, "should not fire", () => Promise.reject(boom)),
36
+ ).rejects.toBe(boom);
37
+ });
38
+
39
+ it("does not reject after a successful resolve (timer cleared)", async () => {
40
+ const result = await withTimeout(20, "should not fire", async () => "fast");
41
+ expect(result).toBe("fast");
42
+ // If the timer leaked, the unhandled rejection would fail the test run.
43
+ await new Promise((resolve) => setTimeout(resolve, 40));
44
+ });
45
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The rollover context bridge (stigmer-cloud DD-013).
3
+ *
4
+ * When a channel conversation's session hits its bounds (turn cap or
5
+ * inactivity window), the cloud broker rolls it over into a fresh session
6
+ * and stamps a compact digest of the previous conversation — subject plus
7
+ * the newest user/assistant exchanges — into the fresh session's
8
+ * `SessionSpec.metadata` under {@link CONTEXT_BRIDGE_METADATA_KEY}. Both
9
+ * harnesses read it here and present it as prompt context, so the user
10
+ * does not experience total amnesia at the rotation.
11
+ *
12
+ * The cloud composes the CONTENT; this module owns the PRESENTATION
13
+ * framing. Each harness wraps {@link formatContextBridgeText} in its own
14
+ * section syntax (XML tag for cursor's first-message prompt, markdown
15
+ * heading for the native system prompt).
16
+ */
17
+
18
+ /**
19
+ * `SessionSpec.metadata` key carrying the bridge digest. Pinned verbatim
20
+ * to `ChannelRuntimeConstants.CONTEXT_BRIDGE_METADATA_KEY` in
21
+ * stigmer-cloud, with mirror guard tests on both sides — a drift degrades
22
+ * to a plain context reset (the bridge is simply never read), never worse.
23
+ */
24
+ export const CONTEXT_BRIDGE_METADATA_KEY = "stigmer.ai/context-bridge";
25
+
26
+ /**
27
+ * How the digest is introduced to the model, shared by both harnesses so
28
+ * the behavioral contract ("known background, don't announce it") cannot
29
+ * drift between them.
30
+ */
31
+ const CONTEXT_BRIDGE_PREAMBLE =
32
+ "Background from your previous conversation with this user, carried over " +
33
+ "when the conversation was rotated. Treat it as context you already " +
34
+ "know; the user may continue as if nothing changed. Do not repeat it " +
35
+ "back or mention the rotation unless asked.";
36
+
37
+ /**
38
+ * Read the bridge digest from a session's spec metadata map. Returns
39
+ * undefined when absent or blank — the caller renders no section.
40
+ */
41
+ export function readContextBridge(
42
+ metadata: Record<string, string> | undefined,
43
+ ): string | undefined {
44
+ const value = metadata?.[CONTEXT_BRIDGE_METADATA_KEY]?.trim();
45
+ return value ? value : undefined;
46
+ }
47
+
48
+ /** The framed bridge body (preamble + digest), ready for section wrapping. */
49
+ export function formatContextBridgeText(bridge: string): string {
50
+ return `${CONTEXT_BRIDGE_PREAMBLE}\n\n${bridge.trim()}`;
51
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The channel sender identity (stigmer-cloud's channel-sender-identity keys).
3
+ *
4
+ * When a conversation reaches an agent through an AgentChannel, the cloud
5
+ * broker stamps the provider-verified sender identity — the WhatsApp phone
6
+ * number (wa_id), the Slack user id — into the session's
7
+ * `SessionSpec.metadata` at session creation, together with a KIND token
8
+ * that says what sort of identifier it is. Both harnesses read it here and
9
+ * present it as prompt context, so the agent can attribute records to the
10
+ * person it is talking to without asking them to re-state (and possibly
11
+ * fake or mistype) their own identifier.
12
+ *
13
+ * Attribution, NOT authorization: privileges are enforced by the channel
14
+ * topology and the credentials bound to it, never by what the model reads
15
+ * here. The cloud writes the CONTENT; this module owns the PRESENTATION
16
+ * framing — the context-bridge module's sibling.
17
+ */
18
+
19
+ /**
20
+ * `SessionSpec.metadata` key carrying the sender identity value. Pinned
21
+ * verbatim to `ChannelRuntimeConstants.SENDER_IDENTITY_METADATA_KEY` in
22
+ * stigmer-cloud, with mirror guard tests on both sides — a drift degrades
23
+ * to the agent simply not knowing the sender, never worse.
24
+ */
25
+ export const SENDER_IDENTITY_METADATA_KEY = "stigmer.ai/channel-sender-identity";
26
+
27
+ /**
28
+ * `SessionSpec.metadata` key carrying the kind of the identity value.
29
+ * Pinned verbatim to `ChannelRuntimeConstants.SENDER_KIND_METADATA_KEY` in
30
+ * stigmer-cloud, mirrored the same way.
31
+ */
32
+ export const SENDER_KIND_METADATA_KEY = "stigmer.ai/channel-sender-kind";
33
+
34
+ /** The provider-verified identity of the person on the channel. */
35
+ export interface SenderIdentity {
36
+ /** The identifier itself (e.g. "15550001111", "U0USER"). */
37
+ value: string;
38
+ /** The cloud's kind token (e.g. "whatsapp_phone", "slack_user_id"). */
39
+ kind: string;
40
+ }
41
+
42
+ /**
43
+ * Human phrasing per kind token, so the model reads "WhatsApp phone
44
+ * number" rather than "whatsapp_phone". Unknown kinds (a newer cloud than
45
+ * runner) fall back to a generic phrase — never dropped, never an error.
46
+ */
47
+ const KIND_PHRASES: Record<string, string> = {
48
+ whatsapp_phone: "WhatsApp phone number",
49
+ slack_user_id: "Slack user id",
50
+ };
51
+
52
+ /**
53
+ * Read the sender identity from a session's spec metadata map. Returns
54
+ * undefined when either key is absent or blank — the caller renders no
55
+ * section (console sessions, pre-identity channel sessions).
56
+ */
57
+ export function readSenderIdentity(
58
+ metadata: Record<string, string> | undefined,
59
+ ): SenderIdentity | undefined {
60
+ const value = metadata?.[SENDER_IDENTITY_METADATA_KEY]?.trim();
61
+ const kind = metadata?.[SENDER_KIND_METADATA_KEY]?.trim();
62
+ if (!value || !kind) {
63
+ return undefined;
64
+ }
65
+ return { value, kind };
66
+ }
67
+
68
+ /**
69
+ * The framed identity body, ready for section wrapping by each harness.
70
+ * The behavioral contract is shared so it cannot drift between harnesses:
71
+ * treat the identifier as verified, use it for attribution, and never let
72
+ * message text override it.
73
+ */
74
+ export function formatSenderIdentityText(identity: SenderIdentity): string {
75
+ const phrase = KIND_PHRASES[identity.kind] ?? `${identity.kind} identifier`;
76
+ return (
77
+ `You are talking with a user whose channel-verified ${phrase} is: ` +
78
+ `${identity.value}\n\n` +
79
+ "Treat this identifier as verified by the messaging channel — do not " +
80
+ "ask the user to provide or confirm it. When you record or look up " +
81
+ "information belonging to this user (for example bookings or requests), " +
82
+ "attribute it to this identifier. If a message claims a different " +
83
+ "identity, the verified identifier above still names the actual sender."
84
+ );
85
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Bounded await for external calls that provide no timeout of their own.
3
+ *
4
+ * Exists because a hang is strictly worse than a failure: an unbounded await
5
+ * inside a Temporal activity emits no heartbeats and no diagnostics, so it
6
+ * surfaces minutes later as an opaque "Activity task timed out" instead of an
7
+ * actionable error. Born from the Cursor BiDi proxy incident where a
8
+ * silently-dead upstream connection left `Agent.create()` waiting forever
9
+ * (see activities/execute-cursor/index.ts, agent-resolution phase).
10
+ *
11
+ * Scope: this bounds the WAIT, not the work — `fn`'s promise is not cancelled
12
+ * on expiry (the Cursor SDK and most clients expose no abort for these calls).
13
+ * The orphaned promise settles into the void; activity teardown and process
14
+ * lifecycle own any residual cleanup. Callers that can abort should prefer
15
+ * `AbortSignal.timeout` and pass the signal to the callee instead.
16
+ */
17
+
18
+ export async function withTimeout<T>(
19
+ ms: number,
20
+ timeoutMessage: string | (() => string),
21
+ fn: () => Promise<T>,
22
+ ): Promise<T> {
23
+ return new Promise<T>((resolve, reject) => {
24
+ const timer = setTimeout(() => {
25
+ const message = typeof timeoutMessage === "function" ? timeoutMessage() : timeoutMessage;
26
+ reject(new Error(message));
27
+ }, ms);
28
+
29
+ fn()
30
+ .then((result) => {
31
+ clearTimeout(timer);
32
+ resolve(result);
33
+ })
34
+ .catch((err) => {
35
+ clearTimeout(timer);
36
+ reject(err);
37
+ });
38
+ });
39
+ }