switchroom 0.18.26 → 0.18.28

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 (35) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/ms-365-write-pretool.mjs +4953 -14
  3. package/dist/cli/switchroom.js +1 -1
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +571 -43
  8. package/telegram-plugin/flushed-turn-supersede.ts +58 -0
  9. package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +358 -53
  11. package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
  12. package/telegram-plugin/gateway/model-command.ts +68 -0
  13. package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
  14. package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
  16. package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
  17. package/telegram-plugin/gateway/worker-pin-reaper.ts +54 -0
  18. package/telegram-plugin/send-gate.test.ts +138 -0
  19. package/telegram-plugin/send-gate.ts +104 -1
  20. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  21. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  22. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  23. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  24. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  25. package/telegram-plugin/tests/model-command.test.ts +112 -0
  26. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  27. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  28. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  29. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  30. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  31. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  32. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  33. package/telegram-plugin/tests/worker-feed-migration-eviction.test.ts +140 -0
  34. package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
  35. package/telegram-plugin/worker-activity-feed.ts +169 -6
@@ -113,6 +113,64 @@ export function decideSupersede(
113
113
  return { supersede: true, deleteMessageIds: [...record.messageIds], reason: 'supersede' }
114
114
  }
115
115
 
116
+ /**
117
+ * How the gateway should CORRECT a flushed message once its canonical `reply`
118
+ * lands. Historically the supersede always did `deleteMessage(A)` + fresh send
119
+ * of the canonical reply B — visible to the user as a delete+replace flicker
120
+ * plus a second device ping. When the reply fits a single message and matches
121
+ * the flushed message's type (both plain text, the normal case), we can instead
122
+ * edit message A in place with B's content: no delete, no flicker, no re-ping.
123
+ *
124
+ * - `edit-in-place` → the gateway feeds `editMessageId` into the existing
125
+ * single-message edit path (the `previewMessageId` edit lane in
126
+ * `sendReplyChunks`), which renders the canonical reply with the SAME rich
127
+ * path a fresh reply uses and, on any edit 400 (message too old / can't be
128
+ * edited / not found), falls back to delete+resend — so correctness never
129
+ * regresses. `deleteMessageIds` is empty (message A becomes message B).
130
+ * - `delete-resend` → the legacy behaviour: delete every flushed message id,
131
+ * then send the canonical reply fresh. Chosen whenever edit-in-place isn't
132
+ * safe (multi-part reply, >1 flushed message, a file/voice-only reply, or a
133
+ * draft-stream preview already owns the single-message edit lane).
134
+ */
135
+ export type SupersedeCorrection =
136
+ | { mode: 'edit-in-place'; editMessageId: number; deleteMessageIds: number[] }
137
+ | { mode: 'delete-resend'; deleteMessageIds: number[] }
138
+
139
+ /**
140
+ * Decide how to correct a superseded flush. Pure so the gateway runs the exact
141
+ * branch the regression tests exercise. Edit-in-place is chosen IFF the flush
142
+ * posted exactly ONE message AND the canonical reply is a single plain-text
143
+ * message with no competing edit target:
144
+ * - `flushMessageIds.length === 1` — a multi-message flush has no single
145
+ * edit target; delete all and resend.
146
+ * - `chunkCount === 1` — a multi-part reply can't collapse into one edit.
147
+ * - `!hasFiles` — a file/album reply is not a plain-text edit.
148
+ * - `!suppressText` — a voice-only reply sends no text body to edit into.
149
+ * - `!hasOpenPreview` — a live draft-stream preview already owns the
150
+ * single-message edit lane; deleting the flush and letting the preview edit
151
+ * keeps exactly one message.
152
+ * `literalText` (`format:'text'`) stays eligible — it is still a text message,
153
+ * and the edit lane renders literal vs rich identically to a fresh send.
154
+ */
155
+ export function decideSupersedeCorrection(input: {
156
+ flushMessageIds: number[]
157
+ chunkCount: number
158
+ hasFiles: boolean
159
+ suppressText: boolean
160
+ hasOpenPreview: boolean
161
+ }): SupersedeCorrection {
162
+ const eligible =
163
+ input.flushMessageIds.length === 1 &&
164
+ input.chunkCount === 1 &&
165
+ !input.hasFiles &&
166
+ !input.suppressText &&
167
+ !input.hasOpenPreview
168
+ if (eligible) {
169
+ return { mode: 'edit-in-place', editMessageId: input.flushMessageIds[0]!, deleteMessageIds: [] }
170
+ }
171
+ return { mode: 'delete-resend', deleteMessageIds: [...input.flushMessageIds] }
172
+ }
173
+
116
174
  /** Sentinel key for records whose flush carried no turnId nonce. */
117
175
  const NULL_TURN_KEY = '<<null-turn>>'
118
176
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * derive-turn-id.ts — the stable per-turn identity, extracted from the gateway
3
+ * monolith so BOTH the enqueue seam and out-of-gateway tests use the SAME
4
+ * function (no mirror-drift between production and the round-trip test).
5
+ *
6
+ * Component 3 — derive the stable per-turn identity from the chat, thread, and
7
+ * originating message id. Stamped into the inbound meta at build time
8
+ * (`origin_turn_id`) AND reconstructed at enqueue time from the same three
9
+ * values, so the id stamped on the message the model reads matches the id on
10
+ * the turn the gateway started for it. Using the message id (not the
11
+ * not-yet-known startedAt) is what lets the two sites agree. Returns null when
12
+ * there is no message id (synthetic / cron turns with no originating inbound —
13
+ * they never need origin routing, the live turn IS the origin).
14
+ *
15
+ * NOTE (#3268): a subagent-handback IS a synthetic inbound, but it MUST still
16
+ * derive a stable non-null id so the dead-air pre-turn card can be adopted by
17
+ * identity at enqueue. The handback inbound builder therefore rounds-trips its
18
+ * fabricated `ts` through `meta.message_id` (mirroring resume-inbound-builder),
19
+ * so `ev.messageId` is populated at enqueue and this function yields the SAME
20
+ * `chatKey#ts` the pre-turn seam recorded at release.
21
+ */
22
+
23
+ import { chatKey } from './chat-key.js'
24
+
25
+ export function deriveTurnId(
26
+ chatId: string,
27
+ threadId: number | null | undefined,
28
+ messageId: string | number | null | undefined,
29
+ ): string | null {
30
+ if (messageId == null || messageId === '' || String(messageId) === '0') return null
31
+ return `${chatKey(chatId, threadId ?? null)}#${messageId}`
32
+ }