@kabmo-labs/kabmo 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +359 -350
  3. package/dist/client/static/js/{index-9js9mrmj.js → index-ae9z0yvm.js} +704 -704
  4. package/dist/client/static/js/index-nzyjf92m.css +1 -0
  5. package/marketplace/plugins/stats/README.md +26 -4
  6. package/marketplace/plugins/stats/package.json +2 -2
  7. package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
  8. package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
  9. package/marketplace/plugins/stats/src/app.tsx +11 -0
  10. package/marketplace/plugins/stats/src/format.test.ts +27 -1
  11. package/marketplace/plugins/stats/src/format.ts +41 -0
  12. package/package.json +2 -2
  13. package/src/client/bootstrap.d.ts +11 -0
  14. package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
  15. package/src/client/components/common/header-controls/index.tsx +9 -2
  16. package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
  17. package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
  18. package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
  19. package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
  20. package/src/client/components/common/server-switcher/index.test.tsx +150 -0
  21. package/src/client/components/common/server-switcher/index.tsx +281 -0
  22. package/src/client/components/common/window-controls/index.test.tsx +113 -0
  23. package/src/client/components/common/window-controls/index.tsx +66 -0
  24. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
  25. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
  26. package/src/client/components/layout/desktop-layout/index.tsx +34 -26
  27. package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
  28. package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
  29. package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
  30. package/src/client/components/mobile/RightSidebar.tsx +2 -2
  31. package/src/client/components/mobile/SessionSidebar.tsx +5 -8
  32. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
  34. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
  35. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
  37. package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
  38. package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
  39. package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
  40. package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
  41. package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
  42. package/src/client/data/settings/omp-schema.json +2 -2
  43. package/src/client/hooks/desktop/servers.test.ts +73 -0
  44. package/src/client/hooks/desktop/servers.ts +126 -0
  45. package/src/client/hooks/settings/model-roles.ts +156 -0
  46. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  47. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  48. package/src/client/hooks/workspace/keybindings.ts +44 -0
  49. package/src/client/hooks/workspace/open-panel.ts +42 -0
  50. package/src/client/lib/desktop/bridge.ts +158 -0
  51. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  52. package/src/client/lib/desktop/titlebar.ts +97 -0
  53. package/src/client/lib/plugins/slots.test.ts +54 -0
  54. package/src/client/lib/plugins/slots.ts +37 -1
  55. package/src/server/db.server.test.ts +44 -3
  56. package/src/server/db.server.ts +26 -2
  57. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  58. package/src/server/lib/db/schema.ts +3 -0
  59. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  60. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  61. package/src/server/lib/model-roles/mock.server.ts +40 -0
  62. package/src/server/lib/model-roles/schema.server.ts +36 -0
  63. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  64. package/src/server/lib/model-roles/store.server.ts +188 -0
  65. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  66. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  67. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  68. package/src/server/lib/omp/rpc/manager.ts +18 -13
  69. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  70. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  71. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  72. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  73. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  74. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  75. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  76. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  77. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  78. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  79. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  80. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  81. package/src/server/lib/panels/compat.server.test.ts +4 -4
  82. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  83. package/src/server/lib/panels/registry.server.test.ts +4 -4
  84. package/src/server/lib/panels/state.server.test.ts +4 -4
  85. package/src/server/lib/panels/sync.server.test.ts +4 -4
  86. package/src/server/lib/plugins/background.test.ts +2 -2
  87. package/src/server/lib/plugins/e2e.test.ts +2 -2
  88. package/src/server/lib/plugins/platform.test.ts +2 -2
  89. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  90. package/src/server/lib/queue/delivery.server.ts +11 -2
  91. package/src/server/lib/updates/shell-release.test.ts +184 -0
  92. package/src/server/lib/updates/shell-release.ts +215 -0
  93. package/src/server/lib/waiting/store.server.test.ts +2 -2
  94. package/src/server/plugins/ssr.ts +13 -10
  95. package/src/server/routes/chat/stream.test.ts +4 -4
  96. package/src/server/routes/models/roles.ts +73 -14
  97. package/src/server/routes/panels/css.test.ts +4 -4
  98. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  99. package/src/server/routes/plugins/routes.test.ts +4 -4
  100. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  101. package/src/server/routes/updates/android.ts +2 -2
  102. package/src/server/routes/updates/index.ts +2 -0
  103. package/src/server/routes/updates/ios.ts +26 -0
  104. package/src/server/routes/waiting/routes.test.ts +2 -2
  105. package/src/shared/lib/models/server-cache.ts +11 -0
  106. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  107. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  108. package/src/shared/lib/updates/shell-ua.ts +41 -20
  109. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  110. package/src/shared/lib/workspace/panel-open.ts +76 -0
  111. package/src/shared/types/model-roles.ts +54 -0
  112. package/src/test-support/isolated-db.ts +17 -5
  113. package/dist/client/static/js/index-3qamd33d.css +0 -1
  114. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  115. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  116. package/src/server/lib/updates/android-release.test.ts +0 -138
  117. package/src/server/lib/updates/android-release.ts +0 -183
@@ -3,51 +3,102 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
- import { describe, expect, test } from 'bun:test';
6
+ /**
7
+ * Auto-titling's two properties that must never regress.
8
+ *
9
+ * 1. **It never overwrites a name the operator set.** Kabmo fires omp's own
10
+ * `/rename` (which persists through `setSessionName(title, "user")`) in the
11
+ * background, and that write has no "don't overwrite" guard of its own — omp
12
+ * only refuses an `"auto"` write over a `"user"` title. The decision to skip
13
+ * a NAMED session is ours, and a bug here silently destroys a name the
14
+ * operator typed.
15
+ *
16
+ * 2. **The run end ALWAYS asks, and it asks on the session's own model.** Both
17
+ * halves were broken on a real install and neither was visible from Kabmo's
18
+ * side: the fallback stood down while an early generation was "in flight"
19
+ * (it had in fact already failed 52 s earlier), and the request ran on omp's
20
+ * configured `tiny` role — `kenari/deepseek-v4-flash` here, whose plan had
21
+ * lapsed — so every attempt answered `429 plan_limit_reached` and no session
22
+ * was ever named. Six measured attempts, four unnamed sessions.
23
+ *
24
+ * The fake `get_state` and the recorded command list are the whole surface: no
25
+ * omp child, no database, no wall-clock waiting.
26
+ */
27
+
28
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
29
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
30
+ import { tmpdir } from 'node:os';
31
+ import { join } from 'node:path';
7
32
 
8
33
  import {
34
+ awaitTitleDispatch,
9
35
  consumeAutoTitleOutput,
10
36
  markTitleRequestSent,
11
37
  triggerAutoSessionTitle,
12
38
  type AutoTitleHost,
13
39
  } from '@/server/lib/omp/session/auto-title.server';
40
+ import { titleModelOverlayPath } from '@/server/lib/omp/session/title-model-overlay.server';
14
41
 
15
42
  /**
16
- * The safety property of auto-titling, and the one thing that must never
17
- * regress: Kabmo fires omp's own `/rename` (which persists through
18
- * `setSessionName(title, "user")`) in the background, and that write has no
19
- * "don't overwrite" guard of its own — omp only refuses an `"auto"` write over
20
- * a `"user"` title. So the decision to skip a NAMED session is ours, and a bug
21
- * here silently destroys a name the operator typed.
22
- *
23
- * These tests drive the guard directly: a fake `get_state` reports a name, and
24
- * the assertion is that no `/rename` reaches the wire.
43
+ * The model pin is a FILE under the agent dir, so the suite points that dir at
44
+ * a temp tree: nothing here may touch the developer's own `~/.omp`.
25
45
  */
46
+ let agentDir: string;
47
+ let previousAgentDir: string | undefined;
48
+
49
+ beforeAll(() => {
50
+ agentDir = mkdtempSync(join(tmpdir(), 'kabmo-auto-title-'));
51
+ previousAgentDir = Bun.env.PI_CODING_AGENT_DIR;
52
+ process.env.PI_CODING_AGENT_DIR = agentDir;
53
+ });
54
+
55
+ afterAll(() => {
56
+ if (previousAgentDir === undefined) delete Bun.env.PI_CODING_AGENT_DIR;
57
+ else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
58
+ rmSync(agentDir, { recursive: true, force: true });
59
+ });
26
60
 
27
61
  interface FakeProc {
28
62
  commands: Array<Record<string, unknown>>;
29
63
  sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T>;
30
64
  }
31
65
 
66
+ /** Hosts built by the current test. The model pin is ONE file (newest wins), so
67
+ * a request left unanswered would keep it pinned for every later test — the
68
+ * drain below is what makes each case independent. */
69
+ const created: AutoTitleHost[] = [];
70
+
71
+ afterEach(async () => {
72
+ for (const host of created.splice(0)) {
73
+ while (host.autoTitleClaims > 0) consumeAutoTitleOutput(host);
74
+ }
75
+ // Let the pin's `finally` (an atomic file replace) run.
76
+ for (let i = 0; i < 16; i += 1) await Promise.resolve();
77
+ });
78
+
32
79
  function makeHost(options: {
33
80
  sessionName?: string;
34
81
  sessionId?: string;
35
- inFlight?: boolean;
36
82
  pending?: boolean;
37
83
  requested?: boolean;
84
+ claims?: number;
38
85
  windowUntil?: number;
39
- /** Called with the host the moment a `/rename` prompt reaches the wire, so a
40
- * test can observe the window state the frame handler would see. */
86
+ model?: { provider: string; modelId: string } | null;
87
+ /** Called the moment a `/rename` prompt reaches the wire. */
41
88
  onPrompt?: (host: AutoTitleHost) => void;
42
89
  /** Make the `/rename` send reject, for the rollback path. */
43
90
  failPrompt?: boolean;
91
+ /** Suppress the frame that answers a `/rename`, so its claim stays open. */
92
+ unanswered?: boolean;
44
93
  }): { host: AutoTitleHost; proc: FakeProc } {
45
94
  const host: AutoTitleHost = {
46
95
  sessionId: 'sess-1',
47
- autoTitleInFlight: options.inFlight ?? false,
48
96
  autoTitleWindowUntil: options.windowUntil ?? 0,
97
+ autoTitleClaims: options.claims ?? 0,
98
+ autoTitleDispatch: null,
49
99
  autoTitlePending: options.pending ?? true,
50
100
  autoTitleRequested: options.requested ?? false,
101
+ autoTitleModel: options.model ?? { provider: 'kenari-jatis', modelId: 'deepseek-v4-1-flash' },
51
102
  proc: undefined as unknown as AutoTitleHost['proc'],
52
103
  };
53
104
  const proc: FakeProc = {
@@ -62,10 +113,14 @@ function makeHost(options: {
62
113
  }
63
114
  options.onPrompt?.(host);
64
115
  if (options.failPrompt) throw new Error('send failed');
116
+ // The generation is reported by frames the fold delivers; a test that
117
+ // wants the claim consumed drives `consumeAutoTitleOutput` itself.
118
+ if (!options.unanswered) consumeAutoTitleOutput(host);
65
119
  return undefined as T;
66
120
  },
67
121
  };
68
122
  host.proc = proc as unknown as AutoTitleHost['proc'];
123
+ created.push(host);
69
124
  return { host, proc };
70
125
  }
71
126
 
@@ -82,50 +137,42 @@ describe('triggerAutoSessionTitle', () => {
82
137
  const renames = sentRenames(proc);
83
138
  expect(renames).toHaveLength(1);
84
139
  expect(renames[0].message).toBe('/rename');
85
- // The request window opens BEFORE the command is sent, so the
86
- // `prompt_result {agentInvoked:false}` omp answers within the same tick is
87
- // already attributable to us when it arrives.
88
- expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
89
140
  });
90
141
 
91
- test('the request window is armed BEFORE the /rename is sent', async () => {
92
- // The regression: omp answers the `/rename` prompt with
93
- // `prompt_result {agentInvoked:false}` in the same tick (measured 3 ms),
94
- // while the operator's turn is still streaming. The frame fold suppresses
95
- // that frame only while the window is armed — arming it after the ack left
96
- // the frame to reach the client, which settled the optimistic turn, blanked
97
- // the generating indicator and cleared the sidebar's live `stream` row
98
- // while the answer kept arriving (measured: indicator on for ~1.2 s of a
99
- // 20 s run).
100
- let windowAtSend = -1;
101
- const { host, proc } = makeHost({ onPrompt: (h) => { windowAtSend = h.autoTitleWindowUntil; } });
102
- await triggerAutoSessionTitle(host, 'opening');
142
+ test('the request claim is armed BEFORE the /rename is sent', async () => {
143
+ // omp answers the `/rename` prompt with `prompt_result {agentInvoked:false}`
144
+ // in the same tick (measured 3 ms) while the operator's turn still streams.
145
+ // The fold suppresses that frame only while a claim is armed.
146
+ let claimsAtSend = -1;
147
+ const { host, proc } = makeHost({
148
+ unanswered: true,
149
+ onPrompt: (h) => {
150
+ claimsAtSend = h.autoTitleClaims;
151
+ },
152
+ });
153
+ void triggerAutoSessionTitle(host, 'opening');
154
+ await awaitTitleDispatch(host);
103
155
 
104
156
  expect(sentRenames(proc)).toHaveLength(1);
105
- expect(windowAtSend).toBeGreaterThan(Date.now());
157
+ expect(claimsAtSend).toBe(1);
106
158
  });
107
159
 
108
- test('rolls the window back when the /rename never reached omp', async () => {
109
- // An armed window with no request behind it would swallow the next
110
- // operator frame inside its 60 s span — and block the settle-stage
111
- // fallback, which stands down while a generation is "in flight".
160
+ test('rolls the claim back when the /rename never reached omp', async () => {
112
161
  const { host, proc } = makeHost({ failPrompt: true });
113
162
  await triggerAutoSessionTitle(host, 'opening');
114
163
 
115
164
  expect(sentRenames(proc)).toHaveLength(1);
165
+ expect(host.autoTitleClaims).toBe(0);
116
166
  expect(host.autoTitleWindowUntil).toBe(0);
117
167
  });
118
168
 
119
169
  test('never overwrites a name the operator set', async () => {
120
- // At either stage: the name is re-read from omp, so a rename made in
121
- // another tab survives both the early attempt and the fallback.
122
170
  for (const stage of ['opening', 'settle'] as const) {
123
171
  const { host, proc } = makeHost({ sessionName: 'My Deliberate Name' });
124
172
  await triggerAutoSessionTitle(host, stage);
125
173
 
126
174
  expect(sentRenames(proc)).toHaveLength(0);
127
- // No request was sent, so nothing may claim the output window either.
128
- expect(host.autoTitleWindowUntil).toBe(0);
175
+ expect(host.autoTitleClaims).toBe(0);
129
176
  }
130
177
  });
131
178
 
@@ -137,27 +184,13 @@ describe('triggerAutoSessionTitle', () => {
137
184
  });
138
185
 
139
186
  test('skips when the session changed under the read', async () => {
140
- // `get_state` answered for a different conversation (a reset or switch
141
- // between the trigger and the read) — titling this session would name the
142
- // wrong one.
143
187
  const { host, proc } = makeHost({ sessionId: 'someone-else' });
144
188
  await triggerAutoSessionTitle(host, 'opening');
145
189
 
146
190
  expect(sentRenames(proc)).toHaveLength(0);
147
191
  });
148
192
 
149
- test('does not stack a second request while one is in flight', async () => {
150
- const { host, proc } = makeHost({ inFlight: true });
151
- await triggerAutoSessionTitle(host, 'opening');
152
-
153
- expect(sentRenames(proc)).toHaveLength(0);
154
- expect(proc.commands).toHaveLength(0);
155
- });
156
-
157
193
  test('the early attempt does not consume the eligibility latch', async () => {
158
- // The fallback exists precisely because the early attempt can come back
159
- // empty. If `opening` consumed the latch, a provider error at the first
160
- // user message would leave the session unnamed for good.
161
194
  const { host } = makeHost({});
162
195
  await triggerAutoSessionTitle(host, 'opening');
163
196
 
@@ -165,39 +198,45 @@ describe('triggerAutoSessionTitle', () => {
165
198
  expect(host.autoTitleRequested).toBe(true);
166
199
  });
167
200
 
168
- test('the fallback retries once the early attempt came back empty', async () => {
169
- // The real sequence: the early `/rename` is accepted, omp answers
170
- // `command_output` (consuming the window) with no title, and the run then
171
- // ends — the fallback is what names the session.
172
- const { host, proc } = makeHost({});
173
- await triggerAutoSessionTitle(host, 'opening');
174
- expect(consumeAutoTitleOutput(host)).toBe(true);
201
+ test('the run end ALWAYS asks, even while an early attempt is unanswered', async () => {
202
+ // The shipped defect: the fallback stood down while the early generation's
203
+ // window was still armed, on the assumption that generation would land. On
204
+ // a real session it had already failed ~52 s earlier, so the run ended with
205
+ // nothing asked again and the session kept its placeholder forever.
206
+ const { host, proc } = makeHost({ unanswered: true });
207
+ void triggerAutoSessionTitle(host, 'opening');
208
+ await awaitTitleDispatch(host);
209
+ expect(sentRenames(proc)).toHaveLength(1);
210
+ expect(host.autoTitleClaims).toBe(1);
175
211
 
176
- await triggerAutoSessionTitle(host, 'settle');
212
+ void triggerAutoSessionTitle(host, 'settle');
213
+ await awaitTitleDispatch(host);
177
214
 
178
215
  expect(sentRenames(proc)).toHaveLength(2);
216
+ // Consumed: this conversation's last chance has been taken.
179
217
  expect(host.autoTitlePending).toBe(false);
180
218
  });
181
219
 
182
- test('the fallback stands down while the early generation is in flight', async () => {
183
- // A short run can end before the tiny model answers (~4s). Re-asking there
184
- // would reserve a new title revision and cancel the generation already in
185
- // flight, for a title derived from the same opening turn.
186
- const { host, proc } = makeHost({});
187
- await triggerAutoSessionTitle(host, 'opening');
188
- expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
189
-
190
- await triggerAutoSessionTitle(host, 'settle');
191
-
192
- expect(sentRenames(proc)).toHaveLength(1);
193
- // The latch is still consumed: the attempt in flight owns this
194
- // conversation's title from here.
195
- expect(host.autoTitlePending).toBe(false);
220
+ test('the fallback is dispatched only after the early attempt acknowledged', async () => {
221
+ // Two frames on one stdin: omp cancels a superseded rename and emits no
222
+ // frame for it, so the two dispatches must not race.
223
+ const order: string[] = [];
224
+ const { host } = makeHost({
225
+ unanswered: true,
226
+ onPrompt: () => order.push(`send:${order.length}`),
227
+ });
228
+ void triggerAutoSessionTitle(host, 'opening');
229
+ await awaitTitleDispatch(host);
230
+ void triggerAutoSessionTitle(host, 'settle');
231
+ await awaitTitleDispatch(host);
232
+
233
+ expect(order).toHaveLength(2);
234
+ // Both were dispatched, and the second only after the first resolved its
235
+ // ack — the chain is what guarantees the ordering.
236
+ expect(host.autoTitleClaims).toBe(2);
196
237
  });
197
238
 
198
239
  test('fires the early attempt once per run', async () => {
199
- // A queued steer message also settles as a user message inside the same
200
- // run; re-asking on it would cancel the first generation for no gain.
201
240
  const { host, proc } = makeHost({});
202
241
  await triggerAutoSessionTitle(host, 'opening');
203
242
  await triggerAutoSessionTitle(host, 'opening');
@@ -206,42 +245,59 @@ describe('triggerAutoSessionTitle', () => {
206
245
  });
207
246
 
208
247
  test('titles from the first run only', async () => {
209
- // The regression this latch exists for: a second turn used to fire its own
210
- // `/rename`, and omp derives that title from the NEWEST turns — so the
211
- // session ended up named after message two.
212
248
  const { host, proc } = makeHost({});
213
249
  await triggerAutoSessionTitle(host, 'opening');
214
250
  await triggerAutoSessionTitle(host, 'settle');
215
- expect(sentRenames(proc)).toHaveLength(1);
251
+ expect(sentRenames(proc)).toHaveLength(2);
216
252
 
217
- // A later run reaches neither stage with the latch consumed.
218
253
  await triggerAutoSessionTitle(host, 'opening');
219
254
  await triggerAutoSessionTitle(host, 'settle');
220
255
 
221
- expect(sentRenames(proc)).toHaveLength(1);
256
+ expect(sentRenames(proc)).toHaveLength(2);
222
257
  });
223
258
 
224
259
  test('skips a conversation that already has a first turn behind it', async () => {
225
- // A child resumed for an existing session (`--resume` after the idle
226
- // reclaim) reports a non-zero message count, so its next turn must not
227
- // name it — the session is either titled already or deliberately unnamed.
228
260
  const { host, proc } = makeHost({ pending: false });
229
261
  await triggerAutoSessionTitle(host, 'opening');
230
262
  await triggerAutoSessionTitle(host, 'settle');
231
263
 
232
264
  expect(sentRenames(proc)).toHaveLength(0);
233
- expect(host.autoTitleWindowUntil).toBe(0);
265
+ expect(host.autoTitleClaims).toBe(0);
234
266
  });
235
267
 
236
268
  test('consumes the latch even when generation is skipped', async () => {
237
- // A declined generation must not leave the door open for the next turn:
238
- // otherwise a first turn whose title the provider refused gets re-asked
239
- // after every later message, which is the same bug one step later.
240
269
  const { host } = makeHost({});
241
270
  await triggerAutoSessionTitle(host, 'settle');
242
271
 
243
272
  expect(host.autoTitlePending).toBe(false);
244
273
  });
274
+
275
+ test('runs the request on the SESSION model, not omp tiny role', async () => {
276
+ // The regression: every attempt ran on omp's configured `tiny` role, whose
277
+ // plan had lapsed, so the generation answered 429 and no session was ever
278
+ // named. The pin is a file omp watches, so its content at send time is the
279
+ // observable proof of which role the request runs under.
280
+ let pinned = '';
281
+ const { host } = makeHost({
282
+ unanswered: true,
283
+ onPrompt: () => {
284
+ pinned = readFileSync(titleModelOverlayPath(), 'utf8');
285
+ },
286
+ });
287
+ void triggerAutoSessionTitle(host, 'opening');
288
+ await awaitTitleDispatch(host);
289
+
290
+ expect(pinned).toContain('tiny: "kenari-jatis/deepseek-v4-1-flash"');
291
+ });
292
+
293
+ test('releases the pin once the request has answered', async () => {
294
+ const { host } = makeHost({});
295
+ await triggerAutoSessionTitle(host, 'opening');
296
+
297
+ // Restored to the no-op overlay, so the next child that loads it is not
298
+ // pinned to a session's model.
299
+ expect(readFileSync(titleModelOverlayPath(), 'utf8')).toBe('{}\n');
300
+ });
245
301
  });
246
302
 
247
303
  describe('auto-title output window', () => {
@@ -250,8 +306,6 @@ describe('auto-title output window', () => {
250
306
  markTitleRequestSent(host);
251
307
 
252
308
  expect(consumeAutoTitleOutput(host)).toBe(true);
253
- // omp emits a single command_output per /rename; a second frame inside the
254
- // window belongs to something the operator typed and must reach the UI.
255
309
  expect(consumeAutoTitleOutput(host)).toBe(false);
256
310
  });
257
311
 
@@ -260,7 +314,7 @@ describe('auto-title output window', () => {
260
314
  expect(consumeAutoTitleOutput(host)).toBe(false);
261
315
  });
262
316
 
263
- test('expires so a stale window cannot swallow an operator command', () => {
317
+ test('expires so a stale claim cannot swallow an operator command', () => {
264
318
  const { host } = makeHost({});
265
319
  markTitleRequestSent(host);
266
320
  host.autoTitleWindowUntil = Date.now() - 1;