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
26 changes: 11 additions & 15 deletions apps/server/src/anthropicAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,7 @@ export function anthropicToOpenAIChat(payload: Record<string, unknown>): 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") {
Expand Down Expand Up @@ -187,7 +185,10 @@ export function anthropicToOpenAIChat(payload: Record<string, unknown>): 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
Expand Down Expand Up @@ -267,7 +268,8 @@ export async function openAIChatToAnthropicMessages(
): Promise<Response> {
const raw = (await openaiResponse.json()) as OpenAIChatResponse | Record<string, unknown>;
if (!openaiResponse.ok) {
const upstreamError = isRecord(raw.error) ? raw.error : {};
const rawRecord = raw as Record<string, unknown>;
const upstreamError = isRecord(rawRecord.error) ? rawRecord.error : {};
const message =
typeof upstreamError.message === "string"
? upstreamError.message
Expand Down Expand Up @@ -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<number, { id: string; name: string; arguments: string }>();
// 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<number>();
Expand Down Expand Up @@ -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 : "");
Expand Down
42 changes: 24 additions & 18 deletions apps/server/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down Expand Up @@ -254,9 +259,10 @@ export function responsesPayloadToChatPayload(
return responsesPayloadToChatPayloadWithContext(payload).chatPayload;
}

export function responsesPayloadToChatPayloadWithContext(
payload: Record<string, unknown>,
): { chatPayload: Record<string, unknown>; contextInputItems: unknown[] } {
export function responsesPayloadToChatPayloadWithContext(payload: Record<string, unknown>): {
chatPayload: Record<string, unknown>;
contextInputItems: unknown[];
} {
const contextInputItems = responseContextInputItems(payload);
const result: Record<string, unknown> = {
model: payload.model,
Expand Down
43 changes: 25 additions & 18 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string> = {};
Expand All @@ -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: () =>
Expand All @@ -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,
Expand Down Expand Up @@ -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) });
}

Expand Down Expand Up @@ -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<string, string> = {};
Expand Down
183 changes: 183 additions & 0 deletions apps/web/src/components/chat/ComposerPendingUserInputPanel.browser.tsx
Original file line number Diff line number Diff line change
@@ -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<UserInputQuestion>;
onSubmit: (answers: Record<string, string | string[]>) => void;
}) {
const [answers, setAnswers] = useState<Record<string, PendingUserInputDraftAnswer>>({});
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<string, PendingUserInputDraftAnswer>) => {
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 (
<div>
<ComposerPendingUserInputPanel
pendingUserInputs={[prompt]}
respondingRequestIds={[]}
answers={answers}
questionIndex={questionIndex}
onToggleOption={onToggleOption}
onAdvance={onAdvance}
/>
<button type="button" onClick={() => onAdvance(undefined)}>
harness-submit
</button>
</div>
);
}

async function mountPanel(
questions: ReadonlyArray<UserInputQuestion>,
onSubmit: (answers: Record<string, string | string[]>) => void,
) {
const host = document.createElement("div");
document.body.append(host);
const screen = await render(<PanelHarness questions={questions} onSubmit={onSubmit} />, {
container: host,
});
const cleanup = async () => {
await screen.unmount();
host.remove();
};
return { [Symbol.asyncDispose]: cleanup, cleanup };
}

const wait = (ms: number) => new Promise<void>((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" });
});
});
Loading