@kabmo-labs/kabmo 0.3.1 → 0.5.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 +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-cs3b557t.js} +792 -792
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
- package/src/client/components/mobile/RightSidebar.tsx +30 -36
- package/src/client/components/mobile/SessionSidebar.tsx +124 -101
- package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +47 -14
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
- package/src/client/components/mobile/panel-chips.test.tsx +155 -0
- package/src/client/components/mobile/panel-chips.tsx +74 -0
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +15 -10
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +162 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/ui/toasts.ts +12 -1
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/ui/titlebar.test.ts +124 -0
- package/src/shared/lib/ui/titlebar.ts +54 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
- package/src/shared/lib/workspace/mobile-dock.ts +55 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- package/src/test-support/silent-socket.ts +69 -0
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -46,7 +46,7 @@ import { toManifest, toMarketplaceCatalog } from '@/server/lib/panels/manifest';
|
|
|
46
46
|
import { basenameOf, bundleUrl, cssUrl, iconUrl, readmeUrlFor } from '@/server/lib/panels/assets.server';
|
|
47
47
|
import { readBundledCatalog } from '@/server/lib/panels/bundled-catalog.server';
|
|
48
48
|
import { describeIncompatibility, runningVersions } from '@/server/lib/panels/compat.server';
|
|
49
|
-
import {
|
|
49
|
+
import { pluginEnablementKey } from '@/shared/lib/workspace/panel-ids';
|
|
50
50
|
|
|
51
51
|
/** The marketplace root: `<data>/marketplace`, overridable for tests. */
|
|
52
52
|
export function getMarketplaceDir(): string {
|
|
@@ -205,7 +205,7 @@ async function runPanelScan(): Promise<PanelScan> {
|
|
|
205
205
|
// panel id (`plugin:<id>`), so one stored list answers for both kinds. Testing
|
|
206
206
|
// the bare manifest id here made a disabled plugin still report `enabled:
|
|
207
207
|
// true` while its panel stayed hidden — two answers for one question.
|
|
208
|
-
const enabled = !disabled.has(
|
|
208
|
+
const enabled = !disabled.has(pluginEnablementKey(manifest.id));
|
|
209
209
|
const entries = [manifest.app, manifest.server].filter((entry): entry is string => Boolean(entry));
|
|
210
210
|
let missingEntry: string | undefined;
|
|
211
211
|
for (const entry of entries) {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { claimHeadQueueItem, hasQueuedItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
|
|
24
|
-
import {
|
|
24
|
+
import { applyModelSnapshot, promptCommand } from '@/server/lib/queue/payload';
|
|
25
|
+
import { requestWaitingSweep } from '@/server/lib/waiting/sweep-trigger';
|
|
25
26
|
import type { QueuedMessage } from '@/shared/types/chat';
|
|
26
27
|
|
|
27
28
|
/** Settling time between agent_end and the next prompt dispatch. Small on
|
|
@@ -94,45 +95,15 @@ function clearPendingTimer(sessionId: string): void {
|
|
|
94
95
|
|
|
95
96
|
/** Send one queued item to the session as a normal prompt. The model snapshot
|
|
96
97
|
* rides first (set_model / set_thinking_level) exactly like the composer's
|
|
97
|
-
* queued delivery did. Returns false when the dispatch failed.
|
|
98
|
+
* queued delivery did. Returns false when the dispatch failed.
|
|
99
|
+
*
|
|
100
|
+
* The command itself is built by `queue/payload.ts`, shared with the waiting
|
|
101
|
+
* prompt's dispatcher: both send a prompt nobody typed in this tab, from the
|
|
102
|
+
* same persisted shape. */
|
|
98
103
|
async function dispatchQueuedItem(session: QueueDeliveryHost, item: QueuedMessage): Promise<boolean> {
|
|
99
104
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (item.model.thinkingLevel !== 'auto') {
|
|
103
|
-
await session.send({ type: 'set_thinking_level', level: item.model.thinkingLevel });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// Attachments reach this point as the persisted display fields — the live
|
|
107
|
-
// `File` never survived the queue's JSON round trip. The base64 payload and
|
|
108
|
-
// the MIME type are therefore read from the fields that DID persist:
|
|
109
|
-
// `dataBase64` for the bytes, `type` for the media type. (Matching on a
|
|
110
|
-
// `data:` preview used to yield zero images, because the composer's preview
|
|
111
|
-
// is a `blob:` object URL that the server cannot read.)
|
|
112
|
-
const images = item.attachments
|
|
113
|
-
.filter((a) => typeof a?.dataBase64 === 'string' && a.dataBase64.length > 0)
|
|
114
|
-
.map((a) => ({
|
|
115
|
-
type: 'image' as const,
|
|
116
|
-
data: a.dataBase64 as string,
|
|
117
|
-
mimeType: typeof a.type === 'string' && a.type.startsWith('image/') ? a.type : 'image/png',
|
|
118
|
-
}));
|
|
119
|
-
// Text attachments are inlined into the prompt, exactly as the composer
|
|
120
|
-
// does at send time — the item's own `text` is the raw composer input, so
|
|
121
|
-
// without this a queued file would deliver as a bare filename mention.
|
|
122
|
-
const textFiles = item.attachments
|
|
123
|
-
.filter((a) => typeof a?.content === 'string' && a.content.length > 0)
|
|
124
|
-
.map((a) => ({
|
|
125
|
-
name: typeof a.name === 'string' && a.name ? a.name : 'attachment',
|
|
126
|
-
mimeType: typeof a.type === 'string' ? a.type : 'text/plain',
|
|
127
|
-
content: a.content as string,
|
|
128
|
-
size: typeof a.size === 'number' ? a.size : (a.content as string).length,
|
|
129
|
-
}));
|
|
130
|
-
await session.send({
|
|
131
|
-
type: 'prompt',
|
|
132
|
-
message: composeMessageWithTextAttachments(item.text, textFiles),
|
|
133
|
-
...(images.length ? { images } : {}),
|
|
134
|
-
...(item.model ? { accessMode: item.model.accessMode } : {}),
|
|
135
|
-
});
|
|
105
|
+
await applyModelSnapshot(session.send, item.model);
|
|
106
|
+
await session.send(promptCommand({ text: item.text, attachments: item.attachments, model: item.model }));
|
|
136
107
|
return true;
|
|
137
108
|
} catch (error) {
|
|
138
109
|
console.error(`[queue] delivery failed for session ${session.sessionId}:`, error);
|
|
@@ -231,7 +202,13 @@ async function deliverNext(session: QueueDeliveryHost): Promise<boolean> {
|
|
|
231
202
|
return false;
|
|
232
203
|
}
|
|
233
204
|
const item = await claimHeadQueueItem(sessionId);
|
|
234
|
-
if (!item)
|
|
205
|
+
if (!item) {
|
|
206
|
+
// The queue just drained: a prompt held until this session finished may now
|
|
207
|
+
// be free to go. The sweep re-checks the queue itself, so requesting one
|
|
208
|
+
// here cannot release a prompt ahead of an item that is still waiting.
|
|
209
|
+
requestWaitingSweep();
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
235
212
|
const ok = await dispatchQueuedItem(session, item);
|
|
236
213
|
if (!ok) {
|
|
237
214
|
await requeueHeadQueueItem(sessionId, item);
|
|
@@ -240,9 +217,24 @@ async function deliverNext(session: QueueDeliveryHost): Promise<boolean> {
|
|
|
240
217
|
await armRetryIfQueued(session);
|
|
241
218
|
return false;
|
|
242
219
|
}
|
|
220
|
+
// This delivery may have been the last one: ask for a sweep so a dependent
|
|
221
|
+
// prompt does not sit until the next tick after the queue emptied.
|
|
222
|
+
requestWaitingSweep();
|
|
243
223
|
return true;
|
|
244
224
|
}
|
|
245
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Whether this process already holds a delivery slot for the session.
|
|
228
|
+
*
|
|
229
|
+
* Read by the waiting-prompt sweep: a run end arms a delivery 500 ms out, so a
|
|
230
|
+
* dependency that looks settled for a moment may still have its queued
|
|
231
|
+
* follow-up about to go out. Waiting for the slot to clear is what keeps a
|
|
232
|
+
* dependent prompt from starting a second turn in the gap.
|
|
233
|
+
*/
|
|
234
|
+
export function hasPendingDelivery(sessionId: string): boolean {
|
|
235
|
+
return getPendingTimers().has(sessionId);
|
|
236
|
+
}
|
|
237
|
+
|
|
246
238
|
/**
|
|
247
239
|
* A user Stop: return anything already claimed for this session's pending
|
|
248
240
|
* delivery to the queue and cancel the timer. The item keeps its head
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one place a stored prompt becomes an omp command.
|
|
8
|
+
*
|
|
9
|
+
* Two dispatchers send prompts nobody typed in this tab: the follow-up queue
|
|
10
|
+
* (`queue/delivery.server.ts`) and a prompt held until another session finishes
|
|
11
|
+
* (`waiting/runtime.server.ts`). They carry the SAME persisted shape — a text,
|
|
12
|
+
* an attachment list whose live `File` did not survive the JSON round trip, and
|
|
13
|
+
* an optional model snapshot — so a second copy of this assembly is how the two
|
|
14
|
+
* drift (one inlining a text file, the other sending a bare filename).
|
|
15
|
+
*
|
|
16
|
+
* The attachment rules are read off the fields that DID persist:
|
|
17
|
+
*
|
|
18
|
+
* - `dataBase64` holds an image's bytes; `type` its media type. Matching on a
|
|
19
|
+
* `data:` preview yields zero images, because the composer's preview is a
|
|
20
|
+
* `blob:` object URL the server cannot read.
|
|
21
|
+
* - `content` is an inlined text file. Without it a queued attachment
|
|
22
|
+
* delivers as a filename mention instead of the file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Attachment, QueuedMessageModel } from '@/shared/types/chat';
|
|
26
|
+
import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
|
|
27
|
+
|
|
28
|
+
export interface StoredPrompt {
|
|
29
|
+
text: string;
|
|
30
|
+
attachments: Attachment[];
|
|
31
|
+
model: QueuedMessageModel | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `prompt` command (with any images) a stored prompt dispatches. */
|
|
35
|
+
export function promptCommand(prompt: StoredPrompt): Record<string, unknown> {
|
|
36
|
+
const images = prompt.attachments
|
|
37
|
+
.filter((attachment) => typeof attachment?.dataBase64 === 'string' && attachment.dataBase64.length > 0)
|
|
38
|
+
.map((attachment) => ({
|
|
39
|
+
type: 'image' as const,
|
|
40
|
+
data: attachment.dataBase64 as string,
|
|
41
|
+
mimeType: typeof attachment.type === 'string' && attachment.type.startsWith('image/')
|
|
42
|
+
? attachment.type
|
|
43
|
+
: 'image/png',
|
|
44
|
+
}));
|
|
45
|
+
const textFiles = prompt.attachments
|
|
46
|
+
.filter((attachment) => typeof attachment?.content === 'string' && attachment.content.length > 0)
|
|
47
|
+
.map((attachment) => ({
|
|
48
|
+
name: typeof attachment.name === 'string' && attachment.name ? attachment.name : 'attachment',
|
|
49
|
+
mimeType: typeof attachment.type === 'string' ? attachment.type : 'text/plain',
|
|
50
|
+
content: attachment.content as string,
|
|
51
|
+
size: typeof attachment.size === 'number' ? attachment.size : (attachment.content as string).length,
|
|
52
|
+
}));
|
|
53
|
+
return {
|
|
54
|
+
type: 'prompt',
|
|
55
|
+
message: composeMessageWithTextAttachments(prompt.text, textFiles),
|
|
56
|
+
...(images.length ? { images } : {}),
|
|
57
|
+
...(prompt.model ? { accessMode: prompt.model.accessMode } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Apply a stored model snapshot to a live child before its prompt. */
|
|
62
|
+
export async function applyModelSnapshot(
|
|
63
|
+
send: (command: Record<string, unknown>) => Promise<unknown>,
|
|
64
|
+
model: QueuedMessageModel | null,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
if (!model) return;
|
|
67
|
+
await send({ type: 'set_model', provider: model.provider, modelId: model.modelId });
|
|
68
|
+
// 'auto' thinking leaves omp's level alone: the snapshot records "no pick",
|
|
69
|
+
// not a level named auto.
|
|
70
|
+
if (model.thinkingLevel !== 'auto') {
|
|
71
|
+
await send({ type: 'set_thinking_level', level: model.thinkingLevel });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -56,7 +56,16 @@ export type RealtimeSignal =
|
|
|
56
56
|
* snapshot and then never moved — the panels rendered whatever existed when
|
|
57
57
|
* they subscribed and only a reload showed the new state.
|
|
58
58
|
*/
|
|
59
|
-
| 'session-data-dirty'
|
|
59
|
+
| 'session-data-dirty'
|
|
60
|
+
/**
|
|
61
|
+
* A waiting prompt was created, cancelled, dispatched or failed.
|
|
62
|
+
*
|
|
63
|
+
* Both halves read it: the server republishes the `waiting` topic, and the
|
|
64
|
+
* client's dispatch watcher adopts the session a prompt became. Nothing else
|
|
65
|
+
* reports a dispatch — no user action causes it, and the spawned session's own
|
|
66
|
+
* transcript is seconds away.
|
|
67
|
+
*/
|
|
68
|
+
| 'waiting-changed';
|
|
60
69
|
|
|
61
70
|
export interface RealtimeSignalEvent {
|
|
62
71
|
signal: RealtimeSignal;
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
TOPIC_SIDEBAR,
|
|
23
23
|
TOPIC_SIDEBAR_STATUS,
|
|
24
24
|
TOPIC_USAGE,
|
|
25
|
+
TOPIC_WAITING,
|
|
25
26
|
sessionPlanTopic,
|
|
26
27
|
sessionQueueTopic,
|
|
27
28
|
sessionTelemetryTopic,
|
|
@@ -67,6 +68,7 @@ export const EXACT_TOPICS = new Map<string, TopicDescriptor>([
|
|
|
67
68
|
[TOPIC_USAGE, { resolve: () => globalDescriptor(TOPIC_USAGE).resolve(TOPIC_USAGE) }],
|
|
68
69
|
[TOPIC_PANELS, { resolve: () => globalDescriptor(TOPIC_PANELS).resolve(TOPIC_PANELS) }],
|
|
69
70
|
[TOPIC_MODELS, { resolve: () => globalDescriptor(TOPIC_MODELS).resolve(TOPIC_MODELS) }],
|
|
71
|
+
[TOPIC_WAITING, { resolve: () => globalDescriptor(TOPIC_WAITING).resolve(TOPIC_WAITING) }],
|
|
70
72
|
]);
|
|
71
73
|
|
|
72
74
|
/** Suffixes a `session:<id>:<suffix>` topic may carry. */
|
|
@@ -20,9 +20,11 @@ import {
|
|
|
20
20
|
TOPIC_PANELS,
|
|
21
21
|
TOPIC_SCHEDULE,
|
|
22
22
|
TOPIC_USAGE,
|
|
23
|
+
TOPIC_WAITING,
|
|
23
24
|
} from '@/shared/lib/realtime/protocol';
|
|
24
25
|
import { type TopicDescriptor } from '@/server/lib/realtime/hub.server';
|
|
25
26
|
import { listScheduledTasks } from '@/server/lib/schedule/store.server';
|
|
27
|
+
import { listWaitingPrompts } from '@/server/lib/waiting/store.server';
|
|
26
28
|
import { buildUsageReport } from '@/server/lib/usage/report.server';
|
|
27
29
|
import { discoverPanelPlugins } from '@/server/lib/panels/registry.server';
|
|
28
30
|
import { loadModelsWithCache } from '@/server/lib/models/registry.server';
|
|
@@ -34,6 +36,9 @@ import { loadModelsWithCache } from '@/server/lib/models/registry.server';
|
|
|
34
36
|
*/
|
|
35
37
|
const RESOLVERS: Record<string, () => Promise<unknown>> = {
|
|
36
38
|
[TOPIC_SCHEDULE]: () => listScheduledTasks(),
|
|
39
|
+
// The waiting list is the same read the runtime sweeps over, so a client and
|
|
40
|
+
// the dispatcher can never disagree about which prompt is still held.
|
|
41
|
+
[TOPIC_WAITING]: () => listWaitingPrompts(),
|
|
37
42
|
// The SAME builder `GET /api/settings/usage` answers with: the panel renders
|
|
38
43
|
// whichever path arrived first, so the two payloads must not diverge.
|
|
39
44
|
[TOPIC_USAGE]: () => buildUsageReport(),
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
TOPIC_SIDEBAR,
|
|
21
21
|
TOPIC_SIDEBAR_STATUS,
|
|
22
22
|
TOPIC_USAGE,
|
|
23
|
+
TOPIC_WAITING,
|
|
23
24
|
isValidTopic,
|
|
24
25
|
reposTopic,
|
|
25
26
|
sessionTopic,
|
|
@@ -93,6 +94,7 @@ export function initRealtimeTopics(): void {
|
|
|
93
94
|
else if (signal === 'workspace-dirty') republishWatchedWorkspaceTopics();
|
|
94
95
|
else if (signal === 'session-data-dirty' && sessionId) republishSessionDataTopics(sessionId);
|
|
95
96
|
else if (signal === 'repos-scanned' && root) publishTopic(reposTopic(root));
|
|
97
|
+
else if (signal === 'waiting-changed') publishTopic(TOPIC_WAITING);
|
|
96
98
|
});
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reject a promise that has not settled within `ms`.
|
|
8
|
+
*
|
|
9
|
+
* Both server-side dispatchers (a scheduled task, a prompt held until another
|
|
10
|
+
* session finishes) wait on a spawn and then on a prompt ack, and neither has a
|
|
11
|
+
* timeout of its own: an omp child that boots but never answers would leave the
|
|
12
|
+
* dispatch in flight for the life of the process. The caller owns the message,
|
|
13
|
+
* because it is the only place that knows what was being waited for.
|
|
14
|
+
*/
|
|
15
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
16
|
+
const { promise: guarded, resolve, reject } = Promise.withResolvers<T>();
|
|
17
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
18
|
+
promise.then(
|
|
19
|
+
(value) => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
resolve(value);
|
|
22
|
+
},
|
|
23
|
+
(error) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
reject(error);
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
return guarded;
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* When was a session's stream row last written?
|
|
8
|
+
*
|
|
9
|
+
* The dependency check needs this and the SIDEBAR does not, which is why it is
|
|
10
|
+
* a query of its own rather than a field on `SessionStreamState`: that payload
|
|
11
|
+
* is the `sidebar:status` wire contract, and widening it for one caller would
|
|
12
|
+
* put a field on every frame that no consumer reads.
|
|
13
|
+
*
|
|
14
|
+
* The conversion happens in SQLite. `updated_at` is written by
|
|
15
|
+
* `CURRENT_TIMESTAMP` — a UTC string with no zone marker — so parsing it in JS
|
|
16
|
+
* would silently read it as local time. `strftime('%s', …)` yields epoch
|
|
17
|
+
* seconds, which is the resolution the row is written at.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getDb } from '@/server/db.server';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Epoch ms of the session's last stream-status write, or null when the session
|
|
24
|
+
* has no row (or the clock cannot be read).
|
|
25
|
+
*/
|
|
26
|
+
export async function streamRowWrittenAt(sessionId: string): Promise<number | null> {
|
|
27
|
+
try {
|
|
28
|
+
const db = await getDb();
|
|
29
|
+
const row = (await db.get(
|
|
30
|
+
"SELECT CAST(strftime('%s', updated_at) AS INTEGER) AS epoch FROM session_stream_state WHERE session_id = ?",
|
|
31
|
+
[sessionId],
|
|
32
|
+
)) as { epoch?: number | null } | undefined;
|
|
33
|
+
return typeof row?.epoch === 'number' ? row.epoch * 1000 : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The waiting-prompt sweep: what it dispatches, what it holds, and what happens
|
|
8
|
+
* when the dispatch fails.
|
|
9
|
+
*
|
|
10
|
+
* Every outside dependency is INJECTED (`WaitingDeps`), never mocked through
|
|
11
|
+
* `mock.module` — that swaps a registry entry for the whole `bun test` run and
|
|
12
|
+
* broke six unrelated suites the last time it was tried here. Injection keeps
|
|
13
|
+
* the blast radius inside this file, and the rules worth pinning are all about
|
|
14
|
+
* ORDER rather than about omp:
|
|
15
|
+
*
|
|
16
|
+
* - a prompt is dispatched exactly once, even for two sweeps that race;
|
|
17
|
+
* - a dependency with a queue still waiting holds the prompt;
|
|
18
|
+
* - a dispatch that throws is retried, then reported, and never silently
|
|
19
|
+
* dropped;
|
|
20
|
+
* - the prompt carries its attachments and its model snapshot.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, test } from 'bun:test';
|
|
24
|
+
import { sweepWaitingPrompts, MAX_DISPATCH_ATTEMPTS, type StreamRowMap, type WaitingDeps } from '@/server/lib/waiting/runtime.server';
|
|
25
|
+
import type { Attachment } from '@/shared/types/chat';
|
|
26
|
+
import type { WaitingPrompt } from '@/shared/types/waiting';
|
|
27
|
+
|
|
28
|
+
const NOW = 1_800_000_000_000;
|
|
29
|
+
const OLD = NOW - 60_000;
|
|
30
|
+
|
|
31
|
+
function prompt(overrides: Partial<WaitingPrompt> = {}): WaitingPrompt {
|
|
32
|
+
return {
|
|
33
|
+
id: 'wait-1',
|
|
34
|
+
pendingId: 'new-1',
|
|
35
|
+
dependsOn: 'dep-a',
|
|
36
|
+
dependsOnTitle: 'Session A',
|
|
37
|
+
prompt: 'do the thing',
|
|
38
|
+
attachments: [],
|
|
39
|
+
model: null,
|
|
40
|
+
modes: null,
|
|
41
|
+
cwd: '/tmp/project',
|
|
42
|
+
folderId: 1,
|
|
43
|
+
status: 'waiting',
|
|
44
|
+
sessionId: null,
|
|
45
|
+
error: null,
|
|
46
|
+
createdAt: OLD,
|
|
47
|
+
updatedAt: OLD,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Harness {
|
|
53
|
+
deps: WaitingDeps;
|
|
54
|
+
/** Commands sent to each spawned session, in order. */
|
|
55
|
+
sent: Record<string, Record<string, unknown>[]>;
|
|
56
|
+
/** Session ids spawned, in order. */
|
|
57
|
+
spawned: string[];
|
|
58
|
+
/** Calls that changed a row, as `verb id`. */
|
|
59
|
+
writes: string[];
|
|
60
|
+
setStreams(rows: StreamRowMap): void;
|
|
61
|
+
setRowWrittenAt(at: number | null): void;
|
|
62
|
+
setQueued(queued: boolean): void;
|
|
63
|
+
setDelivering(delivering: boolean): void;
|
|
64
|
+
failSpawnWith(error: Error | null): void;
|
|
65
|
+
failSendWith(error: Error | null): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function harness(initial: WaitingPrompt[] = [prompt()]): Harness {
|
|
69
|
+
// The attempt counter is the store's own column, not part of the client-facing
|
|
70
|
+
// shape: the harness keeps it beside the rows rather than widening `WaitingPrompt`.
|
|
71
|
+
const attempts = new Map<string, number>();
|
|
72
|
+
let rows = initial.map((row) => ({ ...row }));
|
|
73
|
+
let streams: StreamRowMap = { 'dep-a': { status: 'finish' } };
|
|
74
|
+
let rowWrittenAt: number | null = OLD;
|
|
75
|
+
let queued = false;
|
|
76
|
+
let delivering = false;
|
|
77
|
+
let spawnError: Error | null = null;
|
|
78
|
+
let sendError: Error | null = null;
|
|
79
|
+
const sent: Harness['sent'] = {};
|
|
80
|
+
const spawned: string[] = [];
|
|
81
|
+
const writes: string[] = [];
|
|
82
|
+
let spawnCount = 0;
|
|
83
|
+
|
|
84
|
+
const deps: WaitingDeps = {
|
|
85
|
+
async startNewRpcSession() {
|
|
86
|
+
if (spawnError) throw spawnError;
|
|
87
|
+
spawnCount += 1;
|
|
88
|
+
const id = `spawned-${spawnCount}`;
|
|
89
|
+
spawned.push(id);
|
|
90
|
+
sent[id] = [];
|
|
91
|
+
return {
|
|
92
|
+
realSessionId: id,
|
|
93
|
+
session: {
|
|
94
|
+
async send(command) {
|
|
95
|
+
if (sendError) throw sendError;
|
|
96
|
+
sent[id].push(command);
|
|
97
|
+
return null;
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
getSession: () => undefined,
|
|
103
|
+
loadStreamStates: async () => streams,
|
|
104
|
+
streamRowWrittenAt: async () => rowWrittenAt,
|
|
105
|
+
hasQueuedItem: async () => queued,
|
|
106
|
+
hasPendingDelivery: () => delivering,
|
|
107
|
+
loadPersistedAccessMode: async () => 'always-ask',
|
|
108
|
+
listWaitingPrompts: async () => rows.map((row) => ({ ...row })),
|
|
109
|
+
claimWaitingPrompt: async (id) => {
|
|
110
|
+
const row = rows.find((entry) => entry.id === id);
|
|
111
|
+
if (!row || row.status !== 'waiting') return false;
|
|
112
|
+
row.status = 'dispatched';
|
|
113
|
+
writes.push(`claim ${id}`);
|
|
114
|
+
return true;
|
|
115
|
+
},
|
|
116
|
+
countWaitingAttempt: async (id) => {
|
|
117
|
+
const next = (attempts.get(id) ?? 0) + 1;
|
|
118
|
+
attempts.set(id, next);
|
|
119
|
+
return next;
|
|
120
|
+
},
|
|
121
|
+
markWaitingDispatched: async (id, sessionId) => {
|
|
122
|
+
const row = rows.find((entry) => entry.id === id);
|
|
123
|
+
if (row) {
|
|
124
|
+
row.status = 'dispatched';
|
|
125
|
+
row.sessionId = sessionId;
|
|
126
|
+
}
|
|
127
|
+
writes.push(`dispatched ${id} ${sessionId}`);
|
|
128
|
+
},
|
|
129
|
+
requeueWaitingPrompt: async (id) => {
|
|
130
|
+
const row = rows.find((entry) => entry.id === id);
|
|
131
|
+
if (row) row.status = 'waiting';
|
|
132
|
+
writes.push(`requeue ${id}`);
|
|
133
|
+
},
|
|
134
|
+
failWaitingPrompt: async (id, error) => {
|
|
135
|
+
const row = rows.find((entry) => entry.id === id);
|
|
136
|
+
if (row) {
|
|
137
|
+
row.status = 'error';
|
|
138
|
+
row.error = error;
|
|
139
|
+
}
|
|
140
|
+
writes.push(`fail ${id}`);
|
|
141
|
+
},
|
|
142
|
+
pruneWaitingPrompts: async () => 0,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
deps,
|
|
147
|
+
sent,
|
|
148
|
+
spawned,
|
|
149
|
+
writes,
|
|
150
|
+
setStreams: (next) => {
|
|
151
|
+
streams = next;
|
|
152
|
+
},
|
|
153
|
+
setRowWrittenAt: (next) => {
|
|
154
|
+
rowWrittenAt = next;
|
|
155
|
+
},
|
|
156
|
+
setQueued: (next) => {
|
|
157
|
+
queued = next;
|
|
158
|
+
},
|
|
159
|
+
setDelivering: (next) => {
|
|
160
|
+
delivering = next;
|
|
161
|
+
},
|
|
162
|
+
failSpawnWith: (error) => {
|
|
163
|
+
spawnError = error;
|
|
164
|
+
},
|
|
165
|
+
failSendWith: (error) => {
|
|
166
|
+
sendError = error;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe('sweepWaitingPrompts', () => {
|
|
172
|
+
test('dispatches a prompt once its dependency has finished', async () => {
|
|
173
|
+
const h = harness();
|
|
174
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(1);
|
|
175
|
+
expect(h.spawned).toEqual(['spawned-1']);
|
|
176
|
+
expect(h.sent['spawned-1']).toEqual([{ type: 'prompt', message: 'do the thing' }]);
|
|
177
|
+
expect(h.writes).toContain('dispatched wait-1 spawned-1');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('holds the prompt while the dependency still has a queued follow-up', async () => {
|
|
181
|
+
const h = harness();
|
|
182
|
+
h.setQueued(true);
|
|
183
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
184
|
+
expect(h.spawned).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('holds the prompt while the dependency is still streaming', async () => {
|
|
188
|
+
const h = harness();
|
|
189
|
+
h.setStreams({ 'dep-a': { status: 'stream' } });
|
|
190
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The row's clock is what closes the gap between a run end and the queue it
|
|
194
|
+
// arms 500 ms later, so a freshly written row must not release anything yet.
|
|
195
|
+
test('holds the prompt until the status row has aged past the grace', async () => {
|
|
196
|
+
const h = harness();
|
|
197
|
+
h.setRowWrittenAt(NOW - 100);
|
|
198
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
199
|
+
expect(h.spawned).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('holds the prompt while a delivery slot is armed', async () => {
|
|
203
|
+
const h = harness();
|
|
204
|
+
h.setDelivering(true);
|
|
205
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// The claim is the concurrency answer: the second sweep must not spawn again.
|
|
209
|
+
test('two sweeps racing spawn exactly one session', async () => {
|
|
210
|
+
const h = harness();
|
|
211
|
+
const [first, second] = await Promise.all([
|
|
212
|
+
sweepWaitingPrompts({ now: NOW, deps: h.deps }),
|
|
213
|
+
sweepWaitingPrompts({ now: NOW, deps: h.deps }),
|
|
214
|
+
]);
|
|
215
|
+
expect(first + second).toBe(1);
|
|
216
|
+
expect(h.spawned).toHaveLength(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('a failed spawn returns the prompt to waiting instead of losing it', async () => {
|
|
220
|
+
const h = harness();
|
|
221
|
+
h.failSpawnWith(new Error('omp did not start'));
|
|
222
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
223
|
+
expect(h.writes).toContain('requeue wait-1');
|
|
224
|
+
expect(h.writes).not.toContain('fail wait-1');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a prompt whose dispatch keeps failing is reported, not retried forever', async () => {
|
|
228
|
+
const h = harness();
|
|
229
|
+
h.failSpawnWith(new Error('omp did not start'));
|
|
230
|
+
for (let attempt = 0; attempt < MAX_DISPATCH_ATTEMPTS; attempt += 1) {
|
|
231
|
+
await sweepWaitingPrompts({ now: NOW, deps: h.deps });
|
|
232
|
+
}
|
|
233
|
+
expect(h.writes.filter((write) => write.startsWith('fail '))).toHaveLength(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// The wrapper's `send` reads its own runtime state through `this`, so a
|
|
237
|
+
// dispatch that passes the method detached runs against `undefined` — the
|
|
238
|
+
// real failure was `undefined is not an object (evaluating 'host.restarting')`
|
|
239
|
+
// on every prompt. The harness's send is an object METHOD for that reason,
|
|
240
|
+
// and this asserts the dispatcher never loses its receiver.
|
|
241
|
+
test('sends through the session, never a detached method', async () => {
|
|
242
|
+
const h = harness();
|
|
243
|
+
let receiver: unknown = null;
|
|
244
|
+
h.deps.startNewRpcSession = async () => {
|
|
245
|
+
const session = {
|
|
246
|
+
async send(this: unknown) {
|
|
247
|
+
receiver = this;
|
|
248
|
+
return null;
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
return { realSessionId: 'spawned-x', session };
|
|
252
|
+
};
|
|
253
|
+
await sweepWaitingPrompts({ now: NOW, deps: h.deps });
|
|
254
|
+
expect(receiver).not.toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('carries the attachment bytes and the model snapshot', async () => {
|
|
258
|
+
const attachment: Attachment = { id: 'a1', preview: '', name: 'shot.png', type: 'image/png', dataBase64: 'AAA' };
|
|
259
|
+
const h = harness([
|
|
260
|
+
prompt({
|
|
261
|
+
attachments: [attachment],
|
|
262
|
+
model: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'high', accessMode: 'yolo' },
|
|
263
|
+
}),
|
|
264
|
+
]);
|
|
265
|
+
await sweepWaitingPrompts({ now: NOW, deps: h.deps });
|
|
266
|
+
const commands = h.sent['spawned-1'];
|
|
267
|
+
expect(commands[0]).toEqual({ type: 'set_model', provider: 'anthropic', modelId: 'claude' });
|
|
268
|
+
expect(commands[1]).toEqual({ type: 'set_thinking_level', level: 'high' });
|
|
269
|
+
expect(commands[2]).toMatchObject({ type: 'prompt', accessMode: 'yolo' });
|
|
270
|
+
expect((commands[2].images as { data: string }[])[0]?.data).toBe('AAA');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('nothing is read or spawned when no prompt is waiting', async () => {
|
|
274
|
+
const h = harness([prompt({ status: 'dispatched', sessionId: 'done' })]);
|
|
275
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
276
|
+
expect(h.spawned).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
});
|