@oar-sh/oar 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -1 +0,0 @@
|
|
|
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}
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
// Spawn-free stand-ins for the Copilot SDK, per DEVELOPING.md: no test in this
|
|
2
|
-
// directory starts a runtime process, imports the installed SDK, or talks to a
|
|
3
|
-
// relay. The injection seam is a constructor option on
|
|
4
|
-
// `createCopilotSdkSessionRunner` (`startClientImpl`), not a module mock.
|
|
5
|
-
//
|
|
6
|
-
// Deliberately NOT named `*.test.mjs` so `node --test`'s glob skips it.
|
|
7
|
-
import fs from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
-
|
|
11
|
-
import { createCopilotSdkSessionRunner } from './copilot-sdk-session-process.mjs';
|
|
12
|
-
|
|
13
|
-
const FIXTURE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
14
|
-
|
|
15
|
-
/** Load a scrubbed phase-0 event dump as an array of `SessionEvent`s. */
|
|
16
|
-
export function loadFixture(name) {
|
|
17
|
-
return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, `${name}.json`), 'utf8'));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function tick(ms = 5) {
|
|
21
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function waitFor(predicate, { timeoutMs = 3000, label = 'condition' } = {}) {
|
|
25
|
-
const deadline = Date.now() + timeoutMs;
|
|
26
|
-
while (Date.now() < deadline) {
|
|
27
|
-
if (await predicate()) return true;
|
|
28
|
-
await tick(2);
|
|
29
|
-
}
|
|
30
|
-
throw new Error(`waitFor timed out: ${label}`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Records every relay call; `failRoutes` makes a route throw, like a 500.
|
|
35
|
-
*
|
|
36
|
-
* `routeResponses` maps a route to its response body, or to a function of
|
|
37
|
-
* `(body, callIndexForThatRoute)` — which is how a test drives a route that
|
|
38
|
-
* answers differently per attempt (`/api/continuation-turn`'s retry loop).
|
|
39
|
-
* Unlisted routes answer `{}`, as before.
|
|
40
|
-
*/
|
|
41
|
-
export function makeApiStub({ failRoutes = new Set(), routeResponses = {} } = {}) {
|
|
42
|
-
const calls = [];
|
|
43
|
-
const routeCounts = new Map();
|
|
44
|
-
async function api(method, routePath, body) {
|
|
45
|
-
calls.push({ method, routePath, body });
|
|
46
|
-
const route = String(routePath).split('?')[0];
|
|
47
|
-
if (failRoutes.has(route)) throw new Error(`stub route failure: ${route}`);
|
|
48
|
-
const attempt = routeCounts.get(route) || 0;
|
|
49
|
-
routeCounts.set(route, attempt + 1);
|
|
50
|
-
const canned = routeResponses[route];
|
|
51
|
-
if (typeof canned === 'function') return canned(body, attempt);
|
|
52
|
-
return canned === undefined ? {} : canned;
|
|
53
|
-
}
|
|
54
|
-
api.calls = calls;
|
|
55
|
-
api.bodiesFor = (routePath) => calls.filter((c) => c.routePath === routePath).map((c) => c.body);
|
|
56
|
-
return api;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* An api stub that mints synthetic queue rows, so a test can exercise the
|
|
61
|
-
* continuation path end to end. `POST /api/continuation-turn` answers with a
|
|
62
|
-
* fresh `messageId`, exactly as the relay route does.
|
|
63
|
-
*/
|
|
64
|
-
export function makeContinuationApiStub({
|
|
65
|
-
conversationId = 'conv-1',
|
|
66
|
-
messageIds = ['cont-1', 'cont-2', 'cont-3'],
|
|
67
|
-
...options
|
|
68
|
-
} = {}) {
|
|
69
|
-
const minted = [];
|
|
70
|
-
const stub = makeApiStub({
|
|
71
|
-
...options,
|
|
72
|
-
routeResponses: {
|
|
73
|
-
'/api/continuation-turn': (body, attempt) => {
|
|
74
|
-
const messageId = messageIds[Math.min(attempt, messageIds.length - 1)];
|
|
75
|
-
minted.push({ messageId, body });
|
|
76
|
-
return { ok: true, messageId, conversationId };
|
|
77
|
-
},
|
|
78
|
-
...(options.routeResponses || {}),
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
stub.mintedContinuations = minted;
|
|
82
|
-
return stub;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* A fake CopilotSession. `send()` accepts one `MessageOptions` object (the
|
|
87
|
-
* real signature: there is no second parameter) and, like the real SDK,
|
|
88
|
-
* resolves immediately — the turn's completion arrives on the event callback,
|
|
89
|
-
* which is what `onSend` is for.
|
|
90
|
-
*/
|
|
91
|
-
export function createFakeCopilotSession({ config, onSend = null, onAbort = null }) {
|
|
92
|
-
const session = {
|
|
93
|
-
sessionId: config?.sessionId || 'fake-session',
|
|
94
|
-
config,
|
|
95
|
-
sends: [],
|
|
96
|
-
abortCalls: 0,
|
|
97
|
-
setModelCalls: [],
|
|
98
|
-
disconnected: false,
|
|
99
|
-
emit(event) {
|
|
100
|
-
config?.onEvent?.(event);
|
|
101
|
-
},
|
|
102
|
-
replay(events) {
|
|
103
|
-
for (const event of events) session.emit(event);
|
|
104
|
-
},
|
|
105
|
-
async send(options) {
|
|
106
|
-
session.sends.push(options);
|
|
107
|
-
// Scheduled rather than synchronous so the runner's `await session.send`
|
|
108
|
-
// resolves before the events land, matching the real ordering.
|
|
109
|
-
setTimeout(() => { onSend?.(session, options); }, 0);
|
|
110
|
-
return 'fake-message-id';
|
|
111
|
-
},
|
|
112
|
-
async abort() {
|
|
113
|
-
session.abortCalls += 1;
|
|
114
|
-
setTimeout(() => { onAbort?.(session); }, 0);
|
|
115
|
-
},
|
|
116
|
-
async setModel(model) {
|
|
117
|
-
session.setModelCalls.push(model);
|
|
118
|
-
},
|
|
119
|
-
async disconnect() {
|
|
120
|
-
session.disconnected = true;
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
return session;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* The two ways `resumeSession` fails, which the runner MUST tell apart.
|
|
128
|
-
*
|
|
129
|
-
* - `missing` — the runtime has no state under this id. Shaped like the
|
|
130
|
-
* real thing: the runtime throws `Session not found: <id>`
|
|
131
|
-
* and JSON-RPC wraps it as a generic InternalError (-32603),
|
|
132
|
-
* so the message is the only signal.
|
|
133
|
-
* - `transient` — the call itself failed (connection dropped). Falling
|
|
134
|
-
* through to `createSession` here would blank a live
|
|
135
|
-
* conversation.
|
|
136
|
-
*/
|
|
137
|
-
export function makeResumeFailure(flavor, sessionId = 'conv-1') {
|
|
138
|
-
if (flavor === 'transient') {
|
|
139
|
-
const error = new Error('Pending response rejected since connection got disposed');
|
|
140
|
-
error.code = -32097;
|
|
141
|
-
return error;
|
|
142
|
-
}
|
|
143
|
-
const error = new Error(`Request session.resume failed with message: Session not found: ${sessionId}`);
|
|
144
|
-
error.code = -32603;
|
|
145
|
-
return error;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* A fake CopilotClient. `resumeAvailable: false` models a brand-new
|
|
150
|
-
* conversation (the runtime has no state to resume), which is what makes the
|
|
151
|
-
* runner fall through to `createSession`. `resumeFailure: 'transient'` models
|
|
152
|
-
* the RPC itself failing, which must NOT fall through.
|
|
153
|
-
*/
|
|
154
|
-
export function createFakeCopilotClient({
|
|
155
|
-
resumeAvailable = false,
|
|
156
|
-
resumeFailure = 'missing',
|
|
157
|
-
onSend = null,
|
|
158
|
-
onAbort = null,
|
|
159
|
-
} = {}) {
|
|
160
|
-
const client = {
|
|
161
|
-
stopped: 0,
|
|
162
|
-
resumeAttempts: [],
|
|
163
|
-
createAttempts: [],
|
|
164
|
-
sessions: [],
|
|
165
|
-
resumeAvailable,
|
|
166
|
-
resumeFailure,
|
|
167
|
-
async resumeSession(sessionId, config) {
|
|
168
|
-
client.resumeAttempts.push({ sessionId, config });
|
|
169
|
-
if (!client.resumeAvailable) throw makeResumeFailure(client.resumeFailure, sessionId);
|
|
170
|
-
const session = createFakeCopilotSession({ config, onSend, onAbort });
|
|
171
|
-
session.resumed = true;
|
|
172
|
-
client.sessions.push(session);
|
|
173
|
-
// A session that exists once exists forever, so later reconnects resume.
|
|
174
|
-
client.resumeAvailable = true;
|
|
175
|
-
return session;
|
|
176
|
-
},
|
|
177
|
-
async createSession(config) {
|
|
178
|
-
client.createAttempts.push(config);
|
|
179
|
-
const session = createFakeCopilotSession({ config, onSend, onAbort });
|
|
180
|
-
client.sessions.push(session);
|
|
181
|
-
client.resumeAvailable = true;
|
|
182
|
-
return session;
|
|
183
|
-
},
|
|
184
|
-
async stop() {
|
|
185
|
-
client.stopped += 1;
|
|
186
|
-
return [];
|
|
187
|
-
},
|
|
188
|
-
};
|
|
189
|
-
Object.defineProperty(client, 'session', {
|
|
190
|
-
get: () => client.sessions[client.sessions.length - 1] || null,
|
|
191
|
-
});
|
|
192
|
-
return client;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export const FAKE_SDK_PATHS = {
|
|
196
|
-
sdkDir: '/opt/copilot/pkg/linux-x64/1.0.82/copilot-sdk',
|
|
197
|
-
sdkEntry: '/opt/copilot/pkg/linux-x64/1.0.82/copilot-sdk/index.js',
|
|
198
|
-
versionDir: '/opt/copilot/pkg/linux-x64/1.0.82',
|
|
199
|
-
runtimeEntry: '/opt/copilot/pkg/linux-x64/1.0.82/app.js',
|
|
200
|
-
version: '1.0.82',
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
/** Frozen delivery message; tests derive variants by spread. */
|
|
204
|
-
export const baseMessage = Object.freeze({
|
|
205
|
-
id: 'q-1',
|
|
206
|
-
conversationId: 'conv-1',
|
|
207
|
-
relayMode: 'agent',
|
|
208
|
-
text: 'hello',
|
|
209
|
-
model: 'gpt-5-mini',
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* A stand-in for the relay question bridge.
|
|
214
|
-
*
|
|
215
|
-
* `answers` maps a matcher to a canned reply, so a test can drive `ask_user`
|
|
216
|
-
* and ask-mode tool approvals without a relay. Every call is recorded.
|
|
217
|
-
*/
|
|
218
|
-
export function makeFakeQuestionBridge({
|
|
219
|
-
userInputAnswer = 'the human answer',
|
|
220
|
-
userInputTimedOut = false,
|
|
221
|
-
approve = true,
|
|
222
|
-
approvalTimedOut = false,
|
|
223
|
-
approvalFeedback = 'The user declined this action.',
|
|
224
|
-
onAsk = null,
|
|
225
|
-
} = {}) {
|
|
226
|
-
const bridge = {
|
|
227
|
-
userInputCalls: [],
|
|
228
|
-
approvalCalls: [],
|
|
229
|
-
cancelledCount: 0,
|
|
230
|
-
async askUserInput(request, options) {
|
|
231
|
-
bridge.userInputCalls.push({ request, options });
|
|
232
|
-
if (onAsk) await onAsk(request, options);
|
|
233
|
-
const choices = Array.isArray(request?.choices) ? request.choices : [];
|
|
234
|
-
const answer = typeof userInputAnswer === 'function' ? userInputAnswer(request) : userInputAnswer;
|
|
235
|
-
return {
|
|
236
|
-
answer,
|
|
237
|
-
wasFreeform: userInputTimedOut ? true : !choices.includes(answer),
|
|
238
|
-
timedOut: userInputTimedOut,
|
|
239
|
-
};
|
|
240
|
-
},
|
|
241
|
-
async askToolApproval(request, options) {
|
|
242
|
-
bridge.approvalCalls.push({ request, options });
|
|
243
|
-
if (onAsk) await onAsk(request, options);
|
|
244
|
-
return {
|
|
245
|
-
approved: approvalTimedOut ? false : approve,
|
|
246
|
-
answer: approve ? 'Approve' : 'Deny',
|
|
247
|
-
feedback: approvalFeedback,
|
|
248
|
-
timedOut: approvalTimedOut,
|
|
249
|
-
description: 'a tool',
|
|
250
|
-
};
|
|
251
|
-
},
|
|
252
|
-
async cancelPendingQuestions() {
|
|
253
|
-
bridge.cancelledCount += 1;
|
|
254
|
-
return 0;
|
|
255
|
-
},
|
|
256
|
-
pendingQuestionCount: () => 0,
|
|
257
|
-
};
|
|
258
|
-
return bridge;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* The relay-context prefix the runner prepends to every prompt, for tests that
|
|
263
|
-
* assert on the prompt. `relayToolInstructions` defaults to '' in the harness,
|
|
264
|
-
* so only the mode marker and the mode's standing instructions appear, and the
|
|
265
|
-
* instructions only on the FIRST turn of a given relay mode.
|
|
266
|
-
*/
|
|
267
|
-
export function expectedPromptPrefix(mode = 'agent', { includeInstructions = true } = {}) {
|
|
268
|
-
const marker = `[Relay mode: ${mode}]`;
|
|
269
|
-
if (!includeInstructions) return marker;
|
|
270
|
-
const instructions = {
|
|
271
|
-
agent: 'Proceed as an interactive coding agent and use tools as needed. If you need clarification, pause and ask through the web relay instead of stalling silently. These instructions remain in effect until relay mode changes.',
|
|
272
|
-
plan: 'Draft a concise plan only. Use read-only inspection tools (glob, rg, view) only when they materially improve plan quality; otherwise draft from provided context. Do not edit repository files or run mutating commands unless the user explicitly asks for implementation. If clarification is required, pause and ask through the web relay. These instructions remain in effect until relay mode changes.',
|
|
273
|
-
ask: 'Prioritize clarification questions before doing any implementation work. If the request is ambiguous or underspecified, pause and ask through the web relay before making assumptions. Do not make broad assumptions when a question would materially change the result. These instructions remain in effect until relay mode changes.',
|
|
274
|
-
autopilot: 'Act directly on the request and use tools when needed. Keep moving unless user input is truly blocking. These instructions remain in effect until relay mode changes.',
|
|
275
|
-
}[mode];
|
|
276
|
-
return `${marker} ${instructions}`;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** The message body with the relay-context prefix in front, as sent. */
|
|
280
|
-
export function promptWithPrefix(body, mode = 'agent', options = {}) {
|
|
281
|
-
return `${expectedPromptPrefix(mode, options)} ${body}`;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export function makeRunner({
|
|
285
|
-
stub,
|
|
286
|
-
client,
|
|
287
|
-
startWarning = null,
|
|
288
|
-
runtimeVersion = '1.0.82',
|
|
289
|
-
questionBridge = makeFakeQuestionBridge(),
|
|
290
|
-
...overrides
|
|
291
|
-
} = {}) {
|
|
292
|
-
const started = [];
|
|
293
|
-
return {
|
|
294
|
-
started,
|
|
295
|
-
questionBridge,
|
|
296
|
-
runner: createCopilotSdkSessionRunner({
|
|
297
|
-
api: stub,
|
|
298
|
-
sdkSessionId: 'conv-1',
|
|
299
|
-
cwd: '/tmp/relay-fixture-workspace',
|
|
300
|
-
resolvePathsImpl: () => FAKE_SDK_PATHS,
|
|
301
|
-
// Spawn-free and relay-free by default: no test reads the real
|
|
302
|
-
// relay-tools.md off disk or asks a relay whether previews are on.
|
|
303
|
-
relayToolInstructions: '',
|
|
304
|
-
getPreviewInstructionsImpl: () => '',
|
|
305
|
-
createQuestionBridgeImpl: () => questionBridge,
|
|
306
|
-
startClientImpl: async (options) => {
|
|
307
|
-
started.push(options);
|
|
308
|
-
return {
|
|
309
|
-
sdk: {},
|
|
310
|
-
client,
|
|
311
|
-
paths: options.paths,
|
|
312
|
-
// The real adapter never blocks client start on the version probe;
|
|
313
|
-
// it hands back a promise the runner logs when it settles.
|
|
314
|
-
versionReady: Promise.resolve({ runtimeVersion, versionSkewWarning: startWarning }),
|
|
315
|
-
};
|
|
316
|
-
},
|
|
317
|
-
...overrides,
|
|
318
|
-
}),
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* The events of a fixture up to and including the first one matching `type`.
|
|
324
|
-
* Lets a test replay "everything through session.idle" without hand-listing.
|
|
325
|
-
*/
|
|
326
|
-
export function eventsThrough(events, type) {
|
|
327
|
-
const index = events.findIndex((event) => event.type === type);
|
|
328
|
-
return index === -1 ? events.slice() : events.slice(0, index + 1);
|
|
329
|
-
}
|