@vellumai/assistant 0.12.0-staging.1 → 0.12.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 (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
@@ -10,6 +10,7 @@ import { SENTINEL_REDACTION_VERSION } from "@vellumai/service-contracts/redacted
10
10
  import type pino from "pino";
11
11
  import { v4 as uuid } from "uuid";
12
12
 
13
+ import { onActivationToolCall } from "../activation/turn-hooks.js";
13
14
  import type { AgentEvent } from "../agent/loop.js";
14
15
  import type { AnsweredQuestion } from "../api/events/question-answered.js";
15
16
  import type { AssistantEvent } from "../api/index.js";
@@ -1760,6 +1761,10 @@ export function handleToolUse(
1760
1761
  event: Extract<AgentEvent, { type: "tool_use" }>,
1761
1762
  ): void {
1762
1763
  state.toolUseIdToName.set(event.id, event.name);
1764
+ // Activation checklist: keep the launched task's live step count moving.
1765
+ // Fire-and-forget and throttled inside the hook; a no-op for every
1766
+ // conversation no activation task points at.
1767
+ onActivationToolCall(deps.ctx.conversationId);
1763
1768
  if (event.name === "app_create" || event.name === "app_refresh") {
1764
1769
  state.appBuildToolUsedThisRun = true;
1765
1770
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { v4 as uuid } from "uuid";
11
11
 
12
+ import { onActivationTurnComplete } from "../activation/turn-hooks.js";
12
13
  import { repairHistoryForRun } from "../agent/history-repair/history-repair.js";
13
14
  import type {
14
15
  AgentEvent,
@@ -78,6 +79,7 @@ import type { Provider } from "../providers/types.js";
78
79
  import { resolveCapabilities } from "../runtime/capabilities.js";
79
80
  import { resolveTurnReplyMessageId } from "../runtime/channel-reply-delivery.js";
80
81
  import { isNoResponseOnlyText } from "../runtime/no-response.js";
82
+ import { getByConversation as getPendingInteractionsByConversation } from "../runtime/pending-interactions.js";
81
83
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
82
84
  import { stampTurnOutcome } from "../telemetry/turn-outcome.js";
83
85
  import {
@@ -107,6 +109,7 @@ import {
107
109
  } from "./conversation-agent-loop-handlers.js";
108
110
  import {
109
111
  approveHostAttachmentRead,
112
+ type PersistedAttachmentFile,
110
113
  resolveAssistantAttachments,
111
114
  } from "./conversation-attachments.js";
112
115
  import {
@@ -130,6 +133,7 @@ import {
130
133
  } from "./conversation-runtime-assembly.js";
131
134
  import type { CurrentTurnSurface } from "./conversation-surfaces.js";
132
135
  import {
136
+ hasBlockingPendingSurface,
133
137
  markSurfaceCompleted,
134
138
  settleRunningTaskProgressSurfaces,
135
139
  } from "./conversation-surfaces.js";
@@ -256,6 +260,35 @@ const FALLBACK_TURN_TRUST: TrustContext = {
256
260
  */
257
261
  export type AssistantSurface = CurrentTurnSurface;
258
262
 
263
+ /**
264
+ * Interaction kinds that are a prompt posed to the user. The `host_*` kinds
265
+ * are in-flight tool executions proxied to a client, which are the loop
266
+ * waiting on a machine rather than on a person.
267
+ */
268
+ const USER_PROMPT_INTERACTION_KINDS = new Set([
269
+ "confirmation",
270
+ "acp_confirmation",
271
+ "question",
272
+ "secret",
273
+ ]);
274
+
275
+ /**
276
+ * Whether this turn handed control back to the user instead of delivering.
277
+ *
278
+ * True when something the turn put on screen still needs an answer once the
279
+ * turn is over: a question or confirmation prompt that outlived it, or an
280
+ * interactive surface still awaiting an action. Both are structural, so a
281
+ * question the assistant asks in prose alone reads as a delivered turn.
282
+ */
283
+ function turnEndedAwaitingUser(ctx: Conversation): boolean {
284
+ if (hasBlockingPendingSurface(ctx)) {
285
+ return true;
286
+ }
287
+ return getPendingInteractionsByConversation(ctx.conversationId).some(
288
+ (interaction) => USER_PROMPT_INTERACTION_KINDS.has(interaction.kind),
289
+ );
290
+ }
291
+
259
292
  // ── abort watchdog ───────────────────────────────────────────────────
260
293
 
261
294
  /**
@@ -819,6 +852,11 @@ export async function runAgentLoopImpl(
819
852
  // provider-error turn's only assistant row is the synthetic error text, so
820
853
  // the deferred tail must not treat either as a final reply.
821
854
  let turnCompleted = false;
855
+ // Files this turn attached that survived resolution and persistence, in
856
+ // the shape the activation hook records as artifacts. Rejected directives
857
+ // never reach it, so a checklist card can never point at a file the turn
858
+ // failed to attach.
859
+ let persistedAttachmentFiles: readonly PersistedAttachmentFile[] = [];
822
860
  // True once `releaseTurn` has run. The happy path releases as soon as the
823
861
  // turn's content is settled; the `finally` calls it again as the backstop for
824
862
  // the cancel/error paths that never reached the early release.
@@ -1202,11 +1240,15 @@ export async function runAgentLoopImpl(
1202
1240
 
1203
1241
  // Unified `<turn_context>` actor input for this turn (model-facing grounding
1204
1242
  // metadata; the conversation runtime context remains the source for policy
1205
- // gating). Resolved once at turn start and frozen onto the conversation so
1206
- // the post-compaction hook re-emits this same value during in-loop recovery
1243
+ // gating). Derived from the turn's own actor, so the block describes who
1244
+ // is speaking now rather than who last touched the conversation. Resolved
1245
+ // once at turn start and frozen onto the conversation so the
1246
+ // post-compaction hook re-emits this same value during in-loop recovery
1207
1247
  // instead of re-resolving against contact/member registry state that may
1208
1248
  // have drifted mid-turn.
1209
- const actorContext = resolveTurnInboundActorContext(ctx.trustContext);
1249
+ const actorContext = resolveTurnInboundActorContext(
1250
+ turnOrRestingTrust(ctx),
1251
+ );
1210
1252
  ctx.currentTurnInboundActorContext = actorContext;
1211
1253
 
1212
1254
  // Surface long gaps between user messages so the model can acknowledge
@@ -1925,6 +1967,7 @@ export async function runAgentLoopImpl(
1925
1967
  state.toolContentBlockToolNames,
1926
1968
  );
1927
1969
  const { assistantAttachments, emittedAttachments } = attachmentResult;
1970
+ persistedAttachmentFiles = attachmentResult.persistedFiles;
1928
1971
 
1929
1972
  ctx.lastAssistantAttachments = assistantAttachments;
1930
1973
  ctx.lastAttachmentWarnings = attachmentResult.directiveWarnings;
@@ -2104,6 +2147,31 @@ export async function runAgentLoopImpl(
2104
2147
  try {
2105
2148
  if (turnStarted) {
2106
2149
  ctx.turnCount++;
2150
+
2151
+ // Activation checklist: a completed turn in a conversation an
2152
+ // activation task was launched into finishes that task, unless the
2153
+ // turn ended waiting on the user, in which case the answer's turn
2154
+ // finishes it (see `markActivationTurnComplete`). Cancelled turns
2155
+ // and handoffs deliberately fall through: the task is still
2156
+ // running. No-op for every conversation no task points at.
2157
+ //
2158
+ // Ahead of the turn-boundary commit, and fire-and-forget: a commit
2159
+ // that fails, times out, or is deferred to the next turn must not
2160
+ // leave the task showing as still running while the client is
2161
+ // already free to restart it.
2162
+ if (turnCompleted) {
2163
+ onActivationTurnComplete({
2164
+ conversationId: ctx.conversationId,
2165
+ toolCallCount: state.toolUseIdToName.size,
2166
+ attachedFiles: persistedAttachmentFiles.map((file) => ({
2167
+ path: file.sourcePath,
2168
+ filename: file.displayName,
2169
+ sourceType: file.sourceType,
2170
+ })),
2171
+ endedAwaitingUser: turnEndedAwaitingUser(ctx),
2172
+ });
2173
+ }
2174
+
2107
2175
  const runTurnCommit = async (): Promise<void> => {
2108
2176
  const config = getConfig();
2109
2177
  const maxWait =
@@ -10,6 +10,7 @@ import { getLogger } from "../util/logger.js";
10
10
  import {
11
11
  type ApproveHostRead,
12
12
  type AssistantAttachmentDraft,
13
+ type AttachmentSourceType,
13
14
  contentBlocksToDrafts,
14
15
  deduplicateDrafts,
15
16
  type DirectiveRequest,
@@ -63,10 +64,30 @@ export async function approveHostAttachmentRead(
63
64
  return response.decision === "allow";
64
65
  }
65
66
 
67
+ /**
68
+ * A file the assistant named that survived resolution, validation, and
69
+ * persistence. Rejected directives (missing, oversized, denied) and drafts
70
+ * whose upload was skipped never appear here, so a caller pointing a user
71
+ * at "the files this turn produced" cannot offer a broken one.
72
+ */
73
+ export interface PersistedAttachmentFile {
74
+ /** Absolute path the attachment was read from. */
75
+ sourcePath: string;
76
+ /** Name the attachment was persisted under. */
77
+ displayName: string;
78
+ /**
79
+ * Boundary the path was resolved against. A caller that surfaces the path
80
+ * needs it: only `sandbox_file` paths are the assistant's own workspace,
81
+ * and a `host_file` path is the user's machine.
82
+ */
83
+ sourceType: AttachmentSourceType;
84
+ }
85
+
66
86
  export interface AttachmentResolutionResult {
67
87
  assistantAttachments: AssistantAttachmentDraft[];
68
88
  emittedAttachments: UserMessageAttachment[];
69
89
  directiveWarnings: string[];
90
+ persistedFiles: PersistedAttachmentFile[];
70
91
  }
71
92
 
72
93
  /**
@@ -84,6 +105,17 @@ export async function resolveAssistantAttachments(
84
105
  ): Promise<AttachmentResolutionResult> {
85
106
  let assistantAttachments: AssistantAttachmentDraft[] = [];
86
107
  const emittedAttachments: UserMessageAttachment[] = [];
108
+ const persistedFiles: PersistedAttachmentFile[] = [];
109
+
110
+ const recordPersistedFile = (draft: AssistantAttachmentDraft): void => {
111
+ if (draft.sourcePath) {
112
+ persistedFiles.push({
113
+ sourcePath: draft.sourcePath,
114
+ displayName: draft.filename,
115
+ sourceType: draft.sourceType,
116
+ });
117
+ }
118
+ };
87
119
 
88
120
  log.info(
89
121
  {
@@ -208,6 +240,7 @@ export async function resolveAssistantAttachments(
208
240
  }
209
241
  }
210
242
 
243
+ recordPersistedFile(draft);
211
244
  emittedAttachments.push({
212
245
  id: stored.id,
213
246
  filename: draft.filename,
@@ -220,6 +253,9 @@ export async function resolveAssistantAttachments(
220
253
  });
221
254
  }
222
255
  } else if (assistantAttachments.length > 0) {
256
+ // No assistant message to attach to: the drafts are emitted to the client
257
+ // for this turn only and nothing is stored, so none of them is a
258
+ // persisted file.
223
259
  for (const draft of assistantAttachments) {
224
260
  emittedAttachments.push({
225
261
  filename: draft.filename,
@@ -230,5 +266,10 @@ export async function resolveAssistantAttachments(
230
266
  }
231
267
  }
232
268
 
233
- return { assistantAttachments, emittedAttachments, directiveWarnings };
269
+ return {
270
+ assistantAttachments,
271
+ emittedAttachments,
272
+ directiveWarnings,
273
+ persistedFiles,
274
+ };
234
275
  }
@@ -0,0 +1,25 @@
1
+ import type { AssistantEvent } from "../api/index.js";
2
+ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
3
+
4
+ /**
5
+ * Delivery sink for a top-level conversation: the assistant event hub,
6
+ * scoped to that conversation.
7
+ *
8
+ * The hub attributes an event to a conversation from the id it is handed,
9
+ * falling back to a `conversationId` on the payload, and that attribution
10
+ * decides three things: which conversation-filtered subscribers receive the
11
+ * event, whether it is seq-stamped, and whether it is replayed after a
12
+ * reconnect. Binding the id here makes all three a property of the emitting
13
+ * conversation rather than of each event's schema, so an event whose payload
14
+ * names no conversation (`subagent_spawned`, `subagent_status_changed`) is
15
+ * scoped exactly like one that does.
16
+ *
17
+ * Payload attribution stays the fallback for events published straight to the
18
+ * hub, including `open_conversation`, whose id names the conversation to open
19
+ * rather than the one it was emitted from.
20
+ */
21
+ export function conversationEventSink(
22
+ conversationId: string,
23
+ ): (msg: AssistantEvent) => void {
24
+ return (msg) => broadcastMessage(msg, conversationId);
25
+ }
@@ -64,7 +64,7 @@ import { preactivateHostProxySkills } from "./host-proxy-preactivation.js";
64
64
  import type { UserMessageAttachment } from "./message-protocol.js";
65
65
  import { buildTransportHints } from "./transport-hints.js";
66
66
  import { sameTrustIdentity, type TrustContext } from "./trust-context-types.js";
67
- import { turnOrRestingTrust } from "./trust-context-types.js";
67
+ import { restingTrust, turnOrRestingTrust } from "./trust-context-types.js";
68
68
  import { resolveVerificationSessionIntent } from "./verification-session-intent.js";
69
69
 
70
70
  const log = getLogger("conversation-process");
@@ -646,6 +646,52 @@ export async function kickQueueDrain(
646
646
  }
647
647
  }
648
648
 
649
+ /**
650
+ * Commit `actor` as the actor the turn about to start runs for: the resting
651
+ * slot names them, so the history is scoped to them and every reader of the
652
+ * slot (the `<turn_context>` actor section, memory retrieval, the Slack
653
+ * transcript filters) describes them rather than whoever sent last, and the
654
+ * per-turn snapshot then reads back that same actor, which is also returned
655
+ * for callers that must hold it in a local. A caller with no actor of its own
656
+ * (internal dispatch, or a queued message whose enqueue found the slot empty)
657
+ * passes `undefined`, and the resting actor stands.
658
+ *
659
+ * `reloadHistory` is off only for a steered drain, which keeps its resident
660
+ * history: a steer comes from the actor whose turn it cut off, and that
661
+ * history may hold the in-memory repair of the abandoned `tool_use`, which a
662
+ * reload would discard.
663
+ *
664
+ * The committed actor is captured and snapshotted before the reload awaits:
665
+ * the slot is writable out-of-band across that await (a wake's stamp, a
666
+ * pointer elevation), and a read after it would hand the turn that writer's
667
+ * actor. A reload that fails starts no turn, so the slot is put back to what
668
+ * it held before, guarded so a writer that legitimately moved it in between
669
+ * is left alone.
670
+ */
671
+ async function commitTurnActor(
672
+ conversation: Conversation,
673
+ actor: TrustContext | undefined,
674
+ options: { reloadHistory: boolean },
675
+ ): Promise<TrustContext | undefined> {
676
+ const prior = restingTrust(conversation);
677
+ if (actor) {
678
+ conversation.setTrustContext(actor);
679
+ }
680
+ const turnTrustContext = restingTrust(conversation);
681
+ conversation.currentTurnTrustContext = turnTrustContext;
682
+ if (options.reloadHistory) {
683
+ try {
684
+ await conversation.ensureActorScopedHistory();
685
+ } catch (err) {
686
+ if (actor && restingTrust(conversation) === actor) {
687
+ conversation.setTrustContext(prior ?? null);
688
+ }
689
+ throw err;
690
+ }
691
+ }
692
+ return turnTrustContext;
693
+ }
694
+
649
695
  async function drainSingleMessage(
650
696
  conversation: Conversation,
651
697
  next: QueuedMessage,
@@ -749,8 +795,11 @@ async function drainSingleMessage(
749
795
  // Trust comes from the queued message, not the live slot: the slot holds
750
796
  // whichever actor sent most recently, which is this sender only when nobody
751
797
  // else sent while this message waited.
752
- conversation.currentTurnTrustContext =
753
- next.trustContext ?? conversation.trustContext;
798
+ const turnTrustContext = await commitTurnActor(
799
+ conversation,
800
+ next.trustContext,
801
+ { reloadHistory: !steered },
802
+ );
754
803
  conversation.currentTurnChannelCapabilities =
755
804
  conversation.channelCapabilities;
756
805
 
@@ -1220,10 +1269,10 @@ async function drainSingleMessage(
1220
1269
  cronRunId?: string | null;
1221
1270
  } = {
1222
1271
  isUserMessage: true,
1223
- // Carry the sender's trust into the run. The loop re-initializes the
1224
- // per-turn snapshot on entry, so without this the stamp above is undone
1225
- // and the turn reverts to the conversation's most recent actor.
1226
- turnTrustContext: conversation.currentTurnTrustContext,
1272
+ // Carry the sender's trust into the run from the local captured at the
1273
+ // commit: the loop re-initializes the per-turn snapshot on entry, and the
1274
+ // field is writable out-of-band across the awaits above.
1275
+ turnTrustContext,
1227
1276
  };
1228
1277
  if (next.isInteractive !== undefined) {
1229
1278
  drainLoopOptions.isInteractive = next.isInteractive;
@@ -1359,8 +1408,11 @@ async function drainBatch(
1359
1408
  // `buildPassthroughBatch` refuses to coalesce messages from different
1360
1409
  // actors; without that boundary this would run a tail under the head's
1361
1410
  // trust.
1362
- conversation.currentTurnTrustContext =
1363
- head.trustContext ?? conversation.trustContext;
1411
+ const turnTrustContext = await commitTurnActor(
1412
+ conversation,
1413
+ head.trustContext,
1414
+ { reloadHistory: true },
1415
+ );
1364
1416
  conversation.currentTurnChannelCapabilities =
1365
1417
  conversation.channelCapabilities;
1366
1418
 
@@ -1703,9 +1755,9 @@ async function drainBatch(
1703
1755
  cronRunId?: string | null;
1704
1756
  } = {
1705
1757
  isUserMessage: true,
1706
- // Same reason as the single-message drain: the loop re-initializes the
1707
- // per-turn snapshot, so the head's trust has to travel with the call.
1708
- turnTrustContext: conversation.currentTurnTrustContext,
1758
+ // Same reason as the single-message drain: the head's trust travels from
1759
+ // the local captured at the commit, not a late read of the field.
1760
+ turnTrustContext,
1709
1761
  };
1710
1762
  if (lastPushEligibleUserMessageId !== undefined) {
1711
1763
  drainLoopOptions.notifyUserMessageId = lastPushEligibleUserMessageId;
@@ -1838,20 +1890,16 @@ export async function processMessage(
1838
1890
  metadata: callerMetadata,
1839
1891
  trustContext: committingTrustContext,
1840
1892
  } = options;
1841
- if (committingTrustContext) {
1842
- conversation.setTrustContext(committingTrustContext);
1843
- }
1844
- await conversation.ensureActorScopedHistory();
1845
- // Snapshot persona context at turn start so later tool turns can't pick up
1846
- // a different actor's context if a concurrent request mutates the live fields.
1847
- //
1848
1893
  // Held in a local as well as on the conversation: the field is writable
1849
1894
  // out-of-band while this turn is in flight (`agent-wake` stamps it and
1850
1895
  // restores the prior value in a `finally`), so reading it back at the agent
1851
1896
  // loop call below would reintroduce the late read this capture exists to
1852
1897
  // avoid. The local is what the loop runs under.
1853
- const turnTrustContext = conversation.trustContext;
1854
- conversation.currentTurnTrustContext = turnTrustContext;
1898
+ const turnTrustContext = await commitTurnActor(
1899
+ conversation,
1900
+ committingTrustContext,
1901
+ { reloadHistory: true },
1902
+ );
1855
1903
  conversation.currentTurnAuthContext = conversation.authContext;
1856
1904
  conversation.currentTurnSourceActorPrincipalId =
1857
1905
  sourceActorPrincipalId ?? conversation.authContext?.actorPrincipalId;
@@ -27,10 +27,10 @@ import {
27
27
  } from "../providers/connection-resolution.js";
28
28
  import { RateLimitProvider } from "../providers/ratelimit.js";
29
29
  import { listProviders } from "../providers/registry.js";
30
- import { broadcastMessage } from "../runtime/assistant-event-hub.js";
31
30
  import { getSubagentManager } from "../subagent/index.js";
32
31
  import { getSandboxWorkingDir } from "../util/platform.js";
33
32
  import { Conversation } from "./conversation.js";
33
+ import { conversationEventSink } from "./conversation-event-sink.js";
34
34
  import {
35
35
  removeFromEvictor,
36
36
  touchConversation,
@@ -377,8 +377,9 @@ async function acquireConversation(
377
377
  provider,
378
378
  systemPrompt,
379
379
  // Top-level conversations deliver to the SSE hub for their whole life,
380
- // so every subscribed client sees every event with no per-turn wiring.
381
- broadcastMessage,
380
+ // so every subscribed client sees every event with no per-turn wiring,
381
+ // scoped to this conversation whatever the event's payload carries.
382
+ conversationEventSink(conversationId),
382
383
  workingDir,
383
384
  {
384
385
  maxTokens,
@@ -166,6 +166,24 @@ const NON_BLOCKING_PENDING_SURFACE_TYPES = new Set<SurfaceType>([
166
166
  "voice_picker",
167
167
  ]);
168
168
 
169
+ /**
170
+ * Whether a surface this conversation showed is still waiting on the user.
171
+ *
172
+ * The one-interactive-surface-at-a-time gate reads it to reject a second
173
+ * card, and the turn boundary reads it to tell a turn that delivered from a
174
+ * turn that posed a question and handed control back.
175
+ */
176
+ export function hasBlockingPendingSurface(ctx: {
177
+ pendingSurfaceActions: Map<string, { surfaceType: SurfaceType }>;
178
+ }): boolean {
179
+ for (const entry of ctx.pendingSurfaceActions.values()) {
180
+ if (!NON_BLOCKING_PENDING_SURFACE_TYPES.has(entry.surfaceType)) {
181
+ return true;
182
+ }
183
+ }
184
+ return false;
185
+ }
186
+
169
187
  /**
170
188
  * Surface types that carry no terminal action: the card settles when the user
171
189
  * interacts with it, so no click could ever satisfy an attached `actions`
@@ -3546,10 +3564,7 @@ export async function surfaceProxyResolver(
3546
3564
  // content rather than a question posed to the user, so a pending one
3547
3565
  // never blocks the next surface.
3548
3566
  if (awaitAction) {
3549
- const hasExistingPending = [...ctx.pendingSurfaceActions.values()].some(
3550
- (entry) => !NON_BLOCKING_PENDING_SURFACE_TYPES.has(entry.surfaceType),
3551
- );
3552
- if (hasExistingPending) {
3567
+ if (hasBlockingPendingSurface(ctx)) {
3553
3568
  return {
3554
3569
  content:
3555
3570
  "Another interactive surface is already awaiting user input. Present one at a time — wait for the user to respond to the current surface before showing the next.",
@@ -18,6 +18,8 @@ export const SYNC_TAGS = {
18
18
  documentsList: "documents:list",
19
19
  pluginsList: "plugins:list",
20
20
  conversationsList: "conversations:list",
21
+ /** Activation-checklist progress: task launches, step counts, completions. */
22
+ activationProgress: "activation:progress",
21
23
  featureFlagsClient: "feature-flags:client",
22
24
  featureFlagsAssistant: "feature-flags:assistant",
23
25
  /** ACP credential-failure markers, which drive the inline Connect card.
@@ -58,6 +58,7 @@ import type {
58
58
  import { RouteResponse } from "../runtime/routes/types.js";
59
59
  import { getLogger } from "../util/logger.js";
60
60
  import { mapGatewayIpcConnectError } from "./gateway-ipc-errors.js";
61
+ import { ACTIVATION_SYNC_IPC_METHODS } from "./routes/activation-sync-ipc-routes.js";
61
62
  import { CONTACTS_INFO_IPC_METHODS } from "./routes/contacts-info-ipc-routes.js";
62
63
  import { CONTACTS_MIRROR_IPC_METHODS } from "./routes/contacts-mirror-ipc-routes.js";
63
64
  import { CONVERSATION_SYNC_IPC_METHODS } from "./routes/conversation-sync-ipc-routes.js";
@@ -219,6 +220,7 @@ export class AssistantIpcServer {
219
220
  GUARDIAN_LABEL_IPC_METHODS,
220
221
  CONVERSATION_SYNC_IPC_METHODS,
221
222
  DOCUMENTS_SYNC_IPC_METHODS,
223
+ ACTIVATION_SYNC_IPC_METHODS,
222
224
  EVENTS_IPC_METHODS,
223
225
  ]) {
224
226
  for (const [operationId, handler] of Object.entries(methodMap)) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The daemon-side handler for the worker → daemon activation-progress
3
+ * hand-off. A sidecar worker's own hub has no SSE subscriber, so it asks the
4
+ * daemon to republish the invalidation where clients actually observe it.
5
+ */
6
+
7
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
8
+
9
+ const publishCalls: Array<string | undefined> = [];
10
+
11
+ mock.module("../../../runtime/sync/resource-sync-events.js", () => ({
12
+ publishActivationProgressChanged: (originClientId?: string) => {
13
+ publishCalls.push(originClientId);
14
+ },
15
+ }));
16
+
17
+ import { DB_MIGRATION_READINESS_EXEMPT_OPERATIONS } from "../../../daemon/daemon-readiness.js";
18
+ import { NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD } from "../../../runtime/sync/worker-daemon-notify.js";
19
+ import {
20
+ ACTIVATION_SYNC_IPC_METHODS,
21
+ handleNotifyActivationProgressChanged,
22
+ } from "../activation-sync-ipc-routes.js";
23
+
24
+ describe("activation-sync IPC route", () => {
25
+ beforeEach(() => {
26
+ publishCalls.length = 0;
27
+ });
28
+
29
+ test("republishes the activation-progress invalidation on the daemon", () => {
30
+ const result = handleNotifyActivationProgressChanged({ body: {} });
31
+
32
+ expect(result).toEqual({ ok: true });
33
+ expect(publishCalls).toEqual([undefined]);
34
+ });
35
+
36
+ test("is reachable on the IPC surface under the shared method name", () => {
37
+ expect(
38
+ typeof ACTIVATION_SYNC_IPC_METHODS[
39
+ NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD
40
+ ],
41
+ ).toBe("function");
42
+ });
43
+
44
+ test("is DB-migration readiness gated (absent from the exempt set)", () => {
45
+ expect(
46
+ DB_MIGRATION_READINESS_EXEMPT_OPERATIONS.has(
47
+ NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD,
48
+ ),
49
+ ).toBe(false);
50
+ });
51
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * IPC-only route the sidecar workers (schedule, memory) call after a turn
3
+ * moved a launched checklist task.
4
+ *
5
+ * Workers disable SSE seq stamping (`disableStreamSeqStamping`) so the daemon
6
+ * is the sole seq authority, and the SSE subscribers live in the daemon too. A
7
+ * worker that published `activation:progress` on its own hub would reach
8
+ * nobody, so a task worked by a scheduled or background turn would keep
9
+ * showing as running until the client refetched for some other reason.
10
+ *
11
+ * This route runs the publish on the daemon instead, where real subscribers
12
+ * observe it. The hand-off carries no originating client, so nothing suppresses
13
+ * the broadcast as its own echo.
14
+ *
15
+ * IPC-only: registered directly on the assistant IPC server (see
16
+ * `assistant-server.ts`), never in the shared `ROUTES` array. The handler
17
+ * touches no database, but the IPC server's uniform DB-migration gate still
18
+ * applies (the method is not exempt); the worker's call is best-effort and
19
+ * tolerates that.
20
+ */
21
+
22
+ import type { RouteHandlerArgs } from "../../runtime/routes/types.js";
23
+ import { publishActivationProgressChanged } from "../../runtime/sync/resource-sync-events.js";
24
+ import { NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD } from "../../runtime/sync/worker-daemon-notify.js";
25
+
26
+ /** Republish a worker's activation-progress invalidation to daemon subscribers. */
27
+ export function handleNotifyActivationProgressChanged(_args: RouteHandlerArgs) {
28
+ publishActivationProgressChanged();
29
+ return { ok: true };
30
+ }
31
+
32
+ /**
33
+ * IPC-only activation-sync methods, keyed by operationId. Registered directly
34
+ * on the assistant IPC server (see `assistant-server.ts`).
35
+ */
36
+ export const ACTIVATION_SYNC_IPC_METHODS: Record<
37
+ string,
38
+ (args: RouteHandlerArgs) => unknown
39
+ > = {
40
+ [NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD]:
41
+ handleNotifyActivationProgressChanged,
42
+ };
@@ -10,7 +10,7 @@ Guardian-request producers (access requests, tool approvals, tool-grant escalati
10
10
 
11
11
  Approval-card **source references** (the link back to the channel message that triggered a request) resolve only through `resolveApprovalSourceReference()` in `runtime/approval-source-link.ts` -- producers spread the result into the `guardian.question` context payload and never hand-build links. Channel-format knowledge (id shapes, permalinks, mrkdwn) lives only in `messaging/providers/<channel>/` and `notifications/adapters/<channel>`; the four-layer ownership map is documented at the top of `approval-source-link.ts`. Exception: access-request cards predate the registry and still derive their Slack permalink from payload `messageTs` in `access-request-copy.ts` -- converge them onto the registry rather than adding a third resolution path.
12
12
 
13
- A notification delivered to an external channel (Slack, Telegram, Discord) becomes a conversation row **only after the adapter acknowledges the send**. `pairDeliveryWithConversation` resolves the chat's home conversation before the send for `continue_existing_conversation` channels (`resolveProactiveHomeConversation`: the chat's thread-less inbound conversation, else its `notification:`-namespace conversation) and writes nothing there; on adapter success the broadcaster calls `recordDeliveredChannelPost`, which writes the sent text with the neutral `providerMeta` envelope and `automated: true`, runs the shared post-send reconciliation (`runtime/outbound-post-reconciliation.ts`) so the acknowledged id lands on the envelope and in `channel_outbound_posts`, and marks a resident conversation stale. The delivery audit names that row in `canonical_message_id`; `message_id` keeps its meaning (provider id for a channel delivery, row id for a vellum delivery). A failed or pending delivery therefore has no conversation row, so nothing the channel never accepted can read as the assistant's words. Vellum and passive deliveries keep their pre-send pairing row, which the feed card's deep link and rewritable row depend on. Editing a notification rewrites the canonical row after the channel update succeeds and re-indexes it; deleting the channel post resolves to the row through the index.
13
+ A notification delivered to an external channel (Slack, Telegram, Discord) becomes a conversation row **only after the adapter acknowledges the send**. `pairDeliveryWithConversation` resolves the chat's home conversation before the send for `continue_existing_conversation` channels (`resolveProactiveHomeConversation`: the chat's thread-less inbound conversation, else its `notification:`-namespace conversation; a post into a thread goes to the thread's own conversation as ingress resolves it, since Slack and Telegram key inbound conversations per thread and the replies arrive there) and writes nothing there; on adapter success the broadcaster calls `recordDeliveredChannelPost`, which writes the sent text with the neutral `providerMeta` envelope and `automated: true`, runs the shared post-send reconciliation (`runtime/outbound-post-reconciliation.ts`) so the acknowledged id lands on the envelope and in `channel_outbound_posts`, and marks a resident conversation stale. The delivery audit names that row in `canonical_message_id`; `message_id` keeps its meaning (provider id for a channel delivery, row id for a vellum delivery). A failed or pending delivery therefore has no conversation row, so nothing the channel never accepted can read as the assistant's words. Vellum and passive deliveries keep their pre-send pairing row, which the feed card's deep link and rewritable row depend on. Editing a notification rewrites the canonical row after the channel update succeeds and re-indexes it; deleting the channel post resolves to the row through the index.
14
14
 
15
15
  Guardian-request card rows are **not conversation history**. Only the vellum delivery persists a message row (`pairDeliveryWithConversation` pins it to the conversation the request is _about_, via `buildVellumCardAffinity`); channel guardian cards are delivery projections and pair no conversation at all, with the gateway delivery row (chat id + channel-native message id) as their only persisted envelope. `isGuardianCardRow` in `approval-card-data.ts` is the single definition of which rows are guardian cards (including rows channel deliveries paired before the projection-only policy), derived from the card's own `ui_surface` id rather than a stored marker so old rows need no backfill. **Both** history assemblers must consult it -- `Conversation.loadFromDb` and `loadSlackChronologicalContext`, which re-reads rows rather than using `this.messages` -- or the unfiltered one replays the card between a parked turn's `tool_use` and its `tool_result` and history repair destroys the real result. Surface state is exempt on purpose: the card's buttons must still route after a restart. Full rationale in [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md).
16
16