@kabmo-labs/kabmo 0.5.2 → 0.6.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 (124) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +359 -350
  3. package/dist/client/static/js/{index-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
  4. package/dist/client/static/js/index-nzyjf92m.css +1 -0
  5. package/marketplace/plugins/stats/README.md +26 -4
  6. package/marketplace/plugins/stats/package.json +2 -2
  7. package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
  8. package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
  9. package/marketplace/plugins/stats/src/app.tsx +11 -0
  10. package/marketplace/plugins/stats/src/format.test.ts +27 -1
  11. package/marketplace/plugins/stats/src/format.ts +41 -0
  12. package/package.json +2 -2
  13. package/src/client/bootstrap.d.ts +11 -0
  14. package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
  15. package/src/client/components/common/header-controls/index.tsx +9 -2
  16. package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
  17. package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
  18. package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
  19. package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
  20. package/src/client/components/common/server-switcher/index.test.tsx +150 -0
  21. package/src/client/components/common/server-switcher/index.tsx +281 -0
  22. package/src/client/components/common/window-controls/index.test.tsx +113 -0
  23. package/src/client/components/common/window-controls/index.tsx +66 -0
  24. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
  25. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
  26. package/src/client/components/layout/desktop-layout/index.tsx +34 -26
  27. package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
  28. package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
  29. package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
  30. package/src/client/components/mobile/RightSidebar.tsx +2 -2
  31. package/src/client/components/mobile/SessionSidebar.tsx +10 -15
  32. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
  34. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
  35. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -6
  37. package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
  38. package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
  39. package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
  40. package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
  41. package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
  42. package/src/client/data/settings/omp-schema.json +2 -2
  43. package/src/client/hooks/chat/omp/session-list.test.ts +0 -24
  44. package/src/client/hooks/desktop/servers.test.ts +73 -0
  45. package/src/client/hooks/desktop/servers.ts +126 -0
  46. package/src/client/hooks/settings/model-roles.ts +156 -0
  47. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  48. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  49. package/src/client/hooks/workspace/keybindings.ts +44 -0
  50. package/src/client/hooks/workspace/open-panel.ts +42 -0
  51. package/src/client/lib/desktop/bridge.ts +158 -0
  52. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  53. package/src/client/lib/desktop/titlebar.ts +97 -0
  54. package/src/client/lib/plugins/slots.test.ts +54 -0
  55. package/src/client/lib/plugins/slots.ts +37 -1
  56. package/src/server/db.server.test.ts +44 -3
  57. package/src/server/db.server.ts +26 -2
  58. package/src/server/lib/bundler/build-errors.test.ts +0 -6
  59. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  60. package/src/server/lib/db/schema.ts +3 -0
  61. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  62. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  63. package/src/server/lib/model-roles/mock.server.ts +40 -0
  64. package/src/server/lib/model-roles/schema.server.ts +36 -0
  65. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  66. package/src/server/lib/model-roles/store.server.ts +188 -0
  67. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  68. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  69. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  70. package/src/server/lib/omp/rpc/manager.ts +18 -13
  71. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  72. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  73. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  74. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  75. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  76. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  77. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  78. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  79. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  80. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  81. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  82. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  83. package/src/server/lib/panels/compat.server.test.ts +4 -4
  84. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  85. package/src/server/lib/panels/registry.server.test.ts +4 -4
  86. package/src/server/lib/panels/state.server.test.ts +4 -4
  87. package/src/server/lib/panels/sync.server.test.ts +4 -4
  88. package/src/server/lib/plugins/background.test.ts +2 -2
  89. package/src/server/lib/plugins/e2e.test.ts +2 -2
  90. package/src/server/lib/plugins/platform.test.ts +2 -2
  91. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  92. package/src/server/lib/queue/delivery.server.ts +11 -2
  93. package/src/server/lib/updates/shell-release.test.ts +184 -0
  94. package/src/server/lib/updates/shell-release.ts +215 -0
  95. package/src/server/lib/waiting/store.server.test.ts +2 -2
  96. package/src/server/plugins/ssr.ts +13 -10
  97. package/src/server/routes/chat/stream.test.ts +4 -4
  98. package/src/server/routes/fs/git-merge.test.ts +0 -11
  99. package/src/server/routes/models/roles.ts +73 -14
  100. package/src/server/routes/panels/css.test.ts +4 -4
  101. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  102. package/src/server/routes/panels/routes.test.ts +0 -7
  103. package/src/server/routes/plugins/routes.test.ts +4 -4
  104. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  105. package/src/server/routes/updates/android.ts +2 -2
  106. package/src/server/routes/updates/index.ts +2 -0
  107. package/src/server/routes/updates/ios.ts +26 -0
  108. package/src/server/routes/waiting/routes.test.ts +2 -2
  109. package/src/shared/lib/models/server-cache.ts +11 -0
  110. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  111. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  112. package/src/shared/lib/updates/shell-ua.ts +41 -20
  113. package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
  114. package/src/shared/lib/workspace/mobile-dock.ts +15 -6
  115. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  116. package/src/shared/lib/workspace/panel-open.ts +76 -0
  117. package/src/shared/types/model-roles.ts +54 -0
  118. package/src/test-support/isolated-db.ts +17 -5
  119. package/dist/client/static/js/index-3qamd33d.css +0 -1
  120. package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
  121. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  122. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  123. package/src/server/lib/updates/android-release.test.ts +0 -138
  124. package/src/server/lib/updates/android-release.ts +0 -183
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The shared `/rename` request lifecycle: claims, dispatch order, and the
8
+ * barrier a following prompt waits on.
9
+ *
10
+ * Each rule here was a defect measured against a real child:
11
+ *
12
+ * - a single claim slot meant the SECOND attempt's claim overwrote the first,
13
+ * so the first one's `command_output` reached the timeline as a notice row;
14
+ * - unserialized dispatches raced, and omp cancels a superseded rename
15
+ * WITHOUT emitting the frame that releases its claim;
16
+ * - a prompt admitted before a finished run's rename let the rename name a
17
+ * conversation that had already moved on.
18
+ */
19
+
20
+ import { describe, expect, test } from 'bun:test';
21
+
22
+ import {
23
+ awaitTitleDispatch,
24
+ consumeAutoTitleOutput,
25
+ markTitleRequestSent,
26
+ releaseTitleClaim,
27
+ sendTitleRequest,
28
+ waitForTitleOutcome,
29
+ type TitleRequestHost,
30
+ } from '@/server/lib/omp/session/title-request.server';
31
+
32
+ interface FakeProc {
33
+ commands: Array<Record<string, unknown>>;
34
+ /** Held open so a test controls when a dispatch acknowledges. */
35
+ gate?: Promise<void>;
36
+ sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T>;
37
+ }
38
+
39
+ function makeHost(options: { gate?: Promise<void> } = {}): { host: TitleRequestHost; proc: FakeProc } {
40
+ const proc: FakeProc = {
41
+ commands: [],
42
+ gate: options.gate,
43
+ async sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T> {
44
+ proc.commands.push(command);
45
+ if (command.type === 'prompt' && proc.gate) await proc.gate;
46
+ return undefined as T;
47
+ },
48
+ };
49
+ const host: TitleRequestHost = {
50
+ sessionId: 'sess-1',
51
+ autoTitleWindowUntil: 0,
52
+ autoTitleClaims: 0,
53
+ autoTitleDispatch: null,
54
+ proc,
55
+ };
56
+ return { host, proc };
57
+ }
58
+
59
+ describe('title claims', () => {
60
+ test('one claim per request, and a second frame passes through', () => {
61
+ const { host } = makeHost();
62
+ markTitleRequestSent(host);
63
+
64
+ expect(consumeAutoTitleOutput(host)).toBe(true);
65
+ // The operator's own command output must reach the timeline.
66
+ expect(consumeAutoTitleOutput(host)).toBe(false);
67
+ });
68
+
69
+ test('two outstanding requests keep two claims', () => {
70
+ // The early attempt and the settle fallback can both be unanswered at once.
71
+ // One slot let the first one's frame render as a notice row.
72
+ const { host } = makeHost();
73
+ markTitleRequestSent(host);
74
+ markTitleRequestSent(host);
75
+
76
+ expect(host.autoTitleClaims).toBe(2);
77
+ expect(consumeAutoTitleOutput(host)).toBe(true);
78
+ expect(consumeAutoTitleOutput(host)).toBe(true);
79
+ expect(consumeAutoTitleOutput(host)).toBe(false);
80
+ expect(host.autoTitleWindowUntil).toBe(0);
81
+ });
82
+
83
+ test('an expired claim is dropped instead of swallowing an operator frame', () => {
84
+ const { host } = makeHost();
85
+ markTitleRequestSent(host);
86
+ host.autoTitleWindowUntil = Date.now() - 1;
87
+
88
+ expect(consumeAutoTitleOutput(host)).toBe(false);
89
+ expect(host.autoTitleClaims).toBe(0);
90
+ });
91
+
92
+ test('a release keeps the window armed while another claim is live', () => {
93
+ const { host } = makeHost();
94
+ markTitleRequestSent(host);
95
+ markTitleRequestSent(host);
96
+
97
+ releaseTitleClaim(host);
98
+ expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
99
+ });
100
+ });
101
+
102
+ /** Drain the microtask queue, so a promise that WOULD have resolved has. */
103
+ async function flush(): Promise<void> {
104
+ for (let i = 0; i < 8; i += 1) await Promise.resolve();
105
+ }
106
+
107
+ describe('dispatch order', () => {
108
+ test('a second request is dispatched only after the first acknowledges', async () => {
109
+ const { promise: gate, resolve: open } = Promise.withResolvers<void>();
110
+ const { host, proc } = makeHost({ gate });
111
+
112
+ // The slot is owned by the CALLER, exactly as `triggerAutoSessionTitle`
113
+ // claims it before the first await.
114
+ const first = sendTitleRequest(host, '/rename', null);
115
+ host.autoTitleDispatch = first.then(
116
+ () => undefined,
117
+ () => undefined,
118
+ );
119
+ const second = sendTitleRequest(host, '/rename', host.autoTitleDispatch);
120
+ await flush();
121
+ expect(proc.commands).toHaveLength(1);
122
+
123
+ open();
124
+ await Promise.all([first, second]);
125
+ expect(proc.commands).toHaveLength(2);
126
+ });
127
+
128
+ test('a prompt that follows waits for the dispatch already in flight', async () => {
129
+ const { promise: gate, resolve: open } = Promise.withResolvers<void>();
130
+ const { host, proc } = makeHost({ gate });
131
+
132
+ const pending = sendTitleRequest(host, '/rename', null);
133
+ host.autoTitleDispatch = pending.then(
134
+ () => undefined,
135
+ () => undefined,
136
+ );
137
+
138
+ let released = false;
139
+ const barrier = awaitTitleDispatch(host).then(() => {
140
+ released = true;
141
+ });
142
+ // The dispatch has not acknowledged yet, so the barrier must still be
143
+ // waiting — this is the ordering the requirement depends on. Nothing but
144
+ // `open()` can resolve it, so a microtask flush is a complete check.
145
+ await flush();
146
+ expect(released).toBe(false);
147
+ expect(proc.commands).toHaveLength(1);
148
+
149
+ open();
150
+ await Promise.all([pending, barrier]);
151
+ expect(released).toBe(true);
152
+ });
153
+
154
+ test('the barrier is a no-op when nothing is in flight', async () => {
155
+ const { host } = makeHost();
156
+ await awaitTitleDispatch(host);
157
+ expect(host.autoTitleDispatch).toBeNull();
158
+ });
159
+ });
160
+
161
+ describe('waiting for the outcome', () => {
162
+ test('resolves when the claim is consumed, so the model pin outlives the ack', async () => {
163
+ const { host } = makeHost();
164
+ markTitleRequestSent(host);
165
+
166
+ let done = false;
167
+ const waiting = waitForTitleOutcome(host, 50).then(() => {
168
+ done = true;
169
+ });
170
+ await flush();
171
+ expect(done).toBe(false);
172
+
173
+ consumeAutoTitleOutput(host);
174
+ await waiting;
175
+ expect(done).toBe(true);
176
+ });
177
+
178
+ test('resolves immediately when no request is outstanding', async () => {
179
+ const { host } = makeHost();
180
+ await waitForTitleOutcome(host, 50);
181
+ expect(host.autoTitleClaims).toBe(0);
182
+ });
183
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * ONE background `/rename` request: its dispatch order, its claim on the frames
8
+ * that answer it, and the barrier a following prompt waits on.
9
+ *
10
+ * Split from `auto-title.server.ts` because two callers share it — Kabmo's
11
+ * auto-titling and the session row's "Rename with AI" — and because the three
12
+ * rules below each fix a defect that made a rename silently not happen.
13
+ *
14
+ * ## 1. Claims are COUNTED, not a single slot
15
+ *
16
+ * A title request is answered by a `command_output` ("Session renamed to …" /
17
+ * "Could not generate a session title"), which must stay out of the timeline.
18
+ * The frame carries no correlation id, so attribution is by request. One slot
19
+ * was enough while only one request could be outstanding; a settle attempt that
20
+ * follows an early one re-arms the slot and the FIRST request's frame then
21
+ * reaches the timeline as a notice row. Measured on a real session: the early
22
+ * attempt answered 52 s in, after the run had already ended.
23
+ *
24
+ * ## 2. Dispatches are SERIALIZED on the host
25
+ *
26
+ * omp's `RpcUserInputGate` orders the operator's prompts, but Kabmo sends its
27
+ * own `/rename` straight to the process — outside that gate. Two dispatches
28
+ * therefore race their frames, and a request whose dispatch loses the race is
29
+ * cancelled by omp (`renameTitle` reserves a title revision) without ever
30
+ * emitting the `command_output` that would release its claim.
31
+ *
32
+ * ## 3. A prompt that follows a run end WAITS for the dispatch
33
+ *
34
+ * The requirement is that the rename a finished run asked for is ON THE WIRE
35
+ * before the next prompt is. Both are separate frames on one stdin, so without
36
+ * a barrier the operator's second prompt can be admitted first and the rename
37
+ * then names a conversation that has already moved on. The wait is bounded and
38
+ * cheap: the dispatch resolves on omp's ack (measured ~3 ms), never on the
39
+ * generation.
40
+ */
41
+
42
+ import { PROMPT_ACK_TIMEOUT_MS } from '@/server/lib/omp/rpc/constants';
43
+
44
+ /** omp's builtin that generates a title from the conversation. Sent with no
45
+ * args so omp derives it through its own `renameTitle`. */
46
+ export const GENERATE_TITLE_COMMAND = '/rename';
47
+
48
+ /**
49
+ * How long a claim may suppress a `command_output`. Long enough for a title
50
+ * generation (measured 4-52 s on a real child), short enough that a claim whose
51
+ * request was cancelled cannot swallow an operator's own command output for
52
+ * the rest of the session.
53
+ */
54
+ export const TITLE_CLAIM_WINDOW_MS = 60_000;
55
+
56
+ /**
57
+ * Ceiling on the barrier a prompt pays. The dispatch itself is an ack round
58
+ * trip (~3 ms); this only bounds a child that stopped answering, so a wedged
59
+ * rename can never hold the operator's next prompt.
60
+ */
61
+ export const TITLE_DISPATCH_BARRIER_MS = 10_000;
62
+
63
+ /** How long the session-model pin is held for a request whose outcome never
64
+ * arrives (a cancelled generation emits no frame). */
65
+ export const TITLE_OUTCOME_HOLD_MS = 65_000;
66
+
67
+ /** Host surface a title request needs. Owned by the session wrapper. */
68
+ export interface TitleRequestHost {
69
+ sessionId: string;
70
+ /** Epoch ms until which claims may suppress a `command_output`; 0 when no
71
+ * claim is outstanding. */
72
+ autoTitleWindowUntil: number;
73
+ /** Outstanding requests, one claim each. */
74
+ autoTitleClaims: number;
75
+ /** In-flight DISPATCH (not generation), or null when the wire is idle. */
76
+ autoTitleDispatch: Promise<void> | null;
77
+ proc: {
78
+ sendCommand<T = unknown>(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<T>;
79
+ };
80
+ }
81
+
82
+ /** Resolvers waiting for a claim to be consumed, keyed by the host so a
83
+ * re-evaluated module cannot leak them into another wrapper. */
84
+ const outcomeWaiters = new WeakMap<object, Set<() => void>>();
85
+
86
+ function notifyClaimChanged(host: TitleRequestHost): void {
87
+ const listeners = outcomeWaiters.get(host);
88
+ if (!listeners) return;
89
+ for (const listener of [...listeners]) listener();
90
+ }
91
+
92
+ /**
93
+ * Claim the frames this request is about to produce.
94
+ *
95
+ * Called BEFORE the send, never after its ack: omp answers the prompt within
96
+ * the same tick (measured ~3 ms), so a claim armed on the ack can already be
97
+ * too late for the frame it was meant to cover.
98
+ */
99
+ export function markTitleRequestSent(host: TitleRequestHost): void {
100
+ host.autoTitleClaims += 1;
101
+ host.autoTitleWindowUntil = Date.now() + TITLE_CLAIM_WINDOW_MS;
102
+ }
103
+
104
+ /** Drop one claim (a request that never reached omp, or one whose frame
105
+ * arrived). */
106
+ export function releaseTitleClaim(host: TitleRequestHost): void {
107
+ if (host.autoTitleClaims > 0) host.autoTitleClaims -= 1;
108
+ if (host.autoTitleClaims === 0) host.autoTitleWindowUntil = 0;
109
+ notifyClaimChanged(host);
110
+ }
111
+
112
+ /**
113
+ * True when this frame answers a title request and must stay out of the
114
+ * timeline, consuming its claim. One frame per claim; a second `command_output`
115
+ * inside the window belongs to something the operator typed.
116
+ */
117
+ export function consumeAutoTitleOutput(host: TitleRequestHost): boolean {
118
+ if (host.autoTitleClaims <= 0) return false;
119
+ if (host.autoTitleWindowUntil !== 0 && Date.now() > host.autoTitleWindowUntil) {
120
+ // The claim outlived its window: the request was cancelled or never
121
+ // answered, and an operator's own output must not be swallowed by it.
122
+ host.autoTitleClaims = 0;
123
+ host.autoTitleWindowUntil = 0;
124
+ notifyClaimChanged(host);
125
+ return false;
126
+ }
127
+ releaseTitleClaim(host);
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * Send `/rename`, ordered behind any dispatch already in flight.
133
+ *
134
+ * `after` is the slot to queue behind, and it must be captured BEFORE the
135
+ * caller claims the slot for itself — passing the host's current slot from
136
+ * inside a run that already owns it would make the promise wait on itself.
137
+ *
138
+ * Resolves on omp's ack — the generation continues on its own and is reported
139
+ * by the frames the claim covers.
140
+ */
141
+ export function sendTitleRequest(
142
+ host: TitleRequestHost,
143
+ message: string = GENERATE_TITLE_COMMAND,
144
+ after: Promise<void> | null = host.autoTitleDispatch,
145
+ ): Promise<void> {
146
+ // The slot is owned by the CALLER (`triggerAutoSessionTitle` /
147
+ // `awaitTitleFrame`), which claims it synchronously so a prompt sent in the
148
+ // same tick is ordered behind the whole request, not just its dispatch. This
149
+ // function only queues behind what was already there.
150
+ return (after ?? Promise.resolve()).then(async () => {
151
+ markTitleRequestSent(host);
152
+ try {
153
+ await host.proc.sendCommand({ type: 'prompt', message }, PROMPT_ACK_TIMEOUT_MS);
154
+ } catch (error) {
155
+ // A request that never reached omp can never be answered, so its claim
156
+ // would sit on the window and swallow the next real command output.
157
+ releaseTitleClaim(host);
158
+ throw error;
159
+ }
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Wait for the dispatch already in flight, bounded. A prompt that follows a run
165
+ * end calls this so the rename the run asked for is written to the child's
166
+ * stdin first (see rule 3 in the module doc).
167
+ */
168
+ export async function awaitTitleDispatch(host: { autoTitleDispatch?: Promise<void> | null }): Promise<void> {
169
+ const pending = host.autoTitleDispatch;
170
+ if (!pending) return;
171
+ const { promise, resolve } = Promise.withResolvers<void>();
172
+ const timer = setTimeout(resolve, TITLE_DISPATCH_BARRIER_MS);
173
+ timer.unref?.();
174
+ try {
175
+ await Promise.race([pending, promise]);
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Wait until the claims outstanding at call time have been consumed (or the
183
+ * deadline passes).
184
+ *
185
+ * This is what keeps the session-model pin in place for the whole request: omp
186
+ * resolves the title role when the generation STARTS and again for the card
187
+ * request that follows a successful title, so releasing the pin on the ack
188
+ * would name the session with the configured role — the exact failure the pin
189
+ * exists to avoid.
190
+ */
191
+ export function waitForTitleOutcome(host: TitleRequestHost, timeoutMs = TITLE_OUTCOME_HOLD_MS): Promise<void> {
192
+ const start = host.autoTitleClaims;
193
+ if (start <= 0) return Promise.resolve();
194
+ const { promise, resolve } = Promise.withResolvers<void>();
195
+ const listeners = outcomeWaiters.get(host) ?? new Set<() => void>();
196
+ outcomeWaiters.set(host, listeners);
197
+ let settled = false;
198
+ const finish = () => {
199
+ if (settled) return;
200
+ settled = true;
201
+ clearTimeout(timer);
202
+ listeners.delete(check);
203
+ if (listeners.size === 0 && outcomeWaiters.get(host) === listeners) outcomeWaiters.delete(host);
204
+ resolve();
205
+ };
206
+ const check = () => {
207
+ if (host.autoTitleClaims < start) finish();
208
+ };
209
+ listeners.add(check);
210
+ const timer = setTimeout(finish, timeoutMs);
211
+ timer.unref?.();
212
+ return promise;
213
+ }
@@ -27,12 +27,12 @@ import { isolateDb, releaseDb } from '@/test-support/isolated-db';
27
27
  import { getDb } from '@/server/db.server';
28
28
  import { writeSettingsJson } from '@/server/lib/db/settings-store';
29
29
 
30
- beforeEach(() => {
31
- isolateDb();
30
+ beforeEach(async () => {
31
+ await isolateDb();
32
32
  });
33
33
 
34
- afterEach(() => {
35
- releaseDb();
34
+ afterEach(async () => {
35
+ await releaseDb();
36
36
  });
37
37
 
38
38
  describe('baselines', () => {
@@ -55,19 +55,19 @@ async function scanWith(manifest: unknown) {
55
55
  return discoverPanelPlugins();
56
56
  }
57
57
 
58
- beforeEach(() => {
58
+ beforeEach(async () => {
59
59
  root = fs.mkdtempSync(join(tmpdir(), 'kabmo-compat-'));
60
60
  process.env.KABMO_MARKETPLACE_DIR = root;
61
61
  fs.mkdirSync(root, { recursive: true });
62
62
  // The scan reads the disabled set, which is a DATABASE read — so the suite
63
63
  // needs a database of its own before the first scan, or it opens the real one.
64
- isolateDb();
64
+ await isolateDb();
65
65
  invalidatePanelScan();
66
66
  });
67
67
 
68
- afterEach(() => {
68
+ afterEach(async () => {
69
69
  delete process.env.KABMO_MARKETPLACE_DIR;
70
- releaseDb();
70
+ await releaseDb();
71
71
  invalidatePanelScan();
72
72
  fs.rmSync(root, { recursive: true, force: true });
73
73
  });
@@ -53,18 +53,18 @@ function withStore(run: (bundled: string) => Promise<void>): Promise<void> {
53
53
  });
54
54
  }
55
55
 
56
- beforeEach(() => {
56
+ beforeEach(async () => {
57
57
  root = fs.mkdtempSync(join(tmpdir(), 'kabmo-store-'));
58
58
  process.env.KABMO_MARKETPLACE_DIR = root;
59
59
  // The scan reads the disabled set, so it needs a database of its own before
60
60
  // the first read — see `@/test-support/isolated-db`.
61
- isolateDb();
61
+ await isolateDb();
62
62
  invalidatePanelScan();
63
63
  });
64
64
 
65
- afterEach(() => {
65
+ afterEach(async () => {
66
66
  delete process.env.KABMO_MARKETPLACE_DIR;
67
- releaseDb();
67
+ await releaseDb();
68
68
  invalidatePanelScan();
69
69
  fs.rmSync(root, { recursive: true, force: true });
70
70
  });
@@ -62,19 +62,19 @@ function writePlugin(dirName: string, manifest: unknown): string {
62
62
  return dir;
63
63
  }
64
64
 
65
- beforeEach(() => {
65
+ beforeEach(async () => {
66
66
  root = fs.mkdtempSync(join(tmpdir(), 'kabmo-marketplace-'));
67
67
  process.env.KABMO_MARKETPLACE_DIR = root;
68
68
  // The scan reads the disabled set, which is a DATABASE read — so the suite
69
69
  // needs a database of its own before the first scan, or it opens the real one
70
70
  // and a `setPluginEnabled` below rewrites a live install.
71
- isolateDb();
71
+ await isolateDb();
72
72
  invalidatePanelScan();
73
73
  });
74
74
 
75
- afterEach(() => {
75
+ afterEach(async () => {
76
76
  delete process.env.KABMO_MARKETPLACE_DIR;
77
- releaseDb();
77
+ await releaseDb();
78
78
  invalidatePanelScan();
79
79
  fs.rmSync(root, { recursive: true, force: true });
80
80
  });
@@ -38,12 +38,12 @@ async function readChamberBlob(): Promise<Record<string, unknown>> {
38
38
  return readSettingsJson<Record<string, unknown>>(db, 'omp_chamber_settings', {});
39
39
  }
40
40
 
41
- beforeEach(() => {
42
- isolateDb();
41
+ beforeEach(async () => {
42
+ await isolateDb();
43
43
  });
44
44
 
45
- afterEach(() => {
46
- releaseDb();
45
+ afterEach(async () => {
46
+ await releaseDb();
47
47
  });
48
48
 
49
49
  describe('hiddenRightPanels migration', () => {
@@ -47,7 +47,7 @@ function writePlugin(dir: string, manifest: unknown, files: Record<string, strin
47
47
  }
48
48
  }
49
49
 
50
- beforeEach(() => {
50
+ beforeEach(async () => {
51
51
  root = fs.mkdtempSync(join(tmpdir(), 'omp-sync-work-'));
52
52
  store = fs.mkdtempSync(join(tmpdir(), 'omp-sync-store-'));
53
53
  work = fs.mkdtempSync(join(tmpdir(), 'omp-sync-repos-'));
@@ -55,14 +55,14 @@ beforeEach(() => {
55
55
  process.env.KABMO_BUNDLED_MARKETPLACE_DIR = store;
56
56
  // The baseline and refusal records live in `app_settings`, so this suite needs
57
57
  // an isolated database: a shared handle would read the developer's real one.
58
- isolateDb();
58
+ await isolateDb();
59
59
  invalidatePanelScan();
60
60
  });
61
61
 
62
- afterEach(() => {
62
+ afterEach(async () => {
63
63
  delete process.env.KABMO_MARKETPLACE_DIR;
64
64
  delete process.env.KABMO_BUNDLED_MARKETPLACE_DIR;
65
- releaseDb();
65
+ await releaseDb();
66
66
  invalidatePanelScan();
67
67
  fs.rmSync(root, { recursive: true, force: true });
68
68
  fs.rmSync(store, { recursive: true, force: true });
@@ -26,11 +26,11 @@ let root = '';
26
26
 
27
27
  beforeAll(async () => {
28
28
  root = await mkdtemp(join(tmpdir(), 'omp-plugin-background-'));
29
- isolateDb();
29
+ await isolateDb();
30
30
  });
31
31
 
32
32
  afterAll(async () => {
33
- releaseDb();
33
+ await releaseDb();
34
34
  await rm(root, { recursive: true, force: true });
35
35
  });
36
36
 
@@ -39,7 +39,7 @@ beforeAll(async () => {
39
39
  marketplaceRoot = await mkdtemp(join(tmpdir(), 'omp-plugin-e2e-'));
40
40
  previousOverride = Bun.env.KABMO_MARKETPLACE_DIR;
41
41
  Bun.env.KABMO_MARKETPLACE_DIR = marketplaceRoot;
42
- isolateDb();
42
+ await isolateDb();
43
43
 
44
44
  // Copy the bundled plugin into the working marketplace and build it there —
45
45
  // the same two steps an install performs.
@@ -58,7 +58,7 @@ beforeAll(async () => {
58
58
 
59
59
  afterAll(async () => {
60
60
  await disposePlugin(PLUGIN_ID);
61
- releaseDb();
61
+ await releaseDb();
62
62
  if (previousOverride === undefined) delete Bun.env.KABMO_MARKETPLACE_DIR;
63
63
  else Bun.env.KABMO_MARKETPLACE_DIR = previousOverride;
64
64
  await rm(marketplaceRoot, { recursive: true, force: true });
@@ -37,11 +37,11 @@ let root = '';
37
37
 
38
38
  beforeAll(async () => {
39
39
  root = await mkdtemp(join(tmpdir(), 'omp-plugin-platform-'));
40
- isolateDb();
40
+ await isolateDb();
41
41
  });
42
42
 
43
43
  afterAll(async () => {
44
- releaseDb();
44
+ await releaseDb();
45
45
  await rm(root, { recursive: true, force: true });
46
46
  });
47
47
 
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { afterAll, afterEach, beforeAll, describe, expect, jest, mock, test } from 'bun:test';
16
16
  import type { QueuedMessage } from '@/shared/types/chat';
17
+ import type { QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
17
18
  import {
18
19
  MODEL,
19
20
  beginQueueTest,
@@ -81,6 +82,39 @@ describe('queue dispatch', () => {
81
82
  expect(sent).toEqual([{ type: 'prompt', message: 'session model' }]);
82
83
  });
83
84
 
85
+ // The real wrapper's `send` is a CLASS METHOD that reads its own runtime state
86
+ // through `this`, so a dispatch that hands it over detached runs against
87
+ // `undefined`. Measured in production on every queued item that carried a
88
+ // model snapshot: `undefined is not an object (evaluating 'e.restarting')`,
89
+ // the item was re-queued, and the queue retried it every 2s forever — the
90
+ // follow-up only ever left the queue when the operator re-sent it by hand.
91
+ // `makeHost`'s arrow function cannot see this, so the host here is an object
92
+ // whose `send` reads a field of itself.
93
+ test('the model snapshot and the prompt keep the session as their receiver', async () => {
94
+ const sessionId = beginQueueTest();
95
+ queue.set(sessionId, [queueItem('receiver check')]);
96
+ const sent: Record<string, unknown>[] = [];
97
+ let receiverLost = false;
98
+ const host: QueueDeliveryHost = {
99
+ sessionId,
100
+ isAlive: () => true,
101
+ isBusy: () => false,
102
+ // `this` is the host: the detached call sites read `restarting` off it.
103
+ restarting: false,
104
+ async send(this: { restarting: boolean }, command: Record<string, unknown>) {
105
+ if (this?.restarting !== false) receiverLost = true;
106
+ sent.push(command);
107
+ return {};
108
+ },
109
+ } as QueueDeliveryHost;
110
+
111
+ await settleDelivery(host);
112
+
113
+ expect(receiverLost).toBe(false);
114
+ expect(sent.map((c) => c.type)).toEqual(['set_model', 'set_thinking_level', 'prompt']);
115
+ expect(queue.get(sessionId)).toEqual([]);
116
+ });
117
+
84
118
  // Attachments reach the server as the persisted display fields only: the
85
119
  // composer's `File` never survives the queue row's JSON round trip, and its
86
120
  // image preview is a `blob:` object URL the server cannot read. These pin the
@@ -101,9 +101,18 @@ function clearPendingTimer(sessionId: string): void {
101
101
  * prompt's dispatcher: both send a prompt nobody typed in this tab, from the
102
102
  * same persisted shape. */
103
103
  async function dispatchQueuedItem(session: QueueDeliveryHost, item: QueuedMessage): Promise<boolean> {
104
+ // BOUND, never passed detached: the wrapper's `send` reaches its own runtime
105
+ // state through `this`, so `applyModelSnapshot(session.send, …)` ran it against
106
+ // `undefined` — measured in production as `undefined is not an object
107
+ // (evaluating 'e.restarting')` on every queued item that carried a model
108
+ // snapshot. The item was then re-queued and retried every 2s forever, and the
109
+ // client's nudge hit the same path, so the follow-up never left the queue and
110
+ // only a manual re-send got it out. The waiting-prompt dispatcher binds for the
111
+ // same reason (`waiting/runtime.server.ts`).
112
+ const send = session.send.bind(session);
104
113
  try {
105
- await applyModelSnapshot(session.send, item.model);
106
- await session.send(promptCommand({ text: item.text, attachments: item.attachments, model: item.model }));
114
+ await applyModelSnapshot(send, item.model);
115
+ await send(promptCommand({ text: item.text, attachments: item.attachments, model: item.model }));
107
116
  return true;
108
117
  } catch (error) {
109
118
  console.error(`[queue] delivery failed for session ${session.sessionId}:`, error);