ompchamber 1.2.0 → 2.0.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 (223) hide show
  1. package/README.md +131 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.74ff36419f.js +2 -0
  14. package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
  15. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  16. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  17. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  18. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  19. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  20. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  21. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  22. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  23. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  24. package/dist/client/static/js/async/q.33d6779f98.js +21 -0
  25. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  26. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  27. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  28. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  29. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  31. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  32. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  33. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  34. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  35. package/dist/client/static/js/index.c05535dbde.js +38 -0
  36. package/package.json +3 -3
  37. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  38. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  39. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  40. package/src/client/components/settings/Modal.tsx +11 -2
  41. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  42. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  43. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  45. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  46. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  47. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  48. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  49. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  50. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  51. package/src/client/components/workspace/btw-panel/index.tsx +129 -45
  52. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  53. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  54. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  59. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  63. package/src/client/components/workspace/editor/index.tsx +11 -2
  64. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  65. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  66. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  67. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  68. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  69. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  70. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  71. package/src/client/data/theme/terminal.ts +50 -52
  72. package/src/client/hooks/chat/btw/index.ts +139 -18
  73. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  74. package/src/client/hooks/chat/btw/mode.ts +111 -16
  75. package/src/client/hooks/chat/composer/index.ts +10 -4
  76. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  77. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  78. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  79. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  80. package/src/client/hooks/chat/timeline/index.ts +8 -20
  81. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  82. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  83. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  84. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  85. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  86. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  87. package/src/client/hooks/settings/providers.ts +34 -3
  88. package/src/client/hooks/ui/open-file.ts +7 -1
  89. package/src/client/hooks/ui/theme.ts +82 -57
  90. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  91. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  92. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  93. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  94. package/src/client/hooks/workspace/search-stream.ts +24 -5
  95. package/src/client/main.tsx +6 -0
  96. package/src/client/tailwind.css +19 -56
  97. package/src/server/lib/btw/child.server.ts +41 -0
  98. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  99. package/src/server/lib/btw/prompt.ts +13 -5
  100. package/src/server/lib/btw/registry.server.ts +13 -4
  101. package/src/server/lib/btw/runtime.server.ts +149 -156
  102. package/src/server/lib/btw/schema.server.ts +27 -0
  103. package/src/server/lib/btw/service.server.ts +106 -5
  104. package/src/server/lib/btw/store.server.ts +57 -6
  105. package/src/server/lib/db/schema.ts +4 -0
  106. package/src/server/lib/db/settings-store.test.ts +65 -0
  107. package/src/server/lib/db/settings-store.ts +25 -0
  108. package/src/server/lib/fs/atomic-write.ts +42 -4
  109. package/src/server/lib/fs/config-lock.ts +74 -0
  110. package/src/server/lib/fs/git-ignore.ts +55 -5
  111. package/src/server/lib/fs/ripgrep.ts +119 -0
  112. package/src/server/lib/lifecycle/identity.ts +10 -64
  113. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  114. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  115. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  116. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  117. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  118. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  119. package/src/server/lib/models/provider-registry.server.ts +2 -2
  120. package/src/server/lib/models/registry.server.ts +1 -1
  121. package/src/server/lib/omp/config/behavior.ts +17 -17
  122. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  123. package/src/server/lib/omp/config/document.ts +128 -0
  124. package/src/server/lib/omp/config/extensions.ts +11 -10
  125. package/src/server/lib/omp/config/mcp.ts +4 -57
  126. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  127. package/src/server/lib/omp/config/models-config.ts +92 -0
  128. package/src/server/lib/omp/config/models-validation.ts +159 -0
  129. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  130. package/src/server/lib/omp/config/providers.ts +241 -211
  131. package/src/server/lib/omp/config/roles.ts +6 -14
  132. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  133. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  134. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  135. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  136. package/src/server/lib/omp/rpc/manager.ts +21 -21
  137. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  138. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  139. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  140. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  141. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  142. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  143. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  144. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  145. package/src/server/lib/queue/delivery-harness.ts +88 -0
  146. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  147. package/src/server/lib/queue/delivery.server.ts +105 -13
  148. package/src/server/lib/queue/store.server.ts +30 -5
  149. package/src/server/lib/terminal/foreground.test.ts +164 -0
  150. package/src/server/lib/terminal/foreground.ts +34 -6
  151. package/src/server/lib/terminal/history.ts +8 -7
  152. package/src/server/plugins/compress.ts +33 -5
  153. package/src/server/plugins/ssr.ts +14 -7
  154. package/src/server/routes/btw/topics.ts +89 -2
  155. package/src/server/routes/fs/git.ts +5 -6
  156. package/src/server/routes/fs/search.ts +17 -26
  157. package/src/server/routes/sessions/queue.ts +17 -4
  158. package/src/server/routes/settings/index.ts +2 -0
  159. package/src/server/routes/settings/model-override.ts +69 -0
  160. package/src/server/routes/settings/provider-models.ts +35 -12
  161. package/src/server/routes/settings/providers.ts +31 -1
  162. package/src/server/routes/settings/root.ts +8 -3
  163. package/src/shared/lib/chat/btw/client.ts +34 -0
  164. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  165. package/src/shared/lib/code/shiki-themes.ts +9 -5
  166. package/src/shared/lib/fs/file-tree.ts +17 -12
  167. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  168. package/src/shared/lib/markdown/autolink.ts +50 -0
  169. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  170. package/src/shared/lib/markdown/document-urls.ts +118 -0
  171. package/src/shared/lib/markdown/marked.ts +27 -15
  172. package/src/shared/lib/markdown/mermaid.ts +2 -2
  173. package/src/shared/lib/models/catalog.ts +1 -1
  174. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  175. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  176. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  177. package/src/shared/lib/settings/client.ts +10 -4
  178. package/src/shared/lib/settings/diff.test.ts +31 -0
  179. package/src/shared/lib/settings/diff.ts +37 -0
  180. package/src/shared/lib/theme/catalog.test.ts +168 -0
  181. package/src/shared/lib/theme/catalog.ts +98 -0
  182. package/src/shared/lib/theme/color.ts +88 -0
  183. package/src/shared/lib/theme/css.ts +70 -0
  184. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  185. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  186. package/src/shared/lib/theme/palettes/light.ts +313 -0
  187. package/src/shared/types/btw.ts +83 -4
  188. package/src/shared/types/index.ts +1 -0
  189. package/src/shared/types/settings/provider.ts +8 -1
  190. package/src/shared/types/settings/state.ts +10 -4
  191. package/src/shared/types/theme.ts +48 -0
  192. package/dist/client/static/css/index.b938f4a693.css +0 -2
  193. package/dist/client/static/js/77.8022a800b5.js +0 -139
  194. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  195. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  196. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  197. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  198. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  199. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  200. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  201. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  202. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  203. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  204. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  205. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  206. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  207. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  208. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  209. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  210. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  211. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  212. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  213. package/dist/client/static/js/index.57b39ffa7f.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
  215. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  216. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  217. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  218. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  219. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  220. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  221. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
  222. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/{index.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -33,6 +33,9 @@ function makeHost(options: {
33
33
  sessionName?: string;
34
34
  sessionId?: string;
35
35
  inFlight?: boolean;
36
+ pending?: boolean;
37
+ requested?: boolean;
38
+ windowUntil?: number;
36
39
  }): { host: AutoTitleHost; proc: FakeProc } {
37
40
  const proc: FakeProc = {
38
41
  commands: [],
@@ -51,7 +54,9 @@ function makeHost(options: {
51
54
  host: {
52
55
  sessionId: 'sess-1',
53
56
  autoTitleInFlight: options.inFlight ?? false,
54
- autoTitleWindowUntil: 0,
57
+ autoTitleWindowUntil: options.windowUntil ?? 0,
58
+ autoTitlePending: options.pending ?? true,
59
+ autoTitleRequested: options.requested ?? false,
55
60
  proc: proc as unknown as AutoTitleHost['proc'],
56
61
  },
57
62
  proc,
@@ -66,7 +71,7 @@ function sentRenames(proc: FakeProc): Array<Record<string, unknown>> {
66
71
  describe('triggerAutoSessionTitle', () => {
67
72
  test('names an unnamed session through omp own /rename', async () => {
68
73
  const { host, proc } = makeHost({});
69
- await triggerAutoSessionTitle(host);
74
+ await triggerAutoSessionTitle(host, 'opening');
70
75
 
71
76
  const renames = sentRenames(proc);
72
77
  expect(renames).toHaveLength(1);
@@ -77,17 +82,21 @@ describe('triggerAutoSessionTitle', () => {
77
82
  });
78
83
 
79
84
  test('never overwrites a name the operator set', async () => {
80
- const { host, proc } = makeHost({ sessionName: 'My Deliberate Name' });
81
- await triggerAutoSessionTitle(host);
82
-
83
- expect(sentRenames(proc)).toHaveLength(0);
84
- // No request was sent, so nothing may claim the output window either.
85
- expect(host.autoTitleWindowUntil).toBe(0);
85
+ // At either stage: the name is re-read from omp, so a rename made in
86
+ // another tab survives both the early attempt and the fallback.
87
+ for (const stage of ['opening', 'settle'] as const) {
88
+ const { host, proc } = makeHost({ sessionName: 'My Deliberate Name' });
89
+ await triggerAutoSessionTitle(host, stage);
90
+
91
+ expect(sentRenames(proc)).toHaveLength(0);
92
+ // No request was sent, so nothing may claim the output window either.
93
+ expect(host.autoTitleWindowUntil).toBe(0);
94
+ }
86
95
  });
87
96
 
88
97
  test('treats a whitespace-only name as unnamed', async () => {
89
98
  const { host, proc } = makeHost({ sessionName: ' ' });
90
- await triggerAutoSessionTitle(host);
99
+ await triggerAutoSessionTitle(host, 'opening');
91
100
 
92
101
  expect(sentRenames(proc)).toHaveLength(1);
93
102
  });
@@ -97,18 +106,107 @@ describe('triggerAutoSessionTitle', () => {
97
106
  // between the trigger and the read) — titling this session would name the
98
107
  // wrong one.
99
108
  const { host, proc } = makeHost({ sessionId: 'someone-else' });
100
- await triggerAutoSessionTitle(host);
109
+ await triggerAutoSessionTitle(host, 'opening');
101
110
 
102
111
  expect(sentRenames(proc)).toHaveLength(0);
103
112
  });
104
113
 
105
114
  test('does not stack a second request while one is in flight', async () => {
106
115
  const { host, proc } = makeHost({ inFlight: true });
107
- await triggerAutoSessionTitle(host);
116
+ await triggerAutoSessionTitle(host, 'opening');
108
117
 
109
118
  expect(sentRenames(proc)).toHaveLength(0);
110
119
  expect(proc.commands).toHaveLength(0);
111
120
  });
121
+
122
+ test('the early attempt does not consume the eligibility latch', async () => {
123
+ // The fallback exists precisely because the early attempt can come back
124
+ // empty. If `opening` consumed the latch, a provider error at the first
125
+ // user message would leave the session unnamed for good.
126
+ const { host } = makeHost({});
127
+ await triggerAutoSessionTitle(host, 'opening');
128
+
129
+ expect(host.autoTitlePending).toBe(true);
130
+ expect(host.autoTitleRequested).toBe(true);
131
+ });
132
+
133
+ test('the fallback retries once the early attempt came back empty', async () => {
134
+ // The real sequence: the early `/rename` is accepted, omp answers
135
+ // `command_output` (consuming the window) with no title, and the run then
136
+ // ends — the fallback is what names the session.
137
+ const { host, proc } = makeHost({});
138
+ await triggerAutoSessionTitle(host, 'opening');
139
+ expect(consumeAutoTitleOutput(host)).toBe(true);
140
+
141
+ await triggerAutoSessionTitle(host, 'settle');
142
+
143
+ expect(sentRenames(proc)).toHaveLength(2);
144
+ expect(host.autoTitlePending).toBe(false);
145
+ });
146
+
147
+ test('the fallback stands down while the early generation is in flight', async () => {
148
+ // A short run can end before the tiny model answers (~4s). Re-asking there
149
+ // would reserve a new title revision and cancel the generation already in
150
+ // flight, for a title derived from the same opening turn.
151
+ const { host, proc } = makeHost({});
152
+ await triggerAutoSessionTitle(host, 'opening');
153
+ expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
154
+
155
+ await triggerAutoSessionTitle(host, 'settle');
156
+
157
+ expect(sentRenames(proc)).toHaveLength(1);
158
+ // The latch is still consumed: the attempt in flight owns this
159
+ // conversation's title from here.
160
+ expect(host.autoTitlePending).toBe(false);
161
+ });
162
+
163
+ test('fires the early attempt once per run', async () => {
164
+ // A queued steer message also settles as a user message inside the same
165
+ // run; re-asking on it would cancel the first generation for no gain.
166
+ const { host, proc } = makeHost({});
167
+ await triggerAutoSessionTitle(host, 'opening');
168
+ await triggerAutoSessionTitle(host, 'opening');
169
+
170
+ expect(sentRenames(proc)).toHaveLength(1);
171
+ });
172
+
173
+ test('titles from the first run only', async () => {
174
+ // The regression this latch exists for: a second turn used to fire its own
175
+ // `/rename`, and omp derives that title from the NEWEST turns — so the
176
+ // session ended up named after message two.
177
+ const { host, proc } = makeHost({});
178
+ await triggerAutoSessionTitle(host, 'opening');
179
+ await triggerAutoSessionTitle(host, 'settle');
180
+ expect(sentRenames(proc)).toHaveLength(1);
181
+
182
+ // A later run reaches neither stage with the latch consumed.
183
+ await triggerAutoSessionTitle(host, 'opening');
184
+ await triggerAutoSessionTitle(host, 'settle');
185
+
186
+ expect(sentRenames(proc)).toHaveLength(1);
187
+ });
188
+
189
+ test('skips a conversation that already has a first turn behind it', async () => {
190
+ // A child resumed for an existing session (`--resume` after the idle
191
+ // reclaim) reports a non-zero message count, so its next turn must not
192
+ // name it — the session is either titled already or deliberately unnamed.
193
+ const { host, proc } = makeHost({ pending: false });
194
+ await triggerAutoSessionTitle(host, 'opening');
195
+ await triggerAutoSessionTitle(host, 'settle');
196
+
197
+ expect(sentRenames(proc)).toHaveLength(0);
198
+ expect(host.autoTitleWindowUntil).toBe(0);
199
+ });
200
+
201
+ test('consumes the latch even when generation is skipped', async () => {
202
+ // A declined generation must not leave the door open for the next turn:
203
+ // otherwise a first turn whose title the provider refused gets re-asked
204
+ // after every later message, which is the same bug one step later.
205
+ const { host } = makeHost({});
206
+ await triggerAutoSessionTitle(host, 'settle');
207
+
208
+ expect(host.autoTitlePending).toBe(false);
209
+ });
112
210
  });
113
211
 
114
212
  describe('auto-title output window', () => {
@@ -15,23 +15,51 @@
15
15
  *
16
16
  * The generate path itself is NOT gated: `/rename` with no args reaches
17
17
  * `session.generateTitle()` through `executeAcpBuiltinSlashCommand`, which
18
- * rpc-mode dispatches for a `prompt` frame before any turn starts. Verified
19
- * against omp 18.2.10 — the ack returns `{agentInvoked:false}` immediately, the
20
- * generation lands ~4s later as `session_info_update` + `command_output`, and
21
- * no entry is written to the transcript.
18
+ * rpc-mode dispatches for a `prompt` frame BEFORE that frame is forwarded as a
19
+ * turn — so the request can be sent at any point in a run and never becomes
20
+ * part of the conversation. Verified against omp 18.3.0 — the ack returns
21
+ * `{agentInvoked:false}` immediately, the generation lands ~3-4s later as
22
+ * `session_info_update` + `command_output`, and no entry is written to the
23
+ * transcript.
22
24
  *
23
25
  * WHY THE NAME MUST BE EMPTY FIRST: `/rename` persists through
24
26
  * `setSessionName(title, "user")`. omp's own guard only refuses an `"auto"`
25
27
  * write over a `"user"` title, so a `/rename` fired against an already-named
26
28
  * session would overwrite the operator's own name. The unnamed check below is
27
- * therefore the safety property, not a nicety — it is re-read from `get_state`
29
+ * therefore a safety property, not a nicety — it is re-read from `get_state`
28
30
  * on every attempt rather than cached, so a rename made in another tab is
29
31
  * respected.
30
32
  *
31
- * Firing after every settled run (not just the first) mirrors omp: a session
32
- * whose opening message was a bare greeting stays unnamed, and the next real
33
- * message titles it. The low-signal filter on omp's side returns no title for
34
- * such input, so the retry costs one tiny-model call and nothing else.
33
+ * ONE ATTEMPT PER CONVERSATION, RETRIED ONCE INSIDE THE FIRST RUN. The title
34
+ * is derived from the FIRST run and never re-asked after it. omp's `/rename`
35
+ * derives its title from the newest turns (and `generateRenameTitle` reserves a
36
+ * fresh title revision, which cancels any generation still in flight), so a
37
+ * second attempt after turn two would name the session after whatever the
38
+ * conversation had become by then — and could even discard a slow first
39
+ * generation.
40
+ *
41
+ * There are exactly two triggers, both inside that first run:
42
+ *
43
+ * 1. the first settled USER message — the earliest point at which the
44
+ * transcript carries the conversation's opening intent. (`agent_start` is
45
+ * too early to be usable: omp pushes it before the turn opens, so the
46
+ * transcript is still empty there and the title context comes back blank.
47
+ * See the `message_end` branch of frame-fold.)
48
+ * 2. the terminal `agent_end`, as a fallback when the early attempt produced
49
+ * no title — a provider error, a timeout, a child that went away, or the
50
+ * tiny model declining.
51
+ *
52
+ * The eligibility latch `autoTitlePending` is consumed by the fallback, so that
53
+ * is the last chance this conversation gets and a later run can never title it.
54
+ * `autoTitleRequested` is what keeps the early trigger from firing again on a
55
+ * queued steer message. A session that a skill invocation opened has no
56
+ * user-role message at all (omp delivers it as a `custom` message), so the
57
+ * early trigger never fires there and the fallback is what names it.
58
+ *
59
+ * A first message that omp's low-signal filter rejects ("hi") therefore leaves
60
+ * the session unnamed for good, which is the honest outcome: the fallback does
61
+ * re-ask, and omp declines a second time (measured on omp 18.3.0 — the
62
+ * rejection is a property of the opening message, not of when it is asked).
35
63
  */
36
64
 
37
65
  import { readSettingsJson } from '@/server/lib/db/settings-store';
@@ -63,15 +91,24 @@ const GENERATE_TITLE_COMMAND = '/rename';
63
91
  */
64
92
  const AUTO_TITLE_OUTPUT_WINDOW_MS = 60_000;
65
93
 
66
- /** Wrapper state this module reads and writes. The in-flight flag and the
67
- * output window are owned by the caller because they are per-session runtime
68
- * state, not policy. */
94
+ /** Wrapper state this module reads and writes. The in-flight flag, the
95
+ * output window and the one-shot latch are owned by the caller because they
96
+ * are per-session runtime state, not policy. */
69
97
  export interface AutoTitleHost {
70
98
  sessionId: string;
71
99
  autoTitleInFlight: boolean;
72
100
  /** Epoch ms until which frames belong to our own background rename; 0 when
73
101
  * no request is outstanding. */
74
102
  autoTitleWindowUntil: number;
103
+ /** True only while this conversation is still eligible for a title at all.
104
+ * Seeded from omp's own message count when the child is spawned, so it
105
+ * survives an idle reclaim (a `--resume` child reports the messages it
106
+ * restored) and is consumed by the first run's terminal `agent_end` — the
107
+ * fallback's last chance. */
108
+ autoTitlePending: boolean;
109
+ /** True once the early attempt (the first settled user message) has been
110
+ * made, so a queued steer message cannot re-ask inside the same run. */
111
+ autoTitleRequested: boolean;
75
112
  proc: {
76
113
  sendCommand<T = unknown>(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<T>;
77
114
  };
@@ -113,14 +150,49 @@ async function isAutoTitleEnabled(): Promise<boolean> {
113
150
  }
114
151
 
115
152
  /**
116
- * Generate and persist a session title when the session has none yet.
153
+ * Which of the two attempts inside the first run is asking.
154
+ *
155
+ * - `opening` — the first settled USER message. Earliest usable point, and it
156
+ * does NOT consume the eligibility latch, so `settle` can still retry.
157
+ * - `settle` — the terminal `agent_end`. The fallback, and the attempt that
158
+ * consumes the latch: after it, this conversation is never titled again.
159
+ */
160
+ export type AutoTitleStage = 'opening' | 'settle';
161
+
162
+ /**
163
+ * Ask omp to name this session from its opening turns.
117
164
  *
118
- * Called once per settled run. Every bail-out is deliberate: a named session
119
- * must never be renamed by this path, and a generation already in flight must
120
- * not be duplicated. Failures are swallowed — an auto-title is a convenience,
121
- * and the next turn retries.
165
+ * Called twice at most, both inside the conversation's first run: once when the
166
+ * first user message settles (`opening`), and once at the terminal `agent_end`
167
+ * as a fallback (`settle`). A second run never gets either — see the module
168
+ * doc for why a title derived from a later turn is the bug this gate prevents.
169
+ *
170
+ * Every bail-out is deliberate: a named session is never renamed by this path
171
+ * (the name is re-read from omp, so a rename made in another tab is respected),
172
+ * a generation already in flight is never duplicated, and failures are
173
+ * swallowed — an auto-title is a convenience, and a session whose opening
174
+ * message was low-signal ("hi") simply keeps its placeholder.
122
175
  */
123
- export async function triggerAutoSessionTitle(host: AutoTitleHost): Promise<void> {
176
+ export async function triggerAutoSessionTitle(host: AutoTitleHost, stage: AutoTitleStage): Promise<void> {
177
+ if (!host.autoTitlePending) return;
178
+ if (stage === 'opening') {
179
+ // One early attempt per run. A queued steer message also settles as a user
180
+ // message inside the same run, and re-asking on it would cancel the first
181
+ // generation for no gain — the context is the same turn either way.
182
+ if (host.autoTitleRequested) return;
183
+ // Consumed before the first await: two user messages settling in the same
184
+ // tick must not both pass the gate.
185
+ host.autoTitleRequested = true;
186
+ } else {
187
+ // Consumed before the first await: the fallback is this conversation's last
188
+ // chance, and two settles must never both pass the gate.
189
+ host.autoTitlePending = false;
190
+ // The early attempt is still generating. Its `command_output` has not
191
+ // arrived, so omp is mid-generation and a second `/rename` now would
192
+ // reserve a new title revision and cancel it — same title context, wasted
193
+ // model call. Leave the early attempt to finish.
194
+ if (host.autoTitleWindowUntil > Date.now()) return;
195
+ }
124
196
  if (host.autoTitleInFlight || !host.sessionId) return;
125
197
  if (!(await isAutoTitleEnabled())) return;
126
198
 
@@ -128,7 +200,9 @@ export async function triggerAutoSessionTitle(host: AutoTitleHost): Promise<void
128
200
  try {
129
201
  // Re-read the name from omp rather than trusting a cached value: a rename
130
202
  // made in another tab (or by omp's own TUI on the same session file) would
131
- // otherwise be overwritten by the `"user"` write `/rename` performs.
203
+ // otherwise be overwritten by the `"user"` write `/rename` performs. This is
204
+ // also what makes the fallback a no-op when the early attempt already
205
+ // landed — the name is simply there by then.
132
206
  const state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
133
207
  if (state.sessionName?.trim()) return;
134
208
  // The session changed under us (a reset, or a `switch_session`): the name
@@ -145,7 +219,8 @@ export async function triggerAutoSessionTitle(host: AutoTitleHost): Promise<void
145
219
  markTitleRequestSent(host);
146
220
  } catch {
147
221
  // Provider error, timeout, or a child that went away — leave the session
148
- // unnamed; the next settled run tries again.
222
+ // unnamed; the settle attempt (or the next run, for an aborted turn) tries
223
+ // again.
149
224
  } finally {
150
225
  host.autoTitleInFlight = false;
151
226
  }
@@ -22,7 +22,7 @@ import { isValidSessionSortOption, sortFolders } from '@/shared/lib/workspace/si
22
22
  import { loadOmpSidebarData } from '@/server/lib/omp/session/reader';
23
23
  import { sessionHasSubagents } from '@/server/lib/omp/session/subagent-presence';
24
24
  import { healStaleStreamStatuses, loadStreamStatuses } from '@/shared/lib/omp/session/stream-state.server';
25
- import { getAwaitingInputSessionIds, getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
25
+ import { getAwaitingInputSessionIds } from '@/server/lib/omp/rpc/session-registry';
26
26
  import type { SessionItemData, SessionSortOption, WorkspaceFolderData } from '@/shared/types';
27
27
  import type { OmpSession } from '@/shared/types/omp/session';
28
28
 
@@ -110,17 +110,22 @@ export async function loadSidebarData(): Promise<SessionListPayload> {
110
110
  const sidebarSort = await serverSidebarSort();
111
111
 
112
112
  // Live stream status per session (spinner / one-shot done badge), written by
113
- // the RPC manager on agent_start/agent_end and by the abort command. `stream`
114
- // rows whose session is no longer running are stale (restart mid-run) and heal to
115
- // `finish` right here — the authoritative status travels with the same
116
- // fetch that refreshes the sidebar list.
113
+ // the RPC manager on agent_start/agent_end and by the abort command. A
114
+ // `stream` row whose owning chamber process is gone is stale (that process
115
+ // exited mid-run) and heals to `finish` right here — the authoritative status
116
+ // travels with the same fetch that refreshes the sidebar list.
117
117
  const streamStatuses: Record<string, 'stream' | 'finish' | 'abort'> = {};
118
118
  // Sessions blocked on a dialog nobody has answered yet. Read live from the
119
119
  // process registry (never persisted): the child that owns the question is the
120
120
  // same thing that owns the flag, so a restart cannot leave a stale badge.
121
121
  const awaitingInput = new Set<string>();
122
122
  if (!mock) {
123
- await healStaleStreamStatuses(new Set(getRunningRpcSessionIds()));
123
+ // The database is shared by every chamber instance on the machine, so a
124
+ // `stream` row may belong to a process this one cannot see. Ownership is
125
+ // read from the row itself (its `owner_pid`) against OS liveness — never
126
+ // against this process's own runtime registry, which would make two
127
+ // instances report different statuses for the same run.
128
+ await healStaleStreamStatuses();
124
129
  Object.assign(streamStatuses, await loadStreamStatuses());
125
130
  for (const id of getAwaitingInputSessionIds()) awaitingInput.add(id);
126
131
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * What a queue dispatch actually sends: the model snapshot it was queued with,
8
+ * the attachment payloads it can carry, and the timer-free nudge.
9
+ *
10
+ * The scheduling contract — when a delivery runs, defers, or recovers — lives
11
+ * in `delivery.server.test.ts`; both files share `delivery-harness.ts`, which
12
+ * owns the store mock and the fake clock.
13
+ */
14
+
15
+ import { afterAll, afterEach, describe, expect, jest, mock, test } from 'bun:test';
16
+ import type { QueuedMessage } from '@/shared/types/chat';
17
+ import { MODEL, beginQueueTest, deliverQueueNow, flush, makeHost, queue, queueItem, settleDelivery } from '@/server/lib/queue/delivery-harness';
18
+
19
+ afterEach(() => {
20
+ jest.useRealTimers();
21
+ });
22
+
23
+ afterAll(() => {
24
+ mock.restore();
25
+ });
26
+
27
+ describe('queue dispatch', () => {
28
+ test('delivers the head with the model snapshot it was queued with', async () => {
29
+ const sessionId = beginQueueTest();
30
+ queue.set(sessionId, [queueItem('queued hello')]);
31
+ const { host, sent } = makeHost(sessionId);
32
+
33
+ await settleDelivery(host);
34
+
35
+ expect(sent).toEqual([
36
+ { type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
37
+ { type: 'set_thinking_level', level: 'max' },
38
+ { type: 'prompt', message: 'queued hello', accessMode: 'yolo' },
39
+ ]);
40
+ expect(queue.get(sessionId)).toEqual([]);
41
+ });
42
+
43
+ test("an 'auto' thinking level is left to the session", async () => {
44
+ const sessionId = beginQueueTest();
45
+ queue.set(sessionId, [queueItem('no level', { ...MODEL, thinkingLevel: 'auto' })]);
46
+ const { host, sent } = makeHost(sessionId);
47
+
48
+ await settleDelivery(host);
49
+
50
+ expect(sent).toEqual([
51
+ { type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
52
+ { type: 'prompt', message: 'no level', accessMode: 'yolo' },
53
+ ]);
54
+ });
55
+
56
+ test('an item with no model snapshot is sent without touching the model', async () => {
57
+ const sessionId = beginQueueTest();
58
+ queue.set(sessionId, [queueItem('session model', null)]);
59
+ const { host, sent } = makeHost(sessionId);
60
+
61
+ await settleDelivery(host);
62
+
63
+ expect(sent).toEqual([{ type: 'prompt', message: 'session model' }]);
64
+ });
65
+
66
+ // Attachments reach the server as the persisted display fields only: the
67
+ // composer's `File` never survives the queue row's JSON round trip, and its
68
+ // image preview is a `blob:` object URL the server cannot read. These pin the
69
+ // fields the delivery must use instead — `dataBase64` for the bytes, `type`
70
+ // for the media type, `content` for an inline-able text file.
71
+ test('a queued image rides its persisted base64 payload', async () => {
72
+ const sessionId = beginQueueTest();
73
+ const item = queueItem('with image', null);
74
+ item.attachments = [
75
+ { id: 'i', name: 'shot.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: 'iVBORw0KGgo=' },
76
+ ] as QueuedMessage['attachments'];
77
+ queue.set(sessionId, [item]);
78
+ const { host, sent } = makeHost(sessionId);
79
+
80
+ await settleDelivery(host);
81
+
82
+ expect(sent).toEqual([
83
+ {
84
+ type: 'prompt',
85
+ message: 'with image',
86
+ images: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }],
87
+ },
88
+ ]);
89
+ });
90
+
91
+ test('a queued text file is inlined into the delivered prompt', async () => {
92
+ const sessionId = beginQueueTest();
93
+ const item = queueItem('review this', null);
94
+ item.attachments = [
95
+ { id: 't', name: 'notes.md', type: 'text/markdown', size: 5, preview: '', content: '# hi' },
96
+ ] as QueuedMessage['attachments'];
97
+ queue.set(sessionId, [item]);
98
+ const { host, sent } = makeHost(sessionId);
99
+
100
+ await settleDelivery(host);
101
+
102
+ const prompt = sent.find((c) => c.type === 'prompt');
103
+ expect(prompt?.message).toBe('review this\n\nAttached file: notes.md\n```markdown\n# hi\n```');
104
+ });
105
+
106
+ test('an attachment without a payload is not sent as a blank image', async () => {
107
+ const sessionId = beginQueueTest();
108
+ const item = queueItem('no payload', null);
109
+ item.attachments = [
110
+ { id: 'i', name: 'broken.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: '' },
111
+ ] as QueuedMessage['attachments'];
112
+ queue.set(sessionId, [item]);
113
+ const { host, sent } = makeHost(sessionId);
114
+
115
+ await settleDelivery(host);
116
+
117
+ expect(sent).toEqual([{ type: 'prompt', message: 'no payload' }]);
118
+ });
119
+
120
+ // The nudge is the RECOVERY path, so it must not itself depend on the
121
+ // mechanism it recovers from. A long-lived `--hot` dev server can stop firing
122
+ // `setTimeout` callbacks entirely; a nudge that merely re-armed the settle
123
+ // timer would fail exactly like the delivery it was meant to rescue. Here no
124
+ // timer is ever advanced — the delivery must complete synchronously.
125
+ test('a nudge delivers immediately without waiting on any timer', async () => {
126
+ const sessionId = beginQueueTest();
127
+ queue.set(sessionId, [queueItem('nudged')]);
128
+ const { host, sent } = makeHost(sessionId);
129
+
130
+ // The boolean is the client poll's stop signal: it is how the page knows a
131
+ // tick actually sent something rather than finding nothing to do.
132
+ const delivered = await deliverQueueNow(host);
133
+ await flush();
134
+
135
+ expect(delivered).toBe(true);
136
+ expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
137
+ expect(queue.get(sessionId)).toEqual([]);
138
+ });
139
+
140
+ test('a nudge on a busy session leaves the item queued and reports no delivery', async () => {
141
+ const sessionId = beginQueueTest();
142
+ queue.set(sessionId, [queueItem('busy nudge')]);
143
+ const { host, sent } = makeHost(sessionId);
144
+ host.isBusy = () => true;
145
+
146
+ const delivered = await deliverQueueNow(host);
147
+ await flush();
148
+
149
+ expect(delivered).toBe(false);
150
+ expect(sent).toEqual([]);
151
+ expect(queue.get(sessionId)?.length).toBe(1);
152
+ });
153
+
154
+ test('a nudge with an empty queue reports no delivery', async () => {
155
+ const sessionId = beginQueueTest();
156
+ const { host } = makeHost(sessionId);
157
+
158
+ const delivered = await deliverQueueNow(host);
159
+
160
+ expect(delivered).toBe(false);
161
+ });
162
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Shared harness for the queue-delivery tests — `delivery.server.test.ts`
8
+ * (scheduling, retry, wedge recovery) and `delivery-dispatch.server.test.ts`
9
+ * (what a dispatch sends, and the timer-free nudge).
10
+ *
11
+ * The store mock is registered HERE, exactly once. `mock.module` swaps a
12
+ * registry entry that `delivery.server.ts` resolves at import time, so a second
13
+ * registration in another test file would leave one file's tests driving a
14
+ * queue map the other file asserts against — a split that passes or fails by
15
+ * file order. The in-memory queue below is that shared map; per-test isolation
16
+ * comes from `beginQueueTest`'s unique session id, not from a fresh module.
17
+ *
18
+ * Time is fake: every test drives the clock explicitly, so nothing here waits
19
+ * on the wall clock.
20
+ */
21
+
22
+ import { jest, mock } from 'bun:test';
23
+ import type { QueuedMessage } from '@/shared/types/chat';
24
+ import type { QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
25
+
26
+ /** In-memory stand-in for the SQLite-backed queue. */
27
+ export const queue = new Map<string, QueuedMessage[]>();
28
+
29
+ mock.module('@/server/lib/queue/store.server', () => ({
30
+ claimHeadQueueItem: async (sessionId: string): Promise<QueuedMessage | null> => {
31
+ const items = queue.get(sessionId);
32
+ return items?.shift() ?? null;
33
+ },
34
+ hasQueuedItem: async (sessionId: string): Promise<boolean> => (queue.get(sessionId)?.length ?? 0) > 0,
35
+ requeueHeadQueueItem: async (sessionId: string, item: QueuedMessage): Promise<void> => {
36
+ const items = queue.get(sessionId) ?? [];
37
+ items.unshift(item);
38
+ queue.set(sessionId, items);
39
+ },
40
+ }));
41
+
42
+ // Dynamic import is load-bearing: the module must be evaluated AFTER the
43
+ // `mock.module` call above, so its store bindings resolve to the mock. A static
44
+ // import is hoisted above the registration and would bind the real SQLite store.
45
+ export const { deliverQueueNow, scheduleQueueDelivery } = await import('@/server/lib/queue/delivery.server');
46
+
47
+ export const MODEL = { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max', accessMode: 'yolo' as const };
48
+
49
+ export function queueItem(text: string, model: QueuedMessage['model'] = MODEL): QueuedMessage {
50
+ return { id: `item-${text}`, text, attachments: [], model };
51
+ }
52
+
53
+ /** Records the RPCs a delivery pushes at the session. */
54
+ export function makeHost(sessionId: string): { host: QueueDeliveryHost; sent: Record<string, unknown>[] } {
55
+ const sent: Record<string, unknown>[] = [];
56
+ const host: QueueDeliveryHost = {
57
+ sessionId,
58
+ isAlive: () => true,
59
+ isBusy: () => false,
60
+ send: async (command) => {
61
+ sent.push(command);
62
+ return {};
63
+ },
64
+ };
65
+ return { host, sent };
66
+ }
67
+
68
+ /** Let the delivery's promise chain settle without advancing the clock. */
69
+ export async function flush(): Promise<void> {
70
+ for (let i = 0; i < 20; i++) await Promise.resolve();
71
+ }
72
+
73
+ let counter = 0;
74
+
75
+ /** Per-test setup: an empty queue, fake timers, and a session id of its own. */
76
+ export function beginQueueTest(): string {
77
+ const sessionId = `queue-test-${counter++}`;
78
+ queue.clear();
79
+ jest.useFakeTimers();
80
+ return sessionId;
81
+ }
82
+
83
+ /** One scheduled delivery, past the 500ms settle delay and settled. */
84
+ export async function settleDelivery(host: QueueDeliveryHost): Promise<void> {
85
+ scheduleQueueDelivery(host);
86
+ jest.advanceTimersByTime(600);
87
+ await flush();
88
+ }