Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate (checked [Bug]: OpenCode worktree snapshot caches a builtins-only /skill: list mid-thread and never refreshes (recovered by "Restart agent session") #16912, [Bug]: Project skills that a new worktree receives after creation never appear in the $ picker #14801, [Bug]: Workspace snapshot taken before the first Claude probe finishes leaves the / menu without provider commands for the whole session #11575 — all Claude/Cursor-vSnapshots variants, closed or unrelated).
Area
apps/server (OpenCode provider)
Environment
- T3 Code: v0.1.6 (OpenCode provider adapter,
packages/provider-opencode/src/server/v2/adapter.ts) - OpenCode: v2.2.5, spawned by T3 as
opencode serveon 127.0.0.1 (shared instance) - Linux, worktree-per-thread mode
Summary
On a brand-new worktree, a prompt submitted immediately after thread creation loses its $skill attachment: the adapter's single skill.list call races OpenCode's per-location async skill scan, resolves to an empty/partial set, and the prompt is sent to the model without the <skill_content> block. The user-visible symptom is the prompt showing $repo-thread-orchestrator as plain text and the model not following the skill.
The composer's $ picker does not have this problem, because the workspace-inventory path already waits for the scan (loadOpenCode2Workspace) — so users see the chip compose-side and only lose it after pressing Enter. That asymmetry is what makes this look like a UI/composer bug rather than a submit-path race.
Root cause
packages/provider-opencode/src/server/v2/adapter.ts (~line 3319, submit):
const skills = SKILL_MENTION.test(text)
? skillsNamed(
text,
yield* client.skill.list({ location }).pipe( // ← ONE call
Effect.timeout(INVENTORY_TIMEOUT), // 5s, but the call *succeeds fast* with partial data
Effect.map((list) => new Set(list.data.map((skill) => skill.id))),
Effect.orElseSucceed(() => new Set<string>()), // silent empty fallback
),
)
: [];OpenCode v2's /api/skill is eventually consistent per location[directory]: the first call for a never-before-scanned cwd returns [] (or a small partial set), and the full set only appears after its background scan completes (emits skill.updated for that location). skillsNamed then filters the mentioned name against that empty set and drops it.
By contrast, the workspace status/picker path (driver.ts → listOpenCode2Workspace → loadOpenCode2Workspace in status.ts:534) explicitly handles this:
const first = yield* read;
if (first.commands.length > 0) return first;
yield* scanned.pipe(Effect.timeoutOption("10 seconds")); // waits for command.updated + skill.updated
return yield* read;…so the picker recovers within ~1s, while submit does not.
Reproduction
Exact T3-adapter sequence against a clean opencode serve (session.create(newDirectory) → single skill.list(sameDirectory)):
run 1 → 0 skills, "repo-thread-orchestrator present: False"
run 2 → 2 skills, "repo-thread-orchestrator present: False"
run 3 → 2 skills, "repo-thread-orchestrator present: False"
A second call ≥1s later returns the full 66-skill set including the skill. Verified independently of any user config by running a fresh OpenCode server with an isolated XDG_CONFIG_HOME, and by wire-capturing the provider request (local mock OpenAI-compatible endpoint):
prompt payload sent to /session/prompt |
what the model receives |
|---|---|
text + skills: [{id: "repo-thread-orchestrator"}] |
user message prepended with the full 23 KB <skill_content name="repo-thread-orchestrator"> block |
identical text, no skills field (what a cold submit produces) |
only the literal $repo-thread-orchestrator token, no skill content |
So this is a functional drop of the skill instructions, not just a rendering issue (the raw $name in the transcript is a secondary symptom: the chip is re-derived from the live skill list and degrades to plain text when the list is stale/empty).
Real-world trace
All 5 $repo-thread-orchestrator mentions in this session's native OpenCode history:
| thread | worktree age at send | outcome |
|---|---|---|
| 3 real turns doing orchestrator work | minutes old (user typed a long prompt) | skills: ["repo-thread-orchestrator"] attached ✅ |
| 2 test threads, brand-new worktree each, prompt submitted instantly | < 5s old | never reached the provider (user stopped them) — but these are exactly the conditions that reproduce the race |
No worktree-idle activity on the two fast-submit threads → snapshot never re-checked → consistent with the stale-with-empty-list never recovering (WORKSPACE_SNAPSHOT_TTL_MS re-check only fires on a following request, and nothing arrives because the worktree is idle).
Suggested fix
Mirror the existing retry pattern in submit so send and picker agree:
- On the first
skill.listread, if any$namementioned in the prompt is missing from the returned set (rather than only checking "list is non-empty"), wait for the location'sskill.updated/command.updatedevents (exactly whatloadOpenCode2Workspacealready does onconnection.events) and re-read once. - Optionally reuse a small helper shared with
status.tsso picker and submit can't drift. - Secondary:
loadOpenCode2Workspacekeys its "scan finished" signal offfirst.commands.length > 0; that's only a proxy. If commands arrive before skills (both registries fill independently), it returns early withskills: []and a picks-empty /$picker with no skills until TTL. Keying the skills half offskill.updateddirectly would fix that too.
Happy to provide the exact worktree paths / session IDs / wire captures used above if useful.
Activity