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