fix: allow follow-up messages after cancelling remote OpenCode questions
Cancelling a managed SSH prompt while answering a question could leave the conversation bound to its previous operation and reject the next message. Record the Agent cancellation outcome and reconcile and close the desktop binding before the next prompt.
Add two cancellation regressions and document real SSH/Electron question validation across eight theme and viewport combinations. Local queues and remote question routing are already included in 3b560c9.
Validation: prior focused checks passed 275 tests with 5 skipped; typecheck and lint passed. The final full run passed 4087 tests with 66 skipped and one packaging timeout; that packaging file separately passed 22 tests with 1 skipped. Staged diff whitespace check passed.
Release note: After cancelling a pending OpenCode question on a managed SSH task, you can send another message in the same conversation without an operation identity error.
This commit is contained in:
@@ -1337,6 +1337,25 @@ describe('RuntimeAcpBackend', () => {
|
||||
).toEqual(['offline'])
|
||||
})
|
||||
|
||||
it('retains the owned cancellation outcome when the semantic terminal already exists', async () => {
|
||||
const fixture = harness({ agentOwned: true })
|
||||
let terminalWritten = false
|
||||
fixture.semanticPrompts!.append.mockImplementation(() => {
|
||||
if (terminalWritten) throw new Error('Prompt is already terminal')
|
||||
terminalWritten = true
|
||||
})
|
||||
await open(fixture)
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation())
|
||||
await invoke(fixture, 'runtime/escalateCancellation', {
|
||||
bindingId: 'binding-1', sessionId: 'session-1',
|
||||
operationId: 'request-1', requestId: 'request-1', reason: 'requested'
|
||||
})
|
||||
await expect(invoke(fixture, 'runtime/reconcilePrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1'
|
||||
})).resolves.toEqual({ status: 'terminal', terminalState: 'cancelled', processTree: 'empty' })
|
||||
await fixture.backend.dispose()
|
||||
})
|
||||
|
||||
it('closes an idle model bridge on detach without poisoning or stopping Runtime work', async () => {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
|
||||
@@ -1847,20 +1847,11 @@ export class RuntimeAcpBackend {
|
||||
'Runtime prompt cancellation timed out'
|
||||
).catch(() => undefined)
|
||||
}
|
||||
try {
|
||||
this.#options.semanticPrompts.append({
|
||||
bindingId: binding.request.bindingId,
|
||||
operationId: binding.activeOperationId,
|
||||
kind: 'prompt-terminal',
|
||||
payload: {
|
||||
status: 'cancelled',
|
||||
reason: escalation.reason
|
||||
},
|
||||
terminalState: 'cancelled'
|
||||
})
|
||||
} catch {
|
||||
// A concurrently completed prompt already committed terminal evidence.
|
||||
}
|
||||
this.#appendAbnormalOwnedPromptTerminal(binding, {
|
||||
status: 'cancelled',
|
||||
name: 'PromptCancelled',
|
||||
message: 'Prompt execution was cancelled'
|
||||
})
|
||||
}
|
||||
await this.#stopAndReconcile(binding, 'user-cancelled')
|
||||
return { bindingId: escalation.bindingId, stopped: true }
|
||||
|
||||
@@ -3115,6 +3115,25 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles an owned cancellation before the next prompt in the same conversation', async () => {
|
||||
const fixture = ownedChannel()
|
||||
fixture.pageOwnedPromptTranscript.mockImplementationOnce(() => new Promise<never>(() => {}))
|
||||
const controller = new AbortController()
|
||||
const stream = fixture.instance.run(request, controller.signal)
|
||||
await stream.next()
|
||||
const pending = stream.next()
|
||||
await vi.waitFor(() => expect(fixture.pageOwnedPromptTranscript).toHaveBeenCalledOnce())
|
||||
controller.abort(new Error('cancel pending question'))
|
||||
await expect(pending).rejects.toThrow('cancel pending question')
|
||||
expect(await fixture.store.getByConversation(request.conversationId)).toBeUndefined()
|
||||
const events = await collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'after-cancel' }, new AbortController().signal
|
||||
))
|
||||
expect(events.some(event => event.type === 'done')).toBe(true)
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledTimes(2)
|
||||
await fixture.instance.dispose()
|
||||
})
|
||||
|
||||
it('waits for the in-flight Agent cancellation before releasing its lease', async () => {
|
||||
let releaseEscalation!: () => void
|
||||
const escalation = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -2752,6 +2752,20 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
? abortReason(signal)
|
||||
: new Error('远端 Runtime 事件消费提前结束')
|
||||
)
|
||||
// Cancellation stops transcript polling, so reconcile before reusing
|
||||
// this session for another prompt.
|
||||
if (signal.aborted) {
|
||||
const reconciled = await this.reconcilePromptOperation(
|
||||
context,
|
||||
binding
|
||||
)
|
||||
session.binding = reconciled.terminal
|
||||
? await this.persistClosedBinding(reconciled.binding)
|
||||
: reconciled.binding
|
||||
this.sessions.delete(request.conversationId)
|
||||
this.contexts.delete(request.conversationId)
|
||||
await this.closeContext(context).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
signal.removeEventListener('abort', cancel)
|
||||
prompt.open = false
|
||||
|
||||
Reference in New Issue
Block a user