@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +10 -15
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -6
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/chat/omp/session-list.test.ts +0 -24
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/build-errors.test.ts +0 -6
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/fs/git-merge.test.ts +0 -11
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/panels/routes.test.ts +0 -7
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
- package/src/shared/lib/workspace/mobile-dock.ts +15 -6
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- 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(
|
|
106
|
-
await
|
|
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);
|