From 7746d71bbcb9f7b4e62ee9be960ff5ff4bd3f8b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:32:57 -0700 Subject: [PATCH] fix(native-chat): match a command token, not any slash-leading text Two holes left by the previous round's "no second slash" rule: `/README.md - what does this do?` has no second slash, so it still opted into the result-frame path where an unrelated turn's result can claim the send's identity. A command token has no dot. And trimming leading whitespace contradicted `classifyNativeChatSend`, which deliberately does not trim: "the supported TUIs only treat a line-leading token as a command, so a draft with leading whitespace is prose". ` /clear` was prose to the composer and a command to dispatch. Match a command token instead: `/model`, `/clear`, `/project:foo`. Deliberately stricter than the shared classifier, and the comment says why - over-classifying only suppresses an echo there, but claims a wrong identity here. Found by the review loop on #15657. --- .../claude/claude-structured-dispatch.test.ts | 26 +++++++++++++++++++ src/main/claude/claude-structured-dispatch.ts | 19 ++++++++------ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 6e38f456e57..2e0f9f2fb57 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -234,6 +234,32 @@ describe('Claude structured dispatch image limits', () => { }) }) + it.each([ + ['/README.md - what does this do?', 'a bare filename'], + [' /clear', 'an indented command, which the TUI reads as prose'] + ])('keeps %s off the result path (%s)', async (text) => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text }]) }, + ACK_BUDGET_MS + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'unrelated-turn-result' + }) + expect(session.dispatchWaiters).toHaveLength(1) + + resolveClaudeReplayWaiter(session, userReplayFrame('real-replay', text)) + await expect(dispatched).resolves.toMatchObject({ + providerIdentity: { uuid: 'real-replay' } + }) + }) + // The waiter queue is positional, so shifting the head on a send failure // resolves whichever dispatch happens to be first - reporting a delivered // message as unconfirmed while the send that actually failed keeps waiting. diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index cb1aaa82324..870efa04a4b 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -185,22 +185,25 @@ function waitForReplay( return { waiter, replayed } } +// A command token: `/model`, `/clear`, `/project:foo`. Not `/Users/me/foo.ts` +// (slash) and not `/README.md` (dot). +const SLASH_COMMAND_TOKEN = /^\/[a-z0-9][\w:-]*$/i + /** * A slash command, not a message that merely opens with a path. * * `acceptsResult` lets a send settle on a bare `result` frame, which carries no * correlation to the dispatch that is waiting - so a false positive lets an * unrelated turn's result claim the send's identity, and the real echo then - * appends a second row. `/Users/me/repo/foo.ts - explain this` is an ordinary - * message; the second slash is what gives it away. + * appends a second row. This is stricter than `classifyNativeChatSend`, which + * treats any `/`-leading token as command-ish: over-classifying is safe there + * (it only suppresses an echo) and unsafe here. + * + * No trimming, matching that classifier's documented rule: the supported TUIs + * only treat a LINE-LEADING token as a command, so ` /clear` is prose. */ function isSlashCommandText(text: string): boolean { - const trimmed = text.trimStart() - if (!trimmed.startsWith('/')) { - return false - } - const token = trimmed.slice(1).split(/\s/, 1)[0] ?? '' - return token.length > 0 && !token.includes('/') + return SLASH_COMMAND_TOKEN.test(text.split(/\s/, 1)[0] ?? '') } export async function dispatchClaudeTurn(