@oar-sh/oar 0.9.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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,291 @@
1
+ import fs from 'node:fs';
2
+ import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
3
+ import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
4
+
5
+ const PLAN_BOARD_ACTIONS = [
6
+ { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
7
+ { id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
8
+ { id: 'exit_only', label: 'Stop here', mode: 'agent' },
9
+ ];
10
+
11
+ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}) {
12
+ const summary = String(planText || '').trim();
13
+ if (!summary) return null;
14
+ return {
15
+ queueId: message?.id,
16
+ messageId: message?.id,
17
+ conversationId: message?.conversationId,
18
+ mode: message?.relayMode || 'agent',
19
+ boardType: 'plan_ready',
20
+ title: 'Plan ready for review',
21
+ body: summary,
22
+ actions: PLAN_BOARD_ACTIONS,
23
+ recommendedAction: null,
24
+ context: {
25
+ source: 'exit_plan_mode',
26
+ queueMessageId: message?.id || null,
27
+ conversationId: message?.conversationId || null,
28
+ relayMode: message?.relayMode || 'agent',
29
+ },
30
+ };
31
+ }
32
+
33
+ /**
34
+ * The relay-facing half of a Claude turn: normalized actions in, HTTP calls to
35
+ * the relay's activity/stream/response channels out. Owns no turn or process
36
+ * state — every function takes the turn's relay message and the caller's
37
+ * mutable turn state, so the session process can run any number of turns
38
+ * (delivered or background-continuation) through one publisher.
39
+ */
40
+ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRuns = null } = {}) {
41
+ async function postActivity(message, text, subagentRunId = null, metadata = null) {
42
+ if (!text) return;
43
+ await api('POST', '/api/activity', {
44
+ messageId: message.id,
45
+ conversationId: message.conversationId,
46
+ mode: message.relayMode || 'agent',
47
+ text,
48
+ ...(subagentRunId ? { subagentRunId } : {}),
49
+ ...(metadata && typeof metadata === 'object' ? { metadata } : {}),
50
+ }).catch(() => {});
51
+ }
52
+
53
+ async function dispatchAction(message, action, state) {
54
+ const { channel, payload } = action;
55
+ if (channel === 'init') {
56
+ state.responseModel = payload.model || state.responseModel;
57
+ return;
58
+ }
59
+ if (channel === 'stream') {
60
+ // Only the main thread's text can stand in for the answer. Subagent text
61
+ // is forwarded too (forwardSubagentText), and letting it land here would
62
+ // publish a subagent's prose as the reply on the abort / error / no-result
63
+ // fallback paths below.
64
+ if (!payload.subagentRunId) state.lastStreamedText = payload.text;
65
+ await api('POST', '/api/stream', {
66
+ messageId: message.id,
67
+ conversationId: message.conversationId,
68
+ mode: message.relayMode || 'agent',
69
+ text: payload.text,
70
+ done: payload.done === true,
71
+ ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
72
+ }).catch(() => {});
73
+ return;
74
+ }
75
+ if (channel === 'thought') {
76
+ await api('POST', '/api/thought', {
77
+ messageId: message.id,
78
+ conversationId: message.conversationId,
79
+ mode: message.relayMode || 'agent',
80
+ reasoningId: payload.reasoningId,
81
+ text: payload.text,
82
+ done: payload.done === true,
83
+ ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
84
+ }).catch(() => {});
85
+ return;
86
+ }
87
+ if (channel === 'activity') {
88
+ await postActivity(message, payload.text, payload.subagentRunId, payload.metadata || null);
89
+ return;
90
+ }
91
+ if (channel === 'subagent') {
92
+ await api('POST', '/api/subagent-run', {
93
+ messageId: message.id,
94
+ conversationId: message.conversationId,
95
+ subagentRunId: payload.subagentRunId,
96
+ parentSubagentId: payload.parentSubagentId || undefined,
97
+ displayName: payload.displayName || undefined,
98
+ status: payload.status,
99
+ }).catch(() => {});
100
+ return;
101
+ }
102
+ if (channel === 'result') {
103
+ state.result = payload;
104
+ state.modelUsage = payload.modelUsage || null;
105
+ const text = String(payload.text || '').trim();
106
+ if (text) state.resultTexts.push(text);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Ship the turn's context-window breakdown to the relay, which serves it back
112
+ * to the UI as the composer indicator and the context-usage modal. Advisory
113
+ * data: a failure here must not disturb the turn's response.
114
+ */
115
+ async function publishContextUsage({ message, state, model, sdkSessionId }) {
116
+ if (!state.contextUsage && !state.modelUsage) return;
117
+ await api('POST', '/api/claude-context-usage', {
118
+ conversationId: message.conversationId,
119
+ sdkSessionId,
120
+ model: state.responseModel || model || null,
121
+ contextUsage: state.contextUsage,
122
+ modelUsage: state.modelUsage,
123
+ }).catch((error) => {
124
+ dbg('context usage publish failed', error?.message || String(error));
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Ship the session's plan usage (rate-limit windows + cost totals) to the
130
+ * relay. Falls back to the stable result fields when the experimental usage
131
+ * control request is unavailable, so the card still gets session cost.
132
+ * Advisory: a failure here must not disturb the turn's response.
133
+ */
134
+ async function publishPlanUsage({ message, state, sdkSessionId }) {
135
+ const totalCostUsd = state.result?.totalCostUsd ?? null;
136
+ // A cost with no per-model breakdown is still a usable fallback payload, so
137
+ // it has to count as a reason to publish or the card loses session cost on
138
+ // any turn the SDK reports without `modelUsage`.
139
+ if (!state.planUsage && !state.modelUsage && totalCostUsd === null) return;
140
+ await api('POST', '/api/claude-plan-usage', {
141
+ conversationId: message.conversationId,
142
+ sdkSessionId,
143
+ usage: state.planUsage,
144
+ modelUsage: state.modelUsage,
145
+ totalCostUsd,
146
+ }).catch((error) => {
147
+ dbg('plan usage publish failed', error?.message || String(error));
148
+ });
149
+ }
150
+
151
+ async function publishFinalStream(message, text) {
152
+ await api('POST', '/api/stream', {
153
+ messageId: message.id,
154
+ conversationId: message.conversationId,
155
+ mode: message.relayMode || 'agent',
156
+ text: String(text || ''),
157
+ done: true,
158
+ }).catch(() => {});
159
+ }
160
+
161
+ async function publishResponse(message, { text, model, terminalError = null, modelOrigin }) {
162
+ // Final digests of workflows that settled since the last response ride the
163
+ // next successful response publish — the summarizing turn is the natural
164
+ // transcript anchor for the "Finished background task" card. Terminal
165
+ // failures skip the drain so the digests stay buffered for a later turn;
166
+ // if this POST fails (→ requeue) or the process dies before any response
167
+ // publishes, the drained digests are lost — acceptable v1.
168
+ const workflowRuns = (!terminalError && typeof takeWorkflowRuns === 'function')
169
+ ? takeWorkflowRuns()
170
+ : null;
171
+ await api('POST', '/api/response', {
172
+ messageId: message.id,
173
+ conversationId: message.conversationId,
174
+ text: String(text || ''),
175
+ model: model || null,
176
+ modelOrigin: modelOrigin
177
+ || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
178
+ ...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
179
+ ...(terminalError ? { terminalError } : {}),
180
+ }).catch(async () => {
181
+ await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Post the plan-ready review board for an ExitPlanMode interception. The CLI
187
+ * sends both `plan` and `planFilePath`; the inline plan is authoritative, the
188
+ * plan file covers CLI versions that stop inlining it. Returns false when no
189
+ * plan text could be recovered so the caller's plan-shaped-final-text
190
+ * fallback still gets a chance to post the board.
191
+ */
192
+ async function publishPlanBoard(message, input) {
193
+ let planText = String(input?.plan || input?.summary || '').trim();
194
+ if (!planText) {
195
+ const planFilePath = String(input?.planFilePath || '').trim();
196
+ if (planFilePath) {
197
+ try {
198
+ planText = String(fs.readFileSync(planFilePath, 'utf8') || '').trim();
199
+ } catch (error) {
200
+ dbg('plan file read failed', planFilePath, error?.message || String(error));
201
+ }
202
+ }
203
+ }
204
+ const boardPayload = buildClaudePlanReadyBoardPayload({ message, planText });
205
+ if (!boardPayload) return false;
206
+ await api('POST', '/api/relay-board', boardPayload).catch((error) => {
207
+ dbg('plan board publish failed', error?.message || String(error));
208
+ });
209
+ return true;
210
+ }
211
+
212
+ /**
213
+ * Publish a completed (non-error, non-aborted) turn: final stream snapshot,
214
+ * plan-mode fallback board, response row. Mirrors the pre-session-process
215
+ * turn runner's tail exactly.
216
+ */
217
+ async function publishCompletedTurn({ message, state, responseModel, planBoardPosted }) {
218
+ const result = state.result;
219
+ const finalText = String(
220
+ state.resultTexts.join('\n\n') || result?.text || state.lastStreamedText || '',
221
+ ).trim();
222
+ if (
223
+ !planBoardPosted
224
+ && String(message.relayMode || '').trim().toLowerCase() === 'plan'
225
+ && countPlanLikeLines(finalText) >= 2
226
+ ) {
227
+ const boardPayload = buildClaudePlanReadyBoardPayload({ message, planText: finalText });
228
+ if (boardPayload) {
229
+ boardPayload.context.source = 'plan-mode-fallback';
230
+ await api('POST', '/api/relay-board', boardPayload).catch(() => {});
231
+ }
232
+ }
233
+ // Same reasoning as the Cursor worker: a terminal, non-error result with no
234
+ // prose is a completed turn (the model can end on tool activity alone), and
235
+ // requeuing it re-runs deterministically empty work until the retry cap
236
+ // fails the message with a misleading "Relay timeout".
237
+ const publishedText = finalText || EMPTY_TURN_COMPLETION_NOTE;
238
+ await publishFinalStream(message, publishedText);
239
+ await publishResponse(message, { text: publishedText, model: responseModel });
240
+ }
241
+
242
+ async function publishErrorResult({ message, state, responseModel }) {
243
+ const result = state.result;
244
+ const errorText = result.text
245
+ || `Claude turn failed (${result.subtype || 'unknown error'}).`;
246
+ await publishFinalStream(message, state.lastStreamedText);
247
+ await publishResponse(message, {
248
+ text: errorText,
249
+ model: responseModel,
250
+ terminalError: {
251
+ kind: 'claude-turn-failed',
252
+ code: result.subtype || 'unknown',
253
+ stableCode: `claude.${result.subtype || 'unknown'}`,
254
+ message: errorText,
255
+ failedAt: new Date().toISOString(),
256
+ queueMessageId: String(message.id || '') || null,
257
+ },
258
+ });
259
+ }
260
+
261
+ async function publishTurnException({ message, errorText }) {
262
+ const isAuthError = /authentication|logged in|login|credential|api key/i.test(errorText);
263
+ await publishResponse(message, {
264
+ text: isAuthError
265
+ ? `System note: the Claude runtime could not authenticate (${errorText}). Log in again from Settings → Providers → Claude → Relogin (or run \`claude\` on the relay host), then retry.`
266
+ : `System note: the Claude turn failed (${errorText}). Retry or send a new message.`,
267
+ model: null,
268
+ terminalError: {
269
+ kind: 'claude-turn-failed',
270
+ code: isAuthError ? 'authentication_failed' : 'turn-error',
271
+ stableCode: isAuthError ? 'claude.authentication_failed' : 'claude.turn-error',
272
+ message: errorText,
273
+ failedAt: new Date().toISOString(),
274
+ queueMessageId: String(message.id || '') || null,
275
+ },
276
+ });
277
+ }
278
+
279
+ return {
280
+ postActivity,
281
+ dispatchAction,
282
+ publishContextUsage,
283
+ publishPlanUsage,
284
+ publishFinalStream,
285
+ publishResponse,
286
+ publishPlanBoard,
287
+ publishCompletedTurn,
288
+ publishErrorResult,
289
+ publishTurnException,
290
+ };
291
+ }
@@ -0,0 +1 @@
1
+ {"runId":"wf_d8a3315d-fd5","timestamp":"2026-08-17T15:47:41.320Z","taskId":"wic26ymi4","script":"export const meta = {\n name: 'review-shared-model-modules',\n description: 'Review shared/model-* modules for correctness bugs via parallel lensed reviewers plus adversarial verification',\n phases: [\n { title: 'Review' },\n { title: 'Verify' },\n ],\n}\n\nconst FILES = [\n '/workspace/copilot-remote/shared/model-catalog-freshness.mjs',\n '/workspace/copilot-remote/shared/model-descriptors.mjs',\n '/workspace/copilot-remote/shared/model-id.mjs',\n '/workspace/copilot-remote/shared/model-refresh.mjs',\n]\n\nconst CONTEXT_NOTE = `This review is READ-ONLY. Do not edit, write, or otherwise modify any file. Only use Read/Grep/Bash(read-only inspection) tools.\n\nTarget modules to review for CORRECTNESS BUGS (not style, not simplification):\n${FILES.map(f => `- ${f}`).join('\\n')}\n\nFor context, each module has a sibling test file (e.g. model-id.test.mjs) in the same shared/ directory — you may read those to understand intended behavior, but do not report bugs in the test files themselves, only in the four modules listed above. Also feel free to grep the wider repo (rg -n \"modelId\\\\(\" or similar) to see how these modules are actually called, since a \"correctness bug\" claim is much stronger when you can point at a real call site that would misbehave.`\n\nconst FINDINGS_SCHEMA = {\n type: 'object',\n properties: {\n findings: {\n type: 'array',\n items: {\n type: 'object',\n properties: {\n file: { type: 'string', description: 'absolute path' },\n line: { type: 'number' },\n summary: { type: 'string', description: 'one sentence statement of the defect' },\n failure_scenario: { type: 'string', description: 'concrete input/state that triggers wrong output or a crash' },\n },\n required: ['file', 'summary', 'failure_scenario'],\n },\n },\n },\n required: ['findings'],\n}\n\nconst VERDICT_SCHEMA = {\n type: 'object',\n properties: {\n refuted: { type: 'boolean', description: 'true if this is NOT a real bug (false positive, intended behavior, unreachable, etc.)' },\n reasoning: { type: 'string' },\n },\n required: ['refuted', 'reasoning'],\n}\n\nconst LENSES = [\n {\n key: 'logic',\n prompt: `Review these modules for general logic/correctness bugs: wrong conditionals, off-by-one errors, incorrect boolean logic, wrong operator precedence, mis-ordered comparisons, incorrect return values, or logic that contradicts the module's apparent intent (infer intent from naming, comments, and the sibling test file).\\n\\n${CONTEXT_NOTE}`,\n },\n {\n key: 'async-error-handling',\n prompt: `Review these modules for async/concurrency/error-handling bugs: unhandled promise rejections, missing awaits, race conditions, incorrect error propagation/swallowing, incorrect use of try/catch, stale closures over mutable state, or timing assumptions that don't hold.\\n\\n${CONTEXT_NOTE}`,\n },\n {\n key: 'edge-cases-data',\n prompt: `Review these modules for edge-case and data-validation bugs: null/undefined handling, malformed or unexpected input shapes, boundary conditions (empty arrays/strings, zero, negative numbers), incorrect assumptions about external data (e.g. API responses, file contents, env vars), and mutation of shared/cached state that could leak across calls.\\n\\n${CONTEXT_NOTE}`,\n },\n]\n\nphase('Review')\nconst reviews = await parallel(LENSES.map(l => () =>\n agent(l.prompt, { label: `review:${l.key}`, phase: 'Review', schema: FINDINGS_SCHEMA })\n))\n\nconst allFindings = reviews.filter(Boolean).flatMap(r => r.findings || [])\nlog(`${allFindings.length} raw findings from ${LENSES.length} reviewers, verifying each...`)\n\nif (allFindings.length === 0) {\n return { confirmed: [], raw: [] }\n}\n\nphase('Verify')\nconst verified = await parallel(allFindings.map(f => () =>\n agent(\n `Adversarially verify this correctness-bug claim about ${f.file}${f.line ? ` (near line ${f.line})` : ''}.\\n\\nClaim: ${f.summary}\\nAlleged failure scenario: ${f.failure_scenario}\\n\\nRead the actual file (and its sibling test file, and real call sites elsewhere in the repo if useful) and determine whether this is a REAL bug or a false positive. Be skeptical — default to refuted=true if you cannot concretely demonstrate the failure scenario actually occurs given the real code. Do not modify any files.`,\n { label: `verify:${f.file.split('/').pop()}`, phase: 'Verify', schema: VERDICT_SCHEMA }\n ).then(v => ({ ...f, verdict: v }))\n))\n\nconst confirmed = verified.filter(Boolean).filter(v => v.verdict && v.verdict.refuted === false)\n\nreturn { confirmed, totalRaw: allFindings.length, totalConfirmed: confirmed.length }\n","scriptPath":"/workspace/.claude/projects/-workspace-copilot-remote/00000000-0000-4000-8000-000000000000/workflows/scripts/review-shared-model-modules-wf_d8a3315d-fd5.js","result":{"confirmed":[{"file":"/workspace/copilot-remote/shared/model-id.mjs","line":108,"summary":"canonicalizeModelId fails to strip the provider prefix from bare 'o1'/'o3' model ids, so 'openai/o1' and 'o1' canonicalize to different strings even though the rest of the module treats bare 'o1'/'o3' as first-class, equivalent model ids.","failure_scenario":"canonicalizeModelId('openai/o1') returns 'openai/o1' instead of 'o1' (compare canonicalizeModelId('openai/o1-preview') -> 'o1-preview', which strips correctly). Root cause: BASE_MODEL_PREFIXES (line 17) is derived by filtering MODEL_ID_PREFIXES down to entries without '/', giving ['gpt-','chatgpt-','claude-','gemini-','mai-','o1-','o3-','o4-','codex-'] — only the dash-suffixed 'o1-'/'o3-' forms, not the bare 'o1'/'o3' ids that isLikelyModelIdPrefix (line 39) and isOpenAIModelId (line 96) explicitly special-case as valid standalone model ids. As a result, in canonicalizeModelId's loop (lines 111-118), stripped='o1' never matches any BASE_MODEL_PREFIXES entry via startsWith, so the function falls through to `return candidate` and keeps the redundant 'openai/' prefix. Concretely: filterValidModelIds(['o1', 'openai/o1']) returns ['o1', 'openai/o1'] as two distinct entries instead of deduping to one, defeating the documented intent of filterValidModelIds ('filterValidModelIds dedupes case and provider aliases' per the sibling test) for exactly the two model ids ('o1'/'o3') the file goes out of its way to special-case elsewhere. This is not a purely theoretical shape: server/services/session-worker-launch-service.mjs:34-41 (resolveOpenAIWireApi) explicitly normalizes and compares against both the bare and 'openai/'-prefixed forms of 'o1'/'o3', confirming provider-prefixed bare ids are a real input shape in this codebase; any catalog/dedup path built on canonicalizeModelId (e.g. server-runtime.mjs's baseModelId/model-metadata maps keyed by canonicalizeModelId) would treat 'o1' and 'openai/o1' as two separate models instead of one.","verdict":{"reasoning":"Directly reproduced the failure by running the real module: canonicalizeModelId('openai/o1') === 'openai/o1' (unstripped) while canonicalizeModelId('openai/o1-preview') === 'o1-preview' (stripped), and filterValidModelIds(['o1','openai/o1']) === ['o1','openai/o1'] (two entries instead of one). Root cause matches the claim exactly: BASE_MODEL_PREFIXES (derived from MODEL_ID_PREFIXES filtered to entries without '/') only contains the dashed forms 'o1-'/'o3-', so the startsWith check in canonicalizeModelId's stripping loop never matches the bare stripped remainder 'o1'/'o3', even though isLikelyModelIdPrefix and isOpenAIModelId in the same file explicitly special-case bare 'o1'/'o3' as valid standalone OpenAI model ids. This is an inconsistency within the module's own stated design (provider prefixes should be redundant/stripped for known base model families) for exactly the two families it special-cases elsewhere. Confirmed the sibling test file (shared/model-id.test.mjs) has a test 'filterValidModelIds dedupes case and provider aliases' that verifies this dedup behavior for gpt-*/claude-* families but does not cover o1/o3, so this gap is untested and the existing test suite passes despite the bug. Also confirmed bare/prefixed 'o1'/'o3' handling is a real, non-hypothetical shape used elsewhere (server/services/session-worker-launch-service.mjs resolveOpenAIWireApi explicitly strips 'openai/' and checks both bare and dashed o1/o3 forms), and canonicalizeModelId/filterValidModelIds are used in real dedup/catalog paths in server/server-runtime.mjs and shared/model-refresh.mjs. This is a real, concretely demonstrated correctness bug.","refuted":false}},{"file":"/workspace/copilot-remote/shared/model-id.mjs","line":108,"summary":"canonicalizeModelId() fails to strip the provider prefix from bare 'o1'/'o3' model ids, so 'openai/o1' and 'o1' are treated as two different canonical ids instead of deduping to one.","failure_scenario":"canonicalizeModelId('openai/o1') returns 'openai/o1' unchanged: it only strips a provider prefix when the remainder starts with one of BASE_MODEL_PREFIXES (line 17), and every one of those prefixes requires a trailing '-', e.g. 'o1-', so bare 'o1' never matches. canonicalizeModelId('o1') separately returns 'o1'. Both 'o1' and 'openai/o1' independently pass isValidModelId (isLikelyModelIdPrefix at line 37 and isOpenAIModelId at line 94 both special-case bare 'o1'/'o3'), so filterValidModelIds(['o1', 'openai/o1']) returns ['o1', 'openai/o1'] as two distinct entries instead of collapsing to one, unlike every other provider-prefixed pair (verified: filterValidModelIds(['GPT-5.4','openai/gpt-5.4']) correctly collapses to a single 'gpt-5.4'). Any caller merging model lists that mix bare and provider-prefixed o1/o3 ids (e.g. selectModelIdsForVariantRefresh's snapshotModels, or server-runtime.mjs's validatedModelIdList) will surface the same underlying model twice.","verdict":{"reasoning":"Directly executed the real code from shared/model-id.mjs (Node, --input-type=module) and reproduced the exact scenario: canonicalizeModelId('openai/o1') === 'openai/o1' (unchanged) while canonicalizeModelId('o1') === 'o1'; both pass isValidModelId (true special-casing of bare o1/o3 in isLikelyModelIdPrefix line 37-41 and isOpenAIModelId line 94-106); filterValidModelIds(['o1','openai/o1']) returns ['o1','openai/o1'] (2 entries) whereas the analogous filterValidModelIds(['GPT-5.4','openai/gpt-5.4']) correctly collapses to ['gpt-5.4']. Root cause verified by reading the code: BASE_MODEL_PREFIXES (line 17) is derived from MODEL_ID_PREFIXES entries that lack '/', giving ['gpt-','chatgpt-','claude-','gemini-','mai-','o1-','o3-','o4-','codex-'] — all require a trailing '-', so stripping 'openai/' from 'openai/o1' leaves 'o1', which does not start with 'o1-', so the strip-and-return branch in canonicalizeModelId (line 108-120) never fires and the function falls through to returning the original unstripped candidate. This asymmetry does not occur for any other prefix family (verified openai/o1-mini -> o1-mini works fine) because those always retain their trailing dash in the stripped remainder. Confirmed this is reachable via real call sites: shared/model-refresh.mjs's selectModelIdsForVariantRefresh merges snapshotModels with currentModel/defaultModel through filterValidModelIds, and server/server-runtime.mjs's validatedModelIdList wraps filterValidModelIds identically, matching the claim's alleged caller impact. Also confirmed provider-prefixed ids like 'openai/gpt-5.4' are real formats already used elsewhere in the repo (server/relay.mjs, web-relay model-switching.mjs), making the prefixed input plausible rather than purely synthetic. Ran the full existing test suite (14 tests, all passing) and confirmed there is no test covering the bare-vs-prefixed o1/o3 dedup case, despite adjacent tests explicitly covering bare o1/o3 validity and provider-alias dedup for other families separately — a clear coverage gap consistent with an unintended bug rather than documented/intended behavior. All specifics in the claim (line numbers, function names, exact input/output values) check out against the real file contents and real execution. This is a concrete, reproducible correctness bug, not a false positive.","refuted":false}},{"file":"/workspace/copilot-remote/shared/model-descriptors.mjs","line":77,"summary":"extractModelDescriptors' context-limit fallback (findContextLimitTokens) recurses into the same container keys ('data'/'models'/'list'/etc.) that extractModelDescriptors itself uses to find sibling model entries, so a model with no usable pricing/limits fields of its own can silently inherit a sibling model's context-window value.","failure_scenario":"For raw = { id: 'gpt-5.4', data: [ { id: 'gpt-5.4-mini', contextWindow: 8000 } ] }, extractModelDescriptors(raw) yields contextLimitTokens: 8000 for BOTH the 'gpt-5.4' and 'gpt-5.4-mini' descriptors (reproduced by running the module directly), even though the 8000-token window only belongs to 'gpt-5.4-mini'. This happens because contextLimitForTier() (lines 62-78) falls back to findContextLimitTokens(value) (line 77) whenever a model object has no tokenPrices/limits fields of its own; findContextLimitTokens (lines 9-28) then walks every nested object property of the model, including the same containerKeys ('data','models','list','available','items','entries','result','response' at line 81) that extractModelDescriptors (lines 107-109) separately recurses into to discover additional sibling models. Any upstream RPC/API shape that nests a list of related models under one of those container keys inside an outer model-shaped object (plausible given extractModelDescriptors explicitly supports several heterogeneous list-wrapper shapes across client.listModels()/models.list/modelApi.list in server/relay.mjs) will cross-contaminate context-limit data between unrelated models.","verdict":{"reasoning":"I read shared/model-descriptors.mjs in full and directly executed the unmodified module with the exact reproduction case from the claim: extractModelDescriptors({ id: 'gpt-5.4', data: [{ id: 'gpt-5.4-mini', contextWindow: 8000 }] }). The output shows both 'gpt-5.4' and 'gpt-5.4-mini' get contextLimitTokens: 8000, confirming the claim's central assertion exactly.\n\nMechanism verified by code inspection: contextLimitForTier (line 62-78) falls back to findContextLimitTokens(value) at line 77 when a model object has no own limits/tokenPrices fields, passing the model's *entire* raw object (including any container-key properties it happens to carry). findContextLimitTokens (lines 9-28) recurses into Object.values(value) with zero awareness of the containerKeys array ['data','models','list','available','items','entries','result','response'] that extractModelDescriptors (lines 80-81, 107-109) separately uses to walk into sibling model lists. So when a model-shaped object (has its own valid id) also happens to carry one of those container keys holding sibling model data, findContextLimitTokens will happily discover and return the sibling's contextWindow/maxContextTokens/etc. value as if it belonged to the outer model. This is a genuine, unambiguous logic defect in the shared helper — not a misreading of the code.\n\nI additionally checked reachability against real call sites (server/relay.mjs, .github/extensions/web-relay/model-api/model-switching.mjs, and the installed @github/copilot SDK's rpc.d.ts/types.d.ts for Model/ModelInfo/ModelList). Today's actual known upstream shape is `{ models: Model[] }`, where the wrapper object itself has no id/modelId/model field (so it's never treated as a \"model\" whose fallback search would run), and each individual Model/ModelInfo only carries id/name/capabilities/policy/billing — never a nested container-key list of sibling models inside a single model object. So the specific pathological input isn't produced by any currently-known real RPC response in this repo, and the existing test suite (model-switching.test.mjs) doesn't cover it. However, the claim itself is hedged appropriately (\"any upstream RPC/API shape that nests...\") and the function is explicitly written as a generic, defensive parser meant to tolerate multiple heterogeneous list-wrapper shapes across several different call sites/providers, so this is a real latent correctness defect in shared code (silent cross-contamination of per-model metadata) rather than a purely theoretical/unreachable construct — I concretely reproduced it against the real, unmodified source exactly as alleged.","refuted":false}},{"file":"/workspace/copilot-remote/shared/model-descriptors.mjs","line":59,"summary":"normalizePricing() treats an object that only has an inherited batchSize (no actual input/output/cache rates) as 'has pricing', because its emptiness check counts batchSize as a real price field.","failure_scenario":"For tokenPrices = { batchSize: 100, longContext: { maxPromptTokens: 500000 } } (a longContext entry that only carries a prompt-token budget, no rates of its own), normalizePricing(tokenPrices.longContext, tokenPrices.batchSize) computes { input: null, output: null, cacheRead: null, cacheWrite: null, batchSize: 100 }. Because Object.values(pricing).some(entry => entry !== null) (line 59) is true (batchSize is non-null), the function returns this object instead of null, so extractModelDescriptors' pricing.longContext ends up non-null with every rate field null. A consumer that treats a non-null pricing.longContext as 'this model has distinct long-context pricing' will show/act on pricing that doesn't actually exist.","verdict":{"reasoning":"Empirically reproduced the exact scenario from the claim by executing the real code (node -e importing shared/model-descriptors.mjs): normalizePricing(tokenPrices.longContext, tokenPrices.batchSize) with a longContext object containing only maxPromptTokens returns { input: null, output: null, cacheRead: null, cacheWrite: null, batchSize: <inherited> } instead of null, because the emptiness check at line 59 (Object.values(pricing).some(entry => entry !== null)) iterates over all five pricing keys including batchSize, and batchSize is always populated via the fallbackBatchSize parameter inherited from the parent tokenPrices object. This is a genuine logic defect: batchSize is meant only as a conversion factor for when real per-tier rates exist, not as evidence that the tier itself carries distinct pricing, so it should be excluded from the emptiness check. I additionally traced a concrete real-world consumer (server/public/app/bootstrap.js updateModelPricingDetails) that treats a non-null pricing.longContext as present, and due to Number(null) === 0 being finite, it would render '0 credits' for every rate field rather than skipping display — an actively misleading UI outcome, not just an inert data-shape quirk. The existing test suite (model-switching.test.mjs) only covers longContext objects that DO have explicit inputPrice/outputPrice, so this edge case is untested and unguarded. This is a real, demonstrable bug, not a false positive.","refuted":false}}],"totalRaw":4,"totalConfirmed":4},"agentCount":7,"logs":["4 raw findings from 3 reviewers, verifying each..."],"durationMs":927637,"summary":"Review shared/model-* modules for correctness bugs via parallel lensed reviewers plus adversarial verification","workflowName":"review-shared-model-modules","status":"completed","startTime":1786980733680,"phases":[{"title":"Review"},{"title":"Verify"}],"defaultModel":"claude-sonnet-5","workflowProgress":[{"type":"workflow_phase","index":1,"title":"Review"},{"type":"workflow_phase","index":2,"title":"Verify"},{"type":"workflow_agent","index":1,"label":"review:logic","phaseIndex":1,"phaseTitle":"Review","agentId":"aea17f1059dee40cc","model":"claude-sonnet-5","state":"done","startedAt":1786980733726,"queuedAt":1786980733706,"attempt":1,"lastToolName":"StructuredOutput","promptPreview":"Review these modules for general logic/correctness bugs: wrong conditionals, off-by-one errors, incorrect boolean logic, wrong operator precedence, mis-ordered comparisons, incorrect return values, or logic that contradicts the module's apparent intent (infer intent from naming, comments, and the sibling test file).\n\nThis review is READ-ONLY. Do not edit, write, or otherwise modify any file. Only …","lastProgressAt":1786980993780,"tokens":62208,"toolCalls":27,"durationMs":260054,"resultPreview":"{\"findings\":[{\"file\":\"/workspace/copilot-remote/shared/model-id.mjs\",\"line\":108,\"summary\":\"canonicalizeModelId fails to strip the provider prefix from bare 'o1'/'o3' model ids, so 'openai/o1' and 'o1' canonicalize to different strings even though the rest of the module treats bare 'o1'/'o3' as first-class, equivalent model ids.\",\"failure_scenario\":\"canonicalizeModelId('openai/o1') returns 'op…"},{"type":"workflow_agent","index":2,"label":"review:async-error-handling","phaseIndex":1,"phaseTitle":"Review","agentId":"ad1e58c72b13f1015","model":"claude-sonnet-5","state":"done","startedAt":1786980733728,"queuedAt":1786980733707,"attempt":1,"lastToolName":"StructuredOutput","promptPreview":"Review these modules for async/concurrency/error-handling bugs: unhandled promise rejections, missing awaits, race conditions, incorrect error propagation/swallowing, incorrect use of try/catch, stale closures over mutable state, or timing assumptions that don't hold.\n\nThis review is READ-ONLY. Do not edit, write, or otherwise modify any file. Only use Read/Grep/Bash(read-only inspection) tools.\n\n…","lastProgressAt":1786980852956,"tokens":39063,"toolCalls":17,"durationMs":119228,"resultPreview":"{\"findings\":[]}"},{"type":"workflow_agent","index":3,"label":"review:edge-cases-data","phaseIndex":1,"phaseTitle":"Review","agentId":"ac360fa786779ed28","model":"claude-sonnet-5","state":"done","startedAt":1786980852958,"queuedAt":1786980733707,"attempt":1,"lastToolName":"StructuredOutput","promptPreview":"Review these modules for edge-case and data-validation bugs: null/undefined handling, malformed or unexpected input shapes, boundary conditions (empty arrays/strings, zero, negative numbers), incorrect assumptions about external data (e.g. API responses, file contents, env vars), and mutation of shared/cached state that could leak across calls.\n\nThis review is READ-ONLY. Do not edit, write, or oth…","lastProgressAt":1786981287222,"tokens":71437,"toolCalls":26,"durationMs":434264,"resultPreview":"{\"findings\":[{\"file\":\"/workspace/copilot-remote/shared/model-id.mjs\",\"line\":108,\"summary\":\"canonicalizeModelId() fails to strip the provider prefix from bare 'o1'/'o3' model ids, so 'openai/o1' and 'o1' are treated as two different canonical ids instead of deduping to one.\",\"failure_scenario\":\"canonicalizeModelId('openai/o1') returns 'openai/o1' unchanged: it only strips a provider prefix whe…"},{"type":"workflow_agent","index":4,"label":"verify:model-id.mjs","phaseIndex":2,"phaseTitle":"Verify","agentId":"a5391ad841f01a0ed","model":"claude-sonnet-5","state":"done","startedAt":1786981287253,"queuedAt":1786981287226,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"Directly reproduced the failure by running the real module:…","promptPreview":"Adversarially verify this correctness-bug claim about /workspace/copilot-remote/shared/model-id.mjs (near line 108).\n\nClaim: canonicalizeModelId fails to strip the provider prefix from bare 'o1'/'o3' model ids, so 'openai/o1' and 'o1' canonicalize to different strings even though the rest of the module treats bare 'o1'/'o3' as first-class, equivalent model ids.\nAlleged failure scenario: canon…","lastProgressAt":1786981398337,"tokens":31376,"toolCalls":13,"durationMs":111084,"resultPreview":"{\"reasoning\":\"Directly reproduced the failure by running the real module: canonicalizeModelId('openai/o1') === 'openai/o1' (unstripped) while canonicalizeModelId('openai/o1-preview') === 'o1-preview' (stripped), and filterValidModelIds(['o1','openai/o1']) === ['o1','openai/o1'] (two entries instead of one). Root cause matches the claim exactly: BASE_MODEL_PREFIXES (derived from MODEL_ID_PREFIXES f…"},{"type":"workflow_agent","index":5,"label":"verify:model-id.mjs","phaseIndex":2,"phaseTitle":"Verify","agentId":"acb2d28c2e0ed0942","model":"claude-sonnet-5","state":"done","startedAt":1786981287255,"queuedAt":1786981287226,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"Directly executed the real code from shared/model-id.mjs (N…","promptPreview":"Adversarially verify this correctness-bug claim about /workspace/copilot-remote/shared/model-id.mjs (near line 108).\n\nClaim: canonicalizeModelId() fails to strip the provider prefix from bare 'o1'/'o3' model ids, so 'openai/o1' and 'o1' are treated as two different canonical ids instead of deduping to one.\nAlleged failure scenario: canonicalizeModelId('openai/o1') returns 'openai/o1' unchange…","lastProgressAt":1786981407901,"tokens":30236,"toolCalls":17,"durationMs":120646,"resultPreview":"{\"reasoning\":\"Directly executed the real code from shared/model-id.mjs (Node, --input-type=module) and reproduced the exact scenario: canonicalizeModelId('openai/o1') === 'openai/o1' (unchanged) while canonicalizeModelId('o1') === 'o1'; both pass isValidModelId (true special-casing of bare o1/o3 in isLikelyModelIdPrefix line 37-41 and isOpenAIModelId line 94-106); filterValidModelIds(['o1','openai…"},{"type":"workflow_agent","index":6,"label":"verify:model-descriptors.mjs","phaseIndex":2,"phaseTitle":"Verify","agentId":"a811a30b41823ae7d","model":"claude-sonnet-5","state":"done","startedAt":1786981398339,"queuedAt":1786981287226,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"I read shared/model-descriptors.mjs in full and directly ex…","promptPreview":"Adversarially verify this correctness-bug claim about /workspace/copilot-remote/shared/model-descriptors.mjs (near line 77).\n\nClaim: extractModelDescriptors' context-limit fallback (findContextLimitTokens) recurses into the same container keys ('data'/'models'/'list'/etc.) that extractModelDescriptors itself uses to find sibling model entries, so a model with no usable pricing/limits fields o…","lastProgressAt":1786981661319,"tokens":52733,"toolCalls":29,"durationMs":262979,"resultPreview":"{\"reasoning\":\"I read shared/model-descriptors.mjs in full and directly executed the unmodified module with the exact reproduction case from the claim: extractModelDescriptors({ id: 'gpt-5.4', data: [{ id: 'gpt-5.4-mini', contextWindow: 8000 }] }). The output shows both 'gpt-5.4' and 'gpt-5.4-mini' get contextLimitTokens: 8000, confirming the claim's central assertion exactly.\\n\\nMechanism verified…"},{"type":"workflow_agent","index":7,"label":"verify:model-descriptors.mjs","phaseIndex":2,"phaseTitle":"Verify","agentId":"a07c1f8a2c0589996","model":"claude-sonnet-5","state":"done","startedAt":1786981407902,"queuedAt":1786981287226,"attempt":1,"lastToolName":"StructuredOutput","lastToolSummary":"Empirically reproduced the exact scenario from the claim by…","promptPreview":"Adversarially verify this correctness-bug claim about /workspace/copilot-remote/shared/model-descriptors.mjs (near line 59).\n\nClaim: normalizePricing() treats an object that only has an inherited batchSize (no actual input/output/cache rates) as 'has pricing', because its emptiness check counts batchSize as a real price field.\nAlleged failure scenario: For tokenPrices = { batchSize: 100, long…","lastProgressAt":1786981552276,"tokens":34445,"toolCalls":15,"durationMs":144374,"resultPreview":"{\"reasoning\":\"Empirically reproduced the exact scenario from the claim by executing the real code (node -e importing shared/model-descriptors.mjs): normalizePricing(tokenPrices.longContext, tokenPrices.batchSize) with a longContext object containing only maxPromptTokens returns { input: null, output: null, cacheRead: null, cacheWrite: null, batchSize: <inherited> } instead of null, because the emp…"}],"totalTokens":321498,"totalToolCalls":144}