ompchamber 1.0.0 → 1.1.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/static/css/index.b859cc1cc5.css +2 -0
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
- package/src/client/components/workspace/chat-timeline/index.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/composer/index.ts +28 -8
- package/src/client/hooks/chat/timeline/actions.ts +24 -3
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +13 -1
- package/src/client/hooks/chat/timeline/send.ts +16 -5
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +38 -8
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/composer/client.ts +17 -8
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +157 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +109 -20
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/chat.ts +38 -65
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Assembly of the chat model registry that `GET /api/models` serves: the omp
|
|
8
|
+
* registry (auth providers + models.yml) narrowed by the provider/model hides
|
|
9
|
+
* the operator set, plus the live session's default model.
|
|
10
|
+
*
|
|
11
|
+
* Extracted from `routes/models/root.ts` so the route keeps only its loader and
|
|
12
|
+
* action, and so the preference store can share the same model list.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getDb } from '@/server/db.server';
|
|
16
|
+
import { PROVIDERS_SETTINGS_KEY } from '@/server/lib/models/provider-registry.server';
|
|
17
|
+
import { readSettingsJson } from '@/server/lib/db/settings-store';
|
|
18
|
+
import { readDisabledProviders } from '@/server/lib/omp/config/roles';
|
|
19
|
+
import { runUtilityCommand, type OmpModel } from '@/server/lib/omp/rpc/utility';
|
|
20
|
+
import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
|
|
21
|
+
import type { ModelsData, ProviderItem } from '@/shared/types';
|
|
22
|
+
|
|
23
|
+
const MODELS_CACHE_TTL_MS = 60_000;
|
|
24
|
+
export const SAFE_MODEL_LOAD_FAILURE_MESSAGE = 'Model list is temporarily unavailable. Check your configuration and try again.';
|
|
25
|
+
|
|
26
|
+
const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
|
27
|
+
|
|
28
|
+
function compareModelEntries(a: { id: string; name: string; provider: string }, b: { id: string; name: string; provider: string }): number {
|
|
29
|
+
return modelNameCollator.compare(a.name || a.id, b.name || b.id)
|
|
30
|
+
|| modelNameCollator.compare(a.provider, b.provider)
|
|
31
|
+
|| modelNameCollator.compare(a.id, b.id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// "off" is always a valid selector; the concrete efforts come from the model's
|
|
35
|
+
// baked thinking metadata (omp: getSupportedEfforts = reasoning ? efforts : []).
|
|
36
|
+
function thinkingLevelsFor(model: OmpModel): string[] {
|
|
37
|
+
if (!model.reasoning) return ['off'];
|
|
38
|
+
return ['off', ...(model.thinking?.efforts ?? [])];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function supportsFastMode(model: OmpModel): boolean {
|
|
42
|
+
return model.provider === 'anthropic' || model.provider === 'openai' || model.provider === 'google';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Disabled providers recorded in the chamber's own provider overlay. Used only
|
|
47
|
+
* by the MOCK path of `/api/models`, which has no config.yml `disabledProviders`
|
|
48
|
+
* to read — real mode gets the same answer natively.
|
|
49
|
+
*/
|
|
50
|
+
function isDisabledStoredProvider(value: unknown): value is ProviderItem {
|
|
51
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
52
|
+
const candidate = value as ProviderItem;
|
|
53
|
+
return typeof candidate.slug === 'string' && candidate.disabled === true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readStoredProvidersForModels(): Promise<ProviderItem[]> {
|
|
57
|
+
try {
|
|
58
|
+
const db = await getDb();
|
|
59
|
+
const parsed = await readSettingsJson<unknown>(db, PROVIDERS_SETTINGS_KEY, null);
|
|
60
|
+
if (!Array.isArray(parsed)) return [];
|
|
61
|
+
return parsed.filter(isDisabledStoredProvider);
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hiddenModelKey(provider: string, modelId: string): string {
|
|
68
|
+
return `${provider.trim().toLowerCase()}:${modelId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readHiddenModelKeys(): Promise<Set<string>> {
|
|
72
|
+
try {
|
|
73
|
+
const db = await getDb();
|
|
74
|
+
const parsed = await readSettingsJson<unknown>(db, PROVIDERS_SETTINGS_KEY, null);
|
|
75
|
+
if (!Array.isArray(parsed)) return new Set();
|
|
76
|
+
const hidden = new Set<string>();
|
|
77
|
+
for (const provider of parsed) {
|
|
78
|
+
if (typeof provider !== 'object' || provider === null) continue;
|
|
79
|
+
const { slug, models } = provider as { slug?: unknown; models?: unknown };
|
|
80
|
+
if (typeof slug !== 'string' || !Array.isArray(models)) continue;
|
|
81
|
+
for (const model of models) {
|
|
82
|
+
if (typeof model !== 'object' || model === null) continue;
|
|
83
|
+
const { id, isVisible } = model as { id?: unknown; isVisible?: unknown };
|
|
84
|
+
if (typeof id === 'string' && isVisible === false) hidden.add(hiddenModelKey(slug, id));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return hidden;
|
|
88
|
+
} catch {
|
|
89
|
+
return new Set();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A model is offered only when its provider is neither disabled in omp's
|
|
95
|
+
* config.yml (`disabledProviders`) nor carrying a per-model hide in the
|
|
96
|
+
* chamber overlay. Disabling the provider is the coarse switch — Settings →
|
|
97
|
+
* Providers flips it so the whole provider leaves the chat picker.
|
|
98
|
+
*/
|
|
99
|
+
function filterSelectableModels(
|
|
100
|
+
available: OmpModel[],
|
|
101
|
+
disabledProviders: Set<string>,
|
|
102
|
+
hiddenModelKeys: Set<string>,
|
|
103
|
+
): OmpModel[] {
|
|
104
|
+
return available.filter((model) => (
|
|
105
|
+
!disabledProviders.has(model.provider)
|
|
106
|
+
&& !hiddenModelKeys.has(hiddenModelKey(model.provider, model.id))
|
|
107
|
+
));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isOmpModelEntry(value: unknown): value is OmpModel {
|
|
111
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
112
|
+
const candidate = value as OmpModel;
|
|
113
|
+
return typeof candidate.id === 'string' && typeof candidate.provider === 'string';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isLoginProvider(value: unknown): value is { id: string; name: string; authenticated: boolean } {
|
|
117
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
118
|
+
const candidate = value as { id?: unknown; name?: unknown; authenticated?: unknown };
|
|
119
|
+
return typeof candidate.id === 'string'
|
|
120
|
+
&& typeof candidate.name === 'string'
|
|
121
|
+
&& typeof candidate.authenticated === 'boolean';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadModels(): Promise<ModelsData> {
|
|
125
|
+
const availableResponse = await runUtilityCommand<{ models?: unknown }>(
|
|
126
|
+
{ type: 'get_available_models' },
|
|
127
|
+
120_000,
|
|
128
|
+
);
|
|
129
|
+
const available = Array.isArray(availableResponse.models)
|
|
130
|
+
? availableResponse.models
|
|
131
|
+
.filter(isOmpModelEntry)
|
|
132
|
+
.map((model) => ({
|
|
133
|
+
...model,
|
|
134
|
+
name: typeof model.name === 'string' && model.name.trim().length > 0 ? model.name : model.id,
|
|
135
|
+
}))
|
|
136
|
+
: [];
|
|
137
|
+
|
|
138
|
+
const disabledProviders = await readDisabledProviders();
|
|
139
|
+
const hiddenModelKeys = await readHiddenModelKeys();
|
|
140
|
+
const nameMap: Record<string, string> = {};
|
|
141
|
+
const thinkingLevels: Record<string, string[]> = {};
|
|
142
|
+
const modelList = filterSelectableModels(available, disabledProviders, hiddenModelKeys)
|
|
143
|
+
.map((m) => ({
|
|
144
|
+
id: m.id,
|
|
145
|
+
name: m.name,
|
|
146
|
+
provider: m.provider,
|
|
147
|
+
thinkingLevels: thinkingLevelsFor(m),
|
|
148
|
+
supportsFastMode: supportsFastMode(m),
|
|
149
|
+
contextWindow: m.contextWindow ?? undefined,
|
|
150
|
+
maxTokens: m.maxTokens ?? undefined,
|
|
151
|
+
cost: m.cost,
|
|
152
|
+
}))
|
|
153
|
+
.sort(compareModelEntries);
|
|
154
|
+
|
|
155
|
+
const loginResponse = await runUtilityCommand<{ providers?: unknown }>(
|
|
156
|
+
{ type: 'get_login_providers' },
|
|
157
|
+
30_000,
|
|
158
|
+
);
|
|
159
|
+
const loginProviders = Array.isArray(loginResponse.providers)
|
|
160
|
+
? loginResponse.providers.filter(isLoginProvider)
|
|
161
|
+
: [];
|
|
162
|
+
const connectedProviders = loginProviders
|
|
163
|
+
.filter((provider) => provider.authenticated)
|
|
164
|
+
.map((provider) => ({ id: provider.id, name: provider.name, disabled: disabledProviders.has(provider.id) }));
|
|
165
|
+
for (const m of available) {
|
|
166
|
+
const key = `${m.provider}:${m.id}`;
|
|
167
|
+
nameMap[key] = m.name;
|
|
168
|
+
thinkingLevels[key] = thinkingLevelsFor(m);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let defaultModel: { provider: string; modelId: string } | null = null;
|
|
172
|
+
try {
|
|
173
|
+
const state = await runUtilityCommand<{ model?: { provider?: string; id?: string } }>(
|
|
174
|
+
{ type: 'get_state' },
|
|
175
|
+
30_000,
|
|
176
|
+
);
|
|
177
|
+
const provider = state.model?.provider;
|
|
178
|
+
const modelId = state.model?.id;
|
|
179
|
+
if (provider && modelId && available.some((m) => m.provider === provider && m.id === modelId)) {
|
|
180
|
+
defaultModel = { provider, modelId };
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Default model is cosmetic — the models list is still useful without it.
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { models: nameMap, modelList, defaultModel, thinkingLevels, connectedProviders };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const EMPTY_MODELS: ModelsData = {
|
|
190
|
+
models: {},
|
|
191
|
+
modelList: [],
|
|
192
|
+
defaultModel: null,
|
|
193
|
+
thinkingLevels: {},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export async function loadModelsWithCache(): Promise<ModelsData> {
|
|
197
|
+
const cached = globalThis.__ompChamberModelsCache;
|
|
198
|
+
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
|
199
|
+
const data = await loadModels();
|
|
200
|
+
globalThis.__ompChamberModelsCache = { data, expiresAt: Date.now() + MODELS_CACHE_TTL_MS };
|
|
201
|
+
return data;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function invalidateModelsCache(): void {
|
|
205
|
+
invalidateModelsCaches();
|
|
206
|
+
}
|
|
@@ -22,7 +22,13 @@ import { RpcProcess } from '@/server/lib/omp/rpc/process';
|
|
|
22
22
|
|
|
23
23
|
// Extensions stay ENABLED: they can register models and login providers, and
|
|
24
24
|
// omitting them made the web UI's model/provider lists disagree with the CLI's.
|
|
25
|
-
|
|
25
|
+
//
|
|
26
|
+
// Skills stay ENABLED too. `--no-skills` made `get_available_commands` omit
|
|
27
|
+
// every `skill:<name>` entry (measured: 45 commands without, 52 with), so the
|
|
28
|
+
// composer's `/` popup could not offer the skills omp happily executes — and
|
|
29
|
+
// the chamber's own disk scan missed provider roots omp reads (`~/.agents/skills`).
|
|
30
|
+
// Cost is nil: the ready frame lands at the same ~420ms either way.
|
|
31
|
+
const UTILITY_EXTRA_ARGS = ['--no-session', '--no-lsp'];
|
|
26
32
|
const READY_TIMEOUT_MS = 60_000;
|
|
27
33
|
// Longer than the 60s models-cache TTL on purpose: with idle-kill == TTL every
|
|
28
34
|
// pause past a minute paid a cold multi-second respawn on top of the stale
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The follow-up queue must never wedge.
|
|
8
|
+
*
|
|
9
|
+
* A scheduled delivery whose timer never runs (a `bun --hot` module
|
|
10
|
+
* re-evaluation discards the pending closure; a wedged event loop drops the
|
|
11
|
+
* callback) used to poison the session permanently: the in-flight guard
|
|
12
|
+
* early-returned on the leftover entry, so no later trigger — another
|
|
13
|
+
* agent_end, a tab's mount nudge — could ever schedule again. The queue simply
|
|
14
|
+
* stopped draining, with no error anywhere. Observed live: a dev server whose
|
|
15
|
+
* `setTimeout` from request handlers had stopped firing left every queued
|
|
16
|
+
* follow-up stranded, while a freshly started server delivered the identical
|
|
17
|
+
* item correctly.
|
|
18
|
+
*
|
|
19
|
+
* The store is mocked so the contract under test is delivery's own: which RPCs
|
|
20
|
+
* a claimed item produces, and that a lost timer is superseded rather than
|
|
21
|
+
* allowed to wedge the session. Time is driven by fake timers, so nothing here
|
|
22
|
+
* waits on the wall clock.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterAll, afterEach, beforeEach, describe, expect, jest, mock, test } from 'bun:test';
|
|
26
|
+
import type { QueuedMessage } from '@/shared/types/chat';
|
|
27
|
+
import type { QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
|
|
28
|
+
|
|
29
|
+
/** In-memory stand-in for the SQLite-backed queue. */
|
|
30
|
+
const queue = new Map<string, QueuedMessage[]>();
|
|
31
|
+
|
|
32
|
+
mock.module('@/server/lib/queue/store.server', () => ({
|
|
33
|
+
claimHeadQueueItem: (sessionId: string): QueuedMessage | null => {
|
|
34
|
+
const items = queue.get(sessionId);
|
|
35
|
+
return items?.shift() ?? null;
|
|
36
|
+
},
|
|
37
|
+
requeueHeadQueueItem: async (sessionId: string, item: QueuedMessage): Promise<void> => {
|
|
38
|
+
const items = queue.get(sessionId) ?? [];
|
|
39
|
+
items.unshift(item);
|
|
40
|
+
queue.set(sessionId, items);
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const { scheduleQueueDelivery } = await import('@/server/lib/queue/delivery.server');
|
|
45
|
+
|
|
46
|
+
const MODEL = { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max', accessMode: 'yolo' as const };
|
|
47
|
+
|
|
48
|
+
function queueItem(text: string, model: QueuedMessage['model'] = MODEL): QueuedMessage {
|
|
49
|
+
return { id: `item-${text}`, text, attachments: [], model };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Records the RPCs a delivery pushes at the session. */
|
|
53
|
+
function makeHost(sessionId: string) {
|
|
54
|
+
const sent: Record<string, unknown>[] = [];
|
|
55
|
+
const host: QueueDeliveryHost = {
|
|
56
|
+
sessionId,
|
|
57
|
+
isAlive: () => true,
|
|
58
|
+
isBusy: () => false,
|
|
59
|
+
send: async (command) => {
|
|
60
|
+
sent.push(command);
|
|
61
|
+
return {};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
return { host, sent };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Let the delivery's promise chain settle without advancing the clock. */
|
|
68
|
+
async function flush(): Promise<void> {
|
|
69
|
+
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let sessionId = '';
|
|
73
|
+
let counter = 0;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
sessionId = `queue-test-${counter++}`;
|
|
77
|
+
queue.clear();
|
|
78
|
+
jest.useFakeTimers();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
jest.useRealTimers();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(() => {
|
|
86
|
+
mock.restore();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('queue delivery', () => {
|
|
90
|
+
test('delivers the head with the model snapshot it was queued with', async () => {
|
|
91
|
+
queue.set(sessionId, [queueItem('queued hello')]);
|
|
92
|
+
const { host, sent } = makeHost(sessionId);
|
|
93
|
+
|
|
94
|
+
scheduleQueueDelivery(host);
|
|
95
|
+
jest.advanceTimersByTime(600);
|
|
96
|
+
await flush();
|
|
97
|
+
|
|
98
|
+
expect(sent).toEqual([
|
|
99
|
+
{ type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
|
|
100
|
+
{ type: 'set_thinking_level', level: 'max' },
|
|
101
|
+
{ type: 'prompt', message: 'queued hello', accessMode: 'yolo' },
|
|
102
|
+
]);
|
|
103
|
+
expect(queue.get(sessionId)).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("an 'auto' thinking level is left to the session", async () => {
|
|
107
|
+
queue.set(sessionId, [queueItem('no level', { ...MODEL, thinkingLevel: 'auto' })]);
|
|
108
|
+
const { host, sent } = makeHost(sessionId);
|
|
109
|
+
|
|
110
|
+
scheduleQueueDelivery(host);
|
|
111
|
+
jest.advanceTimersByTime(600);
|
|
112
|
+
await flush();
|
|
113
|
+
|
|
114
|
+
expect(sent).toEqual([
|
|
115
|
+
{ type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
|
|
116
|
+
{ type: 'prompt', message: 'no level', accessMode: 'yolo' },
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('an item with no model snapshot is sent without touching the model', async () => {
|
|
121
|
+
queue.set(sessionId, [queueItem('session model', null)]);
|
|
122
|
+
const { host, sent } = makeHost(sessionId);
|
|
123
|
+
|
|
124
|
+
scheduleQueueDelivery(host);
|
|
125
|
+
jest.advanceTimersByTime(600);
|
|
126
|
+
await flush();
|
|
127
|
+
|
|
128
|
+
expect(sent).toEqual([{ type: 'prompt', message: 'session model' }]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a busy session holds the item instead of sending it', async () => {
|
|
132
|
+
queue.set(sessionId, [queueItem('held')]);
|
|
133
|
+
const { host, sent } = makeHost(sessionId);
|
|
134
|
+
host.isBusy = () => true;
|
|
135
|
+
|
|
136
|
+
scheduleQueueDelivery(host);
|
|
137
|
+
jest.advanceTimersByTime(600);
|
|
138
|
+
await flush();
|
|
139
|
+
|
|
140
|
+
expect(sent).toEqual([]);
|
|
141
|
+
expect(queue.get(sessionId)?.length).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a failed dispatch returns the item to the head', async () => {
|
|
145
|
+
queue.set(sessionId, [queueItem('retry me')]);
|
|
146
|
+
const { host } = makeHost(sessionId);
|
|
147
|
+
host.send = async () => {
|
|
148
|
+
throw new Error('rpc down');
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
scheduleQueueDelivery(host);
|
|
152
|
+
jest.advanceTimersByTime(600);
|
|
153
|
+
await flush();
|
|
154
|
+
|
|
155
|
+
expect(queue.get(sessionId)?.map((i) => i.text)).toEqual(['retry me']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('repeated triggers deliver the head exactly once', async () => {
|
|
159
|
+
queue.set(sessionId, [queueItem('once')]);
|
|
160
|
+
const { host, sent } = makeHost(sessionId);
|
|
161
|
+
|
|
162
|
+
scheduleQueueDelivery(host);
|
|
163
|
+
scheduleQueueDelivery(host);
|
|
164
|
+
scheduleQueueDelivery(host);
|
|
165
|
+
jest.advanceTimersByTime(600);
|
|
166
|
+
await flush();
|
|
167
|
+
|
|
168
|
+
expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
|
|
169
|
+
expect(queue.get(sessionId)).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('a lost timer delays the queue instead of wedging it', async () => {
|
|
173
|
+
queue.set(sessionId, [queueItem('recovered')]);
|
|
174
|
+
const { host, sent } = makeHost(sessionId);
|
|
175
|
+
|
|
176
|
+
// A delivery was scheduled but its callback never ran — the entry is left
|
|
177
|
+
// behind, which is exactly what a hot-reloaded module or a wedged event
|
|
178
|
+
// loop leaves. `clearAllTimers` drops the pending callback WITHOUT running
|
|
179
|
+
// it, so the bookkeeping outlives the timer.
|
|
180
|
+
scheduleQueueDelivery(host);
|
|
181
|
+
jest.clearAllTimers();
|
|
182
|
+
jest.advanceTimersByTime(600);
|
|
183
|
+
await flush();
|
|
184
|
+
expect(sent).toEqual([]);
|
|
185
|
+
|
|
186
|
+
// Inside the staleness bound a new trigger still defers to the pending
|
|
187
|
+
// entry: the head must not be double-booked.
|
|
188
|
+
jest.advanceTimersByTime(1000);
|
|
189
|
+
scheduleQueueDelivery(host);
|
|
190
|
+
jest.advanceTimersByTime(600);
|
|
191
|
+
await flush();
|
|
192
|
+
expect(sent).toEqual([]);
|
|
193
|
+
|
|
194
|
+
// Past it, a later trigger recovers the queue rather than giving up.
|
|
195
|
+
jest.advanceTimersByTime(5000);
|
|
196
|
+
scheduleQueueDelivery(host);
|
|
197
|
+
jest.advanceTimersByTime(600);
|
|
198
|
+
await flush();
|
|
199
|
+
|
|
200
|
+
expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
|
|
201
|
+
expect(queue.get(sessionId)).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -28,16 +28,36 @@ import type { QueuedMessage } from '@/shared/types/chat';
|
|
|
28
28
|
* the same child, and an instant dispatch races it. */
|
|
29
29
|
const DELIVERY_DELAY_MS = 500;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* How long a scheduled-but-not-yet-run delivery may sit before the next
|
|
33
|
+
* trigger supersedes it.
|
|
34
|
+
*
|
|
35
|
+
* Without this, one lost timer wedges the session's queue FOREVER: the guard
|
|
36
|
+
* below early-returns on any existing entry, so nothing would ever schedule
|
|
37
|
+
* again. A timer can be lost without the process dying — `bun --hot`
|
|
38
|
+
* re-evaluating this module discards the pending closure, and a wedged event
|
|
39
|
+
* loop drops the callback — and the symptom is silent: the queue simply never
|
|
40
|
+
* drains and no error is reported. Past this bound the entry is presumed lost
|
|
41
|
+
* and rescheduled, so a client nudge (sent whenever a session with queued
|
|
42
|
+
* items mounts or regains focus) always recovers the queue.
|
|
43
|
+
*/
|
|
44
|
+
const DELIVERY_STALE_MS = 5_000;
|
|
45
|
+
|
|
46
|
+
interface PendingDelivery {
|
|
47
|
+
timer: ReturnType<typeof setTimeout>;
|
|
48
|
+
/** Wall-clock after which this entry is presumed lost and superseded. */
|
|
49
|
+
expiresAt: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
/** Per-session pending delivery timer, so a second agent_end while one is
|
|
32
53
|
* already scheduled cannot double-book the head. */
|
|
33
|
-
const pendingTimers = new Map<string,
|
|
54
|
+
const pendingTimers = new Map<string, PendingDelivery>();
|
|
34
55
|
|
|
35
56
|
function clearPendingTimer(sessionId: string): void {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
57
|
+
const entry = pendingTimers.get(sessionId);
|
|
58
|
+
if (entry === undefined) return;
|
|
59
|
+
clearTimeout(entry.timer);
|
|
60
|
+
pendingTimers.delete(sessionId);
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
/** Send one queued item to the session as a normal prompt. The model snapshot
|
|
@@ -79,15 +99,25 @@ export interface QueueDeliveryHost {
|
|
|
79
99
|
* Schedule the next queued delivery for this session. Called on terminal
|
|
80
100
|
* agent_end and on a client's mount nudge. Idempotent: an in-flight timer is
|
|
81
101
|
* reused, a claimed head can only be won by one caller.
|
|
102
|
+
*
|
|
103
|
+
* A pending entry older than {@link DELIVERY_STALE_MS} is presumed lost (see
|
|
104
|
+
* that constant) and replaced, so a dropped timer delays the queue instead of
|
|
105
|
+
* wedging it.
|
|
82
106
|
*/
|
|
83
107
|
export function scheduleQueueDelivery(session: QueueDeliveryHost): void {
|
|
84
108
|
const sessionId = session.sessionId;
|
|
85
|
-
if (!sessionId
|
|
109
|
+
if (!sessionId) return;
|
|
110
|
+
const pending = pendingTimers.get(sessionId);
|
|
111
|
+
if (pending !== undefined) {
|
|
112
|
+
if (Date.now() < pending.expiresAt) return;
|
|
113
|
+
// Lost timer: clear it so its callback cannot also fire and double-deliver.
|
|
114
|
+
clearTimeout(pending.timer);
|
|
115
|
+
}
|
|
86
116
|
const timer = setTimeout(() => {
|
|
87
117
|
pendingTimers.delete(sessionId);
|
|
88
118
|
void deliverNext(session);
|
|
89
119
|
}, DELIVERY_DELAY_MS);
|
|
90
|
-
pendingTimers.set(sessionId, timer);
|
|
120
|
+
pendingTimers.set(sessionId, { timer, expiresAt: Date.now() + DELIVERY_STALE_MS });
|
|
91
121
|
}
|
|
92
122
|
|
|
93
123
|
async function deliverNext(session: QueueDeliveryHost): Promise<void> {
|