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.
This commit is contained in:
Brennan Benson
2026-08-20 19:32:57 -07:00
parent 279db7d6bc
commit 7746d71bbc
2 changed files with 37 additions and 8 deletions
@@ -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.
+11 -8
View File
@@ -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(