@openchambery/web 1.19.13-beta.3 → 1.19.13-beta.5

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 (113) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-B5MiX004.js} +1 -1
  2. package/dist/assets/AssistantView-B6kZZdri.js +2 -0
  3. package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-C40ituM2.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/{Icon-CkzzhaLr.js → Icon-ZAKaNHiG.js} +1 -1
  6. package/dist/assets/{MarkdownRenderer-v7n_D6nL.js → MarkdownRenderer-mcY597G0.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-YsTrEeUY.js} +6 -3
  8. package/dist/assets/MarkstreamRendererImpl-kNx_G9Sd.js +3 -0
  9. package/dist/assets/MobileDetailNavigation-Uwzh6TWj.js +1 -0
  10. package/dist/assets/MobileShareBridge-D3oSiSC6.js +4 -0
  11. package/dist/assets/MobileSurface-B-SWjAj7.js +2 -0
  12. package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-LgdUonfa.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-DHKq6y3W.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-iUiWTKbM.js} +8 -8
  16. package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-DCDEAC1n.js} +1 -1
  17. package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-CdZi_UXQ.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-fZq6UHvy.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-DyFotcOM.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-CzXos-TI.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-U4ML8hxt.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BtC17YQH.js} +1 -1
  29. package/dist/assets/fileMentionTouchSelection-DSKcxgU_.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-DPohCCUO.js} +1 -1
  32. package/dist/assets/{gitApi-D1mvv14J.js → gitApi-BUubBKri.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-DeGd-RP9.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-CflPxUNi.js} +1 -1
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-CuSU80xd.js} +1 -1
  37. package/dist/assets/main-C6EluFKk.js +169 -0
  38. package/dist/assets/{main-phpzFgMN.js → main-iFGhn-zU.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-Bs_H98PB.js +2 -0
  41. package/dist/assets/mobile-HfLUO-bG.js +2 -0
  42. package/dist/assets/{multirun-CYuxJPBX.js → multirun-BABuRg1O.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-eJvVbQ74.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-WcxDg0DZ.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-DSejAksY.js} +1 -1
  48. package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-Cp-dVXj5.js} +2 -2
  49. package/dist/assets/renderMobileApp-B6rb6wuc.js +28 -0
  50. package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-88DzHVMT.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-CEbKea6x.js +147 -0
  52. package/dist/assets/sessionChangeSummary-C9071lsK.js +2 -0
  53. package/dist/assets/sessionLookup-CEw3tCz1.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-eL2whsf8.js} +1 -1
  55. package/dist/assets/src-B_DG0xy5.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-DHdMj3y4.js} +2 -2
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-DE1UrdtK.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DV9OgOAg.js} +1 -1
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-Ca0mASq5.js} +43 -44
  63. package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-B3KJYYwp.js} +1 -1
  64. package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-B4l9snxd.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-CU2UwmN3.js +413 -0
  67. package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-B9El9yjX.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-PPhFeDFI.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-92n_rGTt.js} +1 -1
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-CwVxwJCj.js} +1 -1
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +104 -18
  79. package/server/lib/assistants/assign.js +173 -13
  80. package/server/lib/assistants/assign.test.js +139 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +496 -11
  83. package/server/lib/assistants/contact-store.test.js +323 -7
  84. package/server/lib/assistants/contact-tools.js +514 -52
  85. package/server/lib/assistants/contact-tools.test.js +390 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +224 -54
  88. package/server/lib/assistants/harness.test.js +372 -12
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +1079 -38
  91. package/server/lib/assistants/service.test.js +1235 -26
  92. package/server/lib/llm/DOCUMENTATION.md +2 -0
  93. package/server/lib/llm/completions.js +4 -0
  94. package/server/lib/llm/completions.test.js +3 -0
  95. package/server/lib/llm/generate.js +12 -5
  96. package/server/lib/llm/generate.test.js +40 -0
  97. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  98. package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
  99. package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
  100. package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +0 -1
  101. package/dist/assets/MobileShareBridge-DtwVLkBL.js +0 -4
  102. package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
  103. package/dist/assets/insertionBoundaries-BBn8YW82.js +0 -4
  104. package/dist/assets/main-Cd7NwXx5.js +0 -169
  105. package/dist/assets/miniChat-D6UYe50H.js +0 -2
  106. package/dist/assets/mobile-w2Ed9I5O.js +0 -2
  107. package/dist/assets/platform-DzsGqlGW.js +0 -2
  108. package/dist/assets/renderMobileApp-BvIE86_O.js +0 -28
  109. package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +0 -147
  110. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  111. package/dist/assets/sessionLookup-CNRDupYq.js +0 -1
  112. package/dist/assets/src-AA-5nXsF.css +0 -2
  113. package/dist/assets/useMobileNavigationStore-DANrWekj.js +0 -413
@@ -24,6 +24,7 @@ export const ASSIGN_CODES = Object.freeze({
24
24
  IMAGE_NOT_SUPPORTED: 'image_not_supported',
25
25
  NO_PROVIDER: 'no_provider',
26
26
  PROMPT_AMBIGUOUS: 'prompt_ambiguous',
27
+ NOT_FOUND: 'not_found',
27
28
  });
28
29
 
29
30
  export const PROJECT_REQUIRED_MESSAGE = 'No registered project is configured. Add a project in Settings before assigning work. Do not use assistant-workspaces.';
@@ -136,7 +137,13 @@ function normalizeProjectRoots(allowedRoots = []) {
136
137
  const roots = [];
137
138
  for (const root of allowedRoots) {
138
139
  if (typeof root !== 'string' || !root.trim()) continue;
139
- const resolved = path.resolve(root.trim());
140
+ // realpath so /var vs /private/var (and prior resolveAssignDirectory results) still match.
141
+ let resolved = path.resolve(root.trim());
142
+ try {
143
+ resolved = fs.realpathSync(resolved);
144
+ } catch {
145
+ // Keep path.resolve when the root is not yet on disk.
146
+ }
140
147
  if (seen.has(resolved)) continue;
141
148
  seen.add(resolved);
142
149
  roots.push(resolved);
@@ -146,7 +153,15 @@ function normalizeProjectRoots(allowedRoots = []) {
146
153
 
147
154
  export function isManagedAssistantWorkspace(candidate, managedWorkspaceRoot) {
148
155
  if (!candidate || !managedWorkspaceRoot) return false;
149
- return contained(path.resolve(candidate), path.resolve(managedWorkspaceRoot));
156
+ const realpathOrResolve = (value) => {
157
+ const resolved = path.resolve(value);
158
+ try {
159
+ return fs.realpathSync(resolved);
160
+ } catch {
161
+ return resolved;
162
+ }
163
+ };
164
+ return contained(realpathOrResolve(candidate), realpathOrResolve(managedWorkspaceRoot));
150
165
  }
151
166
 
152
167
  export function resolveAssignDirectory({
@@ -177,22 +192,24 @@ export function resolveAssignDirectory({
177
192
  const resolveExisting = (candidate) => {
178
193
  const raw = trim(candidate, 4096);
179
194
  if (!raw) return null;
180
- const resolved = path.resolve(raw);
181
- rejectManaged(resolved);
182
- if (!underRoots(resolved)) {
183
- throw new AssignError(
184
- ASSIGN_CODES.WORKSPACE_FORBIDDEN,
185
- 'That path is not a registered project. Add it in Settings, then assign again.',
186
- );
187
- }
195
+ let resolved = path.resolve(raw);
188
196
  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
189
197
  throw new AssignError(ASSIGN_CODES.VALIDATION, 'The project directory does not exist.');
190
198
  }
191
199
  try {
192
- return fs.realpathSync(resolved);
200
+ resolved = fs.realpathSync(resolved);
193
201
  } catch {
194
202
  throw new AssignError(ASSIGN_CODES.VALIDATION, 'The project directory does not exist.');
195
203
  }
204
+ // Compare realpaths so a prior authoritative resolve cannot fail against non-real roots.
205
+ rejectManaged(resolved);
206
+ if (!underRoots(resolved)) {
207
+ throw new AssignError(
208
+ ASSIGN_CODES.WORKSPACE_FORBIDDEN,
209
+ 'That path is not a registered project. Add it in Settings, then assign again.',
210
+ );
211
+ }
212
+ return resolved;
196
213
  };
197
214
 
198
215
  let target = resolveExisting(directory) || resolveExisting(projectPath) || resolveExisting(defaultProjectPath);
@@ -262,17 +279,126 @@ export function hasAssignImageParts(parts = []) {
262
279
  return sanitizeAssignFileParts(parts).some((part) => String(part.mime).toLowerCase().startsWith('image/'));
263
280
  }
264
281
 
282
+ /**
283
+ * Directory from authoritative OpenCode session payload.
284
+ * Prefer session.directory / path / project.worktree — never invent paths.
285
+ */
286
+ export function extractSessionDirectory(session) {
287
+ const payload = session?.data && typeof session.data === 'object' && !Array.isArray(session.data)
288
+ ? session.data
289
+ : session;
290
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
291
+ return trim(payload.directory, 4096)
292
+ || trim(payload.path, 4096)
293
+ || trim(payload.project?.worktree, 4096)
294
+ || trim(payload.project?.directory, 4096)
295
+ || trim(payload.project?.path, 4096)
296
+ || null;
297
+ }
298
+
299
+ /** Title from session payload or index row. */
300
+ export function extractSessionTitle(session) {
301
+ const payload = session?.data && typeof session.data === 'object' && !Array.isArray(session.data)
302
+ ? session.data
303
+ : session;
304
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
305
+ return trim(payload.title, 256) || null;
306
+ }
307
+
308
+ /**
309
+ * Map OpenCode session + messages into contact watch/card status.
310
+ * Used as the baseline when watching an existing session so an already-terminal
311
+ * idle is not treated as a fresh complete transition.
312
+ * - busy/retry → busy (in-flight)
313
+ * - session.error / last assistant error → error
314
+ * - idle / time.completed / missing session → complete
315
+ * - otherwise busy (keep watching; never invent complete from silence)
316
+ */
317
+ export function mapSessionToWatchStatus({
318
+ session = null,
319
+ messages = null,
320
+ missing = false,
321
+ } = {}) {
322
+ if (missing) return 'complete';
323
+ const payload = session?.data && typeof session.data === 'object' && !Array.isArray(session.data)
324
+ ? session.data
325
+ : session;
326
+ if (payload?.error) return 'error';
327
+
328
+ const statusType = typeof payload?.status?.type === 'string'
329
+ ? payload.status.type
330
+ : (typeof payload?.status === 'string'
331
+ ? payload.status
332
+ : (typeof payload?.type === 'string' ? payload.type : ''));
333
+ if (statusType === 'busy' || statusType === 'retry') return 'busy';
334
+ if (statusType === 'question') return 'question';
335
+
336
+ const rows = Array.isArray(messages)
337
+ ? messages
338
+ : (Array.isArray(messages?.data) ? messages.data : []);
339
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
340
+ const info = rows[index]?.info ?? rows[index];
341
+ if (info?.role !== 'assistant') continue;
342
+ if (info?.error) return 'error';
343
+ if (info?.time?.completed) return 'complete';
344
+ break;
345
+ }
346
+
347
+ if (statusType === 'idle' || payload?.time?.completed) return 'complete';
348
+ // No terminal signal — keep in-flight so later idle can settle once.
349
+ return 'busy';
350
+ }
351
+
352
+ /**
353
+ * Normalize a session/message model object to { providerID, modelID }.
354
+ * Accepts `{ providerID, id }` or `{ providerID, modelID }`. Lookup failures stay null.
355
+ */
356
+ export function normalizeAssignSessionModel(model) {
357
+ if (!model || typeof model !== 'object' || Array.isArray(model)) return null;
358
+ const providerID = trim(model.providerID, 256);
359
+ const modelID = trim(model.modelID, 256) || trim(model.id, 256);
360
+ if (!providerID || !modelID) return null;
361
+ return { providerID, modelID };
362
+ }
363
+
364
+ /**
365
+ * Extract the last worker model from a session.get payload and optional messages list.
366
+ * Priority: session.model → newest user/assistant info.model. Never throws.
367
+ */
368
+ export function extractAssignSessionModel({ session = null, messages = null } = {}) {
369
+ const payload = session?.data && typeof session.data === 'object' && !Array.isArray(session.data)
370
+ ? session.data
371
+ : session;
372
+ const fromSession = normalizeAssignSessionModel(payload?.model);
373
+ if (fromSession) return fromSession;
374
+
375
+ const rows = Array.isArray(messages)
376
+ ? messages
377
+ : (Array.isArray(messages?.data) ? messages.data : []);
378
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
379
+ const row = rows[index];
380
+ const info = row?.info && typeof row.info === 'object' ? row.info : row;
381
+ const role = info?.role || row?.role;
382
+ if (role !== 'user' && role !== 'assistant') continue;
383
+ const hit = normalizeAssignSessionModel(info?.model || row?.model);
384
+ if (hit) return hit;
385
+ }
386
+ return null;
387
+ }
388
+
265
389
  /**
266
390
  * Resolve worker model for assign.
267
- * - No explicit selection → assistant default (no catalog required).
391
+ * - No explicit selection → reused session model (catalog match, source session) else assistant default.
268
392
  * - Explicit selection → must resolve uniquely against the connected catalog; never silent fallback.
269
393
  * - Provided but illegal/blank/overlong/conflicting fields fail closed (validation_error).
394
+ * - Session model missing/not-in-catalog degrades to assistant default (never fails assign).
270
395
  */
271
396
  export function resolveAssignWorkerModel({
272
397
  providerID,
273
398
  modelID,
274
399
  model,
275
400
  fallback = {},
401
+ sessionModel = null,
276
402
  catalog = null,
277
403
  } = {}) {
278
404
  const explicitProvider = requireProvidedString(providerID, { field: 'providerID', max: 256 });
@@ -284,6 +410,21 @@ export function resolveAssignWorkerModel({
284
410
  const fallbackModel = trim(fallback.modelID, 256);
285
411
 
286
412
  if (!hasExplicit) {
413
+ const session = normalizeAssignSessionModel(sessionModel);
414
+ if (session && catalog && Array.isArray(catalog.models)) {
415
+ const hit = catalog.models.find((item) => (
416
+ item?.providerID === session.providerID && item?.modelID === session.modelID
417
+ ));
418
+ if (hit) {
419
+ return {
420
+ providerID: session.providerID,
421
+ modelID: session.modelID,
422
+ source: 'session',
423
+ name: typeof hit.name === 'string' ? hit.name : null,
424
+ acceptsImages: hit.acceptsImages === true ? true : hit.acceptsImages === false ? false : null,
425
+ };
426
+ }
427
+ }
287
428
  if (!fallbackProvider || !fallbackModel) {
288
429
  throw new AssignError(ASSIGN_CODES.VALIDATION, 'Assistant is missing a connected provider/model.');
289
430
  }
@@ -354,7 +495,7 @@ export function resolveAssignWorkerModel({
354
495
  }
355
496
 
356
497
  const models = catalog.models.filter((entry) => entry && typeof entry === 'object');
357
- const matches = models.filter((entry) => {
498
+ let matches = models.filter((entry) => {
358
499
  const entryProvider = trim(entry.providerID, 256);
359
500
  const entryModel = trim(entry.modelID, 256);
360
501
  const entryName = trim(entry.name, 512);
@@ -366,6 +507,7 @@ export function resolveAssignWorkerModel({
366
507
  return entryProvider === wantedProvider;
367
508
  }
368
509
  if (!wantedProvider && wantedModel) {
510
+ if (explicitModel) return entryModel === wantedModel;
369
511
  const needle = wantedModel.toLowerCase();
370
512
  return entryModel.toLowerCase() === needle
371
513
  || (entryName && entryName.toLowerCase() === needle)
@@ -374,6 +516,19 @@ export function resolveAssignWorkerModel({
374
516
  return false;
375
517
  });
376
518
 
519
+ // Only the informal `model` alias gets fuzzy matching. Explicit IDs remain exact.
520
+ if (matches.length === 0 && combined && !combined.includes('/') && !explicitModel) {
521
+ const normalize = (value) => trim(value, 512).toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
522
+ const needle = normalize(combined);
523
+ const eligible = models.filter((entry) => !wantedProvider || entry.providerID === wantedProvider);
524
+ if (needle.length >= 2) {
525
+ const exact = eligible.filter((entry) => normalize(entry.modelID) === needle || normalize(entry.name) === needle);
526
+ matches = exact.length > 0 ? exact : eligible.filter((entry) => (
527
+ normalize(entry.modelID).includes(needle) || normalize(entry.name).includes(needle)
528
+ ));
529
+ }
530
+ }
531
+
377
532
  if (matches.length === 0) {
378
533
  const label = wantedProvider && wantedModel
379
534
  ? `${wantedProvider}/${wantedModel}`
@@ -534,6 +689,7 @@ export async function assignSession(input = {}) {
534
689
  providerID: assistant.providerID,
535
690
  modelID: assistant.modelID,
536
691
  },
692
+ sessionModel: input.sessionModel,
537
693
  catalog: input.catalog,
538
694
  });
539
695
  }
@@ -583,6 +739,7 @@ export async function assignSession(input = {}) {
583
739
  throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Worker session APIs are unavailable.');
584
740
  }
585
741
 
742
+ input.signal?.throwIfAborted();
586
743
  let targetSessionID = sessionID;
587
744
  if (!targetSessionID) {
588
745
  let created;
@@ -603,6 +760,9 @@ export async function assignSession(input = {}) {
603
760
  }
604
761
  }
605
762
 
763
+ // A cancelled contact must not submit work after a late create response.
764
+ // Retain that session: creation succeeded, and cancellation is not deletion.
765
+ input.signal?.throwIfAborted();
606
766
  let prompted;
607
767
  let promptError;
608
768
  try {
@@ -8,9 +8,13 @@ import {
8
8
  assignSession,
9
9
  attachmentScopeKey,
10
10
  buildAssignParts,
11
+ extractAssignSessionModel,
12
+ extractSessionDirectory,
11
13
  hasAssignImageParts,
12
14
  isAmbiguousPromptFailure,
13
15
  isManagedAssistantWorkspace,
16
+ mapSessionToWatchStatus,
17
+ normalizeAssignSessionModel,
14
18
  requireProvidedString,
15
19
  resolveAssignDirectory,
16
20
  resolveAssignWorkerModel,
@@ -63,6 +67,22 @@ describe('resolveAssignDirectory', () => {
63
67
  });
64
68
 
65
69
  describe('requireProvidedString / resolveAssignWorkerModel', () => {
70
+ it('resolves a unique informal alias while keeping explicit ids exact and ambiguity closed', () => {
71
+ const catalog = { models: [
72
+ { providerID: 'a', modelID: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
73
+ { providerID: 'a', modelID: 'gpt-5.4', name: 'GPT 5.4' },
74
+ { providerID: 'b', modelID: 'gpt-5.4-mini', name: 'GPT 5.4 Mini' },
75
+ ] }
76
+ expect(resolveAssignWorkerModel({ model: 'sonnet 4.6', catalog }).modelID).toBe('claude-sonnet-4-6')
77
+ expect(resolveAssignWorkerModel({ model: 'gpt54', catalog }).modelID).toBe('gpt-5.4')
78
+ expect(() => resolveAssignWorkerModel({ model: 'gpt', catalog })).toThrow(/Several connected models/)
79
+ expect(() => resolveAssignWorkerModel({ providerID: 'a', modelID: 'sonnet 4.6', catalog })).toThrow(/No connected/)
80
+ expect(() => resolveAssignWorkerModel({ model: 'made-up-model', catalog })).toThrow(/No connected/)
81
+ expect(() => resolveAssignWorkerModel({ modelID: 'GPT 5.4', catalog })).toThrow(/No connected/)
82
+ expect(resolveAssignWorkerModel({ providerID: 'a', model: 'gpt', catalog }).modelID).toBe('gpt-5.4')
83
+ expect(() => resolveAssignWorkerModel({ model: 'sonnet', catalog: { models: [catalog.models[0], { ...catalog.models[0], providerID: 'b' }] } })).toThrow(/Several connected models/)
84
+ })
85
+
66
86
  const catalog = {
67
87
  models: [
68
88
  { providerID: 'openai', modelID: 'gpt-4o', name: 'GPT-4o', acceptsImages: true },
@@ -84,6 +104,64 @@ describe('requireProvidedString / resolveAssignWorkerModel', () => {
84
104
  });
85
105
  });
86
106
 
107
+ it('prefers a catalog-matched session model over the assistant default', () => {
108
+ expect(normalizeAssignSessionModel({ providerID: 'xai', id: 'grok-4.6' })).toEqual({
109
+ providerID: 'xai',
110
+ modelID: 'grok-4.6',
111
+ });
112
+ expect(extractAssignSessionModel({
113
+ session: { model: { providerID: 'xai', id: 'grok-4.6' } },
114
+ })).toEqual({ providerID: 'xai', modelID: 'grok-4.6' });
115
+ expect(extractAssignSessionModel({
116
+ messages: [
117
+ { info: { role: 'user', model: { providerID: 'openai', modelID: 'gpt-4o' } } },
118
+ { info: { role: 'assistant', model: { providerID: 'xai', id: 'grok-4.6' } } },
119
+ ],
120
+ })).toEqual({ providerID: 'xai', modelID: 'grok-4.6' });
121
+ expect(resolveAssignWorkerModel({
122
+ sessionModel: { providerID: 'xai', modelID: 'grok-4.6' },
123
+ fallback: { providerID: 'p', modelID: 'm' },
124
+ catalog,
125
+ })).toMatchObject({
126
+ providerID: 'xai',
127
+ modelID: 'grok-4.6',
128
+ source: 'session',
129
+ acceptsImages: true,
130
+ });
131
+ });
132
+
133
+ it('degrades to the assistant model when the session model is missing from catalog', () => {
134
+ expect(resolveAssignWorkerModel({
135
+ sessionModel: { providerID: 'missing', modelID: 'gone' },
136
+ fallback: { providerID: 'p', modelID: 'm' },
137
+ catalog,
138
+ })).toEqual({
139
+ providerID: 'p',
140
+ modelID: 'm',
141
+ source: 'assistant',
142
+ name: null,
143
+ acceptsImages: null,
144
+ });
145
+ expect(resolveAssignWorkerModel({
146
+ sessionModel: { providerID: 'xai', modelID: 'grok-4.6' },
147
+ fallback: { providerID: 'p', modelID: 'm' },
148
+ catalog: null,
149
+ }).source).toBe('assistant');
150
+ });
151
+
152
+ it('keeps explicit model fail-closed over a session model', () => {
153
+ expect(resolveAssignWorkerModel({
154
+ model: 'openai/gpt-4o',
155
+ sessionModel: { providerID: 'xai', modelID: 'grok-4.6' },
156
+ fallback: { providerID: 'p', modelID: 'm' },
157
+ catalog,
158
+ })).toMatchObject({
159
+ providerID: 'openai',
160
+ modelID: 'gpt-4o',
161
+ source: 'explicit',
162
+ });
163
+ });
164
+
87
165
  it('resolves explicit provider/model against the connected catalog', () => {
88
166
  expect(resolveAssignWorkerModel({
89
167
  model: 'xai/grok-4.6',
@@ -149,6 +227,25 @@ describe('requireProvidedString / resolveAssignWorkerModel', () => {
149
227
  });
150
228
 
151
229
  describe('resolveAssignWorkerVariant', () => {
230
+ it('does not submit or delete a session whose create completes after cancellation', async () => {
231
+ const directory = root();
232
+ const controller = new AbortController();
233
+ const promptExisting = vi.fn();
234
+ const deleteSession = vi.fn();
235
+ const createSession = vi.fn(async () => {
236
+ controller.abort();
237
+ return { data: { id: 'ses_created' } };
238
+ });
239
+ await expect(assignSession({
240
+ prompt: 'Fix', directory, assistant: { providerID: 'p', modelID: 'm' },
241
+ allowedRoots: [directory], managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
242
+ createSession, promptExisting, deleteSession, signal: controller.signal,
243
+ })).rejects.toMatchObject({ name: 'AbortError' });
244
+ expect(createSession).toHaveBeenCalledTimes(1);
245
+ expect(promptExisting).not.toHaveBeenCalled();
246
+ expect(deleteSession).not.toHaveBeenCalled();
247
+ });
248
+
152
249
  it('reuses assistant variant only for same/default model; drops it cross-model', () => {
153
250
  expect(resolveAssignWorkerVariant({
154
251
  source: 'assistant',
@@ -466,4 +563,46 @@ describe('assignSession', () => {
466
563
  expect(deleteSession).not.toHaveBeenCalled();
467
564
  expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_existing' }));
468
565
  });
566
+
567
+ it('mapSessionToWatchStatus baselines terminal idle without inventing busy', () => {
568
+ expect(mapSessionToWatchStatus({
569
+ session: { id: 'ses_1', status: { type: 'idle' }, time: { completed: 1 } },
570
+ messages: [{ info: { role: 'assistant', time: { completed: 1 } } }],
571
+ })).toBe('complete');
572
+ expect(mapSessionToWatchStatus({
573
+ session: { id: 'ses_2', status: { type: 'busy' } },
574
+ })).toBe('busy');
575
+ expect(mapSessionToWatchStatus({
576
+ session: { id: 'ses_3', error: { message: 'boom' } },
577
+ })).toBe('error');
578
+ expect(mapSessionToWatchStatus({ missing: true })).toBe('complete');
579
+ expect(extractSessionDirectory({
580
+ directory: '/repo/app',
581
+ project: { worktree: '/other' },
582
+ })).toBe('/repo/app');
583
+ expect(extractSessionDirectory({
584
+ project: { worktree: '/repo/wt' },
585
+ })).toBe('/repo/wt');
586
+ });
587
+
588
+ it('resolveAssignDirectory accepts a realpath candidate against non-realpath allowed roots', () => {
589
+ const directory = root();
590
+ const project = path.join(directory, 'app');
591
+ fs.mkdirSync(project, { recursive: true });
592
+ const realProject = fs.realpathSync(project);
593
+ // First resolve returns realpath; second call with the same realpath must still pass.
594
+ const first = resolveAssignDirectory({
595
+ directory: project,
596
+ allowedRoots: [directory],
597
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
598
+ });
599
+ expect(first).toBe(realProject);
600
+ const second = resolveAssignDirectory({
601
+ directory: realProject,
602
+ projectPath: realProject,
603
+ allowedRoots: [directory],
604
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
605
+ });
606
+ expect(second).toBe(realProject);
607
+ });
469
608
  });
@@ -1,5 +1,5 @@
1
1
  const SENTENCE = /(?<=[.!?])\s+(?=[A-Z“"‘])/u;
2
- const CONTACT_PLANNING = /\b(let me think|i should|i need to|i'll call|i will call|the user (said|wants|asked)|actually,?|hmm\.?|assign_session|list_projects|list_sessions|create_assistant|schedule_task|tool call|projectPath)\b/i;
2
+ const CONTACT_PLANNING = /\b(let me think|i should|i need to|i'll call|i will call|the user (said|wants|asked)|actually,?|hmm\.?|assign_session|watch_session|stop_session|list_projects|list_sessions|create_assistant|schedule_task|tool call|projectPath)\b/i;
3
3
 
4
4
  /**
5
5
  * A short spoken contact line (not chain-of-thought). Shown before a tool and kept.