-
Notifications
You must be signed in to change notification settings - Fork 5.7k
fix(antigravity): increase cancel timeout and ignore transport errors during prompt drainage #11626
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
13d1024
90956a4
4865ad9
bafa26c
3c761ea
40d4595
bf30119
c9d42f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,52 @@ const mockRuntimeOptions = { | |
| authMethodId: "test", | ||
| } satisfies AcpSessionRuntime.AcpSessionRuntimeOptions; | ||
|
|
||
| describe("isPromptCancellationError", () => { | ||
| it("identifies cancel and abort error messages as cancellations", () => { | ||
| expect( | ||
| AcpSessionRuntime.isPromptCancellationError( | ||
| new EffectAcpErrors.AcpRequestError({ | ||
| code: -32000, | ||
| errorMessage: "context canceled: The request was canceled by the client.", | ||
| }), | ||
| ), | ||
| ).toBe(true); | ||
| expect( | ||
| AcpSessionRuntime.isPromptCancellationError( | ||
| new EffectAcpErrors.AcpRequestError({ | ||
| code: -32000, | ||
| errorMessage: "The operation was aborted", | ||
| }), | ||
| ), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("does not classify authRequired (-32000) or other non-cancellation errors as cancellation", () => { | ||
| expect( | ||
| AcpSessionRuntime.isPromptCancellationError( | ||
| EffectAcpErrors.AcpRequestError.authRequired("Authentication required"), | ||
| ), | ||
| ).toBe(false); | ||
| expect( | ||
| AcpSessionRuntime.isPromptCancellationError( | ||
| new EffectAcpErrors.AcpRequestError({ | ||
| code: -32000, | ||
| errorMessage: "Authentication required", | ||
| }), | ||
| ), | ||
| ).toBe(false); | ||
| expect( | ||
| AcpSessionRuntime.isPromptCancellationError( | ||
| new EffectAcpErrors.AcpRequestError({ | ||
| code: -32603, | ||
| errorMessage: "Internal server error", | ||
| }), | ||
| ), | ||
| ).toBe(false); | ||
| expect(AcpSessionRuntime.isPromptCancellationError(new Error("boom"))).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("AcpSessionRuntime", () => { | ||
| for (const setupMethod of ["session/new", "session/resume"] as const) { | ||
| it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () => | ||
|
|
@@ -221,6 +267,192 @@ describe("AcpSessionRuntime", () => { | |
| }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| ); | ||
|
|
||
| it.effect( | ||
| "drains active prompt successfully when session/cancel notification fails with transport error", | ||
| () => | ||
| Effect.gen(function* () { | ||
| const toolStarted = yield* Deferred.make<void>(); | ||
| const cancelFailed = yield* Deferred.make<void>(); | ||
| let promptRequests = 0; | ||
| const events: Array<AcpSessionRuntime.AcpSessionRuntimeEvent> = []; | ||
| const runtime = yield* AcpSessionRuntime.make({ | ||
| ...mockRuntimeOptions, | ||
| spawn: { | ||
| ...mockRuntimeOptions.spawn, | ||
| env: { | ||
| T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1", | ||
| }, | ||
| }, | ||
| protocolLogging: { | ||
| logOutgoing: true, | ||
| logger: (event) => { | ||
| if ( | ||
| event.direction === "outgoing" && | ||
| typeof event.payload === "object" && | ||
| event.payload !== null && | ||
| "_tag" in event.payload && | ||
| event.payload._tag === "Notification" && | ||
| "tag" in event.payload && | ||
| event.payload.tag === "session/cancel" | ||
| ) { | ||
| return Deferred.succeed(cancelFailed, undefined).pipe( | ||
| Effect.andThen( | ||
| Effect.fail( | ||
| new EffectAcpErrors.AcpTransportError({ | ||
| operation: "call-rpc", | ||
| method: "session/cancel", | ||
| detail: "Broken pipe", | ||
| cause: undefined, | ||
| }), | ||
| ), | ||
| ), | ||
| ) as unknown as Effect.Effect<void, never>; | ||
| } | ||
| return Effect.void; | ||
| }, | ||
| }, | ||
| cancelBehavior: "wait-for-prompt", | ||
| requestLogger: (event) => | ||
| Effect.sync(() => { | ||
| if (event.method === "session/prompt" && event.status === "started") | ||
| promptRequests += 1; | ||
| }), | ||
| }); | ||
| yield* runtime.getEvents().pipe( | ||
| Stream.runForEach((event) => { | ||
| if (event._tag === "EventStreamBarrier") { | ||
| return Deferred.succeed(event.acknowledge, undefined); | ||
| } | ||
| events.push(event); | ||
| if (event._tag === "ToolCallUpdated" && event.toolCall.status === "inProgress") { | ||
| return Deferred.succeed(toolStarted, undefined); | ||
| } | ||
| return Effect.void; | ||
| }), | ||
| Effect.forkChild, | ||
| ); | ||
| yield* runtime.start(); | ||
| const prompt = yield* runtime | ||
| .prompt({ | ||
| prompt: [{ type: "text", text: "first" }], | ||
| }) | ||
| .pipe(Effect.forkChild); | ||
| yield* Deferred.await(toolStarted); | ||
| const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); | ||
| yield* Deferred.await(cancelFailed); | ||
| const replacement = yield* runtime | ||
| .prompt({ | ||
| prompt: [{ type: "text", text: "second" }], | ||
| }) | ||
| .pipe(Effect.forkChild({ startImmediately: true })); | ||
|
|
||
| expect(prompt.pollUnsafe()).toBeUndefined(); | ||
| expect(cancellation.pollUnsafe()).toBeUndefined(); | ||
| expect(promptRequests).toBe(1); | ||
| yield* runtime.request("_test/finish-cancel", {}); | ||
| yield* Fiber.join(cancellation); | ||
|
|
||
| expect(yield* Fiber.join(prompt)).toEqual({ | ||
| stopReason: "cancelled", | ||
| _meta: { nativeCancel: true }, | ||
| }); | ||
| expect( | ||
| events.some( | ||
| (event) => | ||
| event._tag === "ToolCallUpdated" && | ||
| event.toolCall.status === "failed" && | ||
| event.toolCall.detail === "Cancelled.", | ||
| ), | ||
| ).toBe(true); | ||
| const cancelledDelta = events.find( | ||
| (event) => event._tag === "ContentDelta" && event.text === "Request cancelled.", | ||
| ); | ||
| expect(cancelledDelta?._tag).toBe("ContentDelta"); | ||
| if (cancelledDelta?._tag === "ContentDelta") { | ||
| expect( | ||
| events.filter( | ||
| (event) => | ||
| event._tag === "AssistantItemCompleted" && event.itemId === cancelledDelta.itemId, | ||
| ), | ||
| ).toHaveLength(1); | ||
| } | ||
| expect(yield* Fiber.join(replacement)).toMatchObject({ stopReason: "end_turn" }); | ||
| expect(promptRequests).toBe(2); | ||
| }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| ); | ||
|
|
||
| it.effect( | ||
| "drains active prompt successfully when agent cancels by failing in-flight prompt with context canceled", | ||
| () => | ||
| Effect.gen(function* () { | ||
| const toolStarted = yield* Deferred.make<void>(); | ||
| let promptRequests = 0; | ||
| const events: Array<AcpSessionRuntime.AcpSessionRuntimeEvent> = []; | ||
| const runtime = yield* AcpSessionRuntime.make({ | ||
| ...mockRuntimeOptions, | ||
| spawn: { | ||
| ...mockRuntimeOptions.spawn, | ||
| env: { | ||
| T3_ACP_FAIL_PROMPT_ON_CANCEL: "1", | ||
| }, | ||
|
Comment on lines
+395
to
+397
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fresh final-state evidence is that this test now injects only AGENTS.md reference: AGENTS.md:L109-L109 Useful? React with 👍 / 👎. |
||
| }, | ||
| cancelBehavior: "wait-for-prompt", | ||
| requestLogger: (event) => | ||
| Effect.sync(() => { | ||
| if (event.method === "session/prompt" && event.status === "started") | ||
| promptRequests += 1; | ||
| }), | ||
| }); | ||
| yield* runtime.getEvents().pipe( | ||
| Stream.runForEach((event) => { | ||
| if (event._tag === "EventStreamBarrier") { | ||
| return Deferred.succeed(event.acknowledge, undefined); | ||
| } | ||
| events.push(event); | ||
| if (event._tag === "ToolCallUpdated" && event.toolCall.status === "inProgress") { | ||
| return Deferred.succeed(toolStarted, undefined); | ||
| } | ||
| return Effect.void; | ||
| }), | ||
| Effect.forkChild, | ||
| ); | ||
| yield* runtime.start(); | ||
| const prompt = yield* runtime | ||
| .prompt({ | ||
| prompt: [{ type: "text", text: "first" }], | ||
| }) | ||
| .pipe(Effect.forkChild); | ||
| yield* Deferred.await(toolStarted); | ||
| const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); | ||
| const replacement = yield* runtime | ||
| .prompt({ | ||
| prompt: [{ type: "text", text: "second" }], | ||
| }) | ||
| .pipe(Effect.forkChild({ startImmediately: true })); | ||
|
|
||
| expect(prompt.pollUnsafe()).toBeUndefined(); | ||
| expect(cancellation.pollUnsafe()).toBeUndefined(); | ||
| expect(promptRequests).toBe(1); | ||
| yield* runtime.request("_test/finish-cancel", {}); | ||
| yield* Fiber.join(cancellation); | ||
|
|
||
| expect(yield* Fiber.join(prompt)).toEqual({ | ||
| stopReason: "cancelled", | ||
| }); | ||
| expect( | ||
| events.some( | ||
| (event) => | ||
| event._tag === "ToolCallUpdated" && | ||
| event.toolCall.toolCallId === "native-cancel-tool" && | ||
| event.toolCall.status === "failed" && | ||
| event.toolCall.detail === "Cancelled.", | ||
| ), | ||
| ).toBe(true); | ||
| expect(yield* Fiber.join(replacement)).toMatchObject({ stopReason: "end_turn" }); | ||
| expect(promptRequests).toBe(2); | ||
| }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| ); | ||
|
|
||
| it.effect("retires a process when native cancellation times out", () => | ||
| Effect.gen(function* () { | ||
| const toolStarted = yield* Deferred.make<void>(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.