mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user