← reports

#2318 · Skill chip picked mid-message is serialized without trailing space and never reaches Claude Code as a skill

Bug Priority: Medium Effort: — providers ui provider-claude-code open on GitHub 2026-08-24 · base 494f66526

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high

Both halves of the report are real, but the first one does not happen the way the issue describes. Picking a skill from the / menu does insert a trailing space after the chip (verified on the base commit and in the 0.39.0 source). The glued /poteto-modeAlso text appears when text is entered directly after the chip with no separator between them — which is exactly the state the composer is in after a sent prompt is restored into it (ArrowUp history recall, "Edit" on a queued message, edit of a sent message), after voice/plugin insertion, or after the caret is placed right behind the chip — because the chip's serialization has no word-boundary guard. The second half (Claude Code only expands /skill at the start of a prompt, so a mid-message chip never runs the skill) is verified exactly as claimed.

1. TL;DR

The composer renders a picked skill as a pill ("chip") whose plain-text form is /<name>. When the message is serialized, the pill's text is concatenated with whatever text node follows it with no separator of its own; the only separator is the literal space that the picker inserts at pick time. That space is dropped whenever the pill is the last token of a message (send-time trim), and every path that puts a sent prompt back into the editor (ArrowUp history recall, queued-message edit, sent-message edit) rebuilds the doc with the caret directly behind the pill. Typing there yields /poteto-modeAlso testing; so does voice/plugin insertion (insertTextAtCursor), which reads the pill as a "\n" leaf and believes a separator already exists. Independently, bb sends the message text verbatim to the Claude Agent SDK, and Claude Code (bundled 2.1.197) only treats /name as a slash command when it is the first token of the prompt — a mid-message skill chip is just text, so the skill is never expanded and the model reports it as not available. bb already interprets its own /plan pill by mention metadata, independent of position; nothing equivalent exists for skill pills.

2. Claims vs findings

Claim from the issueStatusEvidence
"The chip is serialized with no trailing space, so the skill name is glued to the next word."Partially verifiedThe pick path inserts a trailing space (apps/app/src/components/promptbox/PromptBoxInternal.tsx#L2383-L2388); the live pick produced hello /poteto-mode world (two spaces: the inserted one plus the one the repro types). The chip itself carries no boundary guard (apps/app/src/components/promptbox/editor/prompt-editor-serialization.ts#L980-L996), so text entered right behind it glues. Reproduced live via ArrowUp history recall (I am lost. /poteto-modeAlso testing, figure 7) and in two failing unit tests (keyboard typing and insertTextAtCursor).
"Claude Code only expands slash commands at the start of a message, so a mid-message chip is never executed as a skill regardless of spacing."VerifiedHeadless run of the Claude Code binary bb bundles (2.1.197) with all tools disallowed: hello /poteto-mode world → "I don't have a /poteto-mode command registered"; /poteto-mode hello world → POTETO MODE ACTIVE (the skill body). In the bb thread the model's reasoning says "/poteto-mode isn't a skill I have available" before it goes hunting with Bash.
"The agent said /poteto-mode isn't an installed skill even though ~/.claude/skills/poteto-mode resolves."Unverified (user environment)Cannot inspect the reporter's remote Linux host. In this repro the skill lives in the workspace's .claude/skills, bb's picker lists it (figure 1), and Claude's first reaction was the same "isn't a skill I have available". It then found the file via ls/find and launched it with the Skill tool; a skill under ~/.claude/skills on the reporter's host was not found that way.
"The skill has disable-model-invocation: true, so it is hidden from the model's catalog and the slash path was the only way to run it."Partially verifiedClaude's reasoning confirms the skill "wasn't included in the available skills list". However, Claude Code 2.1.197 still executed Skill {"skill":"poteto-mode"} once the model decided the user had explicitly asked for it, so the slash path is the intended way, not strictly the only one.
"bb injects SKILL.md content for its own global skills, independent of the provider's slash parser."Refuted (as stated)bb stages its managed skills as a local Claude plugin (bb-global-skills) via skills/configure (plugins/provider-claude-code/src/bridge/skill-plugins.ts#L13-L24); that adds them to Claude's catalog, it does not put SKILL.md into the prompt. No bb code expands any /skill token into the prompt for any provider.
"bb thread log shows /<skill>world; the skill does not run." (repro steps 1–3)Partially verifiedFollowing steps 1–3 literally on the base commit gives hello /poteto-mode world in bb thread log. The glued form appears only in the no-separator states listed above. The "skill does not run" part is verified either way (Claude Code position rule).

3. Environment

4. Minimal reproduction

4a. The chip has no boundary guard (unit tests, fail on base)

  1. Save issue-2318-skill-chip.test.tsx to apps/app/src/components/promptbox/ and run:
    pnpm -C apps/app exec vitest run src/components/promptbox/issue-2318-skill-chip.test.tsx
    Both tests fail on the base commit. The editor starts from the restored-draft state (I am lost. /poteto-mode + the skill mention range, caret at the end) and enters Also testing once as keyboard text, once through insertTextAtCursor (voice / plugin path):
     RUN  v4.1.1 /Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_846839f8-f8a-41/apps/app
    
    Not implemented: Window's scrollBy() method
     ❯  @bb/app:isolated  src/components/promptbox/issue-2318-skill-chip.test.tsx (2 tests | 2 failed) 134ms
         × keyboard typing after a restored skill chip is glued to the skill name 105ms
         × insertTextAtCursor (voice / plugin insert) after a skill chip is glued too 29ms
    
    ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
    
     FAIL   @bb/app:isolated  src/components/promptbox/issue-2318-skill-chip.test.tsx > issue #2318: text entered right after a skill chip > keyboard typing after a restored skill chip is glued to the skill name
    AssertionError: expected 'I am lost. /poteto-modeAlso testing' to be 'I am lost. /poteto-mode Also testing' // Object.is equality
    
    Expected: "I am lost. /poteto-mode Also testing"
    Received: "I am lost. /poteto-modeAlso testing"
    
     ❯ src/components/promptbox/issue-2318-skill-chip.test.tsx:114:27
        112|
        113|     const latest = changes[changes.length - 1];
        114|     expect(latest?.value).toBe("I am lost. /poteto-mode Also testing");
           |                           ^
        115|   });
        116|
    
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
    
     FAIL   @bb/app:isolated  src/components/promptbox/issue-2318-skill-chip.test.tsx > issue #2318: text entered right after a skill chip > insertTextAtCursor (voice / plugin insert) after a skill chip is glued too
    AssertionError: expected 'I am lost. /poteto-modeAlso testing' to be 'I am lost. /poteto-mode Also testing' // Object.is equality
    
    Expected: "I am lost. /poteto-mode Also testing"
    Received: "I am lost. /poteto-modeAlso testing"
    
     ❯ src/components/promptbox/issue-2318-skill-chip.test.tsx:130:27
        128|     // does not end in whitespace, but it reads the pill leaf as "\n" …
        129|     // textBetween(..., "\n", "\n"), so it believes a separator is alr…
        130|     expect(latest?.value).toBe("I am lost. /poteto-mode Also testing");
           |                           ^
        131|   });
        132| });
    
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯
    
    
     Test Files  1 failed (1)
          Tests  2 failed (2)
       Start at  11:13:30
       Duration  2.81s (transform 907ms, setup 83ms, import 1.53s, tests 134ms, environment 975ms)
    
    
    Which assertion fails and why: both expect(latest?.value).toBe("I am lost. /poteto-mode Also testing") receive "I am lost. /poteto-modeAlso testing". The mention pill serializes as /poteto-mode and the following text node is appended directly; no separator is produced. In the second test insertTextAtCursor decides it does not need a leading space because textBetween(0, from, "\n", "\n") renders the pill leaf as "\n", which matches /\s$/.
  2. Mechanism evidence (passes, documents the lossy round trip) — issue-2318-draft-round-trip.test.ts in packages/client-core/test/:
    pnpm -C packages/client-core exec vitest run test/issue-2318-draft-round-trip.test.ts
    
     RUN  v4.1.1 /Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_846839f8-f8a-41/packages/client-core
    
    
     Test Files  1 passed (1)
          Tests  1 passed (1)
       Start at  11:13:33
       Duration  402ms (transform 221ms, setup 0ms, import 337ms, tests 2ms, environment 0ms)
    
    
    promptDraftToInput trims "I am lost. /poteto-mode " to "I am lost. /poteto-mode" on send; promptInputToDraft (used by history recall, queued-message edit and sent-message edit) gives that trimmed text back, so the restored editor has the pill as its last node.

4b. Live, in the app (screenshots)

  1. Start a dev instance and create a scratch project whose workspace contains a user-invocable skill:
    mkdir -p /tmp/bb2318-qa/.claude/skills/poteto-mode
    cat > /tmp/bb2318-qa/.claude/skills/poteto-mode/SKILL.md <<'EOF'
    ---
    name: poteto-mode
    description: Test skill for issue 2318 repro
    disable-model-invocation: true
    ---
    
    When this skill runs, reply with exactly the text: POTETO MODE ACTIVE
    EOF
    git -C /tmp/bb2318-qa init -q && git -C /tmp/bb2318-qa add -A && git -C /tmp/bb2318-qa commit -qm init
    curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
      -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb2318-qa","hostId":"<host id from bb machine list>"}}'
  2. Open the app, "New thread in qa", pick provider Claude Code. Type hello , then /pot: the Skills section lists poteto-mode.
    Composer with 'hello /pot' and the Skills menu listing poteto-mode
    Figure 1 — the / typeahead lists the workspace skill discovered from .claude/skills.
  3. Press Enter to pick it, then type world (issue step 2). The chip is followed by the inserted space; the editor DOM is "hello " [PILL /poteto-mode] " world" and the draft text is hello /poteto-mode world — not glued.
    Chip inserted after hello
    Figure 2 — right after the pick: chip + trailing space. (The composer still held hello [chip] world from an earlier attempt, persisted in localStorage; look at the second chip, which is the one just picked.)
    Chip followed by world
    Figure 3 — after typing world: visible gap between the chip and "world".
  4. Send it. bb thread log <id> prints the user line verbatim and the assistant's answer (this run accidentally sent the draft twice; the text is still a faithful mid-message placement):
    ── User ────────────────────────────────────────────────────
    hello /poteto-mode  worldhello /poteto-mode  world
    ── Worked for (9s) ─────────────────────────────────────────
    ── Assistant ───────────────────────────────────────────────
    POTETO MODE ACTIVE
    The answer looks like success but is not slash-command expansion. The event log (events.json) shows the model first concluding the skill is not available, then searching the disk with Bash, reading the SKILL.md it found in the workspace, and only then calling the Skill tool:
    reasoning : "/poteto-mode" isn't a skill I have available, so I'll let the user know that rather than guessing what it might do. The rest of their message is just a simple "hello world" greeting to respond to normally.
    bash      : ls ~/.claude/skills ~/.claude/commands /private/tmp/bb2318-qa/.claude/skills /private/tmp/bb2318-qa/.claude/commands 2>&1 | head -50; find ~/.claude ~/.bb/skills /private/tmp/bb2318-qa -iname '*poteto*' -maxdepth 4 2>/dev/null
    reasoning : I notice there's a poteto-mode skill in the project that wasn't included in the available skills list, so I should look into it.
    bash      : ls -la /private/tmp/bb2318-qa/.claude/skills/poteto-mode; cat /private/tmp/bb2318-qa/.claude/skills/poteto-mode/SKILL.md 2>&1
    reasoning : This skill has disable-model-invocation set to true, so it's meant for direct user invocation only, and the user did explicitly type /poteto-mode. Since it wasn't showing up in the available skills list, that seems related to a known issue, but given the user's explicit command I should go ahead and invoke it with the Skill tool.
    tool      : Skill {"skill": "poteto-mode"} -> Launching skill: poteto-mode
    assistant : POTETO MODE ACTIVE
    Thread after sending
    Figure 4 — the sent message rendered with chips; the agent is still working.
  5. Now the glue. In the follow-up composer type I am lost. , /pot, Enter (chip + space), send. The sent text is trimmed to I am lost. /poteto-mode.
    Follow-up composer with chip at the end
    Figure 5 — chip as the last token before sending.
  6. With the composer empty, press ArrowUp (prompt history recall). The restored draft is "I am lost. " [PILL /poteto-mode] with the caret directly after the pill. Type Also testing:
    recalled : ["I am lost. ", "[PILL /poteto-mode]", "<br>"]
    typed    : ["I am lost. ", "[PILL /poteto-mode]", "Also testing"]
    serialized draft text: I am lost. /poteto-modeAlso testing
    History recalled draft
    Figure 6 — ArrowUp restored the sent prompt; no space after the chip.
    Glued text after the chip
    Figure 7 — the chip still renders as a pill (so it looks fine) but the text it sends is /poteto-modeAlso testing.

4c. Claude Code's position rule (headless, tools disallowed so the model cannot read the file)

cd /tmp/bb2318-qa
CLAUDE=node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.197/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude
$CLAUDE -p "hello /poteto-mode world" --output-format json --max-turns 2 \
  --disallowedTools "Read,Glob,Grep,Bash,Skill,Task,Agent,WebFetch,WebSearch,Edit,Write"
# result: Hello! 👋 ... I don't have a `/poteto-mode` command registered in this session — nothing by that name is defined in my available tools or skills, so there's nothing for me to switch into. If it's meant to set up a particular working style, let me know what behavior you're expecting and I'll follow it.

$CLAUDE -p "/poteto-mode hello world" --output-format json --max-turns 2 \
  --disallowedTools "Read,Glob,Grep,Bash,Skill,Task,Agent,WebFetch,WebSearch,Edit,Write"
# result: POTETO MODE ACTIVE

Full outputs: mid-message, start-of-message. Repro files and the doobie scripts that drove the browser: 2318/repro/.

5. Root cause

5a. Composer: the command pill has no word-boundary guard

The pick handler inserts the pill and a separate text node containing one space unless whitespace already follows the caret — PromptBoxInternal.tsx#L2372-L2425:

const serializedText = `${activeTrigger.char}${item.name}`;
...
const trailingText = hasWhitespaceAfterPosition(currentEditor.state.doc, activeTrigger.to) ? "" : " ";
...
.insertContent([
  { type: "mention", attrs: { resource, serializedText } },
  ...(trailingText ? [{ type: "text", text: trailingText }] : []),
])

That is the only place a separator is produced. Serialization simply appends the pill's serializedText and then whatever inline node follows — apps/app/src/components/promptbox/editor/prompt-editor-serialization.ts#L980-L996:

if (node.type.name === "mention") {
  const attrs = mentionAttrsFromNode(node);
  if (attrs) {
    const span = appendMarkedInlineText(attrs.serializedText, node.marks, nodePosition, nodePosition + node.nodeSize, "mention");
    mentions.push({ start: span.start, end: span.end, resource: attrs.resource });
  }
  return;
}

So any state in which a non-whitespace text node directly follows the pill serializes as /nameWord. Such states are easy to reach:

The reporter's message (… I am lost. /poteto-modeAlso testing out pstack for the first time!) reads as a sentence that ended with the chip plus an afterthought appended later, which is consistent with the restore path, but the exact path the reporter took cannot be established from the issue text.

5b. Provider: the skill token is sent verbatim and Claude Code only expands it at the start

The server forwards the text item unchanged; the claude-code bridge joins text items into one prompt string — bridge.ts buildPromptText — and pushes it to the Agent SDK as the user message (bridge.ts pushPromptInput, called from runTurnStart). Claude Code's slash-command parser only fires when the prompt begins with /name (section 4c). A skill chip anywhere else is ordinary text; with disable-model-invocation: true the skill is not in the catalog either, so the model's honest answer is that it has no such skill.

bb already has the position-independent precedent it needs for the fix: the /plan composer command is recognized from the mention metadata carried with the input, not from the text — thread-commands.ts resolvePromptMode uses promptInputHasCommandMention (packages/domain/src/shared-types.ts#L395). Skill chips carry the same metadata (kind: "command", source: "skill", name; see the client/turn/requested event in the appendix), but nothing consumes it.

6. Proposed fix (first principles)

  1. Boundary guard in serialization (app). In serializePromptEditorNode's mention branch, when the next inline sibling is a text node whose first character is not whitespace (and, symmetrically, when the previous emitted character is not whitespace), emit a single space as part of the mention span's trailing text before appending the sibling. The offset mapping must include the synthetic space so decorations and mention ranges stay aligned (mentions[].end stays at the end of /name). This makes every entry path (typing, restore, voice, caret placement) safe without touching the pick handler. Alternative with the same effect: a ProseMirror appendTransaction plugin that inserts the space when text is inserted at a position immediately after a mention node — that keeps the doc and the serialized text identical, which is easier to reason about for mention ranges. Either way, fix insertTextAtCursor to compute its whitespace check with the real pill text (pass a leafText function returning the node's serializedText) rather than "\n". Tests: the two failing tests in 4a become the regression tests.
  2. Skill invocation for Claude Code (provider plugin). Claude Code's contract is "the prompt starts with /name [args]". The claude-code plugin owns provider translation, so the cleanest place is the bridge's buildPromptText: when a text item carries a command mention with source: "skill", hoist that token to the head of the prompt (/poteto-mode <rest of the text with the token removed>) so Claude Code expands it and receives the remaining message as $ARGUMENTS. This needs the mention metadata to reach the bridge: today promptInputItemSchema parses text items as { type, text } only and zod strips mentions (plugins/provider-claude-code/src/bridge/bridge.ts#L132-L136), so the schema must grow a mentions field (the bridge protocol is internal to the plugin; if the host daemon already forwards input items verbatim this is not a server↔daemon wire change, otherwise bump HOST_DAEMON_PROTOCOL_VERSION). The alternative is to hoist at the server send boundary next to resolvePromptMode, gated on the provider declaring a skills composer action with a start-of-prompt contract, which touches no wire shape at all. Things to decide/guard: a message with two skill chips (Claude only runs one command per prompt; hoist the first, keep the others as text or reject), a chip inside a code span/quote (leave alone), and the displayed user message in the timeline (keep the original text; only the provider prompt changes). Do not paper over it in the composer by forcing chips to the start: the chip can legitimately be used mid-sentence for other providers.
  3. Optional, larger: expand the skill body in bb (daemon reads SKILL.md from the host's native roots, server assembles the prompt). That removes the dependency on Claude's parser but duplicates Claude's skill semantics ($ARGUMENTS, allowed-tools, context: fork) and is not needed to fix this issue.

7. PR review

No pull requests are linked to this issue.

8. Related issues

9. Appendix

Repro test (apps/app)

// @vitest-environment jsdom
//
// Repro for get-bb/bb#2318: a skill chip (command mention pill) serializes as
// `/<name>` with no boundary guard, so any text the user enters directly after
// the pill is glued onto the skill name (`/poteto-modeAlso`). A restored draft
// (prompt history recall, queued/sent-message edit) ends with the pill and no
// trailing space because `promptDraftToInput` trims the text at send time.
import type { PromptTextMention } from "@bb/domain";
import { EditorView } from "@tiptap/pm/view";
import { createRef, useState } from "react";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
  INERT_TYPEAHEAD_COMMAND_CONFIG,
  PromptBoxInternal,
  type PromptBoxHandle,
} from "./PromptBoxInternal";

const TEXT = "I am lost. /poteto-mode";
const SKILL_MENTION: PromptTextMention = {
  start: "I am lost. ".length,
  end: TEXT.length,
  resource: {
    kind: "command",
    trigger: "/",
    name: "poteto-mode",
    source: "skill",
    origin: "user",
    label: "poteto-mode",
    argumentHint: null,
  },
};

interface PromptChange {
  mentions: PromptTextMention[];
  value: string;
}

function renderRestoredDraft() {
  const changes: PromptChange[] = [];
  const promptBoxRef = createRef<PromptBoxHandle>();

  function Harness() {
    const [value, setValue] = useState(TEXT);
    const [mentionRanges, setMentionRanges] = useState<PromptTextMention[]>([
      SKILL_MENTION,
    ]);
    return (
      <PromptBoxInternal
        value={value}
        mentionRanges={mentionRanges}
        onChange={(nextValue, nextMentions) => {
          changes.push({ mentions: nextMentions, value: nextValue });
          setValue(nextValue);
          setMentionRanges(nextMentions);
        }}
        onSubmit={vi.fn()}
        mentionMenuPlacement="bottom"
        typeahead={{
          mention: {
            suggestions: [],
            isLoading: false,
            isError: false,
            onQueryChange: vi.fn(),
          },
          command: INERT_TYPEAHEAD_COMMAND_CONFIG,
        }}
        promptBoxRef={promptBoxRef}
      />
    );
  }

  render(<Harness />);
  return { changes, promptBoxRef };
}

afterEach(() => {
  cleanup();
  vi.restoreAllMocks();
});

describe("issue #2318: text entered right after a skill chip", () => {
  it("keyboard typing after a restored skill chip is glued to the skill name", async () => {
    // Capture the live ProseMirror view: focusEnd() reveals the caret, which
    // calls coordsAtPos on the view.
    let view: EditorView | null = null;
    vi.spyOn(EditorView.prototype, "coordsAtPos").mockImplementation(
      function (this: EditorView) {
        view = this;
        return { left: 0, right: 0, top: 0, bottom: 16 };
      },
    );
    const { changes, promptBoxRef } = renderRestoredDraft();
    await waitFor(() => expect(promptBoxRef.current).not.toBeNull());
    await act(async () => {
      promptBoxRef.current?.focusEnd();
    });
    await waitFor(() => expect(view).not.toBeNull());
    const liveView = view as unknown as EditorView;

    // The restored draft: text, pill, caret right after the pill (as after
    // ArrowUp history recall or "Edit" on a queued message).
    expect(liveView.state.selection.from).toBe(
      liveView.state.doc.content.size - 1,
    );

    // Type "Also testing" the way the keyboard does: a plain text insertion
    // at the caret.
    await act(async () => {
      liveView.dispatch(liveView.state.tr.insertText("Also testing"));
    });

    const latest = changes[changes.length - 1];
    expect(latest?.value).toBe("I am lost. /poteto-mode Also testing");
  });

  it("insertTextAtCursor (voice / plugin insert) after a skill chip is glued too", async () => {
    const { changes, promptBoxRef } = renderRestoredDraft();
    await waitFor(() => expect(promptBoxRef.current).not.toBeNull());
    await act(async () => {
      promptBoxRef.current?.focusEnd();
    });
    await act(async () => {
      promptBoxRef.current?.insertTextAtCursor("Also testing");
    });
    const latest = changes[changes.length - 1];
    // insertTextAtCursor adds a leading space when the text before the caret
    // does not end in whitespace, but it reads the pill leaf as "\n" via
    // textBetween(..., "\n", "\n"), so it believes a separator is already there.
    expect(latest?.value).toBe("I am lost. /poteto-mode Also testing");
  });
});

Round-trip test (packages/client-core)

// Mechanism evidence for get-bb/bb#2318: the separator the composer inserts
// after a skill chip is dropped when the chip is the last token of the message
// (send-time trim), and every "restore a sent/queued prompt into the editor"
// path rebuilds the draft from that trimmed input.
import { describe, expect, it } from "vitest";
import type { PromptMentionResource } from "@bb/domain";
import {
  promptDraftToInput,
  promptInputToDraft,
} from "../src/prompt/prompt-draft.js";

const SKILL_RESOURCE: PromptMentionResource = {
  kind: "command",
  trigger: "/",
  name: "poteto-mode",
  source: "skill",
  origin: "user",
  label: "poteto-mode",
  argumentHint: null,
};

describe("issue #2318: skill chip at the end of a message", () => {
  it("loses the trailing separator on send and does not get it back on restore", () => {
    // What the composer holds after picking the skill from the menu: the
    // pill plus the trailing space `applyCommandSuggestion` inserts.
    const composerText = "I am lost. /poteto-mode ";
    const input = promptDraftToInput({
      text: composerText,
      mentions: [
        {
          start: "I am lost. ".length,
          end: "I am lost. /poteto-mode".length,
          resource: SKILL_RESOURCE,
        },
      ],
      attachments: [],
    });
    expect(input).toEqual([
      {
        type: "text",
        text: "I am lost. /poteto-mode",
        mentions: [
          {
            start: "I am lost. ".length,
            end: "I am lost. /poteto-mode".length,
            resource: SKILL_RESOURCE,
          },
        ],
      },
    ]);

    // History recall / queued-message edit / sent-message edit rebuild the
    // editor from the sent input: the pill is now the last node and the caret
    // lands directly after it.
    const restored = promptInputToDraft(input);
    expect(restored.text).toBe("I am lost. /poteto-mode");
    expect(restored.text.endsWith(" ")).toBe(false);
  });
});

The input the server forwarded for the first turn (client/turn/requested)

[
 {
  "type": "text",
  "text": "hello /poteto-mode  worldhello /poteto-mode  world",
  "mentions": [
   {
    "start": 6,
    "end": 18,
    "resource": {
     "kind": "command",
     "trigger": "/",
     "name": "poteto-mode",
     "source": "skill",
     "origin": "project",
     "label": "poteto-mode",
     "argumentHint": null
    }
   },
   {
    "start": 31,
    "end": 43,
    "resource": {
     "kind": "command",
     "trigger": "/",
     "name": "poteto-mode",
     "source": "skill",
     "origin": "project",
     "label": "poteto-mode",
     "argumentHint": null
    }
   }
  ]
 }
]

Commands run

pnpm install --frozen-lockfile --prefer-offline
git checkout 494f66526 && pnpm exec turbo run build
scripts/bb-dev-app current            # App :16901, Server :24901, Host daemon :32901
curl -s -X POST http://localhost:24901/api/v1/projects ... (see 4b)
doobie < 2318/repro/doobie-09-full-flow.js          # figures 1-4, first send
doobie < 2318/repro/doobie-11-followup-send-chip-at-end.js   # figure 5
doobie < 2318/repro/doobie-12-history-recall-and-type.js     # figures 6-7
BB_SERVER_URL=http://localhost:24901 BB_HOST_DAEMON_PORT=32901 pnpm bb:dev thread log thr_26invj48xz
BB_SERVER_URL=http://localhost:24901 BB_HOST_DAEMON_PORT=32901 pnpm bb:dev thread log thr_26invj48xz --json > 2318/repro/thread-thr_26invj48xz-events.json
pnpm -C apps/app exec vitest run src/components/promptbox/issue-2318-skill-chip.test.tsx
pnpm -C packages/client-core exec vitest run test/issue-2318-draft-round-trip.test.ts
<bundled claude> -p "hello /poteto-mode world" ... / -p "/poteto-mode hello world" ... (see 4c)
git log --oneline 494f66526..origin/main -- apps/app/src/components/promptbox packages/client-core/src/prompt plugins/provider-claude-code apps/server/src/services/threads apps/server/src/services/skills   # empty
pnpm dev:stop   # cleanup

Notes