Files
orca/src/shared/agent-tui-input-clear.test.ts
Brennan Benson bbb3e7e5ee fix(native-chat): mirror multi-line launch drafts into the chat composer (#11253)
* fix(native-chat): mirror multi-line launch drafts into the chat composer

seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline,
so every Linear launch ("Linked Linear issue: X\n<url>") and any GitHub launch
with a typed note was invisible in chat. The rejection existed because the send
path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with
embedded newlines.

Orca injects the draft itself, so when the composer still holds exactly what was
injected the buffer already IS the message: the send becomes the submit key
alone — no clear, no paste, nothing that can concatenate, and multi-line submits
as one turn for free. Only the edited case needs real buffer replacement, and
that now clears every line and verifies against the agent's rendered input line
instead of firing blind.

Measured on real PTYs against Claude Code and codex (both agree exactly):
clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts
for the law, the sequences that do NOT work, and why an upper bound is safe.

* fix(native-chat): send the mobile clear burst as its own write

Live QA caught the bundled form failing: a multi-line burst prefixed onto the
body in the SAME terminal.send reached the agent as LITERAL Ctrl+U characters,
so the parked draft survived and the message arrived as
draft + 21x \x15 + body. Sending the burst as its own non-submitting write —
the shape the image paste has always used — clears as intended.

The body write's own single-Ctrl+U prefix is dropped once that dedicated clear
ran, for the same reason: a Ctrl+U immediately followed by body text in one
write lands as a literal control character and headed the received message.

Re-verified live end to end: received prompt is exactly the draft, one turn,
zero control characters.

* test(native-chat): invert the multi-line Linear launch-draft mirror expectation

The Linear work-item launch seeds `Linked Linear issue: ENG-42\n<url>\n`.
This test pinned the pre-relaxation rule (multi-line drafts withheld), which
the send path no longer needs now that it submits the TUI buffer in place or
clears every line first — so it asserted the exact behavior the fix removes.

Assert the seeded payload instead of absence, so the test fails if the mirror
regresses to single-line-only.

* fix(native-chat): preserve launch draft send contents

* fix(native-chat): preserve confirmed send queue ordering

* fix(native-chat): preserve send pacing after renderer stalls

* test(native-chat): align activation with multiline draft mirroring

* fix(native-chat): clear launch drafts from any cursor

* fix(native-chat): retire mobile-consumed launch drafts

* test(mobile): stabilize QR capacity boundary fixture
2026-07-30 11:08:56 -07:00

88 lines
3.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
AGENT_TUI_CLEAR_INPUT_FORWARD,
AGENT_TUI_CLEAR_INPUT_LINE,
AGENT_TUI_CLEAR_INPUT_MAX,
AGENT_TUI_CLEAR_LINE_SLACK,
AGENT_TUI_CLEAR_MAX_LINES,
buildAgentTuiClearInput,
buildAgentTuiClearInputForText,
countAgentTuiInputLines
} from './agent-tui-input-clear'
const countCtrlU = (bytes: string): number =>
bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_LINE).length
const countCtrlK = (bytes: string): number =>
bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_FORWARD).length
describe('buildAgentTuiClearInput', () => {
// The measured law: N kills + (N-1) joins. A constant here silently under-clears
// (5 clears 3 lines but leaves residue at 4), which is what glues onto the next
// message — so pin every small N, not just one.
it.each([
[1, 1],
[2, 3],
[3, 5],
[4, 7],
[10, 19]
])('clears %i logical lines with %i Ctrl+U', (lines, expected) => {
const clearInput = buildAgentTuiClearInput(lines)
expect(countCtrlU(clearInput)).toBe(expected)
expect(countCtrlK(clearInput)).toBe(expected)
})
it('clears before the cursor before clearing the suffix after it', () => {
expect(buildAgentTuiClearInput(4)).toBe(
AGENT_TUI_CLEAR_INPUT_LINE.repeat(7) + AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(7)
)
})
it('still clears one line for a zero or negative count', () => {
expect(countCtrlU(buildAgentTuiClearInput(0))).toBe(1)
expect(countCtrlU(buildAgentTuiClearInput(-5))).toBe(1)
})
it('caps the burst so a pathological draft cannot emit an unbounded write', () => {
expect(countCtrlU(buildAgentTuiClearInput(10_000))).toBe(2 * AGENT_TUI_CLEAR_MAX_LINES - 1)
expect(AGENT_TUI_CLEAR_INPUT_MAX).toBe(buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES))
})
})
describe('countAgentTuiInputLines', () => {
it.each([
['one line', 1],
['a\nb', 2],
['a\r\nb\r\nc', 3],
['a\rb', 2],
['trailing\n', 2]
])('counts %j as %i logical lines', (text, expected) => {
expect(countAgentTuiInputLines(text)).toBe(expected)
})
it('ignores visual wrapping — only logical newlines cost a Ctrl+U', () => {
expect(countAgentTuiInputLines('x'.repeat(5_000))).toBe(1)
})
})
describe('buildAgentTuiClearInputForText', () => {
it('sizes the burst from the text plus slack for TUI-side edits', () => {
// The injected text is a LOWER bound: the user can type into the TUI line too.
expect(countCtrlU(buildAgentTuiClearInputForText('a\nb'))).toBe(
2 * (2 + AGENT_TUI_CLEAR_LINE_SLACK) - 1
)
})
it('clears strictly more than the draft needs, never less', () => {
const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123\n'
expect(countCtrlU(buildAgentTuiClearInputForText(draft))).toBeGreaterThan(
2 * countAgentTuiInputLines(draft) - 1
)
})
it('a long wrapped single line does not inflate the burst', () => {
expect(buildAgentTuiClearInputForText('y'.repeat(5_000))).toBe(
buildAgentTuiClearInputForText('y')
)
})
})