Skip to content

[Bug]: Mid-sentence Claude skill chips split the original request during native expansion #13256

Description

@Jonathanm10

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server — Claude provider skill dispatch (reported from desktop).

Summary

A skill chip used as a verb in the middle of a sentence changes the structure of the request sent to Claude. For example, you will $implement fixes becomes command metadata for /implement fixes, followed by the orphaned text you will, followed by the expanded skill. The original sentence is absent from the model request.

This is an integration problem between T3's mid-prompt dispatch and Claude Code's native expansion. The rearrangement happens before inference; the reproduction below captures the actual HTTP request with a local fake API, without asking a model to reconstruct its input.

Steps to reproduce

  1. Install a minimal project skill at .claude/skills/implement/SKILL.md:

    ---
    description: Minimal serialization fixture
    ---
    SKILL_BODY_SENTINEL
    
    ARGUMENTS: $ARGUMENTS
  2. In a Claude thread, type you will , insert the implement skill chip, then type fixes.

  3. Send and inspect the provider transcript or outgoing Messages request.

  4. Compare with a message starting with the chip: $implement fixes.

Expected behavior

Preserve the complete user request, including the relationship between the prefix, the skill mention, and the suffix, while invoking the selected skill. A chip displayed inside a sentence should not silently turn that sentence into a separate command plus an incomplete fragment.

Actual behavior

T3 emits these SDK content blocks:

[
  {"type":"text","text":"you will"},
  {"type":"text","text":"/implement fixes"}
]

Claude Code 2.1.280 expands them into these text blocks in the outgoing model request (unrelated system reminders omitted):

[
  "<command-message>implement</command-message>\n<command-name>/implement</command-name>\n<command-args>fixes</command-args>\n",
  "you will",
  "Base directory for this skill: <FIXTURE>/project/.claude/skills/implement\n\nSKILL_BODY_SENTINEL\n\nARGUMENTS: fixes\n"
]

The prefix and skill body are separate text blocks, not a literal you willBase directory string. Flattening them without separators gives that appearance, but the confirmed defect is the loss of the original sentence and movement of the invocation ahead of its prefix.

With a longer message, every paragraph after the chip becomes command arguments. Its appearance in both <command-args> and the skill's ARGUMENTS is native expansion behavior, not evidence of two executions.

Diagnosis and controls

The dispatch was introduced in #9128. At the investigated revision:

  • planClaudeSkillDispatch separates the prefix from the last selected skill and removes its trailing whitespace.
  • buildUserMessageEffect puts that prefix in an earlier text block so Claude expands the final /name block.
  • Native Claude expansion then places command metadata ahead of the preserved prefix and the skill body after it. The planner's claim that surrounding text stays in order does not hold across the invocation after expansion.
Probe Result
Actual T3 planner → native CLI, mid-sentence chip Skill expands; original sentence absent; reproduced repeatedly
Same two blocks directly to CLI, bypassing T3 Same rearrangement
Add two newlines to the prefix block Same rearrangement; a newline-only fix is insufficient
One block: you will /implement fixes Sentence preserved; no native skill expansion
Chip at the start: $implement fixes Skill expands without an orphaned prefix
Remove the skill token Original prose preserved

The smallest trigger is a prefix plus the invocation (before $implement); trailing arguments, multiple paragraphs, attachments, plugins, and conversation history are unnecessary.

This belongs first in T3 because T3 deliberately converts an inline mention into an unconditional slash invocation. Claude's native multi-block expansion explains the resulting order. Plain mid-line CLI input is not equivalent and does not auto-expand. I have not established that Claude violates a documented multi-block ordering guarantee.

Impact

Minor bug or occasional failure. The original reported turn was understood, with no observed work loss. A prefix containing a qualification or negation could become ambiguous after this transformation; no model misinterpretation or unintended execution was demonstrated by this fixture.

Version or commit

  • Reproduced using T3 planner source at f5ef0ddb90a8c36584e181b1913e7b8a5df30ffc.
  • Its Git blob matched current upstream main when checked: 2dc106e853828e3972ae859ed7cd6ac6cf204cb5.
  • Claude Code 2.1.280, also recorded in the original transcript.
  • macOS Darwin 27.0.0; Node 24.21.0.
  • Installed desktop build during investigation: 0.0.43-nightly.20260923.2135. The exact desktop build at the time of the original turn was not independently established.

Verification limits

The eight existing ClaudeSkillDispatch tests pass; they assert the pre-expansion split. The native CLI fixture captures the downstream behavior they do not cover. The existing adapter test could not load in this checkout (SchemaTransformation.transformEffect is not a function; installed Effect beta.103 versus declared rc.115), so I do not claim that suite passed. No browser or desktop automation was performed for this investigation.

Workaround

Put the skill at the start of the message, then write a complete instruction after it, instead of using the chip as a word inside a sentence:

$implement
Please implement the fixes. When finished, report the result.

Executable reproduction

The script below uses a temporary project/config, disables tools and hooks, captures a loopback Messages request, and replies locally. It does not need provider credentials or call a real model. All fixtures and the local server are removed when the process exits.

Save it as repro.py and run:

python3 repro.py --case minimal
# FAIL, exit 1: skill_expanded=true, original_sentence_preserved=false
python3 repro.py --case start
# PASS, exit 0: skill expands at the beginning
python3 repro.py --case minimal --mode plain
# PASS, exit 0: literal sentence preserved, skill_expanded=false
python3 repro.py --case minimal --mode split-newline
# FAIL, exit 1: extra newlines do not restore the sentence

From a T3 checkout, --mode t3 --repo "$PWD" additionally imports and runs the actual planner (Node 24 required). Default mode supplies the same two-block input directly to Claude Code.

Python fixture (standard library only)
#!/usr/bin/env python3
"""Capture Claude's actual model request using a loopback-only fake Messages API."""
import argparse
import http.server
import json
import os
from pathlib import Path
import subprocess
import tempfile
import threading
import time

parser = argparse.ArgumentParser()
parser.add_argument('--repo', default=str(Path.cwd()))
parser.add_argument('--case', choices=['original', 'minimal', 'start', 'prefix-only', 'no-skill'], default='minimal')
parser.add_argument('--mode', choices=['t3', 'plain', 'split', 'split-newline'], default='split')
args = parser.parse_args()
prompts = {
    'original': 'please $implement the fixes\n\nThen report the result',
    'minimal': 'you will $implement fixes',
    'start': '$implement fixes',
    'prefix-only': 'before $implement',
    'no-skill': 'you will implement fixes',
}
prompt = prompts[args.case]
if args.mode == 't3':
    module = Path(args.repo) / 'apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts'
    js = '''import { pathToFileURL } from "node:url";
const { planClaudeSkillDispatch } = await import(pathToFileURL(process.argv[1]).href);
console.log(JSON.stringify(planClaudeSkillDispatch(process.argv[2], new Set(["implement"])) ?? null));'''
    plan = json.loads(subprocess.check_output(['node', '--input-type=module', '-e', js, str(module), prompt], text=True))
else:
    before, _, after = prompt.partition('$implement')
    plan = {'leadingText':before.rstrip() or None, 'commandText':'/implement'+after} if '$implement' in prompt else None
if args.mode == 'plain':
    blocks = [{'type': 'text', 'text': prompt.replace('$implement', '/implement')}]
else:
    blocks = []
    if plan is None:
        blocks.append({'type': 'text', 'text': prompt})
    else:
        if plan.get('leadingText'):
            blocks.append({'type': 'text', 'text': plan['leadingText'] + ('\n\n' if args.mode == 'split-newline' else '')})
        blocks.append({'type': 'text', 'text': plan['commandText']})
captured = []
class Handler(http.server.BaseHTTPRequestHandler):
    def log_message(self, *unused):
        pass
    def do_POST(self):
        data = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))) or '{}')
        if 'messages' not in data:
            self.send_response(200); self.end_headers(); self.wfile.write(b'{}'); return
        captured.append(data)
        response = {'id': 'msg_fixture', 'type': 'message', 'role': 'assistant', 'model': data.get('model'), 'content': [{'type':'text','text':'fixture complete'}], 'stop_reason':'end_turn', 'stop_sequence':None, 'usage':{'input_tokens':1,'output_tokens':1}}
        if not data.get('stream'):
            self.send_response(200); self.send_header('Content-Type','application/json'); self.end_headers(); self.wfile.write(json.dumps(response).encode()); return
        events = [
            ('message_start', {'type':'message_start','message':{**response,'content':[],'stop_reason':None}}),
            ('content_block_start', {'type':'content_block_start','index':0,'content_block':{'type':'text','text':''}}),
            ('content_block_delta', {'type':'content_block_delta','index':0,'delta':{'type':'text_delta','text':'fixture complete'}}),
            ('content_block_stop', {'type':'content_block_stop','index':0}),
            ('message_delta', {'type':'message_delta','delta':{'stop_reason':'end_turn','stop_sequence':None},'usage':{'output_tokens':1}}),
            ('message_stop', {'type':'message_stop'}),
        ]
        self.send_response(200); self.send_header('Content-Type','text/event-stream'); self.end_headers()
        for event, body in events:
            self.wfile.write(('event: '+event+'\ndata: '+json.dumps(body)+'\n\n').encode())

started = time.monotonic()
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
with tempfile.TemporaryDirectory(prefix='t3-skill-fixture-') as tmp:
    root = Path(tmp)
    home = root / 'config'
    cwd = root / 'project'; cwd.mkdir()
    skill = cwd / '.claude' / 'skills' / 'implement' / 'SKILL.md'
    skill.parent.mkdir(parents=True)
    skill.write_text('---\ndescription: Minimal serialization fixture\n---\nSKILL_BODY_SENTINEL\n\nARGUMENTS: $ARGUMENTS\n')
    env = {k:v for k,v in os.environ.items() if not k.startswith(('ANTHROPIC_', 'CLAUDE_', 'CLAUDECODE', 'AWS_', 'GOOGLE_', 'OTEL_'))}
    env.update(ANTHROPIC_BASE_URL=f'http://127.0.0.1:{server.server_port}', ANTHROPIC_API_KEY='local-fixture-only', CLAUDE_CONFIG_DIR=str(home), DISABLE_TELEMETRY='1', DISABLE_ERROR_REPORTING='1', DISABLE_AUTOUPDATER='1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC='1')
    command = ['claude', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--settings', '{"disableAllHooks":true}', '--setting-sources', 'project', '--tools', '', '--model', 'claude-sonnet-4-6', '--system-prompt', 'Serialization fixture. Respond with fixture complete.']
    message = {'type':'user','session_id':'','parent_tool_use_id':None,'message':{'role':'user','content':blocks}}
    try:
        result = subprocess.run(command, input=json.dumps(message)+'\n', text=True, capture_output=True, cwd=cwd, env=env, timeout=25)
    finally:
        server.shutdown(); server.server_close(); thread.join()
    if not captured:
        print('HARNESS ERROR: no Messages request', result.returncode, result.stderr[:1500], result.stdout[-2500:])
        raise SystemExit(2)
    texts = [block.get('text','') for msg in captured[-1]['messages'] if msg.get('role')=='user' for block in (msg.get('content') if isinstance(msg.get('content'),list) else [{'text':msg.get('content','')}]) if block.get('type','text')=='text']
    texts = [s.replace(str(root.resolve()), '<FIXTURE>').replace(tmp, '<FIXTURE>') for s in texts if not s.startswith('<system-reminder>')]
    intended = prompt.replace('$implement', '/implement')
    expanded = any('SKILL_BODY_SENTINEL' in t for t in texts)
    if not expanded and args.mode != 'plain' and args.case != 'no-skill':
        print('HARNESS ERROR: fixture skill did not expand', json.dumps(texts, indent=2))
        raise SystemExit(2)
    preserved = any(intended in t for t in texts)
    verdict = ((preserved and not expanded) if args.mode == 'plain' or args.case == 'no-skill' else expanded and (preserved if args.case != 'start' else any('ARGUMENTS: fixes' in t for t in texts)))
    report = {'case':args.case,'mode':args.mode,'input':prompt,'sdk_blocks':blocks,'model_text_blocks':texts,'skill_expanded':expanded,'original_sentence_preserved':preserved,'cli_exit':result.returncode,'elapsed_seconds':round(time.monotonic()-started,2),'verdict':'PASS' if verdict else 'FAIL'}
    output = Path(__file__).with_name(f'{args.case}-{args.mode}.json')
    output.write_text(json.dumps(report, indent=2)+'\n')
    print(json.dumps(report,indent=2))
    raise SystemExit(0 if verdict else 1)

Activity

  1. juliusmarminge commented on Sep 23, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed. A mid-sentence Claude skill chip is sent as two SDK text blocks, and the original sentence does not survive Claude Code's expansion of the second block. This is on v0.0.42 and on current main (f5ef0ddb9). The latest nightly, v0.0.43-nightly.20260923.2150, is that commit, so updating does not change this. ClaudeSkillDispatch.ts on that commit is blob 2dc106e853828e3972ae859ed7cd6ac6cf204cb5.

    you will $implement fixes plans to the blocks in the report: leading text you will (the space before the chip is trimmed), then /implement fixes. $implement fixes stays a single command block. before $implement plans to before plus /implement. please $implement the fixes, then a following paragraph, puts that paragraph on the command (/implement the fixes\n\nThen report the result).

    The split is planClaudeSkillDispatch. buildUserMessageEffect pushes the leading text first and the /name block last. The test "moves a mid-prompt mention into a trailing slash command" locks that pre-expansion shape in (ClaudeSkillDispatch.test.ts). A literal you will /implement fixes is not a chip, so the planner leaves it as one block, which matches the report's plain-mode result (sentence kept, skill not expanded).

    The file header says text on either side stays in order (ClaudeSkillDispatch.ts). That describes the SDK blocks only. The suffix is appended to the slash command, and Claude Code expands a skill from the last text block when it starts with /. This environment has no claude binary, so the native reorder (command metadata, then the orphaned prefix, then the skill body) is the capture in the report. Nothing in-tree asserts that post-expansion message.

    Two notes:

    1. The command block is pushed at lines 1671–1672, after image blocks, outside the cited 1600–1620 range. Images sit between the prefix and /implement .... The minimal repro needs no attachments.
    2. Text after the chip showing up as <command-args> and ARGUMENTS is the planner: commandText is /${name} plus the remainder, newlines included. That is one expansion.

    The same split happens with no user prefix when effort is Ultrathink. buildPromptText runs applyClaudePromptEffortPrefix before dispatch. $implement fixes becomes Ultrathink:\n$implement fixes, which plans to leading Ultrathink: and command /implement fixes. The slash-command exemption only skips text that already starts with /. A $ chip does not qualify. Chip-first avoids a user-authored prefix, and still splits while Ultrathink is selected. you will $implement fixes with Ultrathink plans to leading Ultrathink:\nyou will.

    No open issue describes this reorder. #9128 (merged, fixes #7671) introduced the split so a chip anywhere would expand. anthropics/claude-code#87113 is the one-skill-per-message limit.

    The workaround holds at a normal effort level: put the chip first, then write the instruction. With Ultrathink, that still produces a leading Ultrathink: block.

    Extra newlines in the prefix block cannot repair the sentence. Expansion requires the last text block to start with /. The intact request has to live in the earlier block (chip rewritten in place), with the message still ending in the /name block so the skill loads. Today that earlier block is only the trimmed prefix, with the skill token removed.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 23, 2026
  3. sipak commented on Sep 29, 2026

    @sipak

    A related case that #13299 won't cover: the $name in my prompt was never meant as a skill.

    I use $name in prompts to refer to a project (a convention across my sessions: $vylety = the project in ~/Projects/vylety). Several of my projects have a skill with the same name, so the composer turns $vylety into a chip on the space and ClaudeSkillDispatch sends it as /vylety. The prompt process specification in $vylety reached Claude Code as the text process specification in followed by the expanded skill body with no arguments. The model read that as a cut-off message and ran the wrong skill.

    With #12098 this now applies to € £ ¥ ₹ … too, and there is no setting for it: showSkillsInSlashMenu only affects the / menu, and the only other lever is skillOverrides: "off", which disables the skill in Claude Code itself.

    Would you consider one of these:

    1. A setting to turn off $/currency skill mentions (the / menu still works for invoking skills), or
    2. Dispatching only chips that were picked from the $ menu, and leaving typed $word as plain text even when it matches a skill name.

    Right now the workaround is to write $vylety, or `$vylety` so the token pattern doesn't match.

  4. alvarohulse commented on Oct 5, 2026

    @alvarohulse

    Seeing this too, on v0.0.43-nightly.20260922.2123. My prompt:

    I will review the PR's. Please make sure the PR is formatted with [/gh-pr chip]

    I wasn't even using Ultrathink. See the screenshot below.

    Image

    Putting the chip first works around it, matching what others have reported.

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

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions