negotium 0.2.4 → 0.2.6

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 (62) hide show
  1. package/dist/agent-helpers.js +241 -127
  2. package/dist/agent-helpers.js.map +19 -19
  3. package/dist/background-bash.js +1 -3
  4. package/dist/background-bash.js.map +4 -4
  5. package/dist/browser-runtime.js +1 -3
  6. package/dist/browser-runtime.js.map +4 -4
  7. package/dist/{chunk-1s9ryz8g.js → chunk-qmbcbhyy.js} +20 -12
  8. package/dist/chunk-qmbcbhyy.js.map +35 -0
  9. package/dist/hosted-agent.js +13 -10
  10. package/dist/hosted-agent.js.map +10 -10
  11. package/dist/main.js +607 -438
  12. package/dist/main.js.map +30 -29
  13. package/dist/mcp-factories.js +238 -123
  14. package/dist/mcp-factories.js.map +18 -18
  15. package/dist/prompts.js +1 -3
  16. package/dist/prompts.js.map +4 -4
  17. package/dist/query-runtime.js +1 -3
  18. package/dist/query-runtime.js.map +5 -5
  19. package/dist/registry.js +3 -3
  20. package/dist/registry.js.map +2 -2
  21. package/dist/rollout.js +1 -1
  22. package/dist/runtime/src/agents/api-topic-agent-switch.ts +6 -12
  23. package/dist/runtime/src/agents/claude-provider.ts +2 -2
  24. package/dist/runtime/src/agents/codex-provider.ts +1 -0
  25. package/dist/runtime/src/agents/index.ts +19 -14
  26. package/dist/runtime/src/agents/maestro-provider.ts +1 -1
  27. package/dist/runtime/src/agents/rollout/codex.ts +25 -18
  28. package/dist/runtime/src/agents/self-config-core.ts +2 -4
  29. package/dist/runtime/src/agents/topic-agent-switch.ts +4 -10
  30. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +91 -19
  31. package/dist/runtime/src/application/switch-topic-model.ts +7 -10
  32. package/dist/runtime/src/index.ts +4 -0
  33. package/dist/runtime/src/mcp/wiki-server.ts +19 -12
  34. package/dist/runtime/src/platform/config.ts +0 -2
  35. package/dist/runtime/src/platform/log-rotation.ts +45 -0
  36. package/dist/runtime/src/platform/mcp-config.ts +9 -3
  37. package/dist/runtime/src/query/active-rooms.ts +7 -1
  38. package/dist/runtime/src/runtime/errors.ts +1 -0
  39. package/dist/runtime/src/runtime/turn-event-stream.ts +46 -1
  40. package/dist/runtime/src/runtime/turn-runner.ts +176 -100
  41. package/dist/runtime/src/runtime/user-turn-envelope.ts +19 -0
  42. package/dist/runtime/src/storage/runtime-gateway-submissions.ts +32 -3
  43. package/dist/runtime/src/storage/runtime-turn-requests.ts +68 -7
  44. package/dist/runtime/src/types.ts +2 -0
  45. package/dist/runtime/src/version.ts +1 -1
  46. package/dist/runtime-helpers.js +1 -3
  47. package/dist/runtime-helpers.js.map +4 -4
  48. package/dist/storage.js.map +1 -1
  49. package/dist/types/packages/core/src/agents/api-topic-agent-switch.d.ts +1 -1
  50. package/dist/types/packages/core/src/agents/self-config-core.d.ts +1 -1
  51. package/dist/types/packages/core/src/platform/config.d.ts +0 -1
  52. package/dist/types/packages/core/src/platform/mcp-config.d.ts +3 -0
  53. package/dist/types/packages/core/src/query/active-rooms.d.ts +7 -1
  54. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +42 -1
  55. package/dist/types/packages/core/src/runtime/user-turn-envelope.d.ts +7 -0
  56. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +17 -0
  57. package/dist/types/packages/core/src/types.d.ts +2 -0
  58. package/dist/types/packages/core/src/version.d.ts +1 -1
  59. package/dist/vault.js +1 -3
  60. package/dist/vault.js.map +4 -4
  61. package/package.json +1 -1
  62. package/dist/chunk-1s9ryz8g.js.map +0 -35
@@ -207,6 +207,8 @@ export function consumePlaywrightUnavailable(userId: string, topic: string | und
207
207
 
208
208
  export interface RuntimeMcpBuildContext {
209
209
  userId: string;
210
+ /** Vault namespace when credentials belong to a different principal. */
211
+ vaultUserId?: string;
210
212
  /** "dm" for DM scope, topic/session name for forum/fork. */
211
213
  session: string;
212
214
  /** REST/WS topic id when known. Prefer for authorization/scope checks. */
@@ -527,13 +529,13 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
527
529
  vault: {
528
530
  ...commonRuntimeMcpPolicy("vault"),
529
531
  build(ctx) {
530
- const { userId, agent } = ctx;
532
+ const { userId, vaultUserId, agent } = ctx;
531
533
  // Normal turns use {{KEY}} directly in tool inputs. Keep the Vault MCP
532
534
  // surface focused on key discovery; broker tools remain available from
533
535
  // the public factory for compatibility but are no longer the default UX.
534
- const args = [`--user-id=${userId}`];
536
+ const args = [`--user-id=${vaultUserId ?? userId}`];
535
537
  if (agent !== "codex") args.push("--list-only=true");
536
- return buildBuiltinMcpServer("vault", ctx, () =>
538
+ return buildBuiltinMcpServer("vault", { ...ctx, userId: vaultUserId ?? userId }, () =>
537
539
  buildStdioMcpServer(agent, VAULT_SERVER, args),
538
540
  );
539
541
  },
@@ -767,6 +769,7 @@ export function getManagerMcpServers(opts: {
767
769
  */
768
770
  export function getForumMcpServers(opts: {
769
771
  userId: string;
772
+ vaultUserId?: string;
770
773
  session: string;
771
774
  topicId?: string;
772
775
  subagentParentTopicId?: string;
@@ -790,6 +793,7 @@ export function getForumMcpServers(opts: {
790
793
  }) {
791
794
  const {
792
795
  userId,
796
+ vaultUserId,
793
797
  session,
794
798
  topicId,
795
799
  subagentParentTopicId,
@@ -824,6 +828,7 @@ export function getForumMcpServers(opts: {
824
828
  "forum",
825
829
  {
826
830
  userId,
831
+ vaultUserId,
827
832
  session,
828
833
  topicId,
829
834
  subagentParentTopicId,
@@ -945,6 +950,7 @@ export function getMcpServersForQuery(opts: AgentQueryOptions): Record<string, u
945
950
  }
946
951
  return getForumMcpServers({
947
952
  userId: opts.userId || "local",
953
+ vaultUserId: opts.vaultUserId,
948
954
  session: opts.session || "default",
949
955
  topicId: opts.topicId,
950
956
  subagentParentTopicId: opts.subagentParentTopicId,
@@ -42,6 +42,8 @@ export interface DeferredInject {
42
42
  /** Topic execution epoch captured when this work was accepted. */
43
43
  runtimeEpoch?: number;
44
44
  userId: string;
45
+ /** Credential namespace when the executor and Vault owner differ. */
46
+ vaultUserId?: string;
45
47
  prompt: string;
46
48
  /** Inject source — the topic name/id this inject came from (never "user"). */
47
49
  origin: string;
@@ -120,8 +122,12 @@ export interface RoomQueryControl {
120
122
  durableRequestIds?: string[];
121
123
  /** Attachment ids currently being processed. User-turn preemption carries them forward. */
122
124
  attachments?: string[];
123
- /** Provider session id this turn resumed from. Superseding user turns restart from this base. */
125
+ /** Latest provider session id observed for this turn. */
124
126
  sessionId?: string | null;
127
+ /** True once the provider has published the native session backing this turn. */
128
+ providerSessionObserved?: boolean;
129
+ /** True once provider output proves that this turn's prompt reached the native session. */
130
+ providerTurnContentObserved?: boolean;
125
131
  abortController: AbortController;
126
132
  abortReason: AbortReason;
127
133
  /** Specific failure surfaced when infrastructure aborts the turn. */
@@ -64,6 +64,7 @@ export function isSessionExpiredError(message: string): boolean {
64
64
  return (
65
65
  message.includes(SESSION_EXPIRED_MSG) ||
66
66
  /session was recorded with model .+ but is resuming with/i.test(message) ||
67
+ /failed to resolve rollout path .+file does not exist/i.test(message) ||
67
68
  /session.*not found|session.*expired|unknown conversation/i.test(message)
68
69
  );
69
70
  }
@@ -64,6 +64,10 @@ import {
64
64
  updateApiMessageUsage,
65
65
  } from "#storage/api-messages";
66
66
  import { getTopic, setTopicSessionId } from "#storage/api-topics";
67
+ import {
68
+ getRuntimeUserTurnRequest,
69
+ markRuntimeUserTurnProviderSessionObserved,
70
+ } from "#storage/runtime-turn-requests";
67
71
  import { recordUsage } from "#storage/token-stats";
68
72
  import type { AgentKind, EffortLevel, PeerRuntimeBridgeContext, UnifiedEvent } from "#types";
69
73
  import type { MessageDto } from "#types/api";
@@ -267,6 +271,30 @@ export async function runTurnEventStream(
267
271
  for await (const event of events) {
268
272
  if (abortController.signal.aborted) break;
269
273
 
274
+ // A provider may publish its session id before it appends the user
275
+ // prompt to the native rollout. Only semantic provider output proves
276
+ // that the prompt made it into that session. This distinction matters
277
+ // when a second user message preempts the turn in that short window:
278
+ // the replacement must replay both messages instead of dropping the
279
+ // first one merely because a session event was seen.
280
+ if (
281
+ !control.providerTurnContentObserved &&
282
+ (event.type === "text_delta" ||
283
+ event.type === "text" ||
284
+ event.type === "reasoning" ||
285
+ event.type === "tool_use" ||
286
+ event.type === "tool_result" ||
287
+ event.type === "file" ||
288
+ event.type === "result")
289
+ ) {
290
+ control.providerTurnContentObserved = true;
291
+ if (control.sessionId) {
292
+ for (const requestId of control.durableRequestIds ?? []) {
293
+ markRuntimeUserTurnProviderSessionObserved(topicId, requestId, control.sessionId);
294
+ }
295
+ }
296
+ }
297
+
270
298
  switch (event.type) {
271
299
  case "text_delta": {
272
300
  pendingSawDelta = true;
@@ -672,6 +700,16 @@ export async function runTurnEventStream(
672
700
  }
673
701
  break;
674
702
  case "session":
703
+ // Keep the control synchronized with the provider, not merely the
704
+ // topic row. A superseding user message can then resume the partial
705
+ // native rollout after this process has fully unwound.
706
+ control.sessionId = event.sessionId;
707
+ control.providerSessionObserved = true;
708
+ if (control.providerTurnContentObserved) {
709
+ for (const requestId of control.durableRequestIds ?? []) {
710
+ markRuntimeUserTurnProviderSessionObserved(topicId, requestId, event.sessionId);
711
+ }
712
+ }
675
713
  // Persist visible topic turns only. Silent ask_session forks use
676
714
  // temporary session ids and must not clobber the topic's durable
677
715
  // session; visible tell/reply injects do belong to the main topic
@@ -788,7 +826,14 @@ export async function runTurnEventStream(
788
826
  // Drain one deferred session-inject if the room is now idle. If a
789
827
  // replacement turn already occupies the slot, leave it queued — that
790
828
  // turn's finally will drain it when it completes.
791
- if (roomId === topicId && outcome.kind !== "session-expired" && !getRoomQuery(topicId)) {
829
+ const queuedUserTurn = roomId === topicId ? getRuntimeUserTurnRequest(topicId) : null;
830
+ const hasReplacementUserTurn = queuedUserTurn !== null && queuedUserTurn.requestId !== queryId;
831
+ if (
832
+ roomId === topicId &&
833
+ outcome.kind !== "session-expired" &&
834
+ !getRoomQuery(topicId) &&
835
+ !hasReplacementUserTurn
836
+ ) {
792
837
  const next = takeDeferredInject(topicId);
793
838
  if (next) redispatchInject(next);
794
839
  }
@@ -69,7 +69,8 @@ import {
69
69
  import {
70
70
  flattenUserTurnAttachments,
71
71
  legacyUserTurnEnvelope,
72
- renderUserPromptBatch,
72
+ renderUserTurnBatch,
73
+ renderUserTurnPrompt,
73
74
  type UserTurnEnvelope,
74
75
  } from "#runtime/user-turn-envelope";
75
76
  import { getActiveVisualForPrompt } from "#runtime/visual-store";
@@ -119,7 +120,7 @@ export * from "#runtime/channel-context";
119
120
  export * from "#runtime/errors";
120
121
  export * from "#runtime/tasks";
121
122
  export * from "#runtime/turn-session";
122
- export { renderUserPromptBatch } from "#runtime/user-turn-envelope";
123
+ export { renderUserPromptBatch, renderUserTurnBatch } from "#runtime/user-turn-envelope";
123
124
  export * from "#runtime/visuals";
124
125
 
125
126
  // In-flight AI turns are tracked room-keyed (topicId → control) in
@@ -321,6 +322,7 @@ function redispatchInject(inject: DeferredInject): void {
321
322
  aiMention: topic.aiMention,
322
323
  },
323
324
  userId: inject.userId,
325
+ vaultUserId: inject.vaultUserId,
324
326
  prompt: inject.prompt,
325
327
  allowAutoContinue: false,
326
328
  origin: inject.origin,
@@ -501,7 +503,12 @@ async function deliverAskError(queryId: string, sourceLabel: string, error: stri
501
503
  }
502
504
  }
503
505
 
504
- function tryReconstructTopicRollout(opts: {
506
+ export type TopicRolloutReconstruction =
507
+ | { kind: "rebuilt"; sessionId: string }
508
+ | { kind: "no-history" }
509
+ | { kind: "failed"; error: string };
510
+
511
+ export function tryReconstructTopicRollout(opts: {
505
512
  topicId: string;
506
513
  topicTitle: string;
507
514
  userId: string;
@@ -510,14 +517,12 @@ function tryReconstructTopicRollout(opts: {
510
517
  cwd: string;
511
518
  model: string;
512
519
  effort?: EffortLevel;
513
- allowFreshSession?: boolean;
514
- }): string | null {
520
+ }): TopicRolloutReconstruction {
515
521
  const { topicId, topicTitle, userId, agent, sessionId, cwd, model, effort } = opts;
516
- if (!sessionId && !opts.allowFreshSession) return null;
517
522
 
518
523
  try {
519
524
  const entries = readConversation(userId, topicTitle);
520
- if (entries.length === 0) return null;
525
+ if (entries.length === 0) return { kind: "no-history" };
521
526
  const result = getRegistryOperations(agent).writeRollout({
522
527
  cwd,
523
528
  entries,
@@ -537,14 +542,18 @@ function tryReconstructTopicRollout(opts: {
537
542
  ? "ai: session expired — rollout reconstructed from unified log"
538
543
  : "ai: provider session bridged from shared conversation log",
539
544
  );
540
- return result.sessionId;
545
+ return { kind: "rebuilt", sessionId: result.sessionId };
541
546
  } catch (err) {
542
547
  logger.warn({ err, topicId, agent, sessionId }, "ai: session reconstruct failed");
543
- return null;
548
+ return { kind: "failed", error: stringifyError(err) };
544
549
  }
545
550
  }
546
551
 
547
- function resolveSessionRetryId(opts: {
552
+ export type SessionRetryResolution =
553
+ | { kind: "retry"; sessionId: string | null }
554
+ | { kind: "failed"; error: string };
555
+
556
+ export function resolveSessionRetry(opts: {
548
557
  topicId: string;
549
558
  topicTitle: string;
550
559
  userId: string;
@@ -556,9 +565,12 @@ function resolveSessionRetryId(opts: {
556
565
  effort?: EffortLevel;
557
566
  externalSessionOwner?: boolean;
558
567
  onSessionReset?: () => void;
559
- }): string | null {
568
+ }): SessionRetryResolution {
560
569
  const reconstructed = tryReconstructTopicRollout(opts);
561
- if (reconstructed) return reconstructed;
570
+ if (reconstructed.kind === "rebuilt") {
571
+ return { kind: "retry", sessionId: reconstructed.sessionId };
572
+ }
573
+ if (reconstructed.kind === "failed") return reconstructed;
562
574
  if (opts.onSessionReset) opts.onSessionReset();
563
575
  else if (!opts.silent && !opts.externalSessionOwner) {
564
576
  clearTopicSessionId(opts.topicId, "session-expired");
@@ -567,7 +579,7 @@ function resolveSessionRetryId(opts: {
567
579
  { topicId: opts.topicId, agent: opts.agent, hadSessionId: Boolean(opts.sessionId) },
568
580
  "ai: session expired — retrying with fresh session",
569
581
  );
570
- return null;
582
+ return { kind: "retry", sessionId: null };
571
583
  }
572
584
 
573
585
  export interface AiTurnSettlement {
@@ -630,6 +642,8 @@ export interface AiTurnExecutionOptions {
630
642
  export interface StartAiTurnParams extends AiTurnExecutionOptions {
631
643
  topic: AiTurnTopic;
632
644
  userId: string;
645
+ /** Credential namespace. Defaults to userId for backwards compatibility. */
646
+ vaultUserId?: string;
633
647
  prompt: string;
634
648
  allowAutoContinue: boolean;
635
649
  agentOverride?: AgentKind;
@@ -675,7 +689,7 @@ export function mergeSupersedingUserTurn(
675
689
  ...(incoming.userMessages ?? [legacyUserTurnEnvelope(incoming.prompt, incoming.attachments)]),
676
690
  ];
677
691
  return {
678
- prompt: renderUserPromptBatch(userMessages.map((message) => message.prompt)),
692
+ prompt: renderUserTurnBatch(userMessages),
679
693
  userMessages,
680
694
  attachments: flattenUserTurnAttachments(userMessages),
681
695
  sessionId: running.sessionId,
@@ -710,13 +724,18 @@ function serializableUserTurnExecution(params: StartAiTurnParams): RuntimeUserTu
710
724
  bridgeSessionFromHistory: params.bridgeSessionFromHistory,
711
725
  peerBridge: params.peerBridge,
712
726
  from: params.from,
727
+ vaultUserId: params.vaultUserId,
713
728
  conversationPrompts: params._conversationPrompts ??
714
729
  params._userMessages?.map((message) => message.prompt) ?? [params.prompt],
715
730
  loggedUserMessageCount: params._loggedUserMessageCount,
716
731
  };
717
732
  }
718
733
 
719
- function waitToStartRemoteUserTurn(params: StartAiTurnParams, queryId: string): string {
734
+ function waitToStartRemoteUserTurn(
735
+ params: StartAiTurnParams,
736
+ queryId: string,
737
+ options?: { omitRequestIds?: string[] },
738
+ ): string {
720
739
  const execution = serializableUserTurnExecution(params);
721
740
  const incomingUserMessages = params._userMessages ?? [
722
741
  legacyUserTurnEnvelope(params.prompt, params.attachments),
@@ -730,6 +749,7 @@ function waitToStartRemoteUserTurn(params: StartAiTurnParams, queryId: string):
730
749
  execution,
731
750
  topicEpoch: execution.runtimeEpoch ?? getRuntimeTopicEpoch(params.topic.id),
732
751
  alreadyIncludedRequestIds: params._durableRequestIds,
752
+ omitRequestIds: options?.omitRequestIds,
733
753
  });
734
754
  for (const supersededRequestId of merged.supersededRequestIds) {
735
755
  if (supersededRequestId !== merged.requestId) {
@@ -785,6 +805,7 @@ async function drainOneDurableUserTurn(): Promise<void> {
785
805
  const queryId = startAiTurn({
786
806
  topic,
787
807
  userId: request.userId,
808
+ vaultUserId: execution?.vaultUserId,
788
809
  prompt: request.prompt,
789
810
  _userMessages: request.userMessages,
790
811
  _conversationPrompts: execution?.conversationPrompts ?? [request.prompt],
@@ -873,6 +894,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
873
894
  }
874
895
  const topic: TopicDto = storedTopic;
875
896
  const { userId, allowAutoContinue, onDispatched } = params;
897
+ const vaultUserId = params.vaultUserId ?? userId;
876
898
  const origin = params.origin ?? "user";
877
899
  const conversationPrompts = params._conversationPrompts ?? [params.prompt];
878
900
  let loggedUserMessageCount = params._loggedUserMessageCount;
@@ -880,9 +902,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
880
902
  let userMessages = isUserOrigin(origin)
881
903
  ? (params._userMessages ?? [legacyUserTurnEnvelope(params.prompt, params.attachments)])
882
904
  : undefined;
883
- let prompt = userMessages
884
- ? renderUserPromptBatch(userMessages.map((message) => message.prompt))
885
- : params.prompt;
905
+ let prompt = userMessages ? renderUserTurnBatch(userMessages) : params.prompt;
886
906
  let attachments = userMessages ? flattenUserTurnAttachments(userMessages) : params.attachments;
887
907
  const execution = resolveTopicTurnExecution(topic, params);
888
908
  const sessionResolution = resolveTopicTurnSession(topic, params.sessionId, {
@@ -953,6 +973,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
953
973
  topicId,
954
974
  runtimeEpoch,
955
975
  userId,
976
+ vaultUserId,
956
977
  prompt,
957
978
  origin,
958
979
  sourceNode,
@@ -1051,18 +1072,27 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1051
1072
  }
1052
1073
  if (decision.action === "abort-replace") {
1053
1074
  const running = decision.running;
1075
+ let omitRequestIds: string[] | undefined;
1054
1076
  if (isUserOrigin(running.origin)) {
1055
- const merged = mergeSupersedingUserTurn(running, { prompt, userMessages, attachments });
1056
- userMessages = merged.userMessages;
1057
- prompt = merged.prompt;
1058
- attachments = merged.attachments;
1059
- // Resume from the session that existed before the interrupted batch.
1060
- // The aborted provider may or may not have persisted its partial turn.
1061
- sessionId = merged.sessionId;
1062
- loggedUserMessageCount = running.userMessages?.length ?? 1;
1063
- durableRequestIds = [
1064
- ...new Set([...(running.durableRequestIds ?? []), ...durableRequestIds]),
1065
- ];
1077
+ if (running.providerTurnContentObserved && running.sessionId) {
1078
+ // The native rollout already contains the interrupted user turn and
1079
+ // any partial assistant/tool history. Send only the new envelope after
1080
+ // the old provider process has exited; replaying the prior prompt makes
1081
+ // the model restart the work and can race two writers on one rollout.
1082
+ sessionId = running.sessionId;
1083
+ omitRequestIds = running.durableRequestIds;
1084
+ } else {
1085
+ const merged = mergeSupersedingUserTurn(running, { prompt, userMessages, attachments });
1086
+ userMessages = merged.userMessages;
1087
+ prompt = merged.prompt;
1088
+ attachments = merged.attachments;
1089
+ // No provider session was observed, so replay from the pre-turn base.
1090
+ sessionId = merged.sessionId;
1091
+ loggedUserMessageCount = running.userMessages?.length ?? 1;
1092
+ durableRequestIds = [
1093
+ ...new Set([...(running.durableRequestIds ?? []), ...durableRequestIds]),
1094
+ ];
1095
+ }
1066
1096
  }
1067
1097
  // The preempted turn was itself a session-inject → re-queue it so the
1068
1098
  // inter-session work isn't lost; it resumes after the user's turn.
@@ -1095,6 +1125,28 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1095
1125
  { topicId, supersededQueryId: running.queryId, supersededOrigin: running.origin },
1096
1126
  "ai: new user message superseded running turn",
1097
1127
  );
1128
+ // Do not synchronously start another provider against the same native
1129
+ // session while the aborted process is still flushing its rollout. The
1130
+ // durable worker can claim this replacement only after the old room lease
1131
+ // is released in streamAgentEvents' finally block.
1132
+ const queuedQueryId = waitToStartRemoteUserTurn(
1133
+ {
1134
+ ...params,
1135
+ topic,
1136
+ prompt,
1137
+ attachments,
1138
+ sessionId,
1139
+ _userMessages: userMessages,
1140
+ _conversationPrompts: conversationPrompts,
1141
+ _loggedUserMessageCount: loggedUserMessageCount,
1142
+ _durableRequestIds: durableRequestIds,
1143
+ _runtimeEpoch: runtimeEpoch,
1144
+ },
1145
+ queryId,
1146
+ { omitRequestIds },
1147
+ );
1148
+ announceQueuedUserTurn({ ...params, topic }, queuedQueryId);
1149
+ return queuedQueryId;
1098
1150
  }
1099
1151
 
1100
1152
  const abortController = new AbortController();
@@ -1117,6 +1169,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1117
1169
  topicId,
1118
1170
  runtimeEpoch,
1119
1171
  userId,
1172
+ vaultUserId,
1120
1173
  prompt,
1121
1174
  origin,
1122
1175
  requestId,
@@ -1203,8 +1256,9 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1203
1256
 
1204
1257
  const workspaceCwd = cwd ?? workspaceCwdFor(topicId);
1205
1258
  mkdirSync(workspaceCwd, { recursive: true });
1259
+ let historyBridgeError: string | undefined;
1206
1260
  if (bridgeSessionFromHistory && !sessionId) {
1207
- const bridgedSessionId = tryReconstructTopicRollout({
1261
+ const bridge = tryReconstructTopicRollout({
1208
1262
  topicId,
1209
1263
  topicTitle: sessionName,
1210
1264
  userId,
@@ -1213,20 +1267,22 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1213
1267
  cwd: workspaceCwd,
1214
1268
  model: resolvedModel,
1215
1269
  ...(resolvedEffort ? { effort: resolvedEffort } : {}),
1216
- allowFreshSession: true,
1217
1270
  });
1218
- if (bridgedSessionId) {
1219
- sessionId = bridgedSessionId;
1220
- control.sessionId = bridgedSessionId;
1221
- if (control.injectParams) control.injectParams.sessionId = bridgedSessionId;
1271
+ if (bridge.kind === "rebuilt") {
1272
+ sessionId = bridge.sessionId;
1273
+ control.sessionId = bridge.sessionId;
1274
+ if (control.injectParams) control.injectParams.sessionId = bridge.sessionId;
1222
1275
  try {
1223
- onSessionId?.(bridgedSessionId);
1276
+ onSessionId?.(bridge.sessionId);
1224
1277
  } catch (err) {
1225
1278
  logger.warn(
1226
1279
  { err, topicId, queryId, agent: agentKind },
1227
1280
  "ai: isolated session owner rejected bridged provider session id",
1228
1281
  );
1229
1282
  }
1283
+ } else if (bridge.kind === "failed") {
1284
+ historyBridgeError =
1285
+ "Conversation history could not be encoded into a provider session; no fresh session was started.";
1230
1286
  }
1231
1287
  }
1232
1288
  const messageAttachmentGroups = userMessages?.map((message, index) =>
@@ -1235,15 +1291,19 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1235
1291
  const promptAttachments =
1236
1292
  messageAttachmentGroups?.flat() ?? materializePromptAttachments(topicId, queryId, attachments);
1237
1293
  const promptWithFiles = userMessages
1238
- ? renderUserPromptBatch(
1239
- userMessages.map((message, index) =>
1240
- promptWithAttachments(message.prompt, messageAttachmentGroups?.[index] ?? []),
1241
- ),
1294
+ ? renderUserTurnBatch(
1295
+ userMessages.map((message, index) => ({
1296
+ ...message,
1297
+ prompt: promptWithAttachments(message.prompt, messageAttachmentGroups?.[index] ?? []),
1298
+ })),
1242
1299
  )
1243
1300
  : promptWithAttachments(prompt, promptAttachments);
1244
1301
  const renderedUserPrompts = userMessages
1245
1302
  ? userMessages.map((message, index) =>
1246
- promptWithAttachments(message.prompt, messageAttachmentGroups?.[index] ?? []),
1303
+ renderUserTurnPrompt({
1304
+ ...message,
1305
+ prompt: promptWithAttachments(message.prompt, messageAttachmentGroups?.[index] ?? []),
1306
+ }),
1247
1307
  )
1248
1308
  : undefined;
1249
1309
  const agentPrompt =
@@ -1419,6 +1479,10 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1419
1479
  : enabledMcp.filter((name) => name !== "playwright");
1420
1480
  const wantsBgBash = !isManager && enabledMcp.includes("background-bash");
1421
1481
  async function* runWithPlaywright(): AsyncGenerator<UnifiedEvent> {
1482
+ if (historyBridgeError) {
1483
+ yield { type: "error", content: historyBridgeError };
1484
+ return;
1485
+ }
1422
1486
  if (prepareSession && !sessionId) {
1423
1487
  if (abortController.signal.aborted) return;
1424
1488
  try {
@@ -1495,6 +1559,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1495
1559
  systemPrompt: effectiveSystemPrompt,
1496
1560
  sessionId,
1497
1561
  userId,
1562
+ vaultUserId,
1498
1563
  session: sessionName,
1499
1564
  sessionType: sessionType ?? (isManager ? "manager" : "forum"),
1500
1565
  abortController,
@@ -1557,10 +1622,10 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1557
1622
  onSessionId,
1558
1623
  { silent, peerBridge, sourceNode },
1559
1624
  )
1560
- .then(async (outcome) => {
1625
+ .then(async (streamOutcome) => {
1626
+ let outcome = streamOutcome;
1561
1627
  if (outcome.kind === "session-expired") {
1562
- if (!silent) WsHub.get().broadcastAborted(topicId, queryId, "stopped");
1563
- const retrySessionId = resolveSessionRetryId({
1628
+ const retry = resolveSessionRetry({
1564
1629
  topicId,
1565
1630
  topicTitle: sessionName,
1566
1631
  userId,
@@ -1573,63 +1638,74 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1573
1638
  externalSessionOwner: Boolean(onSessionId),
1574
1639
  onSessionReset,
1575
1640
  });
1576
- logger.info(
1577
- {
1578
- topicId,
1579
- prevQueryId: queryId,
1580
- agent: agentKind,
1581
- retrySessionId: retrySessionId ? retrySessionId.slice(0, 8) : null,
1582
- },
1583
- "ai: retrying query after session expiry",
1584
- );
1585
- if (!retrySessionId && prepareSession && forkHandle) {
1586
- // The retry will invoke the recipe and mint a different rollout.
1587
- // The expired fork is no longer in use after stream cleanup.
1588
- cleanupAgentFork(forkHandle);
1589
- forkHandle = undefined;
1641
+ if (retry.kind === "failed") {
1642
+ outcome = {
1643
+ kind: "provider-error",
1644
+ error:
1645
+ "Provider session expired and conversation history could not be rebuilt; the existing session was preserved for retry.",
1646
+ };
1647
+ } else {
1648
+ if (!silent) WsHub.get().broadcastAborted(topicId, queryId, "stopped");
1649
+ const retrySessionId = retry.sessionId;
1650
+ logger.info(
1651
+ {
1652
+ topicId,
1653
+ prevQueryId: queryId,
1654
+ agent: agentKind,
1655
+ retrySessionId: retrySessionId ? retrySessionId.slice(0, 8) : null,
1656
+ },
1657
+ "ai: retrying query after session expiry",
1658
+ );
1659
+ if (!retrySessionId && prepareSession && forkHandle) {
1660
+ // The retry will invoke the recipe and mint a different rollout.
1661
+ // The expired fork is no longer in use after stream cleanup.
1662
+ cleanupAgentFork(forkHandle);
1663
+ forkHandle = undefined;
1664
+ }
1665
+ startAiTurn({
1666
+ topic,
1667
+ userId,
1668
+ vaultUserId,
1669
+ prompt,
1670
+ _userMessages: userMessages,
1671
+ _conversationPrompts: conversationPrompts,
1672
+ _loggedUserMessageCount: loggedUserMessageCount,
1673
+ _durableRequestIds: durableRequestIds,
1674
+ attachments,
1675
+ allowAutoContinue,
1676
+ origin,
1677
+ onDispatched,
1678
+ requestId,
1679
+ depth,
1680
+ silent,
1681
+ contextId,
1682
+ agentOverride,
1683
+ modelOverride,
1684
+ effortOverride,
1685
+ sessionId: retrySessionId,
1686
+ sessionScope,
1687
+ turnConcurrency,
1688
+ forkHandle,
1689
+ // Keep the recipe so a later user preemption can replay from a
1690
+ // clean snapshot. A non-empty retrySessionId suppresses immediate
1691
+ // preparation in runWithPlaywright.
1692
+ prepareSession,
1693
+ cwd,
1694
+ sessionName,
1695
+ sessionType,
1696
+ visualTools,
1697
+ fileDeliveryTools,
1698
+ onSessionId,
1699
+ onSessionReset,
1700
+ bridgeSessionFromHistory,
1701
+ onSettled,
1702
+ peerBridge,
1703
+ askReplySources,
1704
+ _runtimeEpoch: runtimeEpoch,
1705
+ _sessionRetried: true,
1706
+ });
1707
+ return;
1590
1708
  }
1591
- startAiTurn({
1592
- topic,
1593
- userId,
1594
- prompt,
1595
- _userMessages: userMessages,
1596
- _conversationPrompts: conversationPrompts,
1597
- _loggedUserMessageCount: loggedUserMessageCount,
1598
- _durableRequestIds: durableRequestIds,
1599
- attachments,
1600
- allowAutoContinue,
1601
- origin,
1602
- onDispatched,
1603
- requestId,
1604
- depth,
1605
- silent,
1606
- contextId,
1607
- agentOverride,
1608
- modelOverride,
1609
- effortOverride,
1610
- sessionId: retrySessionId,
1611
- sessionScope,
1612
- turnConcurrency,
1613
- forkHandle,
1614
- // Keep the recipe so a later user preemption can replay from a
1615
- // clean snapshot. A non-empty retrySessionId suppresses immediate
1616
- // preparation in runWithPlaywright.
1617
- prepareSession,
1618
- cwd,
1619
- sessionName,
1620
- sessionType,
1621
- visualTools,
1622
- fileDeliveryTools,
1623
- onSessionId,
1624
- onSessionReset,
1625
- bridgeSessionFromHistory,
1626
- onSettled,
1627
- peerBridge,
1628
- askReplySources,
1629
- _runtimeEpoch: runtimeEpoch,
1630
- _sessionRetried: true,
1631
- });
1632
- return;
1633
1709
  }
1634
1710
 
1635
1711
  if (outcome.kind === "provider-error") {
@@ -2,6 +2,10 @@
2
2
  export interface UserTurnEnvelope {
3
3
  prompt: string;
4
4
  attachments?: string[];
5
+ /** Authenticated human author. Execution may still run as the local principal. */
6
+ actorUserId?: string;
7
+ /** Display-only author label captured by the trusted ingress. */
8
+ actorLabel?: string;
5
9
  }
6
10
 
7
11
  export function legacyUserTurnEnvelope(prompt: string, attachments?: string[]): UserTurnEnvelope {
@@ -23,3 +27,18 @@ export function renderUserPromptBatch(prompts: readonly string[]): string {
23
27
  ...prompts.map((prompt, index) => `${index + 1}. ${prompt}`),
24
28
  ].join("\n");
25
29
  }
30
+
31
+ export function renderUserTurnPrompt(message: UserTurnEnvelope): string {
32
+ const rawLabel = message.actorLabel?.trim() || message.actorUserId?.trim();
33
+ const label = rawLabel
34
+ ?.replace(/[\r\n\t]+/g, " ")
35
+ .replace(/\[/g, "(")
36
+ .replace(/\]/g, ")")
37
+ .replace(/\s{2,}/g, " ");
38
+ return label ? `[@${label}]: ${message.prompt}` : message.prompt;
39
+ }
40
+
41
+ /** Render ordered user submissions while retaining their structured authorship. */
42
+ export function renderUserTurnBatch(messages: readonly UserTurnEnvelope[]): string {
43
+ return renderUserPromptBatch(messages.map(renderUserTurnPrompt));
44
+ }