Skip to content

[Bug]: OpenCode attachment is dropped on first send from a brand-new worktree (submit-time skill.list races OpenCode's async skill scan) #29513

Closed

Description

@Epickitrolaz

Before submitting

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 serve on 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:

  1. On the first skill.list read, if any $name mentioned in the prompt is missing from the returned set (rather than only checking "list is non-empty"), wait for the location's skill.updated/command.updated events (exactly what loadOpenCode2Workspace already does on connection.events) and re-read once.
  2. Optionally reuse a small helper shared with status.ts so picker and submit can't drift.
  3. Secondary: loadOpenCode2Workspace keys its "scan finished" signal off first.commands.length > 0; that's only a proxy. If commands arrive before skills (both registries fill independently), it returns early with skills: [] and a picks-empty / $ picker with no skills until TTL. Keying the skills half off skill.updated directly would fix that too.

Happy to provide the exact worktree paths / session IDs / wire captures used above if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions