diff --git a/apps/server/src/anthropicAdapter.ts b/apps/server/src/anthropicAdapter.ts
index 6f3c7d0..3b54ec6 100644
--- a/apps/server/src/anthropicAdapter.ts
+++ b/apps/server/src/anthropicAdapter.ts
@@ -129,9 +129,7 @@ export function anthropicToOpenAIChat(payload: Record): Record<
function: {
name: block.name,
arguments:
- typeof block.input === "string"
- ? block.input
- : JSON.stringify(block.input ?? {}),
+ typeof block.input === "string" ? block.input : JSON.stringify(block.input ?? {}),
},
});
} else if (block.type === "text" && typeof block.text === "string") {
@@ -187,7 +185,10 @@ export function anthropicToOpenAIChat(payload: Record): Record<
// are lowercased because some OpenAI-compatible upstreams reject uppercase.
if (Array.isArray(payload.tools)) {
const SERVER_TOOL_PREFIXES = [
- "web_search", "computer", "text_editor", "bash_",
+ "web_search",
+ "computer",
+ "text_editor",
+ "bash_",
"code_execution",
];
const tools = payload.tools
@@ -267,7 +268,8 @@ export async function openAIChatToAnthropicMessages(
): Promise {
const raw = (await openaiResponse.json()) as OpenAIChatResponse | Record;
if (!openaiResponse.ok) {
- const upstreamError = isRecord(raw.error) ? raw.error : {};
+ const rawRecord = raw as Record;
+ const upstreamError = isRecord(rawRecord.error) ? rawRecord.error : {};
const message =
typeof upstreamError.message === "string"
? upstreamError.message
@@ -384,10 +386,7 @@ export function openAIChatStreamToAnthropicStream(
let textBlockOpen = false;
let textBlockStarted = false;
let finalStopReason = "end_turn";
- const toolCallsByIndex = new Map<
- number,
- { id: string; name: string; arguments: string }
- >();
+ const toolCallsByIndex = new Map();
// Track which tool indexes we've already emitted a content_block_start for
// so we can append them after the text block closes.
const emittedToolStarts = new Set();
@@ -460,18 +459,15 @@ export function openAIChatStreamToAnthropicStream(
// we saw in this stream so arguments accumulate together.
const fallbackIndex =
toolCallsByIndex.size > 0 ? Math.max(...toolCallsByIndex.keys()) : 0;
- const idx =
- typeof rawTc.index === "number" ? rawTc.index : fallbackIndex;
+ const idx = typeof rawTc.index === "number" ? rawTc.index : fallbackIndex;
const fn = isRecord(rawTc.function) ? rawTc.function : {};
const existing = toolCallsByIndex.get(idx);
const id =
typeof rawTc.id === "string" && rawTc.id
? rawTc.id
- : existing?.id ?? `toolu_${randomUUID().replace(/-/gu, "").slice(0, 24)}`;
+ : (existing?.id ?? `toolu_${randomUUID().replace(/-/gu, "").slice(0, 24)}`);
const name =
- typeof fn.name === "string" && fn.name
- ? fn.name
- : existing?.name ?? "";
+ typeof fn.name === "string" && fn.name ? fn.name : (existing?.name ?? "");
const args =
(existing?.arguments ?? "") +
(typeof fn.arguments === "string" ? fn.arguments : "");
diff --git a/apps/server/src/gateway.ts b/apps/server/src/gateway.ts
index 4da0ce2..c9a1e67 100644
--- a/apps/server/src/gateway.ts
+++ b/apps/server/src/gateway.ts
@@ -111,21 +111,26 @@ function responsesContentToChatContent(content: unknown): unknown {
if (!Array.isArray(content)) return content ?? "";
const parts: Array<
- | { type: "text"; text: string }
- | { type: "image_url"; image_url: { url: string } }
- > = content.flatMap((part) => {
- if (!isRecord(part)) return [];
- if (
- (part.type === "input_text" || part.type === "output_text" || part.type === "text") &&
- typeof part.text === "string"
- ) {
- return [{ type: "text", text: part.text }];
- }
- if (part.type === "input_image" && typeof part.image_url === "string") {
- return [{ type: "image_url", image_url: { url: part.image_url } }];
- }
- return [];
- });
+ { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
+ > = content.flatMap(
+ (
+ part,
+ ): Array<
+ { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
+ > => {
+ if (!isRecord(part)) return [];
+ if (
+ (part.type === "input_text" || part.type === "output_text" || part.type === "text") &&
+ typeof part.text === "string"
+ ) {
+ return [{ type: "text", text: part.text }];
+ }
+ if (part.type === "input_image" && typeof part.image_url === "string") {
+ return [{ type: "image_url", image_url: { url: part.image_url } }];
+ }
+ return [];
+ },
+ );
if (parts.length === 0) return "";
if (parts.every((part) => part.type === "text")) {
@@ -254,9 +259,10 @@ export function responsesPayloadToChatPayload(
return responsesPayloadToChatPayloadWithContext(payload).chatPayload;
}
-export function responsesPayloadToChatPayloadWithContext(
- payload: Record,
-): { chatPayload: Record; contextInputItems: unknown[] } {
+export function responsesPayloadToChatPayloadWithContext(payload: Record): {
+ chatPayload: Record;
+ contextInputItems: unknown[];
+} {
const contextInputItems = responseContextInputItems(payload);
const result: Record = {
model: payload.model,
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index b11ae89..cc7a3df 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -134,7 +134,7 @@ function headerString(
const lowerName = name.toLowerCase();
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === lowerName)?.[1];
if (Array.isArray(entry)) return entry[0] ?? "";
- return entry ?? "";
+ return (entry as string | undefined) ?? "";
}
function isLoopbackAddress(address: string | null | undefined): boolean {
@@ -231,12 +231,14 @@ const gatewayEffectRouteLayer = HttpRouter.add(
// cookies) happens inside the adapter.
const secretEntries = yield* Effect.all(
upstream.secrets.map((def) =>
- secretStore.get(gatewaySecretName(upstream.id, def.id)).pipe(
- Effect.map((bytes): [string, string] => [
- def.id,
- bytes ? new TextDecoder().decode(bytes).trim() : "",
- ]),
- ),
+ secretStore
+ .get(gatewaySecretName(upstream.id, def.id))
+ .pipe(
+ Effect.map((bytes): [string, string] => [
+ def.id,
+ bytes ? new TextDecoder().decode(bytes).trim() : "",
+ ]),
+ ),
),
);
const secrets: Record = {};
@@ -255,7 +257,8 @@ const gatewayEffectRouteLayer = HttpRouter.add(
try {
if (isResponses) {
- const { chatPayload, contextInputItems } = responsesPayloadToChatPayloadWithContext(payload);
+ const { chatPayload, contextInputItems } =
+ responsesPayloadToChatPayloadWithContext(payload);
chatPayload.model = model;
const upstreamResponse = yield* Effect.tryPromise({
try: () =>
@@ -273,7 +276,11 @@ const gatewayEffectRouteLayer = HttpRouter.add(
? chatStreamToResponsesStream(upstreamResponse, requestedModel, contextInputItems)
: yield* Effect.tryPromise({
try: () =>
- chatResponseToResponsesResponse(upstreamResponse, requestedModel, contextInputItems),
+ chatResponseToResponsesResponse(
+ upstreamResponse,
+ requestedModel,
+ contextInputItems,
+ ),
catch: () => ({
message: "Failed to convert gateway response.",
status: 502 as const,
@@ -364,9 +371,7 @@ const anthropicGatewayEffectRouteLayer = HttpRouter.add(
);
const messages = isRecord(body) && Array.isArray(body.messages) ? body.messages : [];
const system = isRecord(body) && typeof body.system === "string" ? body.system : "";
- const inputTokens = Math.ceil(
- (system.length + JSON.stringify(messages).length) / 4,
- );
+ const inputTokens = Math.ceil((system.length + JSON.stringify(messages).length) / 4);
return HttpServerResponse.jsonUnsafe({ input_tokens: Math.max(inputTokens, 1) });
}
@@ -401,12 +406,14 @@ const anthropicGatewayEffectRouteLayer = HttpRouter.add(
const secretEntries = yield* Effect.all(
upstream.secrets.map((def) =>
- secretStore.get(gatewaySecretName(upstream.id, def.id)).pipe(
- Effect.map((bytes): [string, string] => [
- def.id,
- bytes ? new TextDecoder().decode(bytes).trim() : "",
- ]),
- ),
+ secretStore
+ .get(gatewaySecretName(upstream.id, def.id))
+ .pipe(
+ Effect.map((bytes): [string, string] => [
+ def.id,
+ bytes ? new TextDecoder().decode(bytes).trim() : "",
+ ]),
+ ),
),
);
const secrets: Record = {};
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.browser.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.browser.tsx
new file mode 100644
index 0000000..d52e75b
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.browser.tsx
@@ -0,0 +1,183 @@
+import "../../index.css";
+
+import { ApprovalRequestId, type UserInputQuestion } from "@peakcode/contracts";
+import { useCallback, useRef, useState } from "react";
+import { page } from "vitest/browser";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { render } from "vitest-browser-react";
+
+import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
+import {
+ buildPendingUserInputAnswers,
+ derivePendingUserInputProgress,
+ type PendingUserInputDraftAnswer,
+ togglePendingUserInputOptionSelection,
+} from "../../pendingUserInput";
+import type { PendingUserInput } from "../../session-logic";
+
+const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-pending-user-input-test");
+
+const SINGLE_QUESTION: UserInputQuestion = {
+ id: "q1",
+ header: "Pick one",
+ question: "Choose exactly one thing.",
+ options: [
+ { label: "Single A", description: "first choice" },
+ { label: "Single B", description: "second choice" },
+ ],
+ multiSelect: false,
+};
+
+const MULTI_QUESTION: UserInputQuestion = {
+ id: "q2",
+ header: "Pick many",
+ question: "Choose any number of things.",
+ multiSelect: true,
+ options: [
+ { label: "Multi X", description: "alpha" },
+ { label: "Multi Y", description: "beta" },
+ { label: "Multi Z", description: "gamma" },
+ ],
+};
+
+// Faithful mirror of ChatView's pending-user-input wiring (onToggleActivePendingUserInputOption +
+// onAdvanceActivePendingUserInput), reusing the real helpers so the panel exercises the same logic
+// path it does in production. The harness owns the draft answers and active question index; the panel
+// owns the click handling and 200ms single-select auto-advance.
+function PanelHarness({
+ questions,
+ onSubmit,
+}: {
+ questions: ReadonlyArray;
+ onSubmit: (answers: Record) => void;
+}) {
+ const [answers, setAnswers] = useState>({});
+ const [questionIndex, setQuestionIndex] = useState(0);
+ const answersRef = useRef(answers);
+ const indexRef = useRef(questionIndex);
+
+ const prompt: PendingUserInput = {
+ requestId: REQUEST_ID,
+ createdAt: "2026-06-18T00:00:00.000Z",
+ questions,
+ };
+
+ const onToggleOption = useCallback(
+ (questionId: string, optionLabel: string): PendingUserInputDraftAnswer | null => {
+ const question = questions.find((entry) => entry.id === questionId);
+ if (!question) return null;
+ const nextDraftAnswer = togglePendingUserInputOptionSelection(
+ question,
+ answersRef.current[questionId],
+ optionLabel,
+ );
+ answersRef.current = { ...answersRef.current, [questionId]: nextDraftAnswer };
+ setAnswers(answersRef.current);
+ return nextDraftAnswer;
+ },
+ [questions],
+ );
+
+ const onAdvance = useCallback(
+ (answerOverrides?: Record) => {
+ const draft =
+ answerOverrides && Object.keys(answerOverrides).length > 0
+ ? { ...answersRef.current, ...answerOverrides }
+ : answersRef.current;
+ if (answerOverrides && Object.keys(answerOverrides).length > 0) {
+ answersRef.current = draft;
+ setAnswers(draft);
+ }
+ const progress = derivePendingUserInputProgress(questions, draft, indexRef.current);
+ if (progress.isLastQuestion) {
+ const resolved = buildPendingUserInputAnswers(questions, draft);
+ if (resolved) onSubmit(resolved);
+ return;
+ }
+ const activeQuestionId = progress.activeQuestion?.id ?? null;
+ const hasActiveOverride = activeQuestionId
+ ? answerOverrides?.[activeQuestionId] !== undefined
+ : false;
+ if (!progress.canAdvance && !hasActiveOverride) {
+ return;
+ }
+ const nextIndex = progress.questionIndex + 1;
+ indexRef.current = nextIndex;
+ setQuestionIndex(nextIndex);
+ },
+ [questions, onSubmit],
+ );
+
+ return (
+
+
+
+
+ );
+}
+
+async function mountPanel(
+ questions: ReadonlyArray,
+ onSubmit: (answers: Record) => void,
+) {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const screen = await render(, {
+ container: host,
+ });
+ const cleanup = async () => {
+ await screen.unmount();
+ host.remove();
+ };
+ return { [Symbol.asyncDispose]: cleanup, cleanup };
+}
+
+const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
+
+describe("ComposerPendingUserInputPanel", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("does not auto-submit a multi-select question reached via single-select auto-advance", async () => {
+ const onSubmit = vi.fn();
+ await using _ = await mountPanel([SINGLE_QUESTION, MULTI_QUESTION], onSubmit);
+
+ // Answer the single-select question; this arms the 200ms auto-advance to the multi-select one.
+ await page.getByText("Single A", { exact: true }).click();
+
+ // Click the FIRST option of the multi-select question (Playwright auto-waits for it to render
+ // after the auto-advance). Pre-fix, this set a 200ms timer that auto-submitted the whole round.
+ await page.getByText("Multi X", { exact: true }).click();
+
+ // Regression assertion: no auto-submit — the user must be able to keep selecting.
+ await wait(300);
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ // A second option can still be toggled on, then submitting collects BOTH selections.
+ await page.getByText("Multi Y", { exact: true }).click();
+ await page.getByText("harness-submit", { exact: true }).click();
+
+ await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit).toHaveBeenCalledWith({ q1: "Single A", q2: ["Multi X", "Multi Y"] });
+ });
+
+ it("still auto-submits a lone single-select question on click", async () => {
+ const onSubmit = vi.fn();
+ await using _ = await mountPanel([SINGLE_QUESTION], onSubmit);
+
+ await page.getByText("Single A", { exact: true }).click();
+
+ await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit).toHaveBeenCalledWith({ q1: "Single A" });
+ });
+});
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
index aed440b..d1617e5 100644
--- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
+++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
@@ -80,7 +80,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
const nextDraftAnswer = onToggleOption(questionId, optionLabel);
- if (activeQuestion?.multiSelect) {
+ // Look up the question by id rather than using `activeQuestion` from the closure.
+ // `activeQuestion` can be stale when the React Compiler memoises the callback before
+ // useEffectEvent's ref.impl is refreshed after a question transition.
+ const clickedQuestion = prompt.questions.find((q) => q.id === questionId);
+ if (clickedQuestion?.multiSelect) {
return;
}
if (autoAdvanceTimerRef.current !== null) {
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 4c9d9df..5d12418 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -228,9 +228,9 @@ function channelIsComplete(
): boolean {
return Boolean(
channel &&
- channel.baseUrl.trim().length > 0 &&
- channelHasModel(channel) &&
- channelHasRequiredSecrets(channel, statuses),
+ channel.baseUrl.trim().length > 0 &&
+ channelHasModel(channel) &&
+ channelHasRequiredSecrets(channel, statuses),
);
}
@@ -739,9 +739,10 @@ function SettingsRouteView() {
});
}, [agentConfigStatusQuery.data?.agents, agentConfigStatusQuery.isError]);
const enabledChannelCount =
- gatewayConfigQuery.data?.channels.filter((channel) =>
- channel.enabled &&
- channelIsComplete(channel, channelSecretStatuses(channel.id, gatewaySecretStatuses)),
+ gatewayConfigQuery.data?.channels.filter(
+ (channel) =>
+ channel.enabled &&
+ channelIsComplete(channel, channelSecretStatuses(channel.id, gatewaySecretStatuses)),
).length ?? 0;
const updateGatewayConfigMutation = useMutation({
mutationFn: async (patch: GatewayConfigPatch) => {
@@ -2626,11 +2627,32 @@ function SettingsRouteView() {
{[
- { label: "Root", url: resolveWsHttpUrl("/gateway/openai/v1").replace(/\?token=.*/u, "") },
- { label: "Chat", url: resolveWsHttpUrl("/gateway/openai/v1/chat/completions").replace(/\?token=.*/u, "") },
- { label: "Models", url: resolveWsHttpUrl("/gateway/openai/v1/models").replace(/\?token=.*/u, "") },
- { label: "Responses", url: resolveWsHttpUrl("/gateway/openai/v1/responses").replace(/\?token=.*/u, "") },
- { label: "Anthropic", url: resolveWsHttpUrl("/gateway/anthropic").replace(/\?token=.*/u, "") },
+ {
+ label: "Root",
+ url: resolveWsHttpUrl("/gateway/openai/v1").replace(/\?token=.*/u, ""),
+ },
+ {
+ label: "Chat",
+ url: resolveWsHttpUrl("/gateway/openai/v1/chat/completions").replace(
+ /\?token=.*/u,
+ "",
+ ),
+ },
+ {
+ label: "Models",
+ url: resolveWsHttpUrl("/gateway/openai/v1/models").replace(/\?token=.*/u, ""),
+ },
+ {
+ label: "Responses",
+ url: resolveWsHttpUrl("/gateway/openai/v1/responses").replace(
+ /\?token=.*/u,
+ "",
+ ),
+ },
+ {
+ label: "Anthropic",
+ url: resolveWsHttpUrl("/gateway/anthropic").replace(/\?token=.*/u, ""),
+ },
].map((ep) => (
Agent Setup
- 将网关配置写入各 Agent 的本地配置文件。Codex / Claude Code 经网关协议转换;OpenCode /
- Kilo / Cursor / pi / Cline 经 OpenAI 标准协议转发。点击写入后,手动启动对应 Agent 即可用网关。
+ 将网关配置写入各 Agent 的本地配置文件。Codex / Claude Code
+ 经网关协议转换;OpenCode / Kilo / Cursor / pi / Cline 经 OpenAI
+ 标准协议转发。点击写入后,手动启动对应 Agent 即可用网关。
{agentConfigStatusQuery.isError ? (
@@ -2795,7 +2818,8 @@ function SettingsRouteView() {
if (checked && !channelIsComplete(serverChannel, channelSecrets)) {
toastManager.add({
title: "渠道配置未完成",
- description: "请先配置 Base URL、至少一个模型,以及该渠道要求的全部密钥。",
+ description:
+ "请先配置 Base URL、至少一个模型,以及该渠道要求的全部密钥。",
type: "info",
});
return;
@@ -3686,7 +3710,8 @@ function GatewayChannelCard(props: {
const totalSecrets = props.serverChannel?.secrets.length ?? 0;
const configuredSecrets = props.secretsStatus.filter((s) => s.hasApiKey).length;
const hasAllSecrets =
- Boolean(props.serverChannel) && channelHasRequiredSecrets(props.serverChannel, props.secretsStatus);
+ Boolean(props.serverChannel) &&
+ channelHasRequiredSecrets(props.serverChannel, props.secretsStatus);
const hasModel = channelHasModel(props.serverChannel);
const hasBaseUrl = Boolean(props.serverChannel?.baseUrl.trim());
const canEnable = Boolean(props.serverChannel) && hasAllSecrets && hasModel && hasBaseUrl;
@@ -3871,9 +3896,7 @@ function ChannelModelsEditor(props: {
{props.models.length === 0 ? (
-
- 未配置模型,将使用渠道默认模型字段。
-
+
未配置模型,将使用渠道默认模型字段。
) : (
{props.models.map((model, index) => (
@@ -3920,7 +3943,12 @@ function ChannelModelsEditor(props: {
placeholder="模型 id,如 deepseek-reasoner"
className="h-7 flex-1 text-xs"
/>
-
@@ -3938,7 +3966,10 @@ function ChannelAgentMappingsEditor(props: {
const options = props.models;
// Builds a mappings object that omits empty values, so we never assign
// `undefined` to an optional field (forbidden by exactOptionalPropertyTypes).
- const buildMappings = (agent: "codex" | "claude", value: string): {
+ const buildMappings = (
+ agent: "codex" | "claude",
+ value: string,
+ ): {
codex?: string;
claude?: string;
} => {
@@ -3949,11 +3980,7 @@ function ChannelAgentMappingsEditor(props: {
if (trimmed) next[agent] = trimmed;
return next;
};
- const renderSelect = (
- agent: "codex" | "claude",
- label: string,
- description: string,
- ) => {
+ const renderSelect = (agent: "codex" | "claude", label: string, description: string) => {
const current = props.mappings[agent];
return (
@@ -4027,7 +4054,7 @@ function ChannelSecretsRow(props: {
label={def.label}
sensitive={def.sensitive}
hasApiKey={hasApiKey}
- disabled={props.disabled}
+ disabled={props.disabled ?? false}
onSet={(value) => props.onSetSecret(def.id, value)}
onRemove={() => props.onRemoveSecret(def.id)}
/>