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)} />