diff --git a/.changeset/2099-eos-copilot.md b/.changeset/2099-eos-copilot.md new file mode 100644 index 000000000..a08e5dcba --- /dev/null +++ b/.changeset/2099-eos-copilot.md @@ -0,0 +1,6 @@ +--- +type: Changed +pr: 2172 +--- + +**GitHub Copilot now wires GSD's full lifecycle hook bus and is driven by its capability descriptor** — installing GSD into Copilot registers `preToolUse`, `postToolUse`, `userPromptSubmitted`, and `sessionEnd` handlers in its `hooks/gsd-session.json` (beyond today's `sessionStart`-only advisory), and Copilot's residual hardcoded runtime branches are folded onto descriptor-driven `hostBehaviors`. (#2099) diff --git a/bin/install.js b/bin/install.js index 649bddb22..19cf0ea19 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6851,7 +6851,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // consumer, so its former `&& !isKimi` uninstall guards were removed. // #2096: isAntigravity dropped — unused in this function. // #2098: isCodebuddy dropped — unused in this function. - const { isOpencode, isCodex, isCopilot, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime); + // #2099: isCopilot dropped — both Copilot side-effect branches below are now + // gated on resolveInstallPlan(runtime).installSurface === 'copilot-instructions'. + const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime); const dirName = getDirName(runtime); // Get the target directory based on runtime and install type. Cline local @@ -6880,7 +6882,13 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot // installs, so its cleanup must run even when .github (targetDir) was already // removed — i.e. BEFORE the "target directory missing" early-return below. - if (isCopilot && !isGlobal) { + // #2099: descriptor-driven via resolveInstallPlan(runtime).installSurface === + // 'copilot-instructions' (was hardcoded `isCopilot`). Mirrors the install-time + // gate at the 'copilot-instructions' branch below (~line 10471 equivalent), + // which writes this same repo-root AGENTS.md only for local ('!isGlobal') + // installs — 'copilot-instructions' is unique to copilot's descriptor, so + // this is byte-parity. + if (resolveInstallPlan(runtime).installSurface === 'copilot-instructions' && !isGlobal) { const agentsMdPath = path.join(process.cwd(), 'AGENTS.md'); if (fs.existsSync(agentsMdPath)) { const content = fs.readFileSync(agentsMdPath, 'utf8'); @@ -7064,7 +7072,10 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { } // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup - if (isCopilot) { + // #2099: descriptor-driven via resolveInstallPlan(runtime).installSurface === + // 'copilot-instructions' (was hardcoded `isCopilot`), mirroring the same + // gate used at the install-time 'copilot-instructions' branch. + if (resolveInstallPlan(runtime).installSurface === 'copilot-instructions') { const instructionsPath = path.join(targetDir, 'copilot-instructions.md'); if (fs.existsSync(instructionsPath)) { const content = fs.readFileSync(instructionsPath, 'utf8'); @@ -8223,7 +8234,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // settings-json-adjacent runtime, so the `&& !isKimi` term below was removed. // #2096: isAntigravity dropped — unused in this function. // #2098: isCodebuddy dropped — unused in this function. - const { isOpencode, isCodex, isCopilot, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime); + // #2099: isCopilot dropped — was only used in the hooks-tracking conditional + // above, now covered by hostBehaviors.skipSharedHooksInstall. + const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime); const gsdDir = path.join(configDir, 'gsd-core'); // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/). // Claude local uses flatCommandsDir instead for manifest recording. @@ -8335,7 +8348,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { // skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed. // #2095: kimi is now a hooks/ consumer (native config.toml [[hooks]] bus) — // the redundant `&& !isKimi` was removed so its hook files are tracked too. - if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf) { + // #2099: Copilot's exclusion is likewise descriptor-driven (copilot declares + // skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed. + if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf) { const hooksDir = path.join(configDir, 'hooks'); if (fs.existsSync(hooksDir)) { // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from @@ -8738,7 +8753,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // _DESCRIPTOR_AGENTS_RUNTIMES below, so its legacy converter-dispatch branch // (the `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was // unreachable dead code and was removed rather than re-gated. - const { isOpencode, isZcode, isCodex, isCopilot, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime); + // #2099: isCopilot dropped — copilot is also in _DESCRIPTOR_AGENTS_RUNTIMES + // below, so its three legacy-agent-loop branches (the path-rewrite skip, + // the converter dispatch, and the .agent.md destName ternary) were + // unreachable dead code and were removed rather than re-gated; the + // .agent.md suffix now lives on hostBehaviors.agentFileExtension in + // src/install-engine.cts, and the skipSharedHooksInstall check above no + // longer needs `&& !isCopilot`. + const { isOpencode, isZcode, isCodex, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime); const plan = resolveInstallPlan(runtime); const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -9608,12 +9630,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // _DESCRIPTOR_AGENTS_RUNTIMES above, so this whole branch is already // unreachable for it; the path-rewrite skip for antigravity now lives // in the descriptor-driven `applyAgentPathRewrites` (hostBehaviors.noPathRewrite). - if (!isCopilot) { - content = content.replace(dirRegex, pathPrefix); - content = content.replace(homeDirRegex, pathPrefix); - content = content.replace(bareDirRegex, normalizedPathPrefix); - content = content.replace(bareHomeDirRegex, normalizedPathPrefix); - } + // #2099: `if (!isCopilot)` guard dropped — copilot is ALSO in + // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9564 above), so this whole + // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it; + // isCopilot was therefore always false here, making the guard a no-op. + content = content.replace(dirRegex, pathPrefix); + content = content.replace(homeDirRegex, pathPrefix); + content = content.replace(bareDirRegex, normalizedPathPrefix); + content = content.replace(bareHomeDirRegex, normalizedPathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); // Convert frontmatter for runtime compatibility (agents need different handling) if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') { @@ -9657,8 +9681,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { content = convertClaudeToKiloFrontmatter(content, { isAgent: true, modelOverride: _kiloModelOverride }); } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') { content = convertClaudeAgentToCodexAgent(content); - } else if (isCopilot) { - content = convertClaudeAgentToCopilotAgent(content, isGlobal); + // #2099: `else if (isCopilot)` arm dropped — copilot is unreachable + // here (see the isCopilot-guard-drop comment above); its content + // conversion is applied pre-staging via the descriptor's + // artifactLayout.converter (runtime-artifact-layout.cts), independent + // of this legacy loop. } else if (isWindsurf) { content = convertClaudeAgentToWindsurfAgent(content); } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') { @@ -9698,7 +9725,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the // SKILL.md-body fix shipped via #3629. content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames()); - const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name; + // #2099: `isCopilot ? ... : entry.name` ternary dropped — copilot is + // unreachable here (see the isCopilot-guard-drop comment above), so + // the ternary always evaluated to entry.name in practice; its + // .agent.md suffix is applied by the descriptor-driven fold in + // src/install-engine.cts (hostBehaviors.agentFileExtension). + const destName = entry.name; fs.writeFileSync(path.join(agentsDest, destName), content); } } @@ -9886,7 +9918,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // resolveKimiHooksTomlDir) via installSharedHooksBundle, at the // kimi-hooks-toml branch further below — never under the generic // Agent-Skills configDir GSD installs skills/agents into for kimi. - if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isZcode) { + // #2099: Copilot's exclusion is likewise descriptor-driven (copilot declares + // skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed. + if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isZcode) { if (!installSharedHooksBundle(targetDir)) { failures.push('hooks'); } @@ -10869,7 +10903,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // _hostBehaviors(runtime).doneBannerStyle === 'kimi-agent-file' (descriptor-driven), not this flag. // #2096: isAntigravity dropped — unused in this function. // #2098: isCodebuddy dropped — unused in this function. - const { isOpencode, isCodex, isCopilot, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime); + // #2099: isCopilot dropped — unused in this function. + const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime); const plan = resolveInstallPlan(runtime); if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) { diff --git a/capabilities/copilot/capability.json b/capabilities/copilot/capability.json index ad6f58435..2a1360752 100644 --- a/capabilities/copilot/capability.json +++ b/capabilities/copilot/capability.json @@ -84,7 +84,10 @@ "runtime": "undocumented" }, "hostBehaviors": { - "reapplyCommand": "/gsd-update --reapply" + "reapplyCommand": "/gsd-update --reapply", + "agentFileExtension": ".agent.md", + "skipSharedHooksInstall": true, + "noPathRewrite": true } } } diff --git a/docs/how-to/add-or-update-a-host-integration.md b/docs/how-to/add-or-update-a-host-integration.md index 0c279e70d..5acf1e3e1 100644 --- a/docs/how-to/add-or-update-a-host-integration.md +++ b/docs/how-to/add-or-update-a-host-integration.md @@ -147,6 +147,19 @@ yields the same truth value**, so behavior is unchanged and only the brittle cou the host correctly, negotiation fails closed on a corrupted descriptor, and — with a source-grep behind an `// allow-test-rule:` exemption — that **no `runtime === ''` branch remains** in `bin/install.js`. +**Another completed worked example: `copilot` (#2099).** Copilot was already installing through the +declarative artifactLayout (not the direct `installRuntimeArtifacts` calls step 4 describes), so its +migration folded the *residual* hardcoded branches rather than the whole install path: the `.agent.md` +destination-suffix rename in `src/install-engine.cts` (→ `hostBehaviors.agentFileExtension`), two +uninstall side-effect branches in `bin/install.js` (→ +`resolveInstallPlan(runtime).installSurface === 'copilot-instructions'`, already a live descriptor field +elsewhere in the same file), and two `skipSharedHooksInstall` gates (→ +`hostBehaviors.skipSharedHooksInstall: true`). A dead legacy agent-converter dispatch arm — unreachable +because copilot is a member of `_DESCRIPTOR_AGENTS_RUNTIMES` — was deleted outright rather than re-gated, +mirroring step 6's guard: `tests/declarative-reference-copilot.test.cjs` source-greps both files for the +retired `isCopilot` reads. See the `copilot` section of the reference matrix for the full EoS migration +note, including the two upgrades (multi-event hook bus; negotiated `dispatch.background`) this PR adds. + --- ## Related diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 375c7e00f..38775fec1 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -461,6 +461,8 @@ Documentation gaps: - runtime — docs describe the CLI binary and the SDK (Node.js/Go/Python/Rust) but do not state what runtime the CLI host itself or its plugin/extension loader executes in. - dispatch.nested exact authoritative source is awesome-copilot.github.com (community docs) not docs.github.com. +**EoS migration status (#2099):** Migrated onto the declarative adapter (dogfooded in `tests/declarative-reference-copilot.test.cjs`). The residual `isCopilot` branches were folded onto descriptor-driven `runtime.hostBehaviors`: the `.agent.md` destination-suffix rename in `src/install-engine.cts` now reads `hostBehaviors.agentFileExtension`; `bin/install.js`'s two uninstall side-effect branches (repo-root `AGENTS.md` cleanup, `copilot-instructions.md`/hook cleanup) now gate on `resolveInstallPlan(runtime).installSurface === 'copilot-instructions'` (unique to copilot, so byte-identical); and the two `skipSharedHooksInstall` checks now read `hostBehaviors.skipSharedHooksInstall:true` (copilot's golden has only `hooks/gsd-session.json`, no shared `gsd-*.js` scripts). A dead legacy agent-converter dispatch arm in the inline agent-copy loop — unreachable since copilot is a member of `_DESCRIPTOR_AGENTS_RUNTIMES` — was removed outright; `isCopilot` no longer appears as a live read anywhere in `bin/install.js` or `src/install-engine.cts`. Two upgrades land: (1) **multi-event hook bus** — `buildCopilotHookConfig()` previously emitted only `sessionStart`; this PR wires four additional events — `preToolUse`/`postToolUse`/`userPromptSubmitted`/`sessionEnd` — each a static, deterministic advisory command (no node-runner invocation), so an install's `hooks/gsd-session.json` now registers all five events. (2) **`dispatch.background`** — the descriptor already declared `true`, exceeding the `declarative-cli` profile baseline of `false`; the negotiation contract (`negotiateHostCapabilities`) surfaces that value with no downgrade warning, documenting the legitimate deviation. Note: Copilot's `.agent.md` frontmatter has no background-dispatch field (fields are `description`/`infer`/`mcp-servers`/`model`/`name`/`tools`) — background dispatch remains a negotiated-contract-only axis, not a field GSD's agent artifacts emit. MCP companion tooling is out of scope for this migration (AC4 names only the two upgrades above). + --- ## kilo diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 439e4e193..eb3df8545 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -987,7 +987,10 @@ const capabilities = { "runtime": "undocumented" }, "hostBehaviors": { - "reapplyCommand": "/gsd-update --reapply" + "reapplyCommand": "/gsd-update --reapply", + "agentFileExtension": ".agent.md", + "skipSharedHooksInstall": true, + "noPathRewrite": true } } }, @@ -4444,7 +4447,10 @@ const runtimes = { "runtime": "undocumented" }, "hostBehaviors": { - "reapplyCommand": "/gsd-update --reapply" + "reapplyCommand": "/gsd-update --reapply", + "agentFileExtension": ".agent.md", + "skipSharedHooksInstall": true, + "noPathRewrite": true } } }, diff --git a/src/install-engine.cts b/src/install-engine.cts index b9c779d24..d991f5997 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -342,9 +342,13 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s let destName: string; if (kind.kind === 'agents') { // Agent files already carry the gsd- prefix in the source dir. - // #1575: copilot agents get .agent.md suffix (mirrors inline loop line ~9118). - destName = runtime === 'copilot' - ? entry.name.replace(/\.md$/, '.agent.md') + // #2099: descriptor-driven via hostBehaviors.agentFileExtension (was + // hardcoded `runtime === 'copilot'`). copilot declares '.agent.md'; + // every other runtime's descriptor leaves this unset, so destName falls + // back to entry.name unchanged (byte-parity, #1575 origin comment). + const _agentExt = runtime ? _hostBehaviors(runtime).agentFileExtension : undefined; + destName = _agentExt + ? entry.name.replace(/\.md$/, _agentExt) : entry.name; } else if (namespacedByDir) { // Directory is the namespace; don't double-prefix the filename diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 0a235a09b..79454dba8 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -83,6 +83,21 @@ function _hostBehaviors(runtime: string): Record { ); } +/** + * Public accessor for the `hostBehaviors.agentFileExtension` descriptor field + * (ADR-1239 / #2099 / #2103). Returns the runtime's declared agent-file + * destination-suffix rename target (e.g. copilot's `.agent.md`), or + * `undefined` when the runtime declares none (the generic no-rename + * default). Exported so callers outside this module (surface.cts's + * `_syncGsdDir`) can derive the SAME rename decision as + * install-engine.cts's staged-copy loop from ONE descriptor read, instead of + * duplicating a hardcoded `runtime === 'copilot'` check (#2103 fold). + */ +function agentFileExtensionFor(runtime: string): string | undefined { + const ext = _hostBehaviors(runtime).agentFileExtension; + return typeof ext === 'string' ? ext : undefined; +} + const colorNameToHex = { cyan: '#00FFFF', @@ -2756,10 +2771,12 @@ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames * ~/\.claude\b → normalizedPathPrefix * $HOME/\.claude\b → normalizedPathPrefix * - * Skipped for copilot (hardcoded — #2099 will fold it) and for any runtime - * that declares `hostBehaviors.noPathRewrite` (descriptor-driven, ADR-1239 / - * #2096 — folds the prior hardcoded `runtime === 'antigravity'` literal; - * Antigravity does NOT do path rewrites in the inline loop). NO stamp + * Skipped for any runtime that declares `hostBehaviors.noPathRewrite` + * (descriptor-driven, ADR-1239 / #2096 — folds the prior hardcoded + * `runtime === 'antigravity'` literal; Antigravity does NOT do path rewrites + * in the inline loop / #2103 — folds the prior hardcoded + * `runtime === 'copilot'` literal onto the same descriptor field, since + * copilot also skips these rewrites). NO stamp * (_stampNonClaudeRuntimeDefaults) — agents are NOT stamped in the inline loop. * * ADR-1235 §1: pre-converter cross-cutting for descriptor-driven agent pipeline. @@ -2769,10 +2786,10 @@ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames * @param content raw agent file content * @param runtime canonical runtime ID * @param pathPrefix trailing-slash path prefix (e.g. '$HOME/.cursor/') - * @returns content with path-prefix rewrites applied (or unchanged for copilot / noPathRewrite runtimes) + * @returns content with path-prefix rewrites applied (or unchanged for noPathRewrite runtimes, e.g. copilot) */ function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string { - if (runtime === 'copilot' || _hostBehaviors(runtime).noPathRewrite === true) return content; + if (_hostBehaviors(runtime).noPathRewrite === true) return content; const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); content = content.replace(/~\/\.claude\//g, pathPrefix); content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); @@ -2813,6 +2830,11 @@ function processAttribution( export = { processAttribution, + // #2103: public accessor for hostBehaviors.agentFileExtension, exported so + // surface.cts's _syncGsdDir can derive the .agent.md rename from the SAME + // descriptor read as install-engine.cts (folds a duplicated hardcoded + // `runtime === 'copilot'` literal). + agentFileExtensionFor, yamlIdentifier, yamlQuote, toSingleLine, diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 88d92f2f0..1338c3fcd 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -84,6 +84,39 @@ const GSD_COPILOT_SESSION_HOOK_PWSH = `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` + `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`; +// #2099 UPGRADE 1: multi-event hook bus. Each additional event is a static, +// deterministic advisory (no branching/no-op-style, matching sessionStart's +// tone) so the emitted hooks/gsd-session.json stays golden-trackable — no +// node-runner invocation, no filesystem probing beyond what sessionStart +// already does. +const GSD_COPILOT_PRE_TOOL_MSG = + 'GSD: confirm this tool use is in scope for the active phase before proceeding.'; +const GSD_COPILOT_PRE_TOOL_HOOK_BASH = + `printf '%s' '{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`; +const GSD_COPILOT_PRE_TOOL_HOOK_PWSH = + `'{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`; + +const GSD_COPILOT_POST_TOOL_MSG = + 'GSD: review the tool result against the active phase before continuing.'; +const GSD_COPILOT_POST_TOOL_HOOK_BASH = + `printf '%s' '{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`; +const GSD_COPILOT_POST_TOOL_HOOK_PWSH = + `'{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`; + +const GSD_COPILOT_PROMPT_SUBMIT_MSG = + 'GSD: check this request against .planning/STATE.md scope before acting.'; +const GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH = + `printf '%s' '{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`; +const GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH = + `'{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`; + +const GSD_COPILOT_SESSION_END_MSG = + 'GSD: update .planning/STATE.md with the session outcome before ending.'; +const GSD_COPILOT_SESSION_END_HOOK_BASH = + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`; +const GSD_COPILOT_SESSION_END_HOOK_PWSH = + `'{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`; + // --------------------------------------------------------------------------- // Cursor hook constants // --------------------------------------------------------------------------- @@ -1147,6 +1180,41 @@ function buildCopilotHookConfig(): Record { timeoutSec: 10, }, ], + // #2099 UPGRADE 1: multi-event hook bus — preToolUse (worktree/read-safety + // advisory), postToolUse (context-monitor advisory), userPromptSubmitted + // (prompt-guard advisory), sessionEnd (session-finalize advisory). + preToolUse: [ + { + type: 'command', + bash: GSD_COPILOT_PRE_TOOL_HOOK_BASH, + powershell: GSD_COPILOT_PRE_TOOL_HOOK_PWSH, + timeoutSec: 10, + }, + ], + postToolUse: [ + { + type: 'command', + bash: GSD_COPILOT_POST_TOOL_HOOK_BASH, + powershell: GSD_COPILOT_POST_TOOL_HOOK_PWSH, + timeoutSec: 10, + }, + ], + userPromptSubmitted: [ + { + type: 'command', + bash: GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH, + powershell: GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH, + timeoutSec: 10, + }, + ], + sessionEnd: [ + { + type: 'command', + bash: GSD_COPILOT_SESSION_END_HOOK_BASH, + powershell: GSD_COPILOT_SESSION_END_HOOK_PWSH, + timeoutSec: 10, + }, + ], }, }; } diff --git a/src/surface.cts b/src/surface.cts index 263b773b8..63f536b52 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -521,10 +521,14 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st const kindName = (typeof kind === 'string') ? kind : kind.kind; const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-'; - // #1575: copilot agents are renamed .md -> .agent.md at copy time, mirroring - // the inline agent loop in bin/install.js (line ~9118). Other runtimes keep - // the staged filename verbatim. - const isCopilotAgents = runtime === 'copilot' && kindName === 'agents'; + // #1575 / #2103: agent files are renamed .md -> at copy + // time when the runtime's descriptor declares hostBehaviors.agentFileExtension + // (e.g. copilot's '.agent.md'), mirroring install-engine.cts's staged-copy + // loop (`_copyStaged`) — ONE descriptor read shared by both surfaces instead + // of a duplicated hardcoded `runtime === 'copilot'` literal. Other runtimes + // (no agentFileExtension declared) keep the staged filename verbatim. + const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined; + const isRenamedAgents = !!_agentExt && kindName === 'agents'; if (kindName === 'skills') { // Skills kind: work with directories, not files. @@ -564,8 +568,8 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st const stagedFiles = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); const stagedDestNames = new Set(); for (const file of stagedFiles) { - const destName = isCopilotAgents - ? file.replace(/\.md$/, '.agent.md') + const destName = isRenamedAgents + ? file.replace(/\.md$/, _agentExt) : (kindName === 'agents' || namespacedByDir) ? file : `${kindPrefix}${file.slice(0, -3)}.md`; diff --git a/tests/copilot-upgrades.test.cjs b/tests/copilot-upgrades.test.cjs new file mode 100644 index 000000000..065f8a3eb --- /dev/null +++ b/tests/copilot-upgrades.test.cjs @@ -0,0 +1,149 @@ +'use strict'; + +/** + * Copilot capability UPGRADES — ADR-1239 / #2099 (EoS/copilot). + * + * Drives the user-reachable surface (spawned `bin/install.js` via + * `runMinimalInstall`) plus a direct negotiation-contract check to prove the + * two real upgrades Copilot contributes as part of the EoS migration: + * + * UPGRADE 1 — multi-event hook bus: buildCopilotHookConfig() previously + * emitted only `sessionStart`. This PR wires four additional events — + * `preToolUse`, `postToolUse`, `userPromptSubmitted`, `sessionEnd` — each a + * static, deterministic advisory command (no node-runner invocation), so a + * live install's hooks/gsd-session.json registers all five events. + * + * UPGRADE 2 — dispatch.background: Copilot's capability.json already + * declares `dispatch.background: true`, which legitimately EXCEEDS the + * `declarative-cli` profile baseline (`false`, per + * `PROFILE_BASELINES['declarative-cli']` in src/host-integration.cts). + * NO agent-file/frontmatter change is involved — Copilot's .agent.md + * frontmatter has no background-dispatch field (fields are + * description/infer/mcp-servers/model/name/tools). This is surfaced + * purely through the negotiated contract: a documented `true` value must + * survive negotiation without a downgrade warning. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runMinimalInstall } = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); +const { + profileOf, + negotiateHostCapabilities, + PROFILE_BASELINES, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const COPILOT_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'copilot', 'capability.json'), 'utf8'), +); +const COPILOT_AXES = COPILOT_CAP.runtime.hostIntegration; + +// --------------------------------------------------------------------------- +// UPGRADE 1: multi-event hook bus — all 4 newly-wired events' live-install +// coverage, on top of the pre-existing sessionStart. +// --------------------------------------------------------------------------- + +const NEWLY_WIRED_EVENTS = ['preToolUse', 'postToolUse', 'userPromptSubmitted', 'sessionEnd']; +const ALL_EXPECTED_EVENTS = ['sessionStart', ...NEWLY_WIRED_EVENTS]; + +test('copilot --global: hooks/gsd-session.json wires sessionStart plus all 4 newly-added events (UPGRADE 1)', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'copilot', scope: 'global' }); + t.after(() => cleanup(root)); + + const hookPath = path.join(configDir, 'hooks', 'gsd-session.json'); + assert.ok(fs.existsSync(hookPath), `${hookPath} must exist`); + const parsed = JSON.parse(fs.readFileSync(hookPath, 'utf8')); + + assert.strictEqual(parsed.version, 1, 'hook config version must be 1'); + assert.ok(parsed.hooks && typeof parsed.hooks === 'object', 'has hooks object'); + + for (const eventName of ALL_EXPECTED_EVENTS) { + const eventHooks = parsed.hooks[eventName]; + assert.ok(Array.isArray(eventHooks) && eventHooks.length > 0, + `hooks.${eventName} must exist and be non-empty`); + const entry = eventHooks[0]; + assert.strictEqual(entry.type, 'command', `${eventName} entry type must be 'command'`); + assert.ok(typeof entry.bash === 'string' && entry.bash.length > 0, + `${eventName} entry must have a non-empty inline bash body`); + assert.ok(typeof entry.powershell === 'string' && entry.powershell.length > 0, + `${eventName} entry must have a non-empty inline powershell body`); + assert.strictEqual(entry.timeoutSec, 10, `${eventName} entry must use timeoutSec 10`); + } +}); + +test('each newly-wired event emits a distinct static advisory (no shared boilerplate, no node-runner invocation)', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'copilot', scope: 'global' }); + t.after(() => cleanup(root)); + + const hookPath = path.join(configDir, 'hooks', 'gsd-session.json'); + const parsed = JSON.parse(fs.readFileSync(hookPath, 'utf8')); + + const bashBodies = new Set(); + for (const eventName of ALL_EXPECTED_EVENTS) { + const entry = parsed.hooks[eventName][0]; + assert.ok(entry.bash.includes('"additionalContext"'), + `${eventName} bash body must emit the additionalContext JSON envelope`); + assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.bash), + `${eventName} bash body must not reference an external hook script (cannot dangle)`); + bashBodies.add(entry.bash); + } + assert.strictEqual(bashBodies.size, ALL_EXPECTED_EVENTS.length, + 'each event must emit its own distinct advisory command, not a copy-pasted duplicate'); +}); + +test('a runtime whose hook config omits the new events does NOT get them (descriptor-gated, not a global default)', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'global' }); + t.after(() => cleanup(root)); + + // Claude uses settings.json, not hooks/gsd-session.json — the Copilot + // multi-event bus additions are self-contained to buildCopilotHookConfig + // and must not leak into another runtime's hook surface. + const copilotHookPath = path.join(configDir, 'hooks', 'gsd-session.json'); + assert.ok(!fs.existsSync(copilotHookPath), + 'claude must not have a Copilot-style hooks/gsd-session.json file'); +}); + +// --------------------------------------------------------------------------- +// UPGRADE 2: dispatch.background negotiation — documented true survives, +// exceeding the declarative-cli profile baseline. No agent-file/frontmatter +// change; negotiated-contract only (Copilot .agent.md has no background field). +// --------------------------------------------------------------------------- + +test("copilot classifies as the 'declarative-cli' profile, whose baseline dispatch.background is false", () => { + assert.equal(profileOf(COPILOT_AXES), 'declarative-cli'); + assert.equal(PROFILE_BASELINES['declarative-cli'].dispatch.background, false, + 'sanity: the declarative-cli baseline is false — copilot legitimately exceeds it'); +}); + +test('negotiateHostCapabilities surfaces copilot\'s documented dispatch.background:true with no downgrade warning (UPGRADE 2)', () => { + assert.equal(COPILOT_AXES.dispatch.background, true, + 'sanity: the descriptor declares dispatch.background: true (documented, not undocumented)'); + + const { effective, warnings } = negotiateHostCapabilities(COPILOT_AXES); + + assert.equal(effective.dispatch.background, true, + 'a documented true value must survive negotiation, exceeding the declarative-cli baseline of false'); + assert.ok( + !warnings.some((w) => w.includes('dispatch.background')), + `no warning may be raised for the documented dispatch.background axis, got: ${JSON.stringify(warnings)}`, + ); +}); + +test('copilot .agent.md frontmatter has no background-dispatch field (negotiated contract only, sanity)', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'copilot', scope: 'global' }); + t.after(() => cleanup(root)); + + const agentsDir = path.join(configDir, 'agents'); + const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.endsWith('.agent.md')); + assert.ok(agentFiles.length > 0, 'at least one .agent.md must be installed'); + + const sample = fs.readFileSync(path.join(agentsDir, agentFiles[0]), 'utf8'); + const frontmatterMatch = sample.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(frontmatterMatch, 'agent file must have a frontmatter block'); + assert.ok(!/^background:/m.test(frontmatterMatch[1]), + 'UPGRADE 2 must not add a background: frontmatter field — Copilot .agent.md has no such field'); +}); diff --git a/tests/declarative-reference-copilot.test.cjs b/tests/declarative-reference-copilot.test.cjs new file mode 100644 index 000000000..91bc9ffc3 --- /dev/null +++ b/tests/declarative-reference-copilot.test.cjs @@ -0,0 +1,164 @@ +// allow-test-rule: structural-regression-guard — AC2: assert no `runtime === 'copilot'` string-equality branch, no live `isCopilot` read remains in bin/install.js, src/install-engine.cts, src/surface.cts, or src/runtime-artifact-conversion.cts — a source-text property, so source-grep is the faithful check (#2099, expanded #2103) +'use strict'; + +/** + * Declarative reference host — GitHub Copilot (#2099 / ADR-1239 EoS). + * + * Copilot already installs through the descriptor-driven artifactLayout + * (skills/agents, each with a named `converter`), and its capability.json + * already declares `hostIntegration` + `dispatch` axes. Issue #2099's + * literal premises about a "legacy exclusion list" / writesSharedSettings + * mismatch and a hardcoded RUNTIME_CONTENT_DISPATCH branch were WRONG (see + * the deep-research writeup on the issue) — this migration instead folds + * the real residual `isCopilot` branches: + * 1. src/install-engine.cts's `.agent.md` destination-suffix rename — + * folded onto `hostBehaviors.agentFileExtension`. + * 2. bin/install.js uninstall's two Copilot side-effect branches + * (repo-root AGENTS.md cleanup + copilot-instructions.md/hook + * cleanup) — folded onto `resolveInstallPlan(runtime).installSurface + * === 'copilot-instructions'` (unique to copilot, so byte-parity). + * 3. bin/install.js's two `skipSharedHooksInstall` checks — folded onto + * `hostBehaviors.skipSharedHooksInstall:true` (copilot's golden has + * only hooks/gsd-session.json, no shared gsd-*.js scripts). + * 4. bin/install.js's dead legacy agent-converter dispatch arm in the + * inline agent-copy loop — unreachable because copilot is a member of + * `_DESCRIPTOR_AGENTS_RUNTIMES` (installRuntimeArtifacts already wrote + * the agents before that loop runs) — deleted outright. + * + * This test is the reference-host dogfood mirroring + * tests/declarative-reference-codebuddy.test.cjs: it (1) classifies + * Copilot's profile via profileOf, (2) confirms the public declarative + * adapter classifies it as declarative, (3) round-trips a real install + * proving a gsd agent/skill surface is emitted, (4) proves negotiation + * fails CLOSED on a corrupted descriptor, (5) proves the validator accepts + * the descriptor, and (6) source-greps the folded modules for the retired + * `isCopilot` branches (AC2). + * + * UPGRADE 1 (multi-event hook bus) + UPGRADE 2 (dispatch.background + * negotiation) live-install coverage is in tests/copilot-upgrades.test.cjs + * — not duplicated here. + */ + +const { test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { + profileOf, + negotiateHostCapabilities, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); +const { validateCapability } = require('../gsd-core/bin/lib/capability-validator.cjs'); +const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs'); +const { cleanup } = require('./helpers.cjs'); +const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs'); + +const DESC = path.join(__dirname, '..', 'capabilities', 'copilot', 'capability.json'); +const COPILOT_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); +const COPILOT_AXES = COPILOT_CAP.runtime.hostIntegration; + +// hooks/dist is gitignored and built (mirrors golden-install-parity harness). +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); +}); + +test('Copilot classifies as the declarative-cli reference profile (profileOf)', () => { + const desc = JSON.parse(fs.readFileSync(DESC, 'utf8')); + const axes = desc.runtime.hostIntegration; + assert.ok(axes && axes.embeddingMode, 'copilot descriptor declares hostIntegration axes'); + assert.equal(profileOf(axes), 'declarative-cli', + 'Copilot is a Declarative-CLI host'); +}); + +test('the public declarative adapter classifies Copilot as a declarative host', () => { + const adapter = createDeclarativeAdapter({ runtime: 'copilot' }); + assert.equal(adapter.kind, 'declarative'); + assert.equal(adapter.runtime, 'copilot'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('a real Copilot install emits a gsd agent/skill surface (invocable)', () => { + const { configDir, root } = runMinimalInstall({ runtime: 'copilot', scope: 'global' }); + try { + const files = walk(configDir); + assert.ok(files.length > 0, 'install must emit artifacts'); + const gsdSurface = files.filter((f) => /gsd/i.test(path.relative(configDir, f))); + assert.ok(gsdSurface.length > 0, + 'install must emit a gsd agent/skill surface (declarative reference)'); + const agentsDir = path.join(configDir, 'agents'); + assert.ok(fs.existsSync(agentsDir), 'agents/ directory must exist'); + const agentFiles = fs.readdirSync(agentsDir) + .filter((f) => f.startsWith('gsd-') && f.endsWith('.agent.md')); + assert.ok(agentFiles.length > 0, 'agents/ must contain gsd-*.agent.md files'); + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist'); + } finally { + cleanup(root); + } +}); + +// --------------------------------------------------------------------------- +// #2099 EoS/copilot — fail-closed negotiation + validator acceptance + +// the folded descriptor (mirrors codebuddy/antigravity/qwen reference tests). +// --------------------------------------------------------------------------- + +test('negotiateHostCapabilities never throws for copilot, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...COPILOT_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...COPILOT_AXES, embeddingMode: 'future-unknown' })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...COPILOT_AXES, dispatch: 'corrupted-not-an-object' })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...COPILOT_AXES, dispatch: { ...COPILOT_AXES.dispatch, maxDepth: 'not-a-number' } })); +}); + +test('a partial/empty copilot descriptor degrades to the safe floor, not the declarative-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['declarative-cli']); + assert.ok(result.warnings.length > 0); +}); + +test('capabilities/copilot/capability.json validates — no errors', () => { + const errors = validateCapability(COPILOT_CAP, 'copilot'); + assert.deepEqual(errors, [], `validateCapability must return no errors, got: ${JSON.stringify(errors)}`); +}); + +// -- AC2: the hardcoded branches are retired across all folded modules ------ + +test('no `runtime === "copilot"` string-equality branch (nor live `isCopilot` read) remains in bin/install.js, src/install-engine.cts, src/surface.cts, or src/runtime-artifact-conversion.cts (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + const repoRoot = path.join(__dirname, '..'); + const files = [ + path.join(repoRoot, 'bin', 'install.js'), + path.join(repoRoot, 'src', 'install-engine.cts'), + path.join(repoRoot, 'src', 'surface.cts'), + path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'), + ]; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + const stripped = strip(src); + + // NIT-1: catch both quote styles (single- and double-quoted 'copilot'). + const eqOffenders = stripped.match(/runtime\s*[!=]==\s*["']copilot["']/g) || []; + assert.deepEqual(eqOffenders, [], + `AC2: no hardcoded runtime==='copilot' branch may remain in ${path.relative(repoRoot, file)}; found: ${eqOffenders.join(', ')}`); + + // Excludes legit enumeration sites: --copilot CLI flag parsing, the + // '7':'copilot' numbered-menu map, allRuntimes/_DESCRIPTOR_AGENTS_RUNTIMES + // set literals, config-dir lists, the copilot conversion FUNCTION names + // (convertClaudeCommandToCopilotSkill / convertClaudeAgentToCopilotAgent / + // convertCopilotToolName), and installSurface==='copilot-instructions' + // (a descriptor-field string, not a runtime literal) — none of those + // contain the token `isCopilot`, so a literal-word match is precise here. + const isCopilotHits = stripped.match(/\bisCopilot\b/g) || []; + assert.deepEqual(isCopilotHits, [], + `AC2: no live isCopilot read may remain in ${path.relative(repoRoot, file)}; found ${isCopilotHits.length} occurrence(s)`); + } +}); diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 77d3e23bc..17c7aef42 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -312,7 +312,7 @@ "gsd-core/workflows/validate-phase.md": "2f705775a4b76d42", "gsd-core/workflows/verify-phase.md": "5c72780e34214e27", "gsd-core/workflows/verify-work.md": "c966971a3cbd1d71", - "hooks/gsd-session.json": "0a462834f2a28fee", + "hooks/gsd-session.json": "3382597f61e3a559", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/opencode-review-reconstruction.property.test.cjs b/tests/opencode-review-reconstruction.property.test.cjs index 6416d34af..bec2c8fe4 100644 --- a/tests/opencode-review-reconstruction.property.test.cjs +++ b/tests/opencode-review-reconstruction.property.test.cjs @@ -4,14 +4,30 @@ // Those programs ARE the runtime contract; this test extracts them verbatim from // the workflow and exercises the real jq (not a reimplementation) so the shipped // reconstruction logic is what gets property-tested. +// +// ARCHITECTURE (#2099): the shipped jq program is run over a WHOLE fast-check corpus +// in ONE jq process, not once per generated case. Each generated event stream is +// written as one compact-JSON array per line to a temp file, then `jq -c ` +// (no `-s`) applies PROGRAM to each array — `.` is that array, exactly what +// production's `jq -rs` sees after slurping opencode's one-value-per-line stream — +// emitting one compact-JSON result per line. This is empirically identical to the +// per-stream `-rs` form (verified across embedded-newline/empty/quote/unicode/ +// null-drop cases) AND reads from a file like production (`jq -rs '…' `), so +// there is no stdin pipe to deadlock on large I/O. The prior design spawned ~600 +// synchronous jq subprocesses (numRuns × 3 properties); a single one freezing on a +// contended CI runner hung the whole unit-test chunk to its 600s kill (macOS CI, +// #2099). `node --test`'s --test-force-exit cannot interrupt a synchronous +// execFileSync, so the cure is to stop spawning per case — not just to time-bound it. 'use strict'; const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const { execFileSync } = require('node:child_process'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const fc = require('./helpers/fast-check-setup.cjs'); +const { cleanup } = require('./helpers.cjs'); const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md'); const workflow = fs.readFileSync(reviewPath, 'utf-8'); @@ -34,20 +50,48 @@ const DIAG_PROGRAM = extractJqProgram('OPENCODE_DIAG'); // empty-output diagn // the suite to jq-present non-Windows hosts, mirroring golden-install-parity's win32 // skip. The assertions run in full on every macOS/Linux CI leg. let jqAvailable = false; -try { execFileSync('jq', ['--version'], { stdio: 'ignore' }); jqAvailable = true; } catch { /* no jq on PATH */ } +try { execFileSync('jq', ['--version'], { stdio: 'ignore', timeout: 10000, killSignal: 'SIGKILL' }); jqAvailable = true; } catch { /* no jq on PATH */ } const skipReason = process.platform === 'win32' ? 'jq invocation is not portable under Node child_process arg-quoting on Windows; logic is platform-independent and asserted on macOS/Linux' : (jqAvailable ? false : 'jq not on PATH'); const opts = { skip: skipReason }; -// Run a shipped jq program against a stream of events serialized exactly as -// opencode emits them: one JSON value per line (jq -s slurps them into an array). -// jq -r appends a single trailing newline to the (single) string result; strip it -// to recover the value the workflow's `$(…)` capture would see. -function runJq(program, events) { - const jsonl = events.map((e) => JSON.stringify(e)).join('\n'); - const out = execFileSync('jq', ['-rs', program], { input: jsonl, encoding: 'utf8' }); - return out.endsWith('\n') ? out.slice(0, -1) : out; +// Bound each jq subprocess so a frozen spawn on a contended runner fails fast + +// diagnosably (ETIMEDOUT) rather than hanging the chunk. jq over this corpus +// completes in ~10ms, so 30s is an enormous margin that never trips on a healthy run. +const JQ_EXEC_OPTS = { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 * 1024 }; + +// Run a shipped jq program over an array of event streams in a SINGLE jq process. +// Returns one result per input stream (order preserved), decoded from jq's compact +// (`-c`) JSON output back to the raw string production's `-r` would have captured. +// Both shipped programs yield exactly one value per stream (join(...) / last|"..."); +// the length assertion pins that invariant so a future program change that broke it +// (0 or >1 outputs) fails loudly instead of silently misaligning results. +function runJqBatch(program, streams) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-jq-batch-')); + try { + const file = path.join(dir, 'streams.jsonl'); + fs.writeFileSync(file, streams.map((events) => JSON.stringify(events)).join('\n') + '\n'); + const out = execFileSync('jq', ['-c', program, file], JQ_EXEC_OPTS); + const lines = out.split('\n').filter((line) => line.length > 0); + assert.equal( + lines.length, + streams.length, + `jq must emit exactly one result per stream (got ${lines.length} for ${streams.length})`, + ); + return lines.map((line) => JSON.parse(line)); + } finally { + cleanup(dir); + } +} + +// The intended reconstruction, computed independently of jq. jq is the unit under +// test; this JS is the spec it must match on every generated case. +function expectedReview(events) { + return events + .filter((e) => e.type === 'text' && e.part && typeof e.part.text === 'string') + .map((e) => e.part.text) + .join('\n'); } // Text values safe to round-trip through JSON → jq (utf8) → string. Excludes lone @@ -85,69 +129,82 @@ const eventStream = fc.array(fc.oneof(textEvent, textEvent, nonTextEvent), { maxLength: 30, }); +// Corpus size per property. Matches the prior fast-check-setup numRuns:200 so +// coverage is unchanged; distinct seeds give each property an independent corpus, +// and fixed seeds keep the corpus deterministic across CI runs/OS legs. +const CORPUS = 200; + describe('#1936 OpenCode review reconstruction — jq properties', () => { - test('review == the newline-join of every assistant text part (order preserved)', opts, () => { - fc.assert( - fc.property(eventStream, (events) => { - const expected = events - .filter((e) => e.type === 'text' && e.part && typeof e.part.text === 'string') - .map((e) => e.part.text) - .join('\n'); - assert.equal(runJq(TEXT_PROGRAM, events), expected); - }), - ); + test('review == the newline-join of every assistant text part, over a generated corpus (order preserved)', opts, () => { + const streams = fc.sample(eventStream, { numRuns: CORPUS, seed: 42 }); + const actual = runJqBatch(TEXT_PROGRAM, streams); // one jq process for the whole corpus + streams.forEach((events, i) => { + assert.equal( + actual[i], + expectedReview(events), + `case ${i}: shipped jq review must equal the spec for events=${JSON.stringify(events)}`, + ); + }); }); - test('a stream with no assistant text part reconstructs to empty (drives the #1936 stub)', opts, () => { - fc.assert( - fc.property(fc.array(nonTextEvent, { minLength: 1, maxLength: 20 }), (events) => { - // This is the exact failure the bug describes: the agent runs tool calls - // and ends with step_finish, emitting no text. Reconstruction must be empty - // so the content-gate (`[ -n "$OPENCODE_REVIEW" ]`) falls through to the stub. - assert.equal(runJq(TEXT_PROGRAM, events), ''); - }), - ); + test('a stream with no assistant text part reconstructs to empty (drives the #1936 stub), over a corpus', opts, () => { + // The exact failure the bug describes: the agent runs tool calls and ends with + // step_finish, emitting no text. Reconstruction must be empty so the content-gate + // (`[ -n "$OPENCODE_REVIEW" ]`) falls through to the stub. + const streams = fc.sample(fc.array(nonTextEvent, { minLength: 1, maxLength: 20 }), { numRuns: CORPUS, seed: 43 }); + const actual = runJqBatch(TEXT_PROGRAM, streams); + streams.forEach((events, i) => { + assert.equal(actual[i], '', `case ${i}: text-free stream must reconstruct to '' for ${JSON.stringify(events)}`); + }); }); - test('text parts that are null/absent are dropped, never rendered as "null"', opts, () => { - fc.assert( - fc.property( - fc.array( - fc.oneof( - fc.record({ type: fc.constant('text'), part: fc.record({ text: fc.constant(null) }) }), - fc.record({ type: fc.constant('text'), part: fc.record({}) }), - ), - { minLength: 1, maxLength: 10 }, - ), - (events) => { - const out = runJq(TEXT_PROGRAM, events); - assert.equal(out, ''); - assert.doesNotMatch(out, /null/); - }, + test('text parts that are null/absent are dropped, never rendered as "null", over a corpus', opts, () => { + const nullish = fc.array( + fc.oneof( + fc.record({ type: fc.constant('text'), part: fc.record({ text: fc.constant(null) }) }), + fc.record({ type: fc.constant('text'), part: fc.record({}) }), ), + { minLength: 1, maxLength: 10 }, ); + const streams = fc.sample(nullish, { numRuns: CORPUS, seed: 44 }); + const actual = runJqBatch(TEXT_PROGRAM, streams); + streams.forEach((events, i) => { + assert.equal(actual[i], '', `case ${i}: null/absent text must drop to ''`); + assert.doesNotMatch(actual[i], /null/, `case ${i}: must never render "null"`); + }); + }); + + // Explicit boundary + happy examples (deterministic, not sampled) — all in one + // batched jq spawn. Pins the exact contract the corpus only covers probabilistically. + test('boundary + happy example streams reconstruct exactly (batched)', opts, () => { + const cases = [ + { events: [{ type: 'text', part: { text: 'only' } }], expect: 'only' }, // single text part + { events: [{ type: 'text', part: { text: '' } }], expect: '' }, // empty-string text is kept + { events: [{ type: 'text', part: { text: 'a' } }, { type: 'tool_use', part: { tool: 'r' } }, { type: 'text', part: { text: 'b' } }], expect: 'a\nb' }, // text interleaved with noise + { events: [{ type: 'text', part: { text: 'x\ny' } }], expect: 'x\ny' }, // embedded newline preserved + { events: [{ type: 'tool_use', part: { tool: 'read' } }, { type: 'step_finish', part: { reason: 'stop', tokens: { output: 3 } } }], expect: '' }, // no text at all + { events: Array.from({ length: 30 }, (_v, i) => ({ type: 'text', part: { text: `p${i}` } })), expect: Array.from({ length: 30 }, (_v, i) => `p${i}`).join('\n') }, // max-size all-text stream + ]; + const actual = runJqBatch(TEXT_PROGRAM, cases.map((c) => c.events)); + cases.forEach((c, i) => assert.equal(actual[i], c.expect, `example ${i}: ${JSON.stringify(c.events)}`)); }); // Diagnostic path (empty-output stub). The finding calls out `missing .tokens.output` - // and no-step_finish as real edges — pin them with examples against the shipped jq. + // and no-step_finish as real edges — pin them with examples against the shipped jq, + // all in one batched spawn. describe('diagnostic reconstruction (stop reason + output tokens)', () => { - test('reports reason and output tokens from the LAST step_finish', opts, () => { - const events = [ - { type: 'step_finish', part: { reason: 'tool_calls', tokens: { output: 5 } } }, - { type: 'tool_use', part: {} }, - { type: 'step_finish', part: { reason: 'stop', tokens: { output: 0 } } }, + test('reports reason/tokens from the LAST step_finish and degrades missing fields to "?"', opts, () => { + const cases = [ + { events: [ + { type: 'step_finish', part: { reason: 'tool_calls', tokens: { output: 5 } } }, + { type: 'tool_use', part: {} }, + { type: 'step_finish', part: { reason: 'stop', tokens: { output: 0 } } }, + ], expect: 'stop reason=stop, output tokens=0' }, // LAST step_finish wins + { events: [{ type: 'step_finish', part: { reason: 'stop', tokens: {} } }], expect: 'stop reason=stop, output tokens=?' }, // missing .tokens.output → "?" + { events: [{ type: 'tool_use', part: { tool: 'read' } }], expect: 'stop reason=?, output tokens=?' }, // no step_finish at all → both "?" ]; - assert.equal(runJq(DIAG_PROGRAM, events), 'stop reason=stop, output tokens=0'); - }); - - test('missing .tokens.output degrades to "?" rather than null/garbage', opts, () => { - const events = [{ type: 'step_finish', part: { reason: 'stop', tokens: {} } }]; - assert.equal(runJq(DIAG_PROGRAM, events), 'stop reason=stop, output tokens=?'); - }); - - test('no step_finish at all degrades both fields to "?"', opts, () => { - const events = [{ type: 'tool_use', part: { tool: 'read' } }]; - assert.equal(runJq(DIAG_PROGRAM, events), 'stop reason=?, output tokens=?'); + const actual = runJqBatch(DIAG_PROGRAM, cases.map((c) => c.events)); + cases.forEach((c, i) => assert.equal(actual[i], c.expect, `diag ${i}: ${JSON.stringify(c.events)}`)); }); }); @@ -158,7 +215,7 @@ describe('#1936 OpenCode review reconstruction — jq properties', () => { test('non-JSON stdout does not masquerade as a reconstructed review', opts, () => { let threw = false; try { - execFileSync('jq', ['-rs', TEXT_PROGRAM], { input: 'auth token expired\n', encoding: 'utf8' }); + execFileSync('jq', ['-rs', TEXT_PROGRAM], { ...JQ_EXEC_OPTS, input: 'auth token expired\n' }); } catch { threw = true; }