| author | |
| committer | |
| log | cdb26fe63549df633751ab0eed145b9357b30a21 |
| tree | 857f6bbbd1585cf6aaac0359e98432faff8023fd |
| parent | 87d84280192195b338e27acf2333771a13d04e61 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>2 files changed, 61 insertions(+), 1 deletions(-)
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+38| ... | @@ -775,6 +775,44 @@ it.layer(layer)("AntigravityAdapter", (it) => { | ... | @@ -775,6 +775,44 @@ it.layer(layer)("AntigravityAdapter", (it) => { |
| 775 | }), | 775 | }), |
| 776 | ); | 776 | ); |
| 777 | 777 | ||
| 778 | it.effect("stops commands left running after a turn when the idle turn is stopped", () => | ||
| 779 | Effect.gen(function* () { | ||
| 780 | const h = yield* makeHarness(); | ||
| 781 | yield* h.adapter.startSession({ | ||
| 782 | threadId, | ||
| 783 | cwd: process.cwd(), | ||
| 784 | runtimeMode: "approval-required", | ||
| 785 | }); | ||
| 786 | const sending = yield* h.adapter | ||
| 787 | .sendTurn({ threadId, input: "Start a watcher" }) | ||
| 788 | .pipe(Effect.forkChild); | ||
| 789 | const prompt = yield* h.nextPrompt; | ||
| 790 | yield* h.emitNative({ | ||
| 791 | _tag: "ToolCallUpdated", | ||
| 792 | toolCall: { | ||
| 793 | toolCallId: "watcher-1", | ||
| 794 | kind: "execute", | ||
| 795 | status: "inProgress", | ||
| 796 | command: "tail -f log", | ||
| 797 | data: {}, | ||
| 798 | }, | ||
| 799 | rawPayload: {}, | ||
| 800 | }); | ||
| 801 | yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); | ||
| 802 | yield* Fiber.join(sending); | ||
| 803 | const started = yield* h.waitForEvent((event) => event.type === "task.started"); | ||
| 804 | |||
| 805 | // Monitoring's Stop reaches the adapter as a turn interrupt. With no | ||
| 806 | // prompt to cancel, it has to end the session to stop the command. | ||
| 807 | yield* h.adapter.interruptTurn(threadId); | ||
| 808 | const stopped = yield* h.waitForEvent((event) => event.type === "task.completed"); | ||
| 809 | expect(stopped.payload).toMatchObject({ taskId: started.payload.taskId, status: "stopped" }); | ||
| 810 | yield* h.waitForEvent((event) => event.type === "session.exited"); | ||
| 811 | expect(yield* h.adapter.hasSession(threadId)).toBe(false); | ||
| 812 | expect(h.controls.closed).toBe(1); | ||
| 813 | }), | ||
| 814 | ); | ||
| 815 | |||
| 778 | it.effect("keeps a launched batch active while child tools continue", () => | 816 | it.effect("keeps a launched batch active while child tools continue", () => |
| 779 | Effect.gen(function* () { | 817 | Effect.gen(function* () { |
| 780 | const h = yield* makeHarness(); | 818 | const h = yield* makeHarness(); |
apps/server/src/provider/Layers/AntigravityAdapter.ts+23-1| ... | @@ -1165,14 +1165,36 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi | ... | @@ -1165,14 +1165,36 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi |
| 1165 | const interruptTurn: Adapter["interruptTurn"] = (threadId) => | 1165 | const interruptTurn: Adapter["interruptTurn"] = (threadId) => |
| 1166 | Effect.gen(function* () { | 1166 | Effect.gen(function* () { |
| 1167 | const context = yield* requireSession(threadId); | 1167 | const context = yield* requireSession(threadId); |
| 1168 | // A command that outlived its turn keeps running in the agent, and | ||
| 1169 | // session/cancel only stops a prompt. The agent kills its background | ||
| 1170 | // commands when its session closes, so Stop with nothing else running | ||
| 1171 | // ends the session, as Claude's does. The next turn resumes it. | ||
| 1172 | let idleWithCommands = false; | ||
| 1168 | yield* context.promptLock | 1173 | yield* context.promptLock |
| 1169 | .withPermit( | 1174 | .withPermit( |
| 1170 | Effect.gen(function* () { | 1175 | Effect.gen(function* () { |
| 1176 | // Decided under the prompt lock so a turn cannot start in between. | ||
| 1177 | if (!context.promptFiber && [...context.commands.values()].some((c) => c.promoted)) { | ||
| 1178 | context.stopped = true; | ||
| 1179 | idleWithCommands = true; | ||
| 1180 | return; | ||
| 1181 | } | ||
| 1171 | yield* cancelRequests(context); | 1182 | yield* cancelRequests(context); |
| 1172 | yield* context.runtime.cancel; | 1183 | yield* context.runtime.cancel; |
| 1173 | }), | 1184 | }), |
| 1174 | ) | 1185 | ) |
| 1175 | .pipe(Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause))); | 1186 | .pipe( |
| 1187 | Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause)), | ||
| 1188 | // Once marked stopped the session must close, even if this call is | ||
| 1189 | // interrupted, or it is left unreachable with its commands running. | ||
| 1190 | Effect.ensuring( | ||
| 1191 | Effect.suspend(() => | ||
| 1192 | idleWithCommands | ||
| 1193 | ? withThreadLock(threadId, stopContext(context)).pipe(Effect.ignore) | ||
| 1194 | : Effect.void, | ||
| 1195 | ), | ||
| 1196 | ), | ||
| 1197 | ); | ||
| 1176 | }); | 1198 | }); |
| 1177 | 1199 | ||
| 1178 | const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => | 1200 | const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => |