@ouro.bot/cli 0.1.0-alpha.933 → 0.1.0-alpha.934

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.
package/changelog.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.934",
6
+ "changes": [
7
+ "Hold duplicate Teams activities durably before model execution, steering or quoted queuing, preserving receipts across restart and session reset.",
8
+ "Do not replay uncertain Teams sends after a lost acknowledgement; split-message recovery requires an explicit HTTP 413 and smaller payloads.",
9
+ "Preserve unconsumed Teams steering in its original request context, reject incomplete production identities, and bypass the SDK stream hidden retry loop with acknowledged single-attempt messages.",
10
+ "Keep distinct Teams fallback actors separate when optional AAD metadata is empty.",
11
+ "Include bot and request correlation context on Teams handler and SDK error telemetry."
12
+ ]
13
+ },
4
14
  {
5
15
  "version": "0.1.0-alpha.933",
6
16
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.933",
2
+ "runtimeVersion": "0.1.0-alpha.934",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.933</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.934</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.SessionTransactionError = exports.SessionTurnBusyError = void 0;
40
+ exports.admitSessionActivity = admitSessionActivity;
40
41
  exports.acquireSessionTurnLease = acquireSessionTurnLease;
41
42
  exports.withSessionTurnLease = withSessionTurnLease;
42
43
  exports.withImmediateSessionTurnLease = withImmediateSessionTurnLease;
@@ -176,21 +177,78 @@ function checkTemporary(pin, filePath, identity) {
176
177
  function openLeaseDatabase(lockPath, busyTimeoutMs = 0, confinement = null) {
177
178
  checkConfinement(confinement);
178
179
  const database = new better_sqlite3_1.default(lockPath);
179
- database.pragma(`busy_timeout = ${busyTimeoutMs}`);
180
- database.exec(`
181
- CREATE TABLE IF NOT EXISTS session_turn_lease (
182
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
183
- pid INTEGER NOT NULL,
184
- owner_id TEXT NOT NULL,
185
- owner_token TEXT NOT NULL
186
- )
187
- `);
188
- const columns = new Set(database.prepare(`PRAGMA table_info(session_turn_lease)`).all().map((column) => column.name));
189
- if (!columns.has("boot_identity"))
190
- database.exec(`ALTER TABLE session_turn_lease ADD COLUMN boot_identity TEXT`);
191
- if (!columns.has("process_started_at"))
192
- database.exec(`ALTER TABLE session_turn_lease ADD COLUMN process_started_at TEXT`);
193
- return database;
180
+ try {
181
+ database.pragma(`busy_timeout = ${busyTimeoutMs}`);
182
+ database.transaction(() => {
183
+ database.exec(`
184
+ CREATE TABLE IF NOT EXISTS session_turn_lease (
185
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
186
+ pid INTEGER NOT NULL,
187
+ owner_id TEXT NOT NULL,
188
+ owner_token TEXT NOT NULL
189
+ )
190
+ `);
191
+ const columns = new Set(database.prepare(`PRAGMA table_info(session_turn_lease)`).all().map((column) => column.name));
192
+ if (!columns.has("boot_identity"))
193
+ database.exec(`ALTER TABLE session_turn_lease ADD COLUMN boot_identity TEXT`);
194
+ if (!columns.has("process_started_at"))
195
+ database.exec(`ALTER TABLE session_turn_lease ADD COLUMN process_started_at TEXT`);
196
+ }).immediate();
197
+ return database;
198
+ }
199
+ catch (error) {
200
+ database.close();
201
+ throw error;
202
+ }
203
+ }
204
+ /** Immutable admission, not proof of execution or delivery. Unknown effects must never be replayed. */
205
+ function admitSessionActivity(sessionPath, input) {
206
+ if (typeof input.namespace !== "string" || !input.namespace.trim() || Buffer.byteLength(input.namespace) > 4096
207
+ || typeof input.activityId !== "string" || !input.activityId.trim() || Buffer.byteLength(input.activityId) > 1024
208
+ || typeof input.payloadDigest !== "string" || !/^[a-f0-9]{64}$/.test(input.payloadDigest)) {
209
+ throw new SessionTransactionError("invalid session activity admission");
210
+ }
211
+ const canonical = canonicalSessionPath(sessionPath);
212
+ const lockPath = `${canonical}.turn.lock`;
213
+ fs.mkdirSync(path.dirname(canonical), { recursive: true, mode: 0o700 });
214
+ const receiptId = revision(JSON.stringify([input.namespace, input.activityId]));
215
+ const database = openLeaseDatabase(lockPath, 1000);
216
+ try {
217
+ database.pragma("synchronous = FULL");
218
+ const state = database.transaction(() => {
219
+ database.exec(`
220
+ CREATE TABLE IF NOT EXISTS session_activity_receipts (
221
+ receipt_id TEXT PRIMARY KEY,
222
+ payload_digest TEXT NOT NULL,
223
+ admitted_at TEXT NOT NULL
224
+ )
225
+ `);
226
+ const existing = database.prepare("SELECT payload_digest FROM session_activity_receipts WHERE receipt_id = ?")
227
+ .get(receiptId);
228
+ if (existing) {
229
+ if (typeof existing.payload_digest !== "string" || !/^[a-f0-9]{64}$/.test(existing.payload_digest)) {
230
+ throw new SessionTransactionError("invalid session activity receipt");
231
+ }
232
+ if (existing.payload_digest !== input.payloadDigest)
233
+ throw new SessionTransactionError("conflicting session activity payload");
234
+ return "duplicate";
235
+ }
236
+ // Never evict an admission: losing its identity would authorize uncertain work again.
237
+ if (fs.statSync(lockPath).size >= 64 * 1024 * 1024)
238
+ throw new SessionTransactionError("session activity receipt capacity exceeded");
239
+ database.prepare("INSERT INTO session_activity_receipts (receipt_id, payload_digest, admitted_at) VALUES (?, ?, ?)")
240
+ .run(receiptId, input.payloadDigest, new Date().toISOString());
241
+ return "admitted";
242
+ }).immediate();
243
+ (0, runtime_1.emitNervesEvent)({
244
+ component: "mind", event: "mind.session_activity_admission", message: "recorded session activity admission decision",
245
+ meta: { receiptId, state },
246
+ });
247
+ return { state, receiptId };
248
+ }
249
+ finally {
250
+ database.close();
251
+ }
194
252
  }
195
253
  function acquisitionRecord(options) {
196
254
  const pid = options.pid ?? process.pid;
@@ -72,6 +72,7 @@ const tool_activity_callbacks_1 = require("../heart/tool-activity-callbacks");
72
72
  const commands_2 = require("./commands");
73
73
  const http = __importStar(require("http"));
74
74
  const path = __importStar(require("path"));
75
+ const node_crypto_1 = require("node:crypto");
75
76
  const trust_gate_1 = require("./trust-gate");
76
77
  const pipeline_1 = require("./pipeline");
77
78
  const session_transaction_1 = require("../mind/session-transaction");
@@ -195,6 +196,11 @@ function buildWelcomeCard() {
195
196
  // safeSend (ctx.send). Tool results, kicks, and errors use safeUpdate
196
197
  // (transient status) or safeSend (terminal errors). Reasoning is accumulated
197
198
  // and periodically pushed via safeUpdate on the same flush timer tick.
199
+ function isTeamsSizeRejection(error) {
200
+ return typeof error === "object" && error !== null && "response" in error
201
+ && typeof error.response === "object" && error.response !== null && "status" in error.response
202
+ && error.response.status === 413;
203
+ }
198
204
  function createTeamsCallbacks(stream, controller, sendMessage, options) {
199
205
  const MIN_INITIAL_CHARS = 20;
200
206
  let stopped = false; // set when stream signals cancellation (403)
@@ -298,18 +304,22 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
298
304
  /* v8 ignore next -- defensive guard: stopped set by prior error; tested via flush abort path @preserve */
299
305
  if (stopped)
300
306
  return false;
307
+ let awaitingTransport = false;
301
308
  try {
302
309
  // stream.emit() is typed as void but the Teams SDK returns a Promise
303
310
  // internally (async HTTP). Cast to capture the result for awaiting.
304
311
  const result = stream.emit(streamChunk(text));
305
312
  streamHasContent = true;
306
313
  if (result && typeof result.then === "function") {
314
+ awaitingTransport = true;
307
315
  await result;
308
316
  }
309
317
  return true;
310
318
  }
311
319
  catch (err) {
312
320
  markStopped("tryEmit", err);
321
+ if (awaitingTransport && !isTeamsSizeRejection(err))
322
+ throw err;
313
323
  return false;
314
324
  }
315
325
  }
@@ -321,15 +331,21 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
321
331
  // which has a sendMessage fallback that may still succeed. Caller (flushNow) is
322
332
  // responsible for the `!stopped` precondition; no defensive guard here.
323
333
  async function tryEmitNoAbort(text) {
334
+ let awaitingTransport = false;
324
335
  try {
325
336
  const result = stream.emit(streamChunk(text));
326
337
  streamHasContent = true;
327
338
  if (result && typeof result.then === "function") {
339
+ awaitingTransport = true;
328
340
  await result;
329
341
  }
330
342
  return true;
331
343
  }
332
- catch {
344
+ catch (error) {
345
+ if (awaitingTransport && !isTeamsSizeRejection(error)) {
346
+ markStopped("speak-unknown", error);
347
+ throw error;
348
+ }
333
349
  return false;
334
350
  }
335
351
  }
@@ -606,7 +622,10 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
606
622
  try {
607
623
  await sendMessage(text);
608
624
  }
609
- catch {
625
+ catch (error) {
626
+ // A lost acknowledgement is not a rejection. Only a definite size rejection permits a smaller retry.
627
+ if (!isTeamsSizeRejection(error) || text.length <= RECOVERY_CHUNK_SIZE)
628
+ throw error;
610
629
  const chunks = splitMessage(text, RECOVERY_CHUNK_SIZE);
611
630
  for (const chunk of chunks)
612
631
  await sendMessage(chunk);
@@ -636,19 +655,34 @@ const _turnCoordinator = (0, turn_coordinator_1.createTurnCoordinator)();
636
655
  function teamsTurnKey(conversationId) {
637
656
  return `teams:${conversationId}`;
638
657
  }
658
+ function teamsAdmissionKey(conversationId) {
659
+ return `teams-admission:${conversationId}`;
660
+ }
639
661
  const queuedTeamsTurns = new Map();
640
662
  const activeTeamsActors = new Map();
663
+ const consumingTeamsSteering = new Set();
664
+ function drainTeamsSteering(key) {
665
+ const followUps = _turnCoordinator.drainFollowUps(key);
666
+ const consumed = new Set(followUps);
667
+ const queue = queuedTeamsTurns.get(key);
668
+ if (queue)
669
+ queuedTeamsTurns.set(key, queue.filter(turn => !turn.steering || !consumed.has(turn.steering)));
670
+ return followUps;
671
+ }
641
672
  /** Context-bearing replies cannot steer an already-running tool loop. Keep the
642
673
  * original SDK context/transport alive until a fresh handleTeamsMessage lease.
643
674
  * Ordinary same-actor steering still uses the existing coordinator buffer. */
644
675
  async function coordinateTeamsTurn(conversationId, text, identity, run) {
645
676
  const key = teamsTurnKey(conversationId);
646
- const actorKey = JSON.stringify([identity.aadObjectId ?? identity.userId ?? "", identity.tenantId ?? ""]);
677
+ const actorKey = JSON.stringify([identity.aadObjectId || identity.userId || "", identity.tenantId ?? ""]);
647
678
  if (!_turnCoordinator.tryBeginTurn(key)) {
648
- if (!identity.replyToId && !/\bfeedback:/i.test(text) &&
649
- activeTeamsActors.get(key) === actorKey && !queuedTeamsTurns.get(key)?.length) {
650
- _turnCoordinator.enqueueFollowUp(key, { conversationId, text, receivedAt: Date.now(),
651
- effect: (0, continuity_1.classifySteeringFollowUpEffect)(text) });
679
+ if (!identity.replyToId && !/\bfeedback:/i.test(text) && consumingTeamsSteering.has(key) &&
680
+ activeTeamsActors.get(key) === actorKey && !queuedTeamsTurns.get(key)?.some(turn => !turn.steering)) {
681
+ const steering = { conversationId, text, receivedAt: Date.now(), effect: (0, continuity_1.classifySteeringFollowUpEffect)(text) };
682
+ _turnCoordinator.enqueueFollowUp(key, steering);
683
+ const queue = queuedTeamsTurns.get(key) ?? [];
684
+ queue.push({ actorKey, run, steering, resolve: () => { }, reject: () => { } });
685
+ queuedTeamsTurns.set(key, queue);
652
686
  return;
653
687
  }
654
688
  await new Promise((resolve, reject) => {
@@ -664,6 +698,12 @@ async function coordinateTeamsTurn(conversationId, text, identity, run) {
664
698
  try {
665
699
  while (next) {
666
700
  activeTeamsActors.set(key, next.actorKey);
701
+ if (next.steering) {
702
+ for (const followUp of _turnCoordinator.drainFollowUps(key)) {
703
+ if (followUp !== next.steering)
704
+ _turnCoordinator.enqueueFollowUp(key, followUp);
705
+ }
706
+ }
667
707
  try {
668
708
  await next.run();
669
709
  next.resolve();
@@ -674,6 +714,8 @@ async function coordinateTeamsTurn(conversationId, text, identity, run) {
674
714
  next.reject(error);
675
715
  }
676
716
  first = false;
717
+ // Preserve received order while identity/admission I/O finishes, without holding admission through a model turn.
718
+ await _turnCoordinator.withTurnLock(teamsAdmissionKey(conversationId), async () => { });
677
719
  next = queuedTeamsTurns.get(key)?.shift();
678
720
  }
679
721
  }
@@ -786,7 +828,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
786
828
  catch (error) {
787
829
  if (!(error instanceof feedback_reply_1.FeedbackReplyRefusal))
788
830
  throw error;
789
- stream.emit(error.message);
831
+ await stream.emit(error.message);
790
832
  return;
791
833
  }
792
834
  // Do not emit any activity until this turn owns the durable session.
@@ -818,7 +860,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
818
860
  },
819
861
  mcpManager,
820
862
  drainSteeringFollowUps: () => {
821
- drainedSteeringFollowUps = _turnCoordinator.drainFollowUps(turnKey)
863
+ drainedSteeringFollowUps = drainTeamsSteering(turnKey)
822
864
  .map(({ text: followUpText, effect }) => ({ text: followUpText, effect }));
823
865
  return drainedSteeringFollowUps;
824
866
  },
@@ -881,16 +923,24 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
881
923
  enforceTrustGate: trust_gate_1.enforceTrustGate,
882
924
  drainPending: pending_1.drainPending,
883
925
  drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(agentName, deferredFriendId),
884
- runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
885
- ...opts,
886
- toolContext: {
887
- /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
888
- signin: async () => undefined,
889
- ...opts?.toolContext,
890
- ...owner,
891
- summarize: teamsToolContext.summarize,
892
- },
893
- }),
926
+ runAgent: async (msgs, cb, channel, sig, opts) => {
927
+ consumingTeamsSteering.add(turnKey);
928
+ try {
929
+ return await (0, core_1.runAgent)(msgs, cb, channel, sig, {
930
+ ...opts,
931
+ toolContext: {
932
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
933
+ signin: async () => undefined,
934
+ ...opts?.toolContext,
935
+ ...owner,
936
+ summarize: teamsToolContext.summarize,
937
+ },
938
+ });
939
+ }
940
+ finally {
941
+ consumingTeamsSteering.delete(turnKey);
942
+ }
943
+ },
894
944
  postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
895
945
  const prepared = (0, context_1.postTurnTrim)(turnMessages, usage, hooks);
896
946
  return (0, context_1.deferPostTurnPersist)(sessionPathArg, prepared, usage, state);
@@ -916,7 +966,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
916
966
  if (result.turnOutcome === "command") {
917
967
  if (result.commandAction === "new") {
918
968
  (0, context_1.deleteSession)(sessPath);
919
- stream.emit("session cleared");
969
+ await stream.emit("session cleared");
920
970
  }
921
971
  // For "response" commands: pipeline already emitted the response via onTextChunk
922
972
  await callbacks.flush();
@@ -924,7 +974,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
924
974
  }
925
975
  /* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
926
976
  if (result.failoverMessage) {
927
- stream.emit(result.failoverMessage);
977
+ await stream.emit(result.failoverMessage);
928
978
  }
929
979
  else if (capturedTerminalError) {
930
980
  callbacks.onError(capturedTerminalError, "terminal");
@@ -933,7 +983,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
933
983
  // ── Handle gate result ────────────────────────────────────────
934
984
  if (!result.gateResult.allowed) {
935
985
  if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
936
- stream.emit(result.gateResult.autoReply);
986
+ await stream.emit(result.gateResult.autoReply);
937
987
  }
938
988
  return;
939
989
  }
@@ -981,7 +1031,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
981
1031
  catch (error) {
982
1032
  if (error instanceof session_transaction_1.SessionTurnBusyError || (error instanceof Error && error.name === "SessionTurnBusyError")) {
983
1033
  reportTeamsRequest("failed", conversationId, teamsContext, { traceId, reason: "lease_busy" });
984
- stream.emit("this conversation is busy finishing another turn; please try again");
1034
+ await stream.emit("this conversation is busy finishing another turn; please try again");
985
1035
  return;
986
1036
  }
987
1037
  reportTeamsRequest("failed", conversationId, teamsContext, { traceId, reason: "turn_failed" });
@@ -1173,7 +1223,7 @@ function registerBotHandlers(app, label) {
1173
1223
  });
1174
1224
  /* v8 ignore stop */
1175
1225
  app.on("message", async (ctx) => {
1176
- const { stream, activity, api, signin } = ctx;
1226
+ const { activity, api, signin } = ctx;
1177
1227
  const text = activity.text || "";
1178
1228
  const convId = activity.conversation?.id || "unknown";
1179
1229
  const turnKey = teamsTurnKey(convId);
@@ -1183,126 +1233,172 @@ function registerBotHandlers(app, label) {
1183
1233
  inboundActivityId: activity.id, tenantId: activity.conversation?.tenantId,
1184
1234
  botApi: app.id && api ? { id: app.id, conversations: api.conversations } : undefined,
1185
1235
  };
1236
+ const sendText = async (text) => {
1237
+ try {
1238
+ const receipt = await ctx.send({ type: "message", text, replyToId: activity.id, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
1239
+ const messageId = receipt && typeof receipt === "object" && "id" in receipt && typeof receipt.id === "string" ? receipt.id : undefined;
1240
+ reportTeamsRequest(messageId ? "replied" : "reply_emitted", convId, requestContext, { delivery: messageId ? "accepted" : "unconfirmed", ...(messageId ? { outboundActivityId: messageId } : {}) });
1241
+ }
1242
+ catch (error) {
1243
+ reportTeamsRequest("failed", convId, requestContext, { reason: "send_unknown" });
1244
+ throw error;
1245
+ }
1246
+ };
1247
+ // HttpStream queues void emits and retries unknown HTTP failures internally.
1248
+ // Use the SDK's single-attempt, awaitable send instead of that hidden retry loop.
1249
+ const stream = {
1250
+ emit: value => sendText(typeof value === "string" ? value : value.text),
1251
+ update: () => ctx.send({ type: "typing" }),
1252
+ close: async () => undefined,
1253
+ };
1186
1254
  (0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.message_received", component: "channels", message: `[${label}] incoming teams message`, meta: { stage: "received", inboundActivityId: activity.id, botId: app.id, tenantId: requestContext.tenantId, userId: userId.slice(0, 12), conversationId: convId } });
1187
- const commandRegistry = createTeamsCommandRegistry();
1188
- const parsedSlashCommand = (0, commands_1.parseSlashCommand)(text);
1189
- if (parsedSlashCommand) {
1190
- const dispatchResult = commandRegistry.dispatch(parsedSlashCommand.command, { channel: "teams" });
1191
- if (dispatchResult.handled && dispatchResult.result) {
1192
- if (dispatchResult.result.action === "response") {
1193
- stream.emit(dispatchResult.result.message || "");
1194
- return;
1255
+ let work;
1256
+ await _turnCoordinator.withTurnLock(teamsAdmissionKey(convId), async () => {
1257
+ try {
1258
+ if (process.env.OURO_TEAMS_DEVTOOLS !== "1" && [
1259
+ app.id, activity.id, activity.channelId, activity.conversation?.id,
1260
+ activity.conversation?.tenantId, activity.from?.aadObjectId || activity.from?.id,
1261
+ ].some(value => typeof value !== "string" || !value.trim())) {
1262
+ throw new session_transaction_1.SessionTransactionError("Teams activity identity is incomplete");
1195
1263
  }
1196
- if (dispatchResult.result.action === "new") {
1197
- const commandStore = getFriendStore();
1198
- const commandProvider = activity.from?.aadObjectId ? "aad" : "teams-conversation";
1199
- const commandExternalId = activity.from?.aadObjectId || convId;
1200
- const commandResolver = new friends_1.FriendResolver(commandStore, {
1201
- provider: commandProvider,
1202
- externalId: commandExternalId,
1264
+ if (activity.id) {
1265
+ const store = getFriendStore();
1266
+ const identity = await new friends_1.FriendResolver(store, {
1267
+ provider: activity.from?.aadObjectId ? "aad" : "teams-conversation",
1268
+ externalId: activity.from?.aadObjectId || convId,
1203
1269
  tenantId: activity.conversation?.tenantId,
1204
1270
  displayName: activity.from?.name || "Unknown",
1205
1271
  channel: "teams",
1272
+ }).resolve();
1273
+ const admission = (0, session_transaction_1.admitSessionActivity)((0, config_2.sessionPath)(identity.friend.id, "teams", convId), {
1274
+ namespace: JSON.stringify(["teams.message", channelId, app.id?.toLowerCase() ?? null,
1275
+ activity.conversation?.tenantId?.toLowerCase() ?? null,
1276
+ activity.from?.aadObjectId?.toLowerCase() || userId, convId]),
1277
+ activityId: activity.id,
1278
+ payloadDigest: (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([text, activity.replyToId ?? null])).digest("hex"),
1206
1279
  });
1207
- const commandContext = await commandResolver.resolve();
1208
- (0, context_1.deleteSession)((0, config_2.sessionPath)(commandContext.friend.id, "teams", convId));
1209
- stream.emit("session cleared");
1210
- if (_turnCoordinator.isTurnActive(turnKey)) {
1211
- _turnCoordinator.enqueueFollowUp(turnKey, {
1212
- conversationId: convId,
1213
- text,
1214
- receivedAt: Date.now(),
1215
- effect: "clear_and_supersede",
1216
- });
1280
+ if (admission.state === "duplicate") {
1281
+ reportTeamsRequest("duplicate_held", convId, requestContext, { receiptId: admission.receiptId, outcome: "not_replayed" });
1282
+ return;
1217
1283
  }
1218
- return;
1219
1284
  }
1220
- }
1221
- }
1222
- await coordinateTeamsTurn(convId, text, {
1223
- aadObjectId: activity.from?.aadObjectId, userId, tenantId: activity.conversation?.tenantId,
1224
- replyToId: activity.replyToId,
1225
- }, async () => {
1226
- try {
1227
1285
  reportTeamsRequest("admitted", convId, requestContext);
1228
- // Resolve OAuth connection names for this user's tenant (supports per-tenant overrides).
1229
- const tenantId = activity.conversation?.tenantId;
1230
- const tenantOAuth = (0, config_1.resolveOAuthForTenant)(tenantId);
1231
- // Fetch tokens for both OAuth connections independently.
1232
- // Failures are silently caught -- the tool handler will request signin if needed.
1233
- let graphToken;
1234
- let adoToken;
1235
- let githubToken;
1236
- try {
1237
- const graphRes = await api.users.token.get({ userId, connectionName: tenantOAuth.graphConnectionName, channelId });
1238
- graphToken = graphRes?.token;
1239
- }
1240
- catch { /* no token yet — tool handler will trigger signin */ }
1241
- try {
1242
- const adoRes = await api.users.token.get({ userId, connectionName: tenantOAuth.adoConnectionName, channelId });
1243
- adoToken = adoRes?.token;
1286
+ }
1287
+ catch (error) {
1288
+ reportTeamsRequest("failed", convId, requestContext, { reason: "activity_admission_failed" });
1289
+ (0, runtime_1.emitNervesEvent)({
1290
+ level: "error", component: "channels", event: "channel.activity_admission_error",
1291
+ message: "Teams activity was not safely admitted; no new turn or steering was started",
1292
+ meta: { conversationId: convId, inboundActivityId: activity.id, reason: "activity_admission_failed" },
1293
+ });
1294
+ throw error;
1295
+ }
1296
+ const commandRegistry = createTeamsCommandRegistry();
1297
+ const parsedSlashCommand = (0, commands_1.parseSlashCommand)(text);
1298
+ if (parsedSlashCommand) {
1299
+ const dispatchResult = commandRegistry.dispatch(parsedSlashCommand.command, { channel: "teams" });
1300
+ if (dispatchResult.handled && dispatchResult.result) {
1301
+ if (dispatchResult.result.action === "response") {
1302
+ await stream.emit(dispatchResult.result.message || "");
1303
+ return;
1304
+ }
1305
+ if (dispatchResult.result.action === "new") {
1306
+ const commandStore = getFriendStore();
1307
+ const commandProvider = activity.from?.aadObjectId ? "aad" : "teams-conversation";
1308
+ const commandExternalId = activity.from?.aadObjectId || convId;
1309
+ const commandResolver = new friends_1.FriendResolver(commandStore, {
1310
+ provider: commandProvider,
1311
+ externalId: commandExternalId,
1312
+ tenantId: activity.conversation?.tenantId,
1313
+ displayName: activity.from?.name || "Unknown",
1314
+ channel: "teams",
1315
+ });
1316
+ const commandContext = await commandResolver.resolve();
1317
+ (0, context_1.deleteSession)((0, config_2.sessionPath)(commandContext.friend.id, "teams", convId));
1318
+ await stream.emit("session cleared");
1319
+ if (_turnCoordinator.isTurnActive(turnKey)) {
1320
+ _turnCoordinator.enqueueFollowUp(turnKey, {
1321
+ conversationId: convId,
1322
+ text,
1323
+ receivedAt: Date.now(),
1324
+ effect: "clear_and_supersede",
1325
+ });
1326
+ }
1327
+ return;
1328
+ }
1244
1329
  }
1245
- catch { /* no token yet — tool handler will trigger signin */ }
1330
+ }
1331
+ work = coordinateTeamsTurn(convId, text, {
1332
+ aadObjectId: activity.from?.aadObjectId, userId, tenantId: activity.conversation?.tenantId,
1333
+ replyToId: activity.replyToId,
1334
+ }, async () => {
1246
1335
  try {
1247
- const githubRes = await api.users.token.get({ userId, connectionName: tenantOAuth.githubConnectionName, channelId });
1248
- githubToken = githubRes?.token;
1249
- }
1250
- catch { /* no token yet — tool handler will trigger signin */ }
1251
- (0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.token_status", component: "channels", message: "oauth token availability", meta: { graph: !!graphToken, ado: !!adoToken, github: !!githubToken, tenantId } });
1252
- const teamsContext = {
1253
- inboundActivityId: activity.id,
1254
- replyToId: activity.replyToId,
1255
- graphToken,
1256
- adoToken,
1257
- githubToken,
1258
- signin: async (cn) => {
1259
- try {
1260
- const result = await signin({ connectionName: cn });
1261
- (0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.signin_result", component: "channels", message: `signin(${cn}): ${result ? "token received" : "no token"}`, meta: { connectionName: cn, hasToken: !!result } });
1262
- return result;
1263
- }
1264
- catch (e) {
1265
- const msg = e instanceof Error ? e.message : String(e);
1266
- (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.signin_error", component: "channels", message: `signin(${cn}) failed`, meta: { connectionName: cn, reason: msg.slice(0, 100) } });
1267
- return undefined;
1268
- }
1269
- },
1270
- aadObjectId: activity.from?.aadObjectId,
1271
- tenantId: activity.conversation?.tenantId,
1272
- displayName: activity.from?.name,
1273
- graphConnectionName: tenantOAuth.graphConnectionName,
1274
- adoConnectionName: tenantOAuth.adoConnectionName,
1275
- githubConnectionName: tenantOAuth.githubConnectionName,
1276
- /* v8 ignore next -- bot API availability branch; requires live SDK context @preserve */
1277
- botApi: app.id && api ? { id: app.id, conversations: api.conversations } : undefined,
1278
- teamsSendImage: (0, teams_image_1.createTeamsImageSender)((message) => ctx.send(message), activity.id, aiLabelEntities()),
1279
- };
1280
- /* v8 ignore next 5 -- bot-framework integration callback; tested via handleTeamsMessage sendMessage path @preserve */
1281
- const ctxSend = async (t) => {
1282
- // Use send with replyToId (not reply, which adds a blockquote).
1283
- // replyToId anchors the message after the user's message in Copilot Chat.
1336
+ // Resolve OAuth connection names for this user's tenant (supports per-tenant overrides).
1337
+ const tenantId = activity.conversation?.tenantId;
1338
+ const tenantOAuth = (0, config_1.resolveOAuthForTenant)(tenantId);
1339
+ // Fetch tokens for both OAuth connections independently.
1340
+ // Failures are silently caught -- the tool handler will request signin if needed.
1341
+ let graphToken;
1342
+ let adoToken;
1343
+ let githubToken;
1284
1344
  try {
1285
- const receipt = await ctx.send({ type: "message", text: t, replyToId: activity.id, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
1286
- const messageId = receipt && typeof receipt === "object" && "id" in receipt && typeof receipt.id === "string" ? receipt.id : undefined;
1287
- reportTeamsRequest(messageId ? "replied" : "reply_emitted", convId, teamsContext, { delivery: messageId ? "accepted" : "unconfirmed", ...(messageId ? { outboundActivityId: messageId } : {}) });
1345
+ const graphRes = await api.users.token.get({ userId, connectionName: tenantOAuth.graphConnectionName, channelId });
1346
+ graphToken = graphRes?.token;
1288
1347
  }
1289
- catch (error) {
1290
- reportTeamsRequest("failed", convId, teamsContext, { reason: "send_unknown" });
1291
- throw error;
1348
+ catch { /* no token yet — tool handler will trigger signin */ }
1349
+ try {
1350
+ const adoRes = await api.users.token.get({ userId, connectionName: tenantOAuth.adoConnectionName, channelId });
1351
+ adoToken = adoRes?.token;
1292
1352
  }
1293
- };
1294
- await handleTeamsMessage(text, stream, convId, teamsContext, ctxSend);
1295
- }
1296
- catch (err) {
1297
- reportTeamsRequest("failed", convId, requestContext, { reason: "handler_failed" });
1298
- const msg = err instanceof Error ? err.message : String(err);
1299
- (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.handler_error", component: "channels", message: msg.slice(0, 200), meta: {} });
1300
- }
1353
+ catch { /* no token yet — tool handler will trigger signin */ }
1354
+ try {
1355
+ const githubRes = await api.users.token.get({ userId, connectionName: tenantOAuth.githubConnectionName, channelId });
1356
+ githubToken = githubRes?.token;
1357
+ }
1358
+ catch { /* no token yet — tool handler will trigger signin */ }
1359
+ (0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.token_status", component: "channels", message: "oauth token availability", meta: { graph: !!graphToken, ado: !!adoToken, github: !!githubToken, tenantId } });
1360
+ const teamsContext = {
1361
+ inboundActivityId: activity.id,
1362
+ replyToId: activity.replyToId,
1363
+ graphToken,
1364
+ adoToken,
1365
+ githubToken,
1366
+ signin: async (cn) => {
1367
+ try {
1368
+ const result = await signin({ connectionName: cn });
1369
+ (0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.signin_result", component: "channels", message: `signin(${cn}): ${result ? "token received" : "no token"}`, meta: { connectionName: cn, hasToken: !!result } });
1370
+ return result;
1371
+ }
1372
+ catch (e) {
1373
+ const msg = e instanceof Error ? e.message : String(e);
1374
+ (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.signin_error", component: "channels", message: `signin(${cn}) failed`, meta: { connectionName: cn, reason: msg.slice(0, 100) } });
1375
+ return undefined;
1376
+ }
1377
+ },
1378
+ aadObjectId: activity.from?.aadObjectId,
1379
+ tenantId: activity.conversation?.tenantId,
1380
+ displayName: activity.from?.name,
1381
+ graphConnectionName: tenantOAuth.graphConnectionName,
1382
+ adoConnectionName: tenantOAuth.adoConnectionName,
1383
+ githubConnectionName: tenantOAuth.githubConnectionName,
1384
+ /* v8 ignore next -- bot API availability branch; requires live SDK context @preserve */
1385
+ botApi: app.id && api ? { id: app.id, conversations: api.conversations } : undefined,
1386
+ teamsSendImage: (0, teams_image_1.createTeamsImageSender)((message) => ctx.send(message), activity.id, aiLabelEntities()),
1387
+ };
1388
+ await handleTeamsMessage(text, stream, convId, teamsContext, sendText);
1389
+ }
1390
+ catch (err) {
1391
+ reportTeamsRequest("failed", convId, requestContext, { reason: "handler_failed" });
1392
+ const msg = err instanceof Error ? err.message : String(err);
1393
+ (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.handler_error", component: "channels", message: msg.slice(0, 200), meta: { conversationId: convId, inboundActivityId: activity.id, botId: app.id } });
1394
+ }
1395
+ });
1301
1396
  });
1397
+ await work;
1302
1398
  });
1303
1399
  app.event("error", ({ error }) => {
1304
1400
  const msg = error instanceof Error ? error.message : String(error);
1305
- (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.app_error", component: "channels", message: `[${label}] ${msg}`, meta: {} });
1401
+ (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.app_error", component: "channels", message: `[${label}] ${msg}`, meta: { botId: app.id, label } });
1306
1402
  });
1307
1403
  }
1308
1404
  function findAadObjectId(friend) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.933",
3
+ "version": "0.1.0-alpha.934",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.933",
9
+ "version": "0.1.0-alpha.934",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.933",
3
+ "version": "0.1.0-alpha.934",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },