@stigmer/runner 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/index.d.ts +12 -0
  3. package/dist/activities/execute-cursor/index.js +41 -5
  4. package/dist/activities/execute-cursor/index.js.map +1 -1
  5. package/dist/activities/execute-cursor/prompt-builder.d.ts +11 -0
  6. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  7. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  8. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +28 -0
  9. package/dist/activities/execute-deep-agent/mcp-gate.js +22 -0
  10. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -0
  11. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +11 -0
  12. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  13. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/setup.js +30 -4
  15. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  16. package/dist/shared/channel-attachment.d.ts +3 -1
  17. package/dist/shared/channel-attachment.js +3 -1
  18. package/dist/shared/channel-attachment.js.map +1 -1
  19. package/dist/shared/conversation-attachment.d.ts +81 -0
  20. package/dist/shared/conversation-attachment.js +102 -0
  21. package/dist/shared/conversation-attachment.js.map +1 -0
  22. package/dist/shared/conversation-catchup.d.ts +33 -0
  23. package/dist/shared/conversation-catchup.js +53 -0
  24. package/dist/shared/conversation-catchup.js.map +1 -0
  25. package/package.json +2 -2
  26. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +79 -0
  27. package/src/activities/execute-cursor/index.ts +62 -4
  28. package/src/activities/execute-cursor/prompt-builder.ts +23 -0
  29. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +42 -0
  30. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +39 -1
  31. package/src/activities/execute-deep-agent/mcp-gate.ts +37 -0
  32. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -2
  33. package/src/activities/execute-deep-agent/setup.ts +40 -4
  34. package/src/shared/__tests__/channel-attachment.test.ts +3 -3
  35. package/src/shared/__tests__/conversation-attachment.test.ts +138 -0
  36. package/src/shared/__tests__/conversation-catchup.test.ts +70 -0
  37. package/src/shared/__tests__/synthesized-attachment.test.ts +120 -0
  38. package/src/shared/channel-attachment.ts +3 -1
  39. package/src/shared/conversation-attachment.ts +115 -0
  40. package/src/shared/conversation-catchup.ts +60 -0
@@ -519,3 +519,82 @@ describe("formatImplementPlanSection", () => {
519
519
  expect(prompt).toBe(USER_MESSAGE);
520
520
  });
521
521
  });
522
+
523
+ describe("conversation catchup (cloud DD-006, T03 Sitting 3)", () => {
524
+ const DIGEST =
525
+ "Customer: where is my order?\nTeammate: I've refunded you in full.";
526
+
527
+ it("prefixes the catchup on a RESUMED turn — handback lands mid-session, the case the metadata lane cannot reach", () => {
528
+ const prompt = buildPrompt(
529
+ input({
530
+ resolution: resolution("local", "resumed_successfully"),
531
+ conversationCatchup: DIGEST,
532
+ }),
533
+ );
534
+
535
+ expect(prompt.startsWith("<conversation_catchup>")).toBe(true);
536
+ expect(prompt).toContain(DIGEST);
537
+ expect(prompt.endsWith(USER_MESSAGE)).toBe(true);
538
+ });
539
+
540
+ it("orders a resumed turn's prefixes directives-first, catchup last — context sits closest to the task", () => {
541
+ const prompt = buildPrompt(
542
+ input({
543
+ resolution: resolution("local", "resumed_successfully"),
544
+ interactionMode: InteractionMode.PLAN,
545
+ conversationCatchup: DIGEST,
546
+ }),
547
+ );
548
+
549
+ expect(prompt.indexOf("<interaction_mode>"))
550
+ .toBeLessThan(prompt.indexOf("<conversation_catchup>"));
551
+ expect(prompt.indexOf("<conversation_catchup>"))
552
+ .toBeLessThan(prompt.indexOf(USER_MESSAGE));
553
+ });
554
+
555
+ it("carries the catchup on the first execution too, AFTER the bridge (DD-007 D-d: bridge first, catchup second)", () => {
556
+ const prompt = buildPrompt(
557
+ input({
558
+ resolution: resolution("local", "created_first_execution"),
559
+ contextBridge: "User: hi\nAssistant: hello",
560
+ conversationCatchup: DIGEST,
561
+ }),
562
+ );
563
+
564
+ expect(prompt).toContain("<conversation_catchup>");
565
+ expect(prompt.indexOf("<previous_conversation_context>"))
566
+ .toBeLessThan(prompt.indexOf("<conversation_catchup>"));
567
+ // Still CONTEXT: the approval protocol keeps its pinned
568
+ // last-before-task slot.
569
+ expect(prompt.indexOf("<conversation_catchup>"))
570
+ .toBeLessThan(prompt.indexOf("<tool_approval_protocol>"));
571
+ });
572
+
573
+ it("never reaches a HITL reinvocation — the same turn's original prompt already carried it", () => {
574
+ const decisions = new Map([["call-1", ApprovalAction.APPROVE]]);
575
+ const prompt = buildPrompt(
576
+ input({
577
+ resolution: resolution("local", "resumed_successfully"),
578
+ approvalDecisions: decisions,
579
+ pendingApprovals: [
580
+ create(PendingApprovalSchema, { toolCallId: "call-1", message: "Write file: a.txt" }),
581
+ ],
582
+ conversationCatchup: DIGEST,
583
+ }),
584
+ );
585
+
586
+ expect(prompt).not.toContain("<conversation_catchup>");
587
+ expect(prompt).not.toContain(DIGEST);
588
+ });
589
+
590
+ it("a resumed turn without a catchup stays the bare user message — most turns carry none", () => {
591
+ const prompt = buildPrompt(
592
+ input({
593
+ resolution: resolution("local", "resumed_successfully"),
594
+ conversationCatchup: undefined,
595
+ }),
596
+ );
597
+
598
+ expect(prompt).toBe(USER_MESSAGE);
599
+ });
600
+ });
@@ -48,6 +48,7 @@ import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantTo
48
48
  import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
49
49
  import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js";
50
50
  import { readContextBridge } from "../../shared/context-bridge.js";
51
+ import { readConversationCatchup } from "../../shared/conversation-catchup.js";
51
52
  import { readSenderIdentity } from "../../shared/sender-identity.js";
52
53
  import {
53
54
  injectCallerIdentityEnv,
@@ -69,6 +70,10 @@ import {
69
70
  discoverChannelMessaging,
70
71
  synthesizeChannelAttachment,
71
72
  } from "../../shared/channel-attachment.js";
73
+ import {
74
+ readChannelConversationId,
75
+ synthesizeConversationAttachment,
76
+ } from "../../shared/conversation-attachment.js";
72
77
  import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
73
78
  import { mergeApprovalPolicies } from "./approval-policy.js";
74
79
  import { deriveActiveLeases, isUnattendedApprovalMode } from "../../shared/approval-policy.js";
@@ -79,7 +84,7 @@ import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
79
84
  import { resolveSkills } from "./skill-resolver.js";
80
85
  import { removeStigmerSymlink } from "../../shared/workspace/stigmer-link.js";
81
86
  import { resolveAttachments } from "./attachment-resolver.js";
82
- import { buildEnhancedPrompt, buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
87
+ import { buildEnhancedPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
83
88
  import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
84
89
  import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
85
90
  import {
@@ -685,6 +690,31 @@ async function executeCursorInner(
685
690
  };
686
691
  }
687
692
  }
693
+
694
+ // Phase 4a4: Synthesize the conversation participation attachment
695
+ // (channel-conversations DD-008 D-c) — the third sibling. The
696
+ // channel-id session label IS the attachment decision (stamped
697
+ // server-side on every channel session; a free local read, unlike
698
+ // the channels discovery RPC above). HTTP-only: synthesize answers
699
+ // undefined with no bridge endpoint by design (see
700
+ // shared/conversation-attachment.ts).
701
+ const conversationAttachment = synthesizeConversationAttachment(
702
+ readChannelConversationId(session.metadata?.labels),
703
+ {
704
+ bridgeEndpoint: config.mcpBridgeEndpoint,
705
+ credential: attachmentCredential,
706
+ backendEndpoint: config.stigmerBackendEndpoint,
707
+ },
708
+ );
709
+ if (conversationAttachment) {
710
+ const resolvedServers = injectSynthesizedAttachment(
711
+ mcpResolution.resolvedServers, conversationAttachment, "conversation participation",
712
+ );
713
+ mcpResolution = {
714
+ resolvedServers,
715
+ cursorConfig: toCursorMcpConfig(resolvedServers),
716
+ };
717
+ }
688
718
  const mcpConfig = mcpResolution.cursorConfig;
689
719
 
690
720
  // Phase 4b: Merge approval policies from all layers.
@@ -1071,6 +1101,7 @@ async function executeCursorInner(
1071
1101
  contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1072
1102
  senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1073
1103
  sessionContext: readSessionContext(blueprint.sessionSpec.metadata),
1104
+ conversationCatchup: readConversationCatchup(spec.conversationCatchup),
1074
1105
  });
1075
1106
 
1076
1107
  // Phase 10a: Inject structured output instruction for Cursor harness
@@ -1706,9 +1737,15 @@ async function executeCursorInner(
1706
1737
  attachmentPaths,
1707
1738
  pendingApprovals: adjudicatedApprovals,
1708
1739
  interactionMode,
1740
+ // buildFromPlan was silently dropped here until T03 Sitting 3 —
1741
+ // a build turn that hit handle recovery lost its directive. The
1742
+ // fresh prompt must carry every per-turn directive the original
1743
+ // did.
1744
+ buildFromPlan,
1709
1745
  contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1710
1746
  senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1711
1747
  sessionContext: readSessionContext(blueprint.sessionSpec.metadata),
1748
+ conversationCatchup: readConversationCatchup(spec.conversationCatchup),
1712
1749
  });
1713
1750
 
1714
1751
  console.log(
@@ -2305,6 +2342,18 @@ export interface BuildPromptInput {
2305
2342
  * turn.
2306
2343
  */
2307
2344
  sessionContext?: string;
2345
+ /**
2346
+ * Conversation catchup from the execution spec's `conversation_catchup`
2347
+ * (cloud DD-006): what happened on the channel conversation that the
2348
+ * agent has not seen. PER-TURN, so unlike the three standing values
2349
+ * above it rides BOTH prompt paths — the enhanced prompt and a resumed
2350
+ * turn's prefix (the `interaction_mode` shape). Handback lands
2351
+ * mid-session on a resumed agent: the resumed path is the one that
2352
+ * matters. Once delivered, the digest persists in the agent's own
2353
+ * conversation store; the next turn's field is composed fresh and is
2354
+ * usually blank.
2355
+ */
2356
+ conversationCatchup?: string;
2308
2357
  }
2309
2358
 
2310
2359
  /**
@@ -2336,6 +2385,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2336
2385
  attachmentPaths,
2337
2386
  interactionMode,
2338
2387
  buildFromPlan,
2388
+ conversationCatchup,
2339
2389
  } = input;
2340
2390
 
2341
2391
  const isHitlReinvocation = approvalDecisions !== undefined && approvalDecisions.size > 0;
@@ -2353,15 +2403,22 @@ export function buildPrompt(input: BuildPromptInput): string {
2353
2403
 
2354
2404
  // A successfully resumed agent carries its own conversation context via the
2355
2405
  // SDK's native store — send the raw user message with no preamble. The
2356
- // exceptions are the per-EXECUTION directives, which never inherit from the
2406
+ // exceptions are the per-EXECUTION values, which never inherit from the
2357
2407
  // session's first turn: the interaction-mode prefix (a follow-up can switch
2358
2408
  // Agent→Plan mid-session, and for Cursor the prompt is the only plan-mode
2359
- // enforcement) and the implement-plan directive (the build turn is usually
2360
- // a follow-up on a resumed agent).
2409
+ // enforcement), the implement-plan directive (the build turn is usually a
2410
+ // follow-up on a resumed agent), and the conversation catchup (handback
2411
+ // ALWAYS lands mid-session on a resumed agent — this prefix is the property
2412
+ // the metadata lane structurally cannot deliver, cloud DD-006). Catchup
2413
+ // last: it is context, and context sits closest to the task (the enhanced
2414
+ // prompt's own ordering doctrine).
2361
2415
  if (resolution.reason === "resumed_successfully") {
2362
2416
  const prefixes = [
2363
2417
  formatInteractionModePrefix(interactionMode),
2364
2418
  formatImplementPlanSection(buildFromPlan, attachmentPaths),
2419
+ conversationCatchup !== undefined
2420
+ ? formatConversationCatchupSection(conversationCatchup)
2421
+ : undefined,
2365
2422
  ].filter((p): p is string => p !== undefined);
2366
2423
  return prefixes.length > 0
2367
2424
  ? [...prefixes, userMessage].join("\n\n")
@@ -2386,6 +2443,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2386
2443
  contextBridge: input.contextBridge,
2387
2444
  senderIdentity: input.senderIdentity,
2388
2445
  sessionContext: input.sessionContext,
2446
+ conversationCatchup,
2389
2447
  });
2390
2448
  }
2391
2449
 
@@ -21,6 +21,7 @@ import type { DatastoreUsage, SubAgent } from "@stigmer/protos/ai/stigmer/agenti
21
21
  import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
22
22
  import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
23
23
  import { formatContextBridgeText } from "../../shared/context-bridge.js";
24
+ import { formatConversationCatchupText } from "../../shared/conversation-catchup.js";
24
25
  import { formatDatastoresSection } from "../../shared/datastore-attachment.js";
25
26
  import {
26
27
  formatChannelTemplatesSection,
@@ -107,6 +108,16 @@ export interface EnhancedPromptOptions {
107
108
  * store — the context is constant for the session's lifetime.
108
109
  */
109
110
  sessionContext?: string;
111
+ /**
112
+ * Conversation catchup (cloud DD-006): what happened on the channel
113
+ * conversation that the agent has not seen, read from the execution
114
+ * spec's `conversation_catchup`. PER-TURN, unlike the three standing
115
+ * siblings above: it rides BOTH prompt paths — this enhanced prompt and
116
+ * a resumed turn's prefix (the `interaction_mode` shape) — because
117
+ * handback lands mid-session on a resumed agent, the exact case the
118
+ * metadata lane cannot reach.
119
+ */
120
+ conversationCatchup?: string;
110
121
  }
111
122
 
112
123
  /**
@@ -200,6 +211,14 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
200
211
  sections.push(formatContextBridgeSection(options.contextBridge));
201
212
  }
202
213
 
214
+ // Catchup after the bridge (DD-007 D-d: bridge first, catchup second) —
215
+ // the bridge carries the pre-takeover conversation, the catchup the human
216
+ // episode, strictly newer by construction; recency puts it closer to the
217
+ // task.
218
+ if (options.conversationCatchup) {
219
+ sections.push(formatConversationCatchupSection(options.conversationCatchup));
220
+ }
221
+
203
222
  // Always last before the task: the platform's tool-approval protocol. Placed
204
223
  // here for recency so it outweighs any "ask the user first" guidance Cursor
205
224
  // surfaces from a connected MCP server (see formatToolApprovalProtocol).
@@ -345,6 +364,10 @@ export function formatSessionContextSection(context: string): string {
345
364
  return `<session_context>\n${formatSessionContextText(context)}\n</session_context>`;
346
365
  }
347
366
 
367
+ export function formatConversationCatchupSection(digest: string): string {
368
+ return `<conversation_catchup>\n${formatConversationCatchupText(digest)}\n</conversation_catchup>`;
369
+ }
370
+
348
371
  export function formatSkillsSection(skills: SkillMetadata[]): string {
349
372
  const entries = skills.map(
350
373
  (s) => `- **${s.name}**: ${s.description}\n Path: \`${s.path}\``,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The deep-agent MCP gate, arm by arm. A missing arm here means a tool
3
+ * source silently dropped for exactly the agents whose only source it
4
+ * is — the failure DD-006 D7 documented for channel messaging and the
5
+ * reason the predicate lives in its own testable module instead of
6
+ * inline in setup.ts (whose import graph forbids a setup.test.ts).
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { shouldConnectMcp } from "../mcp-gate.js";
12
+
13
+ const nothing = {
14
+ mcpServerUsageCount: 0,
15
+ datastoreUsageCount: 0,
16
+ channelMessagingCount: 0,
17
+ conversationChannelId: undefined,
18
+ };
19
+
20
+ describe("shouldConnectMcp", () => {
21
+ it("skips the MCP block when no tool source exists", () => {
22
+ expect(shouldConnectMcp(nothing)).toBe(false);
23
+ });
24
+
25
+ it("enters on declared MCP server usages alone", () => {
26
+ expect(shouldConnectMcp({ ...nothing, mcpServerUsageCount: 1 })).toBe(true);
27
+ });
28
+
29
+ it("enters on datastore usages alone (the records attachment)", () => {
30
+ expect(shouldConnectMcp({ ...nothing, datastoreUsageCount: 1 })).toBe(true);
31
+ });
32
+
33
+ it("enters on a serving proactive channel alone (the channels attachment)", () => {
34
+ expect(shouldConnectMcp({ ...nothing, channelMessagingCount: 1 })).toBe(true);
35
+ });
36
+
37
+ it("enters on a channel conversation alone (the conversation attachment)", () => {
38
+ // The reply-only pilot shape: no declared servers, no datastores,
39
+ // no proactive channel — the escalation tool is the ONLY source.
40
+ expect(shouldConnectMcp({ ...nothing, conversationChannelId: "agch_1" })).toBe(true);
41
+ });
42
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
3
- import { buildEnhancedSystemPrompt } from "../prompt-builder.js";
3
+ import { buildEnhancedSystemPrompt, composeUserMessage } from "../prompt-builder.js";
4
4
  import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
5
5
  import { SourceType } from "../../../shared/workspace/types.js";
6
6
  import type { ProvisionResult } from "../../../shared/workspace/types.js";
@@ -408,3 +408,41 @@ describe("buildEnhancedSystemPrompt", () => {
408
408
  });
409
409
  });
410
410
  });
411
+
412
+ describe("composeUserMessage (conversation catchup, cloud DD-006 / A27)", () => {
413
+ const MESSAGE = "where is my order?";
414
+ const DIGEST =
415
+ "Customer: I want a refund\nTeammate: I've refunded you in full.";
416
+
417
+ it("prepends the framed catchup to the turn's user message — history durability rides the checkpointer, not the rebuilt system prompt", () => {
418
+ const composed = composeUserMessage(MESSAGE, DIGEST);
419
+
420
+ expect(composed.endsWith(MESSAGE)).toBe(true);
421
+ expect(composed).toContain(DIGEST);
422
+ expect(composed).toContain("you have not seen");
423
+ expect(composed.indexOf(DIGEST)).toBeLessThan(composed.indexOf(MESSAGE));
424
+ });
425
+
426
+ it("separates the catchup from the customer's message with a horizontal rule", () => {
427
+ expect(composeUserMessage(MESSAGE, DIGEST)).toContain("\n\n---\n\n");
428
+ });
429
+
430
+ it("leaves the message untouched when there is no catchup — most turns carry none", () => {
431
+ expect(composeUserMessage(MESSAGE, undefined)).toBe(MESSAGE);
432
+ });
433
+
434
+ it("never renders in the system prompt — the rebuilt-per-invocation lane would forget the digest one turn later", () => {
435
+ const prompt = buildEnhancedSystemPrompt({
436
+ instructions: "Test",
437
+ provisionResults: [],
438
+ containerRoot: "",
439
+ skillsPromptSection: "",
440
+ workspaceFileRefs: [],
441
+ workspaceRoot: "/workspace",
442
+ injectedFiles: [],
443
+ });
444
+
445
+ expect(prompt).not.toContain("Conversation catchup");
446
+ expect(prompt).not.toContain("you have not seen");
447
+ });
448
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The deep-agent harness's MCP gate: whether this execution enters MCP
3
+ * resolution at all (resolve, backfill, synthesized-attachment
4
+ * injection, connect). Unlike the Cursor harness — which resolves MCP
5
+ * unconditionally — deep-agent skips the whole block when no tool
6
+ * source exists, so EVERY tool source must appear here or its tools are
7
+ * silently dropped for exactly the agents whose only source it is
8
+ * (proactive-messaging DD-006 D7 learned this for channel messaging).
9
+ *
10
+ * Extracted from setup.ts as a pure function because setup.ts is
11
+ * untestable at file load (its import graph is why no setup.test.ts
12
+ * exists); the gate is the one piece whose regression is silent, so it
13
+ * gets its own module and an arm-by-arm test.
14
+ */
15
+
16
+ /** One flag per tool source. Adding a source? It gates here or it is dropped. */
17
+ export interface McpToolSources {
18
+ /** Declared MCP server usages (agent spec + session spec). */
19
+ readonly mcpServerUsageCount: number;
20
+ /** Declared datastore usages (the records attachment, T05). */
21
+ readonly datastoreUsageCount: number;
22
+ /** Serving proactive-messaging channels (the channels attachment, DD-006). */
23
+ readonly channelMessagingCount: number;
24
+ /** The serving channel id when this session IS a live channel
25
+ * conversation (the conversation attachment, DD-008). */
26
+ readonly conversationChannelId: string | undefined;
27
+ }
28
+
29
+ /** True when any tool source demands MCP resolution and connect. */
30
+ export function shouldConnectMcp(sources: McpToolSources): boolean {
31
+ return (
32
+ sources.mcpServerUsageCount > 0 ||
33
+ sources.datastoreUsageCount > 0 ||
34
+ sources.channelMessagingCount > 0 ||
35
+ sources.conversationChannelId !== undefined
36
+ );
37
+ }
@@ -11,6 +11,7 @@ import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecuti
11
11
  import type { ProvisionResult, GitMetadata } from "../../shared/workspace/types.js";
12
12
  import { SourceType } from "../../shared/workspace/types.js";
13
13
  import { formatContextBridgeText } from "../../shared/context-bridge.js";
14
+ import { formatConversationCatchupText } from "../../shared/conversation-catchup.js";
14
15
  import {
15
16
  formatSenderIdentityText,
16
17
  type SenderIdentity,
@@ -241,9 +242,28 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
241
242
  return prompt;
242
243
  }
243
244
 
245
+ /**
246
+ * Compose the turn's USER MESSAGE for the graph invocation: the framed
247
+ * conversation catchup (cloud DD-006), when present, prepended to the
248
+ * customer's message. In the user message and never the system prompt (A27):
249
+ * the system prompt is rebuilt per invocation and would forget the digest one
250
+ * turn later, while a message enters the checkpointer with the turn and
251
+ * persists in history — the same durability the cursor harness gets from its
252
+ * prompt prefix. The caller's `spec.message` is never mutated; the prepend
253
+ * exists only in the graph input.
254
+ */
255
+ export function composeUserMessage(
256
+ message: string,
257
+ conversationCatchup: string | undefined,
258
+ ): string {
259
+ return conversationCatchup
260
+ ? `${formatConversationCatchupText(conversationCatchup)}\n\n---\n\n${message}`
261
+ : message;
262
+ }
263
+
244
264
  function buildWorkspacePromptSection(
245
- provisionResults: ProvisionResult[],
246
- containerRoot: string,
265
+ provisionResults: ProvisionResult[],
266
+ containerRoot: string,
247
267
  ): string {
248
268
  if (provisionResults.length === 0) return "";
249
269
 
@@ -25,6 +25,7 @@ import type { StigmerClient } from "../../client/stigmer-client.js";
25
25
  import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js";
26
26
  import { createCheckpointer } from "../../shared/checkpointer/factory.js";
27
27
  import { readContextBridge } from "../../shared/context-bridge.js";
28
+ import { readConversationCatchup } from "../../shared/conversation-catchup.js";
28
29
  import { readSenderIdentity } from "../../shared/sender-identity.js";
29
30
  import {
30
31
  injectCallerIdentityEnv,
@@ -44,7 +45,12 @@ import {
44
45
  formatChannelTemplatesSection,
45
46
  synthesizeChannelAttachment,
46
47
  } from "../../shared/channel-attachment.js";
48
+ import {
49
+ readChannelConversationId,
50
+ synthesizeConversationAttachment,
51
+ } from "../../shared/conversation-attachment.js";
47
52
  import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
53
+ import { shouldConnectMcp } from "./mcp-gate.js";
48
54
  import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
49
55
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
50
56
  import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
@@ -59,7 +65,7 @@ import { resolveSessionWorkspaceRoot } from "../../shared/workspace/session-root
59
65
  import { buildWorkspaceFileTree } from "../../shared/workspace/file-tree.js";
60
66
  import { reportSetupProgress } from "../../shared/status.js";
61
67
  import { resolveEnvironment, type EnvironmentResult } from "./environment.js";
62
- import { buildEnhancedSystemPrompt } from "./prompt-builder.js";
68
+ import { buildEnhancedSystemPrompt, composeUserMessage } from "./prompt-builder.js";
63
69
  import { buildMiddlewareStack } from "../../middleware/index.js";
64
70
  import type { GracefulStopMiddleware } from "../../middleware/index.js";
65
71
  import { createThinkTool, createWebFetchTool, resolveGuardPosture } from "../../tools/index.js";
@@ -361,8 +367,18 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
361
367
  // answer — no tool, no section, execution unharmed.
362
368
  const channelMessaging = await discoverChannelMessaging(client, exchangedRunnerToken);
363
369
 
370
+ // The conversation-attachment decision (DD-008 D-c): the channel-id
371
+ // session label, stamped server-side on every channel session — a
372
+ // free, synchronous read, so no hoisted discovery needed.
373
+ const conversationChannelId = readChannelConversationId(session.metadata?.labels);
374
+
364
375
  let resolvedMcpServers: Awaited<ReturnType<typeof resolveMcpServers>> | null = null;
365
- if (mcpServerUsages.length > 0 || datastoreUsages.length > 0 || channelMessaging.length > 0) {
376
+ if (shouldConnectMcp({
377
+ mcpServerUsageCount: mcpServerUsages.length,
378
+ datastoreUsageCount: datastoreUsages.length,
379
+ channelMessagingCount: channelMessaging.length,
380
+ conversationChannelId,
381
+ })) {
366
382
  await reportSetupProgress(client, executionId, "Connecting tools…");
367
383
  const transportPosture = resolveMcpTransportPosture(config.mode);
368
384
  // The MCP-bound env map (and ONLY it) carries the reserved
@@ -424,6 +440,21 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
424
440
  );
425
441
  }
426
442
  }
443
+
444
+ // The conversation participation attachment (DD-008 D-c) — the
445
+ // third sibling, same after-backfill rule. HTTP-only: synthesize
446
+ // answers undefined with no bridge endpoint by design (see
447
+ // shared/conversation-attachment.ts).
448
+ const conversationAttachment = synthesizeConversationAttachment(conversationChannelId, {
449
+ bridgeEndpoint: config.mcpBridgeEndpoint,
450
+ credential: attachmentCredential,
451
+ backendEndpoint: config.stigmerBackendEndpoint,
452
+ });
453
+ if (conversationAttachment) {
454
+ backfilledServers = injectSynthesizedAttachment(
455
+ backfilledServers, conversationAttachment, "conversation participation",
456
+ );
457
+ }
427
458
  resolvedMcpServers = { resolvedServers: backfilledServers };
428
459
  timing.mark("backfill_mcp");
429
460
 
@@ -743,8 +774,13 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
743
774
  ...(isPlanMode ? { permissions: planModePermissions } : {}),
744
775
  } as Parameters<typeof createDeepAgent>[0]);
745
776
 
746
- // Step 11: Prepare invocation input and config
747
- let userMessage = execution.spec!.message;
777
+ // Step 11: Prepare invocation input and config. The conversation catchup
778
+ // (cloud DD-006) rides the USER MESSAGE, not the system prompt — see
779
+ // composeUserMessage for the durability rationale (A27).
780
+ let userMessage = composeUserMessage(
781
+ execution.spec!.message,
782
+ readConversationCatchup(execution.spec!.conversationCatchup),
783
+ );
748
784
  if (outputSchema) {
749
785
  userMessage += `\n\n---\nIMPORTANT: When your analysis is complete, provide your findings as structured output matching the required schema. The system will capture your structured response automatically.`;
750
786
  }
@@ -3,9 +3,9 @@
3
3
  * discovery with the never-throw failure posture, both connection
4
4
  * shapes, the structural approval-freedom the datastore attachment
5
5
  * pinned before it, and the prompt section's filter/order/cap rules
6
- * (DD-006 D6). The cross-repo pinned strings (slug, route, roster) are
7
- * guarded here and in the mcp-server integration test the
8
- * TOOL_CALL_LIMIT precedent.
6
+ * (DD-006 D6). The route is the cross-repo string, guarded here and in
7
+ * the mcp-server integration test (the TOOL_CALL_LIMIT precedent); the
8
+ * slug and roster are runner-internal and guarded here alone.
9
9
  */
10
10
 
11
11
  import { describe, expect, it, vi } from "vitest";