Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// @vitest-environment jsdom

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { PendingInteractionUserQuestionQuestion } from "@bb/domain";
import { invalidateThreadPendingInteractionResolutionQueries } from "@/hooks/cache-owners/mutation-cache-effects";
import { UserQuestionAnswerForm } from "./UserQuestionInteractionContent";

const mocks = vi.hoisted(() => ({
reset: vi.fn(),
stop: vi.fn(),
}));

vi.mock("@/hooks/mutations/thread-interaction-mutations", () => ({
useResolveThreadPendingInteraction: () => ({
error: new Error("Pending interaction pint_stale is already interrupted"),
isPending: false,
mutateAsync: vi.fn(),
reset: mocks.reset,
}),
}));

vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({
useStopThread: () => ({
isPending: false,
mutate: mocks.stop,
}),
}));

vi.mock("@/hooks/cache-owners/mutation-cache-effects", () => ({
invalidateThreadPendingInteractionResolutionQueries: vi.fn(),
}));

const questions: PendingInteractionUserQuestionQuestion[] = [
{
id: "q1",
prompt: "Which path should we use?",
shortLabel: "Path",
multiSelect: false,
options: [
{ value: "q1:option-1", label: "Staging" },
{ value: "q1:option-2", label: "Production" },
],
allowFreeText: true,
},
];

describe("UserQuestionAnswerForm", () => {
it("offers a recoverable refresh action for a stale submission", () => {
const queryClient = new QueryClient();

render(
<QueryClientProvider client={queryClient}>
<UserQuestionAnswerForm
interactionId="pint_stale"
isResolving={false}
questions={questions}
threadId="thr_stale"
/>
</QueryClientProvider>,
);

expect(screen.getByText(/expired or been resolved elsewhere/)).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "Refresh question state" }),
);

expect(mocks.reset).toHaveBeenCalledTimes(1);
expect(
invalidateThreadPendingInteractionResolutionQueries,
).toHaveBeenCalledWith({
queryClient,
threadId: "thr_stale",
});
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { useMemo, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { PendingInteractionUserQuestionQuestion } from "@bb/domain";
import { Button } from "@bb/shared-ui/button";
import { QuestionForm } from "@bb/shared-ui/question-form";
import { useResolveThreadPendingInteraction } from "@/hooks/mutations/thread-interaction-mutations";
import { useStopThread } from "@/hooks/mutations/thread-runtime-mutations";
import { getMutationErrorMessage } from "@/lib/mutation-errors";
import { invalidateThreadPendingInteractionResolutionQueries } from "@/hooks/cache-owners/mutation-cache-effects";
import { useStickyFooterAvailableHeight } from "./useStickyFooterAvailableHeight.js";

interface UserQuestionAnswerFormProps {
Expand All @@ -30,6 +33,7 @@ export function UserQuestionAnswerForm({
);
const rootRef = useRef<HTMLDivElement>(null);
const availableHeight = useStickyFooterAvailableHeight(rootRef);
const queryClient = useQueryClient();
const resolvePendingInteraction = useResolveThreadPendingInteraction();
const stopThread = useStopThread();
const disabled = resolvePendingInteraction.isPending || isResolving;
Expand All @@ -40,6 +44,13 @@ export function UserQuestionAnswerForm({
lifecycleOperation: "resolve_interaction",
})
: null;
const refreshInteractionState = () => {
resolvePendingInteraction.reset();
invalidateThreadPendingInteractionResolutionQueries({
queryClient,
threadId,
});
};
return (
<div
ref={rootRef}
Expand All @@ -66,7 +77,20 @@ export function UserQuestionAnswerForm({
/>
{error ? (
<div className="mt-2 shrink-0 rounded-md border border-surface-destructive-border bg-surface-destructive px-3 py-2 text-xs text-destructive-text">
{error}
<p>{error}</p>
<p className="mt-1">
This question may have expired or been resolved elsewhere. Refresh
its state before trying again.
</p>
<Button
className="mt-2"
size="sm"
type="button"
variant="outline"
onClick={refreshInteractionState}
>
Refresh question state
</Button>
</div>
) : null}
</div>
Expand Down
75 changes: 75 additions & 0 deletions apps/server/test/services/pending-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,81 @@ describe("pending interaction lifecycle", () => {
});
});

it("keeps a Codex user question answerable after five minutes and provider request re-registration", async () => {
vi.useFakeTimers();
try {
await withTestHarness(async (harness) => {
const { host } = seedHostSession(harness.deps, {
id: "host-pending-interaction-codex-user-question-lifetime",
});
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
});
const environment = seedEnvironment(harness.deps, {
hostId: host.id,
projectId: project.id,
});
const thread = seedThread(harness.deps, {
projectId: project.id,
environmentId: environment.id,
providerId: "codex",
});
const request: PendingInteractionCreate = {
threadId: thread.id,
turnId: "turn-codex-user-question-lifetime",
providerId: "codex",
providerThreadId: "provider-thread-codex-user-question-lifetime",
providerRequestId: "request-codex-user-question-lifetime",
payload: createUserQuestionPayload({
prompt: "Which audited path should we use?",
}),
};

const created = registerPendingInteraction(
harness.deps,
harness.deps.pendingInteractions,
request,
);
if (created.outcome === "rejected") {
throw new Error(
`Expected interaction registration to succeed: ${created.reason}`,
);
}

await vi.advanceTimersByTimeAsync(300_000);
expect(
harness.deps.pendingInteractions.getThreadInteraction({
threadId: thread.id,
interactionId: created.interaction.id,
}),
).toMatchObject({ status: "pending", payload: request.payload });

const duplicate = registerPendingInteraction(
harness.deps,
harness.deps.pendingInteractions,
request,
);
expect(duplicate).toEqual({
outcome: "existing",
interaction: created.interaction,
});

const resolution = createUserAnswerResolution({
freeText: "Use the audited release path.",
});
expect(
harness.deps.pendingInteractions.resolvePendingInteraction({
threadId: thread.id,
interactionId: created.interaction.id,
resolution,
}),
).toMatchObject({ status: "resolving", resolution });
});
} finally {
vi.useRealTimers();
}
});

it("interrupts pending user-question interactions without orphaning state", async () => {
await withTestHarness(async (harness) => {
const { host } = seedHostSession(harness.deps, {
Expand Down
26 changes: 14 additions & 12 deletions apps/server/test/services/plugins/ask-user-question-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,11 @@ describe("ask-user-question builtin plugin", () => {
return command.dynamicTools;
}

it("advertises the tool to codex with the Zod-derived schema", async () => {
it("advertises the tool to providers without native user questions", async () => {
const tools = await dynamicToolsFor({
providerId: "codex",
model: "gpt-5.6",
label: "codex-project",
providerId: "pi",
model: "pi",
label: "pi-project",
});
const tool = tools.find(
(candidate) => candidate.name === "AskUserQuestion",
Expand Down Expand Up @@ -142,13 +142,15 @@ describe("ask-user-question builtin plugin", () => {
expect(Object.keys(schema.properties)).toEqual(["questions"]);
});

it("withholds the tool from claude-code, which asks natively", async () => {
const tools = await dynamicToolsFor({
providerId: "claude-code",
model: "claude-opus-4-6",
label: "claude-project",
});
it.each([
["claude-code", "claude-opus-4-6", "claude-project"],
["codex", "gpt-5.6", "codex-project"],
])(
"withholds the tool from %s, which asks natively",
async (providerId, model, label) => {
const tools = await dynamicToolsFor({ providerId, model, label });

expect(tools.map((tool) => tool.name)).not.toContain("AskUserQuestion");
});
expect(tools.map((tool) => tool.name)).not.toContain("AskUserQuestion");
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ describe("first-party provider plugins", () => {
supportsThreadArchive: true,
supportsThreadRename: true,
supportsServiceTier: true,
supportsNativeUserQuestion: false,
supportsNativeUserQuestion: true,
permissionModes: ["accept-edits", "auto", "full"],
supportsFork: true,
supportsSessionRewind: true,
Expand Down
8 changes: 4 additions & 4 deletions plugins/ask-user-question/src/server-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async function resultText(
}

describe("provider gating", () => {
it.each(["claude-code", "some-plugin-provider"])(
it.each(["claude-code", "codex", "some-plugin-provider"])(
"withholds the tool from %s, which declares it natively",
async (providerId) => {
const host = createHost();
Expand All @@ -69,7 +69,7 @@ describe("provider gating", () => {
},
);

it.each(["codex", "pi", "acp-cursor"])(
it.each(["pi", "acp-cursor"])(
"registers the tool for %s with the schema generated from its input parser",
async (providerId) => {
const host = createHost();
Expand All @@ -85,7 +85,7 @@ describe("provider gating", () => {
},
);

it.each(["codex", "pi", "acp-cursor"])(
it.each(["pi", "acp-cursor"])(
"does not prescribe provider-specific plan tools to %s",
async (providerId) => {
const host = createHost();
Expand All @@ -102,7 +102,7 @@ describe("provider gating", () => {
it("advertises multiSelect as optional and defaults it during execution", async () => {
const host = createHost();
const resolved = await host.harness.resolveAgentConfiguration(
configurationContext("codex"),
configurationContext("pi"),
);
expect(resolved.tools[0]?.inputSchema).toMatchObject({
additionalProperties: false,
Expand Down
2 changes: 1 addition & 1 deletion plugins/provider-codex/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default function plugin(bb: BbPluginApi) {
maintenance: { health: true, usage: true, installation: true },
capabilities: {
supportsServiceTier: true,
supportsNativeUserQuestion: false,
supportsNativeUserQuestion: true,
fork: "checkpoint",
supportsManualCompaction: true,
supportsThreadArchive: true,
Expand Down
Loading
Loading