fix(stream): match Command Code CLI transport behavior

This commit is contained in:
Patrick Wozniak
2026-08-25 15:54:18 +02:00
parent 349e50f829
commit c51a790530
9 changed files with 426 additions and 85 deletions
+7
View File
@@ -28,6 +28,7 @@ export interface AuthServer {
export interface AuthServerOptions {
startPort?: number
portRange?: number
expectedState?: string
}
function listenOnAvailablePort(
@@ -181,6 +182,12 @@ export async function startAuthServer(options: AuthServerOptions = {}): Promise<
return
}
if (options.expectedState !== undefined && state !== options.expectedState) {
res.writeHead(403)
res.end(JSON.stringify({ success: false, error: "Invalid state token" }))
return
}
res.writeHead(200)
res.end(JSON.stringify({ success: true }))
+42 -11
View File
@@ -107,6 +107,7 @@ export function getApiKey(
} = {},
): string | undefined {
const env = options.env ?? process.env
if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY
if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY
const home = options.homeDir?.() ?? homedir()
@@ -138,10 +139,11 @@ export function getApiKey(
return undefined
}
// Hosts such as OMP may pass the literal env-var name "$COMMANDCODE_API_KEY"
// (or "COMMANDCODE_API_KEY") as the "resolved" registry key instead of the
// actual credential. Treat those as unresolved.
// Hosts such as OMP may pass a literal env-var name as the "resolved" registry
// key instead of the actual credential. Treat those as unresolved.
export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([
"$COMMAND_CODE_API_KEY",
"COMMAND_CODE_API_KEY",
"$COMMANDCODE_API_KEY",
"COMMANDCODE_API_KEY",
])
@@ -162,6 +164,16 @@ export function pickCommandCodeApiKey(
}
export function textContent(message: { content?: unknown }): string {
if (typeof message.content === "string") return message.content
if (message.content === null || message.content === undefined) return ""
if (!Array.isArray(message.content)) {
try {
return JSON.stringify(message.content) ?? String(message.content)
} catch {
return String(message.content)
}
}
return recordArray(message.content)
.filter((part) => part.type === "text")
.map((part) => stringValue(part.text) ?? "")
@@ -182,7 +194,12 @@ export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
}))
}
function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
interface ToolCallState {
callIds: ReadonlySet<string>
resultIds: ReadonlySet<string>
}
function toolCallState(messages?: readonly MessageLike[]): ToolCallState {
const callIds = new Set<string>()
const resultIds = new Set<string>()
@@ -194,12 +211,12 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
if (id) callIds.add(id)
}
}
} else if (message.role === "toolResult") {
if (message.toolCallId) resultIds.add(message.toolCallId)
} else if (message.role === "toolResult" && message.toolCallId) {
resultIds.add(message.toolCallId)
}
}
return new Set([...callIds].filter((id) => resultIds.has(id)))
return { callIds, resultIds }
}
export function messagesToCC(
@@ -210,7 +227,7 @@ export function messagesToCC(
if (!allowImages) assertTextOnlyMessages(messages)
const out: unknown[] = []
const pairedToolCallIds = completeToolCallIds(messages)
const { callIds, resultIds } = toolCallState(messages)
for (const message of messages ?? []) {
if (message.role === "user") {
@@ -220,23 +237,37 @@ export function messagesToCC(
})
} else if (message.role === "assistant") {
const parts: unknown[] = []
const missingResults: unknown[] = []
for (const content of recordArray(message.content)) {
if (content.type === "text") {
parts.push({ type: "text", text: stringValue(content.text) ?? "" })
} else if (content.type === "toolCall") {
const toolCallId = stringValue(content.id) ?? ""
if (!pairedToolCallIds.has(toolCallId)) continue
const toolName = stringValue(content.name) ?? ""
if (!toolCallId) continue
parts.push({
type: "tool-call",
toolCallId,
toolName: stringValue(content.name) ?? "",
toolName,
input: recordOrEmpty(content.arguments),
})
if (!resultIds.has(toolCallId)) {
missingResults.push({
type: "tool-result",
toolCallId,
toolName,
output: {
type: "error-text",
value: "No result — the tool call did not complete (interrupted or lost).",
},
})
}
}
}
if (parts.length > 0) out.push({ role: "assistant", content: parts })
if (missingResults.length > 0) out.push({ role: "tool", content: missingResults })
} else if (message.role === "toolResult") {
if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId)) continue
if (!message.toolCallId || !callIds.has(message.toolCallId)) continue
out.push({
role: "tool",
content: [
+55 -16
View File
@@ -148,6 +148,10 @@ function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): strin
return typeof mapped === "string" && mapped !== "off" ? mapped : undefined
}
function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
export function projectSlugFromPath(pathName: string): string {
const slug = pathName
.toLowerCase()
@@ -232,17 +236,15 @@ export function createStreamCommandCode(deps: CoreDependencies) {
const stream = deps.createStream()
async function run() {
// OMP may pass the legacy env-var name "COMMANDCODE_API_KEY" (old pi)
// or "$COMMANDCODE_API_KEY" (new pi) as the apiKey value instead of
// resolving it. Filter out these specific strings.
const LEGACY_API_KEY_REF = "$COMMANDCODE_API_KEY"
const OLD_API_KEY_REF = "COMMANDCODE_API_KEY"
// Some hosts pass a literal env-var reference instead of resolving it.
const PLACEHOLDER_API_KEYS = new Set([
"$COMMAND_CODE_API_KEY",
"COMMAND_CODE_API_KEY",
"$COMMANDCODE_API_KEY",
"COMMANDCODE_API_KEY",
])
const hostKey =
options?.apiKey &&
options.apiKey !== LEGACY_API_KEY_REF &&
options.apiKey !== OLD_API_KEY_REF
? options.apiKey
: undefined
options?.apiKey && !PLACEHOLDER_API_KEYS.has(options.apiKey) ? options.apiKey : undefined
const apiKey =
hostKey ??
@@ -262,7 +264,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
usage: defaultUsage(),
stopReason: "error",
errorMessage:
"No Command Code API key. Run /login and select Command Code, set the COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json",
"No Command Code API key. Run /login and select Command Code, set COMMAND_CODE_API_KEY (or legacy COMMANDCODE_API_KEY), or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json",
timestamp: now(),
}
stream.push({ type: "error", reason: "error", error: msg })
@@ -424,6 +426,15 @@ export function createStreamCommandCode(deps: CoreDependencies) {
}
case "finish": {
const rawFinishReason = stringValue(event.rawFinishReason)
if (
rawFinishReason &&
/^(?:network|connection|upstream)[-_\s]?error$/i.test(rawFinishReason)
) {
throw new Error(
`Provider finished with reason "${rawFinishReason}" — upstream connection failed mid-stream`,
)
}
const usage = commandCodeUsage(event)
if (usage) {
const details = commandCodeInputTokenDetails(usage)
@@ -447,6 +458,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
break
}
case "abort": {
throw abortError("Request aborted")
}
case "error": {
const message =
commandCodeErrorMessage(event.error) ??
@@ -464,7 +479,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
if (controller.signal.aborted) throw abortError("Aborted")
const workingDir = cwd()
const threadId = uuid()
const threadId = options?.sessionId
? isUuid(options.sessionId)
? options.sessionId
: undefined
: uuid()
const reasoningEffort = mappedReasoningEffort(model, options)
const timeoutMs = options?.timeoutMs
@@ -492,8 +511,8 @@ export function createStreamCommandCode(deps: CoreDependencies) {
tools: toolsToJson(context.tools),
system: systemPromptToText(context.systemPrompt),
max_tokens: generateMaxTokens(model, options),
temperature: 0.3,
stream: true,
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
},
threadId,
@@ -523,7 +542,8 @@ export function createStreamCommandCode(deps: CoreDependencies) {
"x-cli-environment": "production",
"x-project-slug": projectSlugFromPath(workingDir),
"x-taste-learning": "true",
"x-co-flag": "false",
...(options?.sessionId ? { "x-session-id": options.sessionId } : {}),
"User-Agent": "cli",
...options?.headers,
}
const bodyStr = JSON.stringify(body)
@@ -636,6 +656,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
const { done, value } = await raceAbort(reader.read(), attemptController.signal)
if (done) {
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
if (!finished) {
throw new Error(
"Stream ended unexpectedly before completion (no finish event) — response was truncated",
)
}
break
}
if (controller.signal.aborted) throw abortError("Aborted")
@@ -659,7 +684,12 @@ export function createStreamCommandCode(deps: CoreDependencies) {
} catch {}
reader = undefined
if (controller.signal.aborted) throw streamError
if (
controller.signal.aborted ||
(streamError instanceof Error && streamError.name === "AbortError")
) {
throw streamError
}
// Never retry after visible content was emitted (including timeout mid-stream).
const canRetry = output.content.length === 0 && attempt < maxRetries
@@ -679,6 +709,12 @@ export function createStreamCommandCode(deps: CoreDependencies) {
throw streamError
}
if (!finished) {
throw new Error(
"Stream ended unexpectedly before completion (no finish event) — response was truncated",
)
}
// Stream completed successfully.
endTextBlock()
endThinking()
@@ -696,7 +732,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
}
}
} catch (error: unknown) {
const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
const reason: ErrorReason =
controller.signal.aborted || (error instanceof Error && error.name === "AbortError")
? "aborted"
: "error"
output.stopReason = reason
output.errorMessage =
reason === "aborted"
+33 -10
View File
@@ -18,7 +18,8 @@ import { startAuthServer } from "./auth-server.ts"
const STUDIO_BASE_URL = "https://commandcode.ai"
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire
const DEFAULT_AUTH_TIMEOUT_MS = 15_000
const DEFAULT_AUTH_TIMEOUT_MS = 120_000
const DEFAULT_API_BASE = "https://api.commandcode.ai"
export interface OAuthLoginCallbacks {
onAuth(params: { url: string }): void
@@ -95,9 +96,34 @@ export function sanitizeApiKey(input: string): string {
.trim()
}
export async function validateApiKey(
apiKey: string,
options: { fetchImpl?: typeof fetch; apiBase?: string } = {},
): Promise<void> {
let response: Response
try {
response = await (options.fetchImpl ?? fetch)(
`${options.apiBase ?? DEFAULT_API_BASE}/alpha/whoami`,
{
headers: { Authorization: `Bearer ${apiKey}` },
},
)
} catch (error) {
throw new Error(
`Could not validate the Command Code API key: ${error instanceof Error ? error.message : String(error)}`,
)
}
if (response.status === 401) throw new Error("Invalid Command Code API key")
if (!response.ok) {
throw new Error(`Could not validate the Command Code API key (${response.status})`)
}
}
async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) {
const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message }))
if (!apiKey) throw new Error("No Command Code API key provided")
await validateApiKey(apiKey)
return credentialsFromApiKey(apiKey)
}
@@ -130,9 +156,10 @@ async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise<LoginCho
}
async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const stateToken = generateStateToken()
let authServer
try {
authServer = await startAuthServer()
authServer = await startAuthServer({ expectedState: stateToken })
} catch {
return promptForApiKey(
callbacks,
@@ -140,7 +167,6 @@ async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
)
}
const stateToken = generateStateToken()
const callbackUrl = `http://localhost:${authServer.port}/callback`
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(stateToken)}`
@@ -164,12 +190,6 @@ async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
throw error
}
// Validate state token to prevent CSRF.
if (callback.state !== stateToken) {
authServer.server.close()
throw new Error("State token mismatch. Authentication may have been tampered with.")
}
return credentialsFromApiKey(callback.apiKey)
}
@@ -182,7 +202,10 @@ async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const choice = await chooseLoginFlow(callbacks)
if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey)
if (choice.type === "apiKey") {
await validateApiKey(choice.apiKey)
return credentialsFromApiKey(choice.apiKey)
}
if (choice.type === "prompt") {
return promptForApiKey(callbacks, "Paste your Command Code API key:")
}
+2
View File
@@ -112,6 +112,8 @@ export interface StreamOptions {
headers?: Record<string, string>
fetch?: typeof fetch
maxTokens?: number
temperature?: number
sessionId?: string
/** Resolved pi thinking level; forwarded only through the model's map. */
reasoning?: string
onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise<unknown>