mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(orchestration): submit mail pointers to Cursor CLI instead of leaving them in the composer (#33347)
Orchestration mailbox pointers now get their delayed Enter for Cursor too (the Cursor no-Enter skip from #3156 left mail delivered but never acted on). Verified with real cursor-agent. Fixes #19760. Supersedes #19761. Co-authored-by: Maple <54388301+zleo-ai@users.noreply.github.com>
This commit is contained in:
@@ -246,7 +246,6 @@ export abstract class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWith
|
||||
isAgentSettledForDelivery: (leaf) => this.checkDeliverySettledAndArmRecheck(leaf),
|
||||
isPtyAwaitingUserInput: (ptyId) => this.isPtyAwaitingUserInput(ptyId),
|
||||
getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle),
|
||||
getTabTitle: (tabId) => this.tabs.get(tabId)?.title,
|
||||
getCliCommand: (terminalHandle) => this.getTerminalOrchestrationCliCommand(terminalHandle),
|
||||
getTerminalHandleForLeafKey: (leafKey) => this.handleByLeafKey.get(leafKey),
|
||||
resolveSubmitTarget: (leaf, ptyId) => this.resolveOrchestrationPointerSubmitTarget(leaf, ptyId),
|
||||
|
||||
@@ -579,7 +579,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('injects pending orchestration messages into Cursor Agent without auto-submitting', async () => {
|
||||
it('submits the mail pointer to Cursor Agent only after the paste-burst delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
@@ -606,11 +606,12 @@ describe('OrcaRuntimeService', () => {
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const submitWrites = write.mock.calls.filter(
|
||||
([ptyId, text]) => ptyId === 'pty-1' && text === '\r'
|
||||
)
|
||||
expect(submitWrites).toHaveLength(0)
|
||||
const isSubmit = ([ptyId, text]: unknown[]): boolean => ptyId === 'pty-1' && text === '\r'
|
||||
// Why: Cursor reads Enter within a 35ms keystroke burst as a newline, so it must trail the pointer.
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(write.mock.calls.filter(isSubmit)).toHaveLength(0)
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(write.mock.calls.filter(isSubmit)).toHaveLength(1)
|
||||
|
||||
const unread = db.getUnreadMessages(mailbox)
|
||||
expect(unread).toHaveLength(1)
|
||||
@@ -622,41 +623,6 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('still auto-submits to a non-Cursor agent when its idle title mentions Cursor Agent', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const db = new InMemoryOrchestrationMessages()
|
||||
const write = vi.fn().mockReturnValue(true)
|
||||
setInMemoryOrchestrationMessages(runtime, db)
|
||||
runtime.setPtyController({
|
||||
write,
|
||||
writeWithSettlement: settledWriteStub(write),
|
||||
kill: vi.fn(),
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
syncSinglePty(runtime, 'pty-1', { tabTitle: 'cursor-repro-branch' })
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
bindSinglePtyRun(db, terminal.handle)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;. Investigate Cursor Agent\x07', 100)
|
||||
runtime.onPtyData('pty-1', '\x1b]0;* Investigate Cursor Agent\x07', 101)
|
||||
db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello claude' })
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(terminal.handle)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
||||
db.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not replay an already-delivered message on a later idle transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -26,7 +26,6 @@ export type PointerDeliveryDependencies<TWaiter extends OrchestrationMessageWait
|
||||
isAgentSettledForDelivery: (leaf: OrchestrationMailboxLeaf) => boolean
|
||||
isPtyAwaitingUserInput: (ptyId: string) => boolean
|
||||
getMessageWaiters: (mailboxHandle: string) => ReadonlySet<TWaiter> | undefined
|
||||
getTabTitle: (tabId: string) => string | null | undefined
|
||||
getCliCommand: (terminalHandle: string) => OrchestrationCliCommand
|
||||
getTerminalHandleForLeafKey: (leafKey: string) => string | undefined
|
||||
resolveSubmitTarget: (
|
||||
|
||||
@@ -19,7 +19,10 @@ const LEAF = {
|
||||
lastOscTitle: null
|
||||
}
|
||||
|
||||
function pointerDeps(db: OrchestrationDb, writePty: () => WriteSettlement) {
|
||||
function pointerDeps(
|
||||
db: OrchestrationDb,
|
||||
writePty: (ptyId: string, data: string) => WriteSettlement
|
||||
) {
|
||||
return {
|
||||
mailboxOwner: { resolve: () => 'run:run-1' },
|
||||
deliveryTarget: { resolveTerminalHandle: () => 'term-1', deferForAbsenceProbe: () => false },
|
||||
@@ -30,7 +33,6 @@ function pointerDeps(db: OrchestrationDb, writePty: () => WriteSettlement) {
|
||||
// These cases exercise staging and Enter phases, not the idle gate; the pane is settled.
|
||||
isAgentSettledForDelivery: () => true,
|
||||
getMessageWaiters: () => undefined,
|
||||
getTabTitle: () => null,
|
||||
getCliCommand: () => 'orca' as const,
|
||||
getTerminalHandleForLeafKey: () => 'term-1',
|
||||
resolveSubmitTarget: () => ({
|
||||
@@ -204,6 +206,26 @@ describe('mailbox pointer staging watermark', () => {
|
||||
expect(writePty.mock.calls.length).toBeGreaterThan(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
// Why: a pointer left unsubmitted in Cursor's composer stacks up and rides along with the user's next prompt.
|
||||
it('submits the pointer with Enter for a Cursor Agent pane', async () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const message = db.insertMessage({ from: 'a', to: 'run:run-1', subject: 'mail' })
|
||||
const writePty = vi.fn((_ptyId: string, _data: string) => WRITE_ACCEPTED)
|
||||
const state = new OrchestrationMailboxPointerState()
|
||||
const cursorLeaf = { ...LEAF, lastOscTitle: 'Cursor Agent' }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies only the deps the staging path reads.
|
||||
stageOrchestrationMailboxPointer({
|
||||
...stageArgs(db, state),
|
||||
deps: pointerDeps(db, writePty),
|
||||
leaf: cursorLeaf,
|
||||
messages: [{ id: message.id, type: 'status', sequence: 1 }]
|
||||
} as never)
|
||||
|
||||
await vi.waitFor(() => expect(writePty).toHaveBeenLastCalledWith('pty-1', '\r'))
|
||||
expect(writePty.mock.calls[0]?.[1]).toContain('You have 1 orchestration message')
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('retiring a pty mid-delivery', () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isCursorAgentTitle } from '../../../shared/agent-detection'
|
||||
import { formatMessagePointer } from './formatter'
|
||||
import type {
|
||||
OrchestrationMailboxPointerMessage,
|
||||
@@ -147,16 +146,6 @@ function finishPointerWriteAndStageEnter<TWaiter extends OrchestrationMessageWai
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
[args.leaf.lastOscTitle, args.leaf.paneTitle, args.deps.getTabTitle(args.leaf.tabId)].some(
|
||||
isCursorAgentTitle
|
||||
)
|
||||
) {
|
||||
db.markAsDelivered(flight.stagedMessageIds, args.mailboxHandle)
|
||||
args.state.clearWatermark(args.mailboxHandle, args.newestSequence, ptyId)
|
||||
args.redrive(args.mailboxHandle)
|
||||
return
|
||||
}
|
||||
const submitEnter = (): void =>
|
||||
submitOrchestrationMailboxPointer(
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ import path from 'node:path'
|
||||
/** `detectAgentStatusFromTitle` reads these as agent-name + strong keyword. */
|
||||
export const CODEX_IDLE_TITLE = 'Codex done'
|
||||
export const CODEX_WORKING_TITLE = 'Codex working'
|
||||
/** Also satisfies `isCursorAgentTitle`, which suppresses the synthesized Enter. */
|
||||
/** Cursor Agent idle title (`isCursorAgentTitle`). */
|
||||
export const CURSOR_IDLE_TITLE = 'Cursor Ready'
|
||||
|
||||
export type AgentLedgerEntry = {
|
||||
|
||||
@@ -627,25 +627,22 @@ test.describe('orchestration push-on-idle mail delivery', () => {
|
||||
await expectSubmitted(pane)
|
||||
})
|
||||
|
||||
test('writes the pointer but never Enter for a Cursor agent pane', async ({
|
||||
test('writes and submits the pointer for a Cursor agent pane', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}) => {
|
||||
test.setTimeout(180_000)
|
||||
const { client, openAgentPane } = await setUpMailFixture(orcaPage, electronApp)
|
||||
const pane = await openAgentPane()
|
||||
// Cursor treats injected PTY text as editable prompt content, so submitting
|
||||
// has to stay under user control there too.
|
||||
pane.agent.setTitle(CURSOR_IDLE_TITLE)
|
||||
await waitForObservedTitle(client, pane.handle, CURSOR_IDLE_TITLE)
|
||||
const mailbox = await createRunMailbox(client, pane, 'Cursor no-submit')
|
||||
const mailbox = await createRunMailbox(client, pane, 'Cursor pointer submit')
|
||||
|
||||
const subject = 'Cursor no-submit'
|
||||
const subject = 'Cursor pointer submit'
|
||||
await sendMail(client, mailbox, { subject })
|
||||
|
||||
await expectPointed(pane)
|
||||
await orcaPage.waitForTimeout(2_000)
|
||||
expectNotSubmitted(pane)
|
||||
await expectSubmitted(pane)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user