Skip to content

Skill body never reaches the model for /skill invocations in client/server sessions (UI says "Activated skill", agent gets a bare prompt) #873

Description

@yumhum

Summary

In a client/server session (jcode serve + TUI client), invoking a skill as /skill <prompt>
shows "Activated skill: …" in the UI, but the skill body never reaches the model. The agent
receives only the trailing prompt, and the /skill token is stripped from the stored message too,
so nothing downstream can tell a skill invocation happened.

From the model's side, /grill-with-docs do the thing is indistinguishable from do the thing.

Reproduction

jcode v0.75.0 (5ae2385), Linux x86_64, TUI client against a jcode serve --socket … daemon.

/grill-with-docs do you see a SKILL body for grill-with-docs in your system prompt? answer YES or NO and quote its first line

Observed — UI prints Activated skill: grill-with-docs - A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go., and the model replies:

NO.

I only have the one-line catalog entry, not a skill body. Its first line reads:
/grill-with-docs - A relentless interview to sharpen a plan or design, …

To load the actual body, I must run the Skill tool.

Expected — the model's system prompt contains # Active Skill\n\n# Skill: grill-with-docs …,
i.e. the SKILL.md content.

The one-line text the model does see is the # Available Skills catalog entry that
build_system_prompt_split_with_capabilities adds for every registered skill, so this is not
partial delivery. Nothing skill-specific arrives at all.

Impact

Silent, and the failure mode is bad: the UI affirms the skill is active, so the operator assumes
the agent is following it. The agent, having received a bare prompt, does something else entirely.
In my case that cost roughly 200k tokens and half an hour of work in the wrong direction before
either of us noticed.

Every /skill invocation in a remote/daemon session is affected, which on this machine is all of
them.

Cause

The skill body reaches the model only by being written into the local system prompt:

  • crates/jcode-tui/src/tui/app/turn_memory.rs:17-21 reads self.active_skill and passes
    skill_prompt to crate::prompt::build_system_prompt_split.
  • crates/jcode-base/src/prompt.rs:541-544 appends it as # Active Skill\n\n{skill} to the
    dynamic part.

That path is only taken by the local run loop (turn.rs:83). In a remote session the prompt
is built server-side, and the request that carries the turn has nowhere to put the skill:

// crates/jcode-protocol/src/wire.rs:39-49
Request::Message {
    id: u64,
    content: String,
    images: Vec<(String, String)>,
    system_reminder: Option<String>,
}

There is no active_skill field, and active_skill appears nowhere in jcode-protocol,
jcode-message-types or jcode-session-types. The client sets its own App::active_skill
(crates/jcode-tui/src/tui/app/input.rs:3691) and the server sets its own
Agent::active_skill (crates/jcode-app-core/src/agent/turn_execution.rs:781), but only from
its own CLI loop. The two are never connected.

Meanwhile the client has already consumed the marker:

// crates/jcode-tui/src/tui/app/input.rs:3691-3701
self.active_skill = Some(skill_name.clone());
self.push_display_message(/* "Activated skill: …" */);
if let Some(prompt) = trailing_prompt {
    input = prompt;          // <- the /name token is dropped here
} else {
    return;
}

crates/jcode-tui/src/tui/app/remote/input_dispatch.rs:203-216 then deliberately re-derives just
the trailing prompt for the remote request. So the server is sent do the thing, with no way to
learn which skill was meant.

Confirmed in storage — ~/.jcode/sessions/session_<id>.json holds the message as:

{"type": "text", "text": "regarding this please /tmp/claudistan-harness-agnostic-handoff.md"}

for an input that was typed as /grill-with-docs regarding this please /tmp/….

Suggested fix

Carry the active skill on the wire. Add an optional field to Request::Message (and
SoftInterrupt), e.g. active_skill: Option<String>, and have the server resolve it against its
own registry when building the system prompt — the server already owns
Agent::active_skill and prompting.rs already consumes it, so only the plumbing is missing.

Sending the resolved body instead of the name would also work and would survive the client and
server disagreeing about which skills exist, at the cost of a larger payload on every turn.

Whichever is chosen, a regression test that asserts the rendered system prompt contains
# Active Skill after a remote /skill x invocation would pin it. The existing coverage
(app/tests.rs:972, remote_tests.rs:504) only asserts active_skill is set on the client,
which is true today and not sufficient.

Secondary issue: bare /skill with no prompt

When there is no trailing prompt the client returns early
(input.rs:3699-3700), so no turn is sent at all. The skill is armed for the next message. That
is defensible, but combined with the above it means a bare /skill and a /skill <prompt> fail in
two different ways, neither visible.

Note on a natural workaround that does not work

Putting the slash at the end — do the thing /grill-with-docs — silently does nothing at all, not
even the UI notice. SkillRegistry::resolve_invocation requires the slash at position 0:

// crates/jcode-base/src/skill/invocation.rs:16
let invocation = input.trim().strip_prefix('/')?;

Worth a mention in the docs, since it is the first thing an operator tries.

The working workaround today is to call the Skill tool rather than the slash command, or to
paste the SKILL.md body inline.

Metadata

Metadata

Assignees

No one assigned

    Labels

    autonomous: clearHands-off: unambiguous bug, obvious fix, no decisions. Don't even look - an agent can fully solve.bugSomething isn't workingpriority: highP1 - important bug or impactful feature, fix soontriage: reproducibleClear repro + clear fix path

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions