From 0dcd5b3dcd3956c4d60b6ac783bcf46b9f506f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20D=C3=A9=C3=AFs=20Nuel?= Date: Mon, 13 Jul 2026 12:30:11 +0200 Subject: [PATCH] fix(opencode): drop skills emission to avoid provider 400 errors OpenCode v1.x does not recognize `skills` as an agent field; the CLI forwards unknown top-level options to the LLM provider as payload fields. Strict providers (Pydantic `additionalProperties: false`, e.g. GLM-5.2) reject them with HTTP 400 `Extra inputs are not permitted`, which made every OpenCode-backed agent with a pivot `agents[].skills` binding unusable from providers with strict schemas. Stop emitting `skills` from the OpenCode agent fragment. The pivot field is still consumed by the Claude Code adapter (frontmatter) and the Codex adapter (instruction hint). OpenCode agents are expected to reference their skills from the prompt body (e.g. an `## Available skills` section) or rely on the opencode `skill` tool at runtime. Tested: - TestGenerateAgent, TestGenerateAgentSkills, TestGoldenOpenCodeJSON updated to assert the key is absent - TestEndToEnd_SkillsRoundTrip updated to assert the OpenCode build agent has no `skills` key (Claude and native-only `legacy-helper` paths still validated) - Golden file (expected_opencode.json) and init bootstrap fixture updated - Docs (README, docs/SKILLS.md, docs/prd/shenron.md D9 + FR11) updated to describe the new policy --- README.md | 20 +++++++++++++++---- docs/SKILLS.md | 12 +++++++++-- docs/prd/shenron.md | 6 +++--- internal/adapter/opencode/adapter_test.go | 11 ++++++---- internal/adapter/opencode/agent.go | 10 +++++++--- .../opencode/testdata/expected_opencode.json | 3 --- .../cli/testdata/init/opencode/opencode.json | 1 - internal/integration_test.go | 4 ++-- 8 files changed, 45 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1fa5d71..ad2838e 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,20 @@ native changes, then writes the corresponding Claude Code, Codex, and OpenCode f | Agent prompts | Markdown body | `developer_instructions` | `prompts/.md` referenced from JSON | | Slash commands | `~/.claude/commands/.md` | `~/.codex/prompts/.md` | `command.` plus `command/.md` | | Permissions | `tools` and `permissionMode` | Sandbox, approvals, and web search | Native `permission` object | -| Per-agent skills | YAML frontmatter `skills` | Instruction hint | Native JSON `skills` array | +| Per-agent skills | YAML frontmatter `skills` | Instruction hint | Dropped (see `docs/SKILLS.md`) | | Bootstrap with `shenron install` | After OpenCode | After Claude Code | Preferred import source | Shenron targets Claude Code, Codex, and OpenCode. +> **Why no `skills` in OpenCode output?** OpenCode v1.x does not recognize +> `skills` as an agent field, so the CLI forwards unknown top-level options to +> the LLM provider as payload fields. Strict providers (Pydantic +> `additionalProperties: false`, e.g. GLM-5.2) reject them with 400 +> `Extra inputs are not permitted`. Pivot `agents[].skills` is therefore not +> emitted to OpenCode. Claude Code (native frontmatter) and Codex (instruction +> hint) keep receiving the bindings, and OpenCode agents are expected to +> reference skills from their prompt instead. + ## Install Shenron requires Go 1.24 or newer. @@ -175,14 +184,17 @@ skills: | `systemPrompt` / `promptFile` | Mutually exclusive. `promptFile` is relative to the pivot directory and must exist. | | `permissions` | Portable grants translated by each adapter. | | `extensions` | Target-specific overrides and fields. | -| `skills` | Optional ordered list of kebab-case skill names, emitted as native agent metadata. Local skill existence is not required. | +| `skills` | Optional ordered list of kebab-case skill names, emitted to Claude Code frontmatter and Codex instructions only (see below). Local skill existence is not required. | ### Per-agent skills and global skill references These two fields have different purposes: -- `agents[].skills` binds skills to an agent. It round-trips through OpenCode's - JSON `skills` array and Claude Code's frontmatter `skills` list. +- `agents[].skills` binds skills to an agent. It is emitted to Claude Code's + agent frontmatter and added as a Codex instruction hint. **OpenCode output + drops the field** because OpenCode v1.x forwards unknown agent keys to the + LLM provider, which strict providers reject (see the table note above and + [`docs/SKILLS.md`](docs/SKILLS.md)). - Top-level `skills: [{name: ...}]` stores global references only. Shenron does not manage skill contents or install skills on another machine. diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 999c88c..d27396a 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -1,8 +1,16 @@ # Agent skill bindings `shenron.yaml` is the source of truth for skill metadata. `shenron push ` -emits each binding to both the Claude Code agent frontmatter and the OpenCode -agent object. +emits each binding to the **Claude Code** agent frontmatter and adds it to the +**Codex** agent's instruction prompt. + +> **OpenCode output does not contain a `skills` key on generated agents.** +> OpenCode v1.x does not recognize `skills` as an agent field, so the CLI +> forwards unknown top-level options to the LLM provider as payload fields. +> Strict providers (Pydantic `additionalProperties: false`, e.g. GLM-5.2) +> reject them with HTTP 400 `Extra inputs are not permitted`. Shenron therefore +> drops the field for OpenCode; OpenCode agents are expected to reference +> skills from their prompt body (e.g. an `## Available skills` section). | agent | skills | file | |---|---|---| diff --git a/docs/prd/shenron.md b/docs/prd/shenron.md index 695539b..6f7595a 100644 --- a/docs/prd/shenron.md +++ b/docs/prd/shenron.md @@ -65,7 +65,7 @@ Chaque agent dans le pivot a les champs suivants : | `promptFile` | string (chemin relatif au répertoire du fichier pivot) | Non | Alternative à `systemPrompt` : référence un fichier Markdown externe contenant le prompt système. Exemple : `prompts/build.md` (relatif au répertoire où se trouve `shenron.yaml`). | | `permissions` | objet | Non | Permissions de l'agent (voir section dédiée) | | `extensions` | map string→any | Non | Champs spécifiques à un CLI cible (ex. `claudeCode.maxTurns`, `opencode.steps`). Le cœur ignore ces champs ; les adaptateurs les consomment. | -| `skills` | list[string] (kebab-case) | Non | Skills que cet agent déclare utiliser. Référence des noms de skills dans `~/.agents/skills/`. Les adaptateurs émettent ce champ dans le format natif. | +| `skills` | list[string] (kebab-case) | Non | Skills que cet agent déclare utiliser. Référence des noms de skills dans `~/.agents/skills/`. Émis par l'adaptateur Claude Code (frontmatter) et par l'adaptateur Codex (hint d'instruction). **Omis par l'adaptateur OpenCode** (voir décision D9). | **Règle :** `systemPrompt` et `promptFile` ne peuvent pas être spécifiés simultanément. @@ -389,7 +389,7 @@ Les questions ouvertes de conception ont été résolues avant la v1. | D6 | Walk-up CWD→root + fallback `$HOME/.shenron/shenron.yaml`. Les deux modes (projet et global) coexistent. | FR1 | | D7 | Adaptateur pur + méthode `MergeFile` optionnelle dans l'interface `Adapter`. L'adaptateur OpenCode renvoie les fragments JSON pour les blocs `agent`/`command` ; le cœur fait le merge en préservant les champs hors-scope et l'indentation. | FR10 | | D8 | Nom du binaire : `shenron`. Distribution : binaire statique macOS/Linux via GitHub Releases + script `curl\|sh`. Pas de package npm/pip/brew en v1. | Stack | -| D9 | Le binding skills-par-agent est désormais dans le scope v1 (amend FR2). D5 reste inchangé pour le contenu des skills, qui demeure read-only. Les adaptateurs émettent `skills` comme metadata, pas comme contenu. L'existence locale d'une skill n'est pas bloquante. | FR2, FR7, FR10 | +| D9 | Le binding skills-par-agent est désormais dans le scope v1 (amend FR2). D5 reste inchangé pour le contenu des skills, qui demeure read-only. Les adaptateurs Claude Code et Codex émettent `skills` comme metadata ; **l'adaptateur OpenCode omet la clé `skills`** parce qu'OpenCode v1.x la forward au provider LLM en tant qu'option top-level inconnue, ce que les providers stricts (schéma Pydantic `additionalProperties: false`, ex. GLM-5.2) rejettent en 400 `Extra inputs are not permitted`. Les agents OpenCode sont censés référencer leurs skills depuis leur prompt (`## Available skills`). L'existence locale d'une skill n'est pas bloquante. | FR2, FR7, FR10 | | D10 | Le flux single-pivot de la v1 (`init` / `validate` / `diff` / `push` sur un `shenron.yaml` nu) a été retiré au profit du flux par package. Le contrat CLI actuel vit dans `docs/prd/scope-flatten-commands.md` : cinq commandes top-level (`install`, `list`, `update`, `diff`, `push`), `init` et `validate` supprimés, `--store` promu au niveau racine. FR1-FR4 (schéma pivot) restent valides ; FR5-FR8 sont remplacés par les FR1-FR7 du nouveau PRD. | FR5, FR6, FR7, FR8 | ## Acceptance criteria (v1) @@ -413,7 +413,7 @@ Une session de test valide le scénario suivant : 8. `shenron push --target opencode` → refuse d'écraser (sauf `--force`). 9. `shenron push --target claude-code` → écrit `~/.claude/agents/build.md` à partir du même pivot, avec le frontmatter YAML Claude Code. 10. Les permissions mappées (`edit: ask`, `bash` avec patterns) apparaissent correctement dans les deux fichiers natifs. -11. `shenron push` round-trip un champ `skills: [foo]` modifié : il est visible dans `opencode.json` et `~/.claude/agents/.md` après le push. +11. `shenron push` round-trip un champ `skills: [foo]` modifié : il est visible dans `~/.claude/agents/.md` (frontmatter) et comme instruction Codex, mais **absent de `opencode.json`** (voir décision D9). 12. `shenron push --target codex` génère les agents TOML et prompts Markdown, puis `shenron diff --target codex` retourne "No changes". ## Stack diff --git a/internal/adapter/opencode/adapter_test.go b/internal/adapter/opencode/adapter_test.go index 8c21c01..82cfc35 100644 --- a/internal/adapter/opencode/adapter_test.go +++ b/internal/adapter/opencode/adapter_test.go @@ -82,7 +82,6 @@ func TestGenerateAgent(t *testing.T) { "model": "anthropic/claude-sonnet-4-5", "temperature": 0.7, "prompt": "{file:./prompts/build.md}", - "skills": []string{"test-driven-development"}, "steps": 50, "permission": map[string]any{ "glob": "allow", @@ -106,6 +105,11 @@ func TestGenerateAgent(t *testing.T) { } func TestGenerateAgentSkills(t *testing.T) { + // OpenCode v1.x does not recognize `skills` as an agent field; the opencode + // CLI forwards unknown top-level options to the LLM provider as payload + // fields, which strict providers reject. Shenron must therefore drop the + // `skills` key entirely from generated OpenCode fragments, even when the + // pivot declares agent-level bindings. agent := pivot.AgentDefinition{ ID: "build", Description: "Build agent", @@ -117,9 +121,8 @@ func TestGenerateAgentSkills(t *testing.T) { if err != nil { t.Fatal(err) } - want := []string{"test-driven-development"} - if !reflect.DeepEqual(fragment["skills"], want) { - t.Errorf("skills = %#v, want %#v", fragment["skills"], want) + if _, ok := fragment["skills"]; ok { + t.Errorf("fragment must not contain a `skills` key for OpenCode; got %#v", fragment["skills"]) } } diff --git a/internal/adapter/opencode/agent.go b/internal/adapter/opencode/agent.go index f4fb3c8..b0e68e0 100644 --- a/internal/adapter/opencode/agent.go +++ b/internal/adapter/opencode/agent.go @@ -42,9 +42,13 @@ func GenerateAgentFragment(agent pivot.AgentDefinition, pivotDir string) (jsonFr fragment["permission"] = perms } - if len(agent.Skills) > 0 { - fragment["skills"] = agent.Skills - } + // Pivot `agent.Skills` is intentionally NOT emitted to the OpenCode fragment. + // OpenCode v1.x does not recognize `skills` as an agent field, so it forwards + // unknown top-level options to the LLM provider as payload fields. Strict + // providers (e.g. GLM-5.2) reject them with 400 "Extra inputs are not + // permitted". Skill bindings remain available to the agent through the + // Claude Code and Codex adapters, and through prompt-side `## Available skills` + // sections authored in the pivot. promptContent, err = resolvePromptContent(agent, pivotDir) if err != nil { diff --git a/internal/adapter/opencode/testdata/expected_opencode.json b/internal/adapter/opencode/testdata/expected_opencode.json index 52dfcad..3de785f 100644 --- a/internal/adapter/opencode/testdata/expected_opencode.json +++ b/internal/adapter/opencode/testdata/expected_opencode.json @@ -16,9 +16,6 @@ "webfetch": "deny" }, "prompt": "{file:./prompts/build.md}", - "skills": [ - "test-driven-development" - ], "steps": 50, "temperature": 0.7 }, diff --git a/internal/cli/testdata/init/opencode/opencode.json b/internal/cli/testdata/init/opencode/opencode.json index 97fde84..395c84e 100644 --- a/internal/cli/testdata/init/opencode/opencode.json +++ b/internal/cli/testdata/init/opencode/opencode.json @@ -12,7 +12,6 @@ "lsp": "deny" }, "prompt": "{file:./prompts/build.md}", - "skills": ["test-driven-development"], "steps": 50 } }, diff --git a/internal/integration_test.go b/internal/integration_test.go index 57c3a77..4704466 100644 --- a/internal/integration_test.go +++ b/internal/integration_test.go @@ -233,8 +233,8 @@ func TestEndToEnd_SkillsRoundTrip(t *testing.T) { } agents := root["agent"].(map[string]any) build := agents["build"].(map[string]any) - if got := stringSliceValue(build["skills"]); len(got) != 1 || got[0] != "test-driven-development" { - t.Errorf("OpenCode build skills = %#v", got) + if _, ok := build["skills"]; ok { + t.Errorf("OpenCode build must not carry a `skills` key (forwarded to LLM providers as an unknown payload field); got %#v", build["skills"]) } legacy := agents["legacy-helper"].(map[string]any) if got := stringSliceValue(legacy["skills"]); len(got) != 1 || got[0] != "native-existing-skill" {