Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/features/chat/lib/sessionTargetTransition.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ vi.mock("@/shared/api/acpApi", () => ({
vi.mock("@/shared/api/acpConnection", () => ({
getClient: () => mockGetClient(),
getBackendClient: () => mockGetClient(),
captureBackendConnectionGeneration: () => ({
isCurrent: () => true,
invalidate: async () => {},
}),
}));

const managedRuntimeConfig: RuntimeConfig = {
Expand Down Expand Up @@ -118,12 +122,16 @@ describe("transitionSessionTarget with managed Goose models", () => {
expect(mockSetProvider).toHaveBeenCalledWith(
"legacy-session",
"databricks_v2",
{ requestId: undefined },
{ requestId: undefined, assertActive: expect.any(Function) },
);
expect(mockSetModel).toHaveBeenCalledWith(
"legacy-session",
"goose-gpt-5-5",
{ providerId: "databricks_v2", requestId: undefined },
{
providerId: "databricks_v2",
requestId: undefined,
assertActive: expect.any(Function),
},
);
expect(mockSetModel).not.toHaveBeenCalledWith("legacy-session", "goose");
});
Expand Down Expand Up @@ -297,13 +305,17 @@ describe("transitionSessionTarget with managed Goose models", () => {
expect(mockSetProvider).toHaveBeenCalledWith(
"managed-opus-session",
"databricks_v2",
{ requestId: undefined },
{ requestId: undefined, assertActive: expect.any(Function) },
);
expect(mockSetModel).toHaveBeenCalledOnce();
expect(mockSetModel).toHaveBeenCalledWith(
"managed-opus-session",
"goose-claude-opus-4-8",
{ providerId: "databricks_v2", requestId: undefined },
{
providerId: "databricks_v2",
requestId: undefined,
assertActive: expect.any(Function),
},
);
});
});
79 changes: 79 additions & 0 deletions src/features/security/acp/securityPermissionHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,83 @@ describe("security permission explanation fallback", () => {

useSecurityConfirmationStore.getState().cancel("external-agent-session");
});

it("does not enqueue a request from an already retired generation", async () => {
const lifetime = new AbortController();
lifetime.abort();
await expect(
handleSecurityPermissionRequest(securityRequest(), lifetime.signal),
).resolves.toEqual({ outcome: { outcome: "cancelled" } });
expect(useSecurityConfirmationStore.getState().pendingBySessionId).toEqual(
{},
);
expect(readinessMocks.readDefaultProviderReadiness).not.toHaveBeenCalled();
});

it("retires only the detached generation's requests, including queued ones", async () => {
inferenceMocks.alertLacksExplanation
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false);
const lifetime = new AbortController();
const first = handleSecurityPermissionRequest(
securityRequest(),
lifetime.signal,
);
const second = handleSecurityPermissionRequest(
securityRequest(),
lifetime.signal,
);
const replacementRequest = securityRequest();
const replacement = handleSecurityPermissionRequest(
replacementRequest,
new AbortController().signal,
);

lifetime.abort();
await expect(first).resolves.toEqual({ outcome: { outcome: "cancelled" } });
await expect(second).resolves.toEqual({
outcome: { outcome: "cancelled" },
});
const store = useSecurityConfirmationStore.getState();
expect(
store.pendingBySessionId["external-agent-session"]?.map(
(pending) => pending.request,
),
).toEqual([replacementRequest]);
store.resolveWith(
"external-agent-session",
replacementRequest,
"allow-once",
);
await expect(replacement).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow-once" },
});
});

it("does not start delayed inference for a retired confirmation", async () => {
let resolveReadiness!: (value: unknown) => void;
readinessMocks.readDefaultProviderReadiness.mockReturnValueOnce(
new Promise((resolve) => {
resolveReadiness = resolve;
}),
);
const lifetime = new AbortController();
const pending = handleSecurityPermissionRequest(
securityRequest(),
lifetime.signal,
);
lifetime.abort();
resolveReadiness({
status: "ready",
providerId: "anthropic",
modelId: "claude-sonnet",
});
await pending;
await Promise.resolve();
expect(inferenceMocks.inferSecurityExplanation).not.toHaveBeenCalled();
expect(useSecurityConfirmationStore.getState().pendingBySessionId).toEqual(
{},
);
});
});
17 changes: 15 additions & 2 deletions src/features/security/acp/securityPermissionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ function isSecurityRequest(alertText: string): boolean {
*/
export function handleSecurityPermissionRequest(
request: RequestPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
if (signal?.aborted) {
return Promise.resolve({ outcome: { outcome: "cancelled" } });
}
const alertText = collectContentText(request.toolCall.content);

if (!isSecurityRequest(alertText)) {
Expand All @@ -95,17 +99,25 @@ export function handleSecurityPermissionRequest(
// decision while inference runs in the background; other sessions remain
// interactive.
const promise = new Promise<RequestPermissionResponse>((resolve) => {
const cancel = () =>
useSecurityConfirmationStore.getState().cancelRequest(request);
signal?.addEventListener("abort", cancel, { once: true });
useSecurityConfirmationStore.getState().enqueue({
request,
title: request.toolCall.title ?? "Tool call",
command,
alertText,
resolve,
resolve: (response) => {
signal?.removeEventListener("abort", cancel);
resolve(response);
},
});
// Store subscribers may synchronously detach the requesting transport.
if (signal?.aborted) cancel();
});

// If the alert only has confidence (no explanation), infer one
if (command && alertLacksExplanation(alertText)) {
if (!signal?.aborted && command && alertLacksExplanation(alertText)) {
const store = useSecurityConfirmationStore.getState();
store.setInferredExplanation(request.sessionId, request, {
status: "loading",
Expand All @@ -114,6 +126,7 @@ export function handleSecurityPermissionRequest(
const confidence = extractConfidence(alertText);
readDefaultProviderReadiness()
.then(async (readiness) => {
if (signal?.aborted) return { status: "failed" as const };
if (readiness.status === "needs_setup") {
return { status: "needs_setup" as const };
}
Expand Down
15 changes: 15 additions & 0 deletions src/features/security/stores/securityConfirmationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface SecurityConfirmationState {
optionId: string,
) => void;
cancel: (sessionId: string) => void;
cancelRequest: (request: RequestPermissionRequest) => void;
cancelAll: (sessionId: string) => void;
blockAll: (sessionId: string) => void;
mountSurface: (sessionId: string) => void;
Expand Down Expand Up @@ -121,6 +122,20 @@ export const useSecurityConfirmationStore = create<SecurityConfirmationState>(
pending.resolve({ outcome: { outcome: "cancelled" } });
},

cancelRequest: (request) => {
const sessionId = request.sessionId;
const queue = get().pendingBySessionId[sessionId];
const pending = queue?.find((entry) => entry.request === request);
if (!pending) return;
const remaining = queue?.filter((entry) => entry !== pending) ?? [];
set((state) => ({
pendingBySessionId: remaining.length
? { ...state.pendingBySessionId, [sessionId]: remaining }
: removeSessionPending(state.pendingBySessionId, sessionId),
}));
pending.resolve({ outcome: { outcome: "cancelled" } });
},

cancelAll: (sessionId) => {
const pending = get().pendingBySessionId[sessionId] ?? [];
if (pending.length === 0) {
Expand Down
8 changes: 7 additions & 1 deletion src/shared/api/__tests__/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ const mockAppendSessionSystemPrompt = vi.fn();
const mockForkSession = vi.fn();
const mockRenameSession = vi.fn();
const mockArchiveSession = vi.fn();
const noRequestProviderContext = { requestId: undefined };
const noRequestProviderContext = {
requestId: undefined,
assertActive: expect.any(Function),
};
const noRequestModelContext = (providerId: string) => ({
providerId,
requestId: undefined,
assertActive: expect.any(Function),
});

const managedRuntimeConfig: RuntimeConfig = {
Expand Down Expand Up @@ -955,6 +959,7 @@ describe("acpCreateSession", () => {
expect(mockLoadSession).toHaveBeenCalledWith(
"acp-session-1",
"/tmp/project",
expect.any(Function),
);
expect(mockSetProvider).toHaveBeenCalledWith(
"acp-session-1",
Expand Down Expand Up @@ -1223,6 +1228,7 @@ describe("acpPrepareSession", () => {
expect(mockLoadSession).toHaveBeenCalledWith(
"acp-session-1",
"/tmp/project",
expect.any(Function),
);
expect(mockNewSession).not.toHaveBeenCalled();
expect(mockSetProvider).toHaveBeenCalledWith(
Expand Down
Loading
Loading