mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 19:46:34 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b731bc19e2 | ||
|
|
fedf017e25 | ||
|
|
f4a9b93013 | ||
|
|
cbef698861 | ||
|
|
ab2d251155 | ||
|
|
3d7ba38965 | ||
|
|
37a6ba893e | ||
|
|
6c6871fd2a | ||
|
|
1e864dd8c6 | ||
|
|
21980a4448 | ||
|
|
9cca8dd6e0 | ||
|
|
91028a690b | ||
|
|
e82aa92e64 | ||
|
|
874538d702 |
@@ -125,7 +125,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.8",
|
||||
"@opencode-ai/pty": "0.1.9",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
@@ -350,7 +350,6 @@
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -365,7 +364,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.8",
|
||||
"@opencode-ai/pty": "0.1.9",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -2157,19 +2156,19 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.8", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.8", "@opencode-ai/pty-darwin-x64": "0.1.8", "@opencode-ai/pty-linux-arm64-gnu": "0.1.8", "@opencode-ai/pty-linux-arm64-musl": "0.1.8", "@opencode-ai/pty-linux-x64-gnu": "0.1.8", "@opencode-ai/pty-linux-x64-musl": "0.1.8" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-+OHAANB6xgtO4zVMK5Q3l50Iot2fYcHL003rNpvxxCXLkpdO90UnnRCL9jrll1SWkWoDZKaUDlR/Scw6TUTN4A=="],
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.9", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.9", "@opencode-ai/pty-darwin-x64": "0.1.9", "@opencode-ai/pty-linux-arm64-gnu": "0.1.9", "@opencode-ai/pty-linux-arm64-musl": "0.1.9", "@opencode-ai/pty-linux-x64-gnu": "0.1.9", "@opencode-ai/pty-linux-x64-musl": "0.1.9" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-9WysQgX9J3RXfZy/t/8MGqf1IGLeckyHQsT/eVnKiBnp+GnzOXtZePryj08CN/3GT2BuP5tWqMRMH0JzCMsrgg=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DqVcCfa9P5zpM0QpvXD/QqZXFdXvtIoaBHVHjYnTt8oqlnmMWqG4OVowzPNQg07NudW6apwX1Jop1wuERgWGw=="],
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-a2OZGutBdVGDO+X4t37L2K8wD1phTcLJBcNLj8j3LqCmaHbJmQyNYYFo6i8loZCTjjhb+8Wq0wMxSLny4tqdwA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-keEDxfpy0mFJYmsTqea9IUbpLKpbe9yL4ISU8tYA0daWiKuR8ZlfLTlLGltaFNO7l7q6mURMeI4cZzwySOX/Kw=="],
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-+vyRLwzNMzP/JFtYEIkMHxRC9Lkd7sUeuAqI5medtdgZmvTwS3NdkHbq6zQQG3rTQ9asObPKilvcpQDf+LYHRQ=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-NVJvlVtPbYRyjABwLkm1iboijpSZMXv4lfg4qda7UCzAgiDZ0cWauXeW5wtd9bbnUbHw4rdNVU6YweuwASgZXw=="],
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vjNKhCsw6mI6w+9bITCyCmb5nS+XiWA8xMGn02GOKE9AahcVX9JIbjUF1zAmPXZ1QD7nxWoanh4FvnqukbYPHw=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-GAhnCs7b7B8Hqa7AEN7k5ra4IqybFYePgbCSOHn3Y4mofp/5NjDlkOG/VFM7rHAy05iEmRi5WtOPRv0Mkca8zA=="],
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-xpS0N6/uEiJPabv6Ib9BpOlfyZdcUES7sMVa4bCrgcxy6y4bnQMeGF/Ju4u7LBABD/rjBKZM0XhPMnvZ6qoX1w=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-vSbty1L0BuOQlK7DmukuhdxNKv2vzqyxOjHmh+TULptMDbDZwE1EybASoXB7dYog9as85adFCz8ZTF7Boyfxjg=="],
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-N1Dx8rOLkpJd2DSetZZW9dPnpL3mxbRQagk/7K7TbS7M1rDnLazhq8/T9vulLET8KTwu6lmWcZYuSDfPLNWXnA=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-2GhmJLl+Ly/JjzMS2Eqvk7SgeuHIB0cQ4Wrmnj4WKhr2V7lS+pdUEApN6S0SG/FK0TYJlWeHeOIpNZgDqzEPPg=="],
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-osb203LrlwXpQKQABrCfD5QRVK9Ajiq5atulC/VuMye6KkErcetDnjQvobjhaEjgnSEGPCH4U9sM84okGqip5Q=="],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-6Fc26RulNnJZtMUxeryWYqCt/7xIs1E+cOQSddAC9LA=",
|
||||
"aarch64-linux": "sha256-p7cyVH12IGAo6HyaoSgO1fBx5fNTCZGTpvbqzIpe+A8=",
|
||||
"aarch64-darwin": "sha256-U90pWWqe7DRiUpY5KUUp4exQpa/ddb/bQ5Ldxe46LfQ=",
|
||||
"x86_64-darwin": "sha256-Ak6omOq2BBRLQEMxPydiQY9ajaJGQR61P6az4+xxZVM="
|
||||
"x86_64-linux": "sha256-Q7BQ46mKePJtaKzhHxahIXy/pZczPmm5cQuBDrgd2Bc=",
|
||||
"aarch64-linux": "sha256-pqk4iUhXzEc4ei9zpeGpPjX7Q6pxH1K5rgotD5Wf91s=",
|
||||
"aarch64-darwin": "sha256-1q3mK5zLqQA0vz7KErDOkjeAnmsTReI0lhBJfIobC/E=",
|
||||
"x86_64-darwin": "sha256-dBMQ6tZxt5VjgWTZELHgPk6fVhBfNYfmY+AnQ3iJ88Q="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "google-vertex-messages", "bedrock-converse", "openrouter"])
|
||||
const RESPECTS_INLINE_HINTS = new Set([
|
||||
"anthropic-messages",
|
||||
"google-vertex-messages",
|
||||
"bedrock-converse",
|
||||
"openrouter",
|
||||
])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
@@ -69,14 +69,22 @@ export interface OptionsInput {
|
||||
// SDK Metadata:2649 {user_id?: string | null}
|
||||
readonly metadata?: { readonly user_id?: string | null }
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
|
||||
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly container?:
|
||||
| string
|
||||
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly inference_geo?: string | null
|
||||
readonly inferenceGeo?: string | null
|
||||
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
|
||||
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
|
||||
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
|
||||
readonly output_config?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
readonly outputConfig?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
@@ -259,7 +267,11 @@ const AnthropicToolChoice = Schema.Union([
|
||||
type: Schema.Literals(["auto", "any", "none"]),
|
||||
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("tool"),
|
||||
name: Schema.String,
|
||||
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
])
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
@@ -506,7 +518,11 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||
// histories constructed directly from provider events.
|
||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
|
||||
return {
|
||||
type: wireType,
|
||||
tool_use_id: scrubToolCallID(part.id),
|
||||
content: payload,
|
||||
} satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
|
||||
@@ -554,9 +570,7 @@ const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string |
|
||||
return undefined
|
||||
}
|
||||
|
||||
const citationsFromMetadata = (
|
||||
metadata: MediaPart["metadata"],
|
||||
): AnthropicDocumentBlock["citations"] | undefined => {
|
||||
const citationsFromMetadata = (metadata: MediaPart["metadata"]): AnthropicDocumentBlock["citations"] | undefined => {
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const raw = ProviderShared.isRecord(metadata.anthropic)
|
||||
? (metadata.anthropic.citations ?? metadata.citations)
|
||||
@@ -706,8 +720,7 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
|
||||
})
|
||||
|
||||
const requireThinkingSignature = (request: LLMRequest) => {
|
||||
if (request.model.compatibility?.requireSignature !== undefined)
|
||||
return request.model.compatibility.requireSignature
|
||||
if (request.model.compatibility?.requireSignature !== undefined) return request.model.compatibility.requireSignature
|
||||
const provider = request.model.provider.toLowerCase()
|
||||
const model = request.model.id.toLowerCase()
|
||||
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
|
||||
@@ -900,21 +913,24 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions as Record<string, unknown> | undefined
|
||||
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const rawServiceTier =
|
||||
(input as Record<string, unknown> | undefined)?.service_tier ??
|
||||
(input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const service_tier =
|
||||
rawServiceTier === "auto" || rawServiceTier === "standard_only"
|
||||
? (rawServiceTier as "auto" | "standard_only")
|
||||
: undefined
|
||||
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
|
||||
const metadata =
|
||||
ProviderShared.isRecord(rawMetadata) &&
|
||||
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
? { user_id: rawMetadata.user_id as string | null }
|
||||
: undefined
|
||||
const container =
|
||||
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
|
||||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
|
||||
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
? ((input as Record<string, unknown>).container as
|
||||
| string
|
||||
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
: undefined
|
||||
const rawInferenceGeo =
|
||||
(input as Record<string, unknown> | undefined)?.inference_geo ??
|
||||
@@ -965,8 +981,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive")
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "enabled") return undefined
|
||||
const budget =
|
||||
@@ -1418,9 +1433,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.index === undefined)
|
||||
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
|
||||
if (!block.id)
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
|
||||
)
|
||||
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`))
|
||||
}
|
||||
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
|
||||
}
|
||||
@@ -1473,10 +1486,9 @@ export const route = Route.make({
|
||||
provider: "anthropic",
|
||||
providerMetadataKey: "anthropic",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(
|
||||
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
|
||||
{ baseURL: DEFAULT_BASE_URL },
|
||||
),
|
||||
endpoint: Endpoint.path((input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH), {
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
}),
|
||||
auth: Auth.none,
|
||||
framing,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
|
||||
@@ -652,7 +652,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message:
|
||||
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
|
||||
event.exception.details.message ??
|
||||
event.exception.details.originalMessage ??
|
||||
"Bedrock Converse stream error",
|
||||
code: event.exception.type,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -82,7 +82,9 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
"Failed to parse Bedrock Converse event-stream payload",
|
||||
)) as Record<string, unknown>
|
||||
delete parsed.p
|
||||
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
|
||||
out.push(
|
||||
messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed },
|
||||
)
|
||||
}
|
||||
return [cursor, out] as const
|
||||
})
|
||||
|
||||
@@ -570,7 +570,12 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
googleMetadata({ thoughtSignature: state.textSignature }),
|
||||
)
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized:
|
||||
@@ -675,8 +680,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
id,
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata:
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
providerMetadata: part.thoughtSignature
|
||||
? googleMetadata({ thoughtSignature: part.thoughtSignature })
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
|
||||
@@ -496,7 +496,11 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
const lowering = {
|
||||
...options,
|
||||
toolCallID: (id: string) => {
|
||||
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
|
||||
if (mistral)
|
||||
return id
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.slice(0, 9)
|
||||
.padEnd(9, "0")
|
||||
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
|
||||
return id.slice(0, 40)
|
||||
@@ -505,7 +509,8 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
}
|
||||
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
|
||||
const bridgeTools = () => {
|
||||
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
|
||||
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
|
||||
messages.push({ role: "assistant", content: "Done." })
|
||||
}
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
@@ -557,7 +562,10 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
|
||||
if (
|
||||
message.role === "assistant" &&
|
||||
message.content.every((part) => part.type === "text" && part.text.trim() === "")
|
||||
)
|
||||
continue
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message, lowering)
|
||||
@@ -588,7 +596,10 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
|
||||
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
|
||||
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
|
||||
// require `max_tokens`.
|
||||
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
|
||||
const detectMaxTokensField = (
|
||||
provider: string,
|
||||
baseURL: string | undefined,
|
||||
): "max_tokens" | "max_completion_tokens" => {
|
||||
const p = provider.toLowerCase()
|
||||
const url = (baseURL ?? "").toLowerCase()
|
||||
if (
|
||||
@@ -638,7 +649,8 @@ const detectSupportsStore = (provider: string, baseURL: string | undefined): boo
|
||||
const isChutes = p === "chutes" || url.includes("chutes.ai")
|
||||
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
|
||||
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
|
||||
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
|
||||
const isVercelAiGateway =
|
||||
p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
|
||||
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
|
||||
const isOpencode = p === "opencode" || url.includes("opencode.ai")
|
||||
const isNonStandard =
|
||||
@@ -670,11 +682,7 @@ const detectSupportsStrictMode = (provider: string, baseURL: string | undefined)
|
||||
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
|
||||
}
|
||||
|
||||
const detectZaiToolStream = (
|
||||
provider: string,
|
||||
baseURL: string | undefined,
|
||||
modelID: string,
|
||||
): boolean => {
|
||||
const detectZaiToolStream = (provider: string, baseURL: string | undefined, modelID: string): boolean => {
|
||||
const p = provider.toLowerCase()
|
||||
const url = (baseURL ?? "").toLowerCase()
|
||||
const isZai =
|
||||
@@ -724,10 +732,10 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
|
||||
const supportsUsageInStreaming =
|
||||
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
|
||||
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
|
||||
const supportsStrictMode =
|
||||
request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
|
||||
const zaiToolStream =
|
||||
request.model.compatibility?.zaiToolStream ??
|
||||
detectZaiToolStream(provider, baseURL, request.model.id)
|
||||
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
const hasActiveTools = request.tools.length > 0
|
||||
return {
|
||||
@@ -816,11 +824,10 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
const cached =
|
||||
(usage.prompt_tokens_details?.cached_tokens ??
|
||||
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
|
||||
(usage as { cached_tokens?: number | null }).cached_tokens ??
|
||||
undefined) as number | undefined
|
||||
const cached = (usage.prompt_tokens_details?.cached_tokens ??
|
||||
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
|
||||
(usage as { cached_tokens?: number | null }).cached_tokens ??
|
||||
undefined) as number | undefined
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
@@ -936,13 +943,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
|
||||
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason =
|
||||
rawFinishReason
|
||||
? {
|
||||
normalized: yield* mapFinishReason(event, rawFinishReason),
|
||||
raw: choice?.native_finish_reason ?? rawFinishReason,
|
||||
}
|
||||
: state.finishReason
|
||||
const finishReason = rawFinishReason
|
||||
? {
|
||||
normalized: yield* mapFinishReason(event, rawFinishReason),
|
||||
raw: choice?.native_finish_reason ?? rawFinishReason,
|
||||
}
|
||||
: state.finishReason
|
||||
const delta = choice?.delta
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
|
||||
@@ -29,10 +29,9 @@ export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
|
||||
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||
(value): value is ServiceTier => typeof value === "string",
|
||||
{ title: "ServiceTier" },
|
||||
)
|
||||
export const ServiceTier = Schema.declare<ServiceTier>((value): value is ServiceTier => typeof value === "string", {
|
||||
title: "ServiceTier",
|
||||
})
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
|
||||
@@ -34,37 +34,35 @@ export const onDone: (
|
||||
state: OpenResponses.ParserState,
|
||||
item: Item,
|
||||
tools: Definitions,
|
||||
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(function* (
|
||||
state,
|
||||
item,
|
||||
tools,
|
||||
) {
|
||||
const tool = tools[item.type]
|
||||
if (!tool) return [state, []] satisfies OpenResponses.StepResult
|
||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: tool.result
|
||||
? yield* tool.result(item)
|
||||
: item.error !== undefined && item.error !== null
|
||||
? { type: "error", value: item.error }
|
||||
: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
})
|
||||
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(
|
||||
function* (state, item, tools) {
|
||||
const tool = tools[item.type]
|
||||
if (!tool) return [state, []] satisfies OpenResponses.StepResult
|
||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: tool.result
|
||||
? yield* tool.result(item)
|
||||
: item.error !== undefined && item.error !== null
|
||||
? { type: "error", value: item.error }
|
||||
: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
},
|
||||
)
|
||||
|
||||
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("groq")
|
||||
|
||||
export type ProviderOptions = Pick<OpenAIProviderOptionsInput, "reasoningEffort"> & {
|
||||
/** Controls visible reasoning on GPT-OSS; other models always use parsed reasoning. */
|
||||
readonly includeReasoning?: boolean
|
||||
readonly parallelToolCalls?: boolean
|
||||
readonly serviceTier?: "on_demand" | "flex" | "auto" | "performance" | (string & {})
|
||||
readonly user?: string
|
||||
}
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const Options = Schema.Struct({
|
||||
includeReasoning: Schema.optional(Schema.Boolean),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
serviceTier: Schema.optional(Schema.String),
|
||||
user: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "groq-chat",
|
||||
body: {
|
||||
schema: Schema.Struct({
|
||||
...OpenAIChat.bodyFields,
|
||||
reasoning_format: Schema.optional(Schema.Literal("parsed")),
|
||||
include_reasoning: Schema.optional(Schema.Boolean),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
service_tier: Schema.optional(Schema.String),
|
||||
user: Schema.optional(Schema.String),
|
||||
}),
|
||||
from: Effect.fn("Groq.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(
|
||||
request.providerOptions ?? {},
|
||||
)
|
||||
const gptOSS = request.model.id.startsWith("openai/gpt-oss-")
|
||||
return {
|
||||
...(yield* OpenAIChat.fromRequest(request)),
|
||||
reasoning_format: gptOSS ? undefined : ("parsed" as const),
|
||||
include_reasoning: gptOSS ? options.includeReasoning : undefined,
|
||||
parallel_tool_calls: options.parallelToolCalls,
|
||||
service_tier: options.serviceTier,
|
||||
user: options.user,
|
||||
}
|
||||
}),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: "groq-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.groq.baseURL },
|
||||
auth: AuthOptions.bearer(input, "GROQ_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<ProviderOptions>({
|
||||
id: modelID,
|
||||
compatibility: {
|
||||
maxTokensField: "max_completion_tokens",
|
||||
reasoningField: "reasoning",
|
||||
requireReasoning: false,
|
||||
supportsStore: false,
|
||||
supportsStrictMode: false,
|
||||
},
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Groq from "./groq.js"
|
||||
@@ -12,6 +12,7 @@ export * as GoogleVertex from "./google-vertex.js"
|
||||
export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
export * as GoogleVertexMessages from "./google-vertex-messages.js"
|
||||
export * as GoogleVertexResponses from "./google-vertex-responses.js"
|
||||
export * as Groq from "./groq.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
|
||||
@@ -339,9 +339,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return onHalt
|
||||
? parsed.pipe(
|
||||
Stream.concat(
|
||||
Stream.suspend(() =>
|
||||
Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable))),
|
||||
),
|
||||
Stream.suspend(() => Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable)))),
|
||||
),
|
||||
)
|
||||
: parsed
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"tags": ["prefix:azure", "provider:azure"],
|
||||
"name": "azure/chat-streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:53.198Z"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"tags": ["prefix:azure", "provider:azure"],
|
||||
"name": "azure/responses-calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:55.170Z"
|
||||
},
|
||||
|
||||
+1
-4
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"tags": ["prefix:azure", "provider:azure"],
|
||||
"name": "azure/responses-continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:56.397Z"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"tags": ["prefix:azure", "provider:azure"],
|
||||
"name": "azure/responses-streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:54.158Z"
|
||||
},
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "openai.gpt-oss-120b",
|
||||
"tags": [
|
||||
"prefix:bedrock-mantle",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:openai-responses"
|
||||
],
|
||||
"tags": ["prefix:bedrock-mantle", "provider:amazon-bedrock", "protocol:openai-responses"],
|
||||
"name": "bedrock-mantle/streams-text",
|
||||
"recordedAt": "2026-08-25T03:29:02.968Z"
|
||||
},
|
||||
|
||||
+2
-8
@@ -7,13 +7,7 @@
|
||||
"route": "cloudflare-workers-ai",
|
||||
"transport": "http",
|
||||
"model": "@cf/openai/gpt-oss-20b",
|
||||
"tags": [
|
||||
"prefix:cloudflare-workers-ai",
|
||||
"provider:cloudflare-workers-ai",
|
||||
"tool",
|
||||
"tool-call",
|
||||
"golden"
|
||||
]
|
||||
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -35,4 +29,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"name": "google-vertex/calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||
},
|
||||
|
||||
+1
-5
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"name": "google-vertex/continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"name": "google-vertex/streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||
},
|
||||
|
||||
+47
File diff suppressed because one or more lines are too long
Vendored
+47
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "openai/gpt-oss-20b",
|
||||
"tags": ["prefix:groq-chat", "provider:groq", "protocol:groq-chat", "text", "usage"],
|
||||
"name": "groq-chat/streams-text-with-usage",
|
||||
"recordedAt": "2026-08-26T14:40:09.833Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai/gpt-oss-20b\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_completion_tokens\":512,\"include_reasoning\":false,\"service_tier\":\"on_demand\",\"user\":\"recorded-test\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"seed\":94036161}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}},\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[],\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-6
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:openai-responses-cache",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"cache"
|
||||
],
|
||||
"tags": ["prefix:openai-responses-cache", "provider:openai", "protocol:openai-responses", "cache"],
|
||||
"name": "openai-responses-cache/reports-cached-tokens-on-identical-second-call",
|
||||
"recordedAt": "2026-08-25T03:29:25.124Z"
|
||||
},
|
||||
|
||||
Vendored
+1
-8
@@ -5,14 +5,7 @@
|
||||
"route": "openai-responses",
|
||||
"transport": "http",
|
||||
"model": "gpt-5.5",
|
||||
"tags": [
|
||||
"prefix:openai-responses",
|
||||
"provider:openai",
|
||||
"flagship",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"golden"
|
||||
],
|
||||
"tags": ["prefix:openai-responses", "provider:openai", "flagship", "tool", "tool-loop", "golden"],
|
||||
"name": "openai-responses/openai-responses-gpt-5-5-tool-loop",
|
||||
"recordedAt": "2026-08-20T06:30:22.262Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
|
||||
"name": "pdf/openai-tool-result",
|
||||
"recordedAt": "2026-08-25T03:29:08.297Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
|
||||
"name": "pdf/openai-user-input",
|
||||
"recordedAt": "2026-08-25T03:29:05.645Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:xai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:xai-responses", "tool", "tool-result"],
|
||||
"name": "pdf/xai-tool-result",
|
||||
"recordedAt": "2026-08-25T03:29:11.774Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:xai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:xai-responses", "user-input"],
|
||||
"name": "pdf/xai-user-input",
|
||||
"recordedAt": "2026-08-25T03:29:10.612Z"
|
||||
},
|
||||
|
||||
+1
-1
@@ -52,4 +52,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:vercel-ai-gateway",
|
||||
"protocol:openai-chat",
|
||||
"reasoning"
|
||||
],
|
||||
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"],
|
||||
"name": "vercel-ai-gateway-reasoning",
|
||||
"recordedAt": "2026-07-18T11:28:42.077Z"
|
||||
},
|
||||
@@ -31,4 +26,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +89,7 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies network error text as provider internal", () => {
|
||||
expect(
|
||||
["network error", "network-error", "network_error"].map(
|
||||
(message) => classifyProviderFailure({ message })._tag,
|
||||
),
|
||||
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/togetherai"),
|
||||
import("@opencode-ai/ai/providers/cerebras"),
|
||||
import("@opencode-ai/ai/providers/deepinfra"),
|
||||
import("@opencode-ai/ai/providers/groq"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
|
||||
@@ -515,7 +515,10 @@ describe("Gemini route", () => {
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
|
||||
{
|
||||
functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } },
|
||||
thoughtSignature: "sig_1",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -606,10 +609,7 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { name: "shot", args: {} } },
|
||||
{ functionCall: { name: "shot", args: {} } },
|
||||
],
|
||||
parts: [{ functionCall: { name: "shot", args: {} } }, { functionCall: { name: "shot", args: {} } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@@ -1071,7 +1071,9 @@ describe("Gemini route", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
|
||||
messages: [
|
||||
Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.contents).toEqual([
|
||||
@@ -1572,9 +1574,7 @@ describe("Gemini route", () => {
|
||||
{ candidates: [{ content: { role: "model", parts: null } }] },
|
||||
{ candidates: [{ content: null, finishReason: null }] },
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
|
||||
],
|
||||
candidates: [{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const }],
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,9 +26,7 @@ const recorded = recordedTests({
|
||||
describe("Google Vertex Gemini recorded", () => {
|
||||
recorded.effect("streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Reply with exactly one word: hello" }))
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { configure } from "@opencode-ai/ai/providers/groq"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, LLMResponse, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const apiKey = process.env.GROQ_API_KEY ?? "fixture"
|
||||
const recorded = recordedTests({
|
||||
prefix: "groq-chat",
|
||||
provider: "groq",
|
||||
protocol: "groq-chat",
|
||||
requires: ["GROQ_API_KEY"],
|
||||
})
|
||||
|
||||
const weather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string", enum: ["Paris", "London"] } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
|
||||
describe("Groq recorded", () => {
|
||||
recorded.effect.with(
|
||||
"streams text with usage",
|
||||
{ tags: ["text", "usage"], metadata: { model: "openai/gpt-oss-20b" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: configure({
|
||||
apiKey,
|
||||
providerOptions: {
|
||||
includeReasoning: false,
|
||||
reasoningEffort: "low",
|
||||
serviceTier: "on_demand",
|
||||
user: "recorded-test",
|
||||
},
|
||||
}).model("openai/gpt-oss-20b"),
|
||||
prompt: "Reply with exactly one word: hello",
|
||||
generation: { maxTokens: 512 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body).toMatchObject({
|
||||
max_completion_tokens: 512,
|
||||
stream_options: { include_usage: true },
|
||||
include_reasoning: false,
|
||||
service_tier: "on_demand",
|
||||
user: "recorded-test",
|
||||
})
|
||||
expect(compiled.body.max_tokens).toBeUndefined()
|
||||
expect(compiled.body.store).toBeUndefined()
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.text.toLowerCase().trim()).toBe("hello")
|
||||
expect(response.reasoning).toBe("")
|
||||
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
for (const item of [
|
||||
{
|
||||
name: "continues Qwen parallel tool calls",
|
||||
model: configure({ apiKey, providerOptions: { parallelToolCalls: true, reasoningEffort: "none" } }).model(
|
||||
"qwen/qwen3.6-27b",
|
||||
),
|
||||
cities: ["Paris", "London"],
|
||||
reasoning: false,
|
||||
},
|
||||
{
|
||||
name: "replays GPT OSS reasoning through a tool loop",
|
||||
model: configure({ apiKey, providerOptions: { includeReasoning: true, reasoningEffort: "low" } }).model(
|
||||
"openai/gpt-oss-20b",
|
||||
),
|
||||
cities: ["Paris"],
|
||||
reasoning: true,
|
||||
},
|
||||
]) {
|
||||
recorded.effect.with(
|
||||
item.name,
|
||||
{
|
||||
tags: ["tool", "tool-loop", "usage", item.reasoning ? "reasoning" : "parallel"],
|
||||
metadata: { model: item.model.id },
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: item.model,
|
||||
prompt: `Look up the current weather in ${item.cities.join(" and ")}. Call lookup_weather once for each city in the same response before answering. After receiving all results, report each city's weather in one short sentence.`,
|
||||
tools: [weather],
|
||||
toolChoice: "required",
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body.stream_options).toEqual({ include_usage: true })
|
||||
expect(compiled.body.store).toBeUndefined()
|
||||
expect(compiled.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
|
||||
expect(compiled.body.tools[0].function.strict).toBeUndefined()
|
||||
if (!item.reasoning) expect(compiled.body.parallel_tool_calls).toBe(true)
|
||||
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.normalized).toBe("tool-calls")
|
||||
expect(first.toolCalls).toHaveLength(item.cities.length)
|
||||
expect(new Set(first.toolCalls.map((call) => call.id)).size).toBe(item.cities.length)
|
||||
expect(first.toolCalls.map((call) => call.input)).toEqual(
|
||||
expect.arrayContaining(item.cities.map((city) => ({ city }))),
|
||||
)
|
||||
expect(first.toolCalls.every((call) => call.name === "lookup_weather")).toBe(true)
|
||||
expectUsage(first)
|
||||
if (item.reasoning) {
|
||||
expect(first.reasoning.length).toBeGreaterThan(0)
|
||||
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
}
|
||||
|
||||
const followUp = LLMRequest.update(request, {
|
||||
toolChoice: ToolChoice.make("none"),
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
...first.toolCalls.map((call) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
|
||||
),
|
||||
],
|
||||
})
|
||||
const replay = yield* compileRequest(followUp)
|
||||
if (item.reasoning) {
|
||||
expect(replay.body.messages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ role: "assistant", reasoning: first.reasoning })]),
|
||||
)
|
||||
}
|
||||
expect(replay.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
|
||||
|
||||
const second = yield* LLMClient.generate(followUp)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
expect(second.toolCalls).toHaveLength(0)
|
||||
expect(second.text.toLowerCase()).toContain("sunny")
|
||||
item.cities.forEach((city) => expect(second.text).toContain(city))
|
||||
expectUsage(second)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
}
|
||||
|
||||
recorded.effect.with(
|
||||
"streams Qwen parsed reasoning",
|
||||
{ tags: ["reasoning", "usage"], metadata: { model: "qwen/qwen3.6-27b" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: configure({
|
||||
apiKey,
|
||||
providerOptions: { reasoningEffort: "default" },
|
||||
}).model("qwen/qwen3.6-27b"),
|
||||
prompt:
|
||||
"What is 173 multiplied by 219? Think through the arithmetic, then reply with only the final integer.",
|
||||
generation: { maxTokens: 2048 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body).toMatchObject({ reasoning_format: "parsed", reasoning_effort: "default" })
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.text).not.toContain("<think>")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
|
||||
function expectUsage(response: LLMResponse) {
|
||||
expect(response.usage).toBeDefined()
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.outputTokens).toBeGreaterThan(0)
|
||||
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LanguageModel, LLM, Message } from "../../src/index.js"
|
||||
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
|
||||
import { Groq } from "../../src/providers/groq.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { weatherTool } from "../recorded-scenarios.js"
|
||||
|
||||
it.effect("Groq reuses Chat streaming and defaults to parsed reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Groq.protocol.stream).toBe(OpenAIChat.protocol.stream)
|
||||
const model = Groq.configure({ apiKey: "fixture" }).model("llama-3.3-70b-versatile")
|
||||
expect(model.route.endpoint.baseURL).toBe("https://api.groq.com/openai/v1")
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Hello", tools: [weatherTool], generation: { maxTokens: 64 } }),
|
||||
)
|
||||
expect(compiled.body).toMatchObject({
|
||||
max_completion_tokens: 64,
|
||||
stream_options: { include_usage: true },
|
||||
reasoning_format: "parsed",
|
||||
})
|
||||
for (const key of ["store", "max_tokens", "include_reasoning", "parallel_tool_calls", "service_tier", "user"])
|
||||
expect(compiled.body[key]).toBeUndefined()
|
||||
expect(compiled.body.tools?.[0]?.function).not.toHaveProperty("strict")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq lowers its own options for custom catalog identities and endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = LanguageModel.update(
|
||||
Groq.model("qwen/qwen3.6-27b", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-client": "test" },
|
||||
body: { custom: "value" },
|
||||
providerOptions: {
|
||||
reasoningEffort: "default",
|
||||
parallelToolCalls: true,
|
||||
serviceTier: "flex",
|
||||
user: "test-user",
|
||||
},
|
||||
}),
|
||||
{ provider: "custom-groq" },
|
||||
)
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { parallelToolCalls: false, includeReasoning: false } }),
|
||||
)
|
||||
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
|
||||
expect(model.route.defaults.http?.body).toEqual({ custom: "value" })
|
||||
expect(compiled.body).toMatchObject({
|
||||
reasoning_effort: "default",
|
||||
reasoning_format: "parsed",
|
||||
parallel_tool_calls: false,
|
||||
service_tier: "flex",
|
||||
user: "test-user",
|
||||
})
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
for (const key of ["reasoningFormat", "reasoningEffort", "parallelToolCalls", "serviceTier"])
|
||||
expect(compiled.body).not.toHaveProperty(key)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq replays reasoning only when present and preserves explicit reasoning exclusion", () =>
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Groq.configure({ apiKey: "fixture" }).model("openai/gpt-oss-20b"),
|
||||
messages: [
|
||||
Message.user("Think"),
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Thinking" },
|
||||
{ type: "text", text: "Answer" },
|
||||
]),
|
||||
Message.user("Again"),
|
||||
Message.assistant("Answer only"),
|
||||
Message.user("Continue"),
|
||||
],
|
||||
providerOptions: { reasoningEffort: "low", includeReasoning: false },
|
||||
}),
|
||||
)
|
||||
expect(compiled.body).toMatchObject({ reasoning_effort: "low", include_reasoning: false })
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
expect(compiled.body.messages[1]).toMatchObject({ reasoning: "Thinking", content: "Answer" })
|
||||
expect(compiled.body.messages[1]).not.toHaveProperty("reasoning_content")
|
||||
expect(compiled.body.messages[3]).not.toHaveProperty("reasoning")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq omits reasoning_format for the GPT-OSS family by default", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const id of ["openai/gpt-oss-20b", "openai/gpt-oss-120b", "openai/gpt-oss-safeguard-20b"]) {
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model(id), prompt: "Hello" }),
|
||||
)
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq validates option types", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const providerOptions of [{ includeReasoning: "false" }, { parallelToolCalls: "false" }]) {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model("qwen"), prompt: "Hello", providerOptions }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { Cerebras, DeepInfra, Groq, TogetherAI } from "../../src/providers/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
@@ -155,6 +155,12 @@ describe("native OpenAI-compatible providers", () => {
|
||||
token: "deepinfra-secret",
|
||||
url: "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
},
|
||||
{
|
||||
model: Groq.configure().model("llama"),
|
||||
env: { GROQ_API_KEY: "groq-secret" },
|
||||
token: "groq-secret",
|
||||
url: "https://api.groq.com/openai/v1/chat/completions",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(scenarios, (scenario) =>
|
||||
|
||||
@@ -80,7 +80,9 @@ describe("Z.ai Images", () => {
|
||||
|
||||
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
|
||||
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image(
|
||||
"model",
|
||||
),
|
||||
prompt: "A red circle \uD800 on a white background \u{1F600}",
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
|
||||
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
{
|
||||
location: { directory: sdk().directory },
|
||||
query,
|
||||
type: dirs === "true" ? "directory" : "file",
|
||||
type: dirs === "true" ? undefined : "file",
|
||||
limit: options?.limit,
|
||||
},
|
||||
{ signal: options?.signal },
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.8",
|
||||
"@opencode-ai/pty": "0.1.9",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
|
||||
@@ -663,6 +663,15 @@ export type SessionLogOutput =
|
||||
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.step.streamed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly assistantMessageID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
@@ -974,6 +983,19 @@ export type SessionLogOutput =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly to: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.message.content.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly content: ReadonlyArray<SessionMessage.AssistantContentEncoded>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
@@ -1013,6 +1035,18 @@ export type SessionMessageInput = { readonly sessionID: Session.ID; readonly mes
|
||||
export type SessionMessageOutput = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: SessionMessageInput) => Effect.Effect<SessionMessageOutput, E>
|
||||
|
||||
export type SessionMessageUpdateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly content: ReadonlyArray<
|
||||
SessionMessage.AssistantText | SessionMessage.AssistantReasoning | SessionMessage.AssistantTool
|
||||
>
|
||||
}
|
||||
export type SessionMessageUpdateOutput = SessionMessage.Assistant
|
||||
export type SessionMessageUpdateOperation<E = never> = (
|
||||
input: SessionMessageUpdateInput,
|
||||
) => Effect.Effect<SessionMessageUpdateOutput, E>
|
||||
|
||||
export type SessionEnvironmentInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly variables: { readonly [x: string]: string }
|
||||
@@ -1071,6 +1105,7 @@ export interface SessionApi<E = never> {
|
||||
readonly interrupt: SessionInterruptOperation<E>
|
||||
readonly background: SessionBackgroundOperation<E>
|
||||
readonly message: SessionMessageOperation<E>
|
||||
readonly messageUpdate: SessionMessageUpdateOperation<E>
|
||||
readonly environment: SessionEnvironmentOperation<E>
|
||||
readonly view: SessionViewOperation<E>
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ import type {
|
||||
SessionBackgroundOutput,
|
||||
SessionMessageInput,
|
||||
SessionMessageOutput,
|
||||
SessionMessageUpdateInput,
|
||||
SessionMessageUpdateOutput,
|
||||
SessionEnvironmentInput,
|
||||
SessionEnvironmentOutput,
|
||||
SessionViewInput,
|
||||
@@ -651,6 +653,17 @@ const EndpointSessionMessage = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionMessageUpdate = (raw: RawClient["server.session"]) => (input: SessionMessageUpdateInput) =>
|
||||
preserveEffect<SessionMessageUpdateOutput>()(
|
||||
raw["session.messageUpdate"]({
|
||||
params: { sessionID: input["sessionID"], messageID: input["messageID"] },
|
||||
payload: { content: input["content"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
|
||||
preserveEffect<SessionEnvironmentOutput>()(
|
||||
raw["session.environment"]({
|
||||
@@ -711,6 +724,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
interrupt: EndpointSessionInterrupt(raw),
|
||||
background: EndpointSessionBackground(raw),
|
||||
message: EndpointSessionMessage(raw),
|
||||
messageUpdate: EndpointSessionMessageUpdate(raw),
|
||||
environment: EndpointSessionEnvironment(raw),
|
||||
view: EndpointSessionView(raw),
|
||||
})
|
||||
|
||||
@@ -80,6 +80,8 @@ import type {
|
||||
SessionBackgroundOutput,
|
||||
SessionMessageInput,
|
||||
SessionMessageOutput,
|
||||
SessionMessageUpdateInput,
|
||||
SessionMessageUpdateOutput,
|
||||
SessionEnvironmentInput,
|
||||
SessionEnvironmentOutput,
|
||||
SessionViewInput,
|
||||
@@ -929,6 +931,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
messageUpdate: (input: SessionMessageUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionMessageUpdateOutput }>(
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
|
||||
body: { content: input["content"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 409, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionEnvironmentOutput>(
|
||||
{
|
||||
|
||||
@@ -174,6 +174,12 @@ export type SessionMessageProviderState1 = { [x: string]: any }
|
||||
|
||||
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
|
||||
|
||||
export type SessionMessageToolStateRunning1 = {
|
||||
status: "running"
|
||||
input: { [x: string]: any }
|
||||
metadata: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
export type SessionInterruptResponse = { interrupted: boolean }
|
||||
@@ -714,6 +720,16 @@ export type SessionStepStarted = {
|
||||
data: { sessionID: string; assistantMessageID: string; agent: string; model: ModelRef; snapshot?: string }
|
||||
}
|
||||
|
||||
export type SessionStepStreamed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.step.streamed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string }
|
||||
}
|
||||
|
||||
export type SessionTextStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1315,6 +1331,15 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
state?: SessionMessageProviderState1
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type ToolContent1 = ToolTextContent | ToolFileContent1
|
||||
|
||||
export type ModelCompatibility = {
|
||||
@@ -1743,6 +1768,21 @@ export type SessionToolFailed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateCompleted1 = {
|
||||
status: "completed"
|
||||
input: { [x: string]: any }
|
||||
content: [ToolContent1, ...Array<ToolContent1>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateError1 = {
|
||||
status: "error"
|
||||
input: { [x: string]: any }
|
||||
error: SessionStructuredError
|
||||
content?: [ToolContent1, ...Array<ToolContent1>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
@@ -2009,6 +2049,21 @@ export type SessionMessageAssistantTool = {
|
||||
time: { created: number; ran?: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantTool1 = {
|
||||
type: "tool"
|
||||
id: string
|
||||
name: string
|
||||
executed?: boolean
|
||||
providerState?: SessionMessageProviderState1
|
||||
providerResultState?: SessionMessageProviderState1
|
||||
state:
|
||||
| SessionMessageToolStateStreaming
|
||||
| SessionMessageToolStateRunning1
|
||||
| SessionMessageToolStateCompleted1
|
||||
| SessionMessageToolStateError1
|
||||
time: { created: number; ran?: number; completed?: number }
|
||||
}
|
||||
|
||||
export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type FormFields2 = [FormField1, ...Array<FormField1>]
|
||||
@@ -2028,7 +2083,7 @@ export type SessionInboxEnqueued = {
|
||||
export type SessionMessageAssistant = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number; completed?: number }
|
||||
time: { created: number; streamed?: number; completed?: number }
|
||||
type: "assistant"
|
||||
agent: string
|
||||
model: ModelRef
|
||||
@@ -2043,6 +2098,11 @@ export type SessionMessageAssistant = {
|
||||
retry?: SessionMessageAssistantRetry
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantContentEncoded =
|
||||
| SessionMessageAssistantText1
|
||||
| SessionMessageAssistantReasoning1
|
||||
| SessionMessageAssistantTool1
|
||||
|
||||
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
|
||||
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
|
||||
@@ -2051,6 +2111,50 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields2 }
|
||||
|
||||
export type SessionMessageInfo =
|
||||
| SessionMessageAgentSelected
|
||||
| SessionMessageModelSelected
|
||||
| SessionMessageLocationSwitched
|
||||
| SessionMessageUser
|
||||
| SessionMessageSynthetic
|
||||
| SessionMessageSystem
|
||||
| SessionMessageSkill
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.message.content.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; messageID: string; content: Array<SessionMessageAssistantContentEncoded> }
|
||||
}
|
||||
|
||||
export type IntegrationMethod =
|
||||
| IntegrationOAuthMethod
|
||||
| IntegrationCommandMethod
|
||||
| IntegrationKeyMethod
|
||||
| IntegrationEnvMethod
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "form.created"
|
||||
location?: LocationRef
|
||||
data: { form: FormInfo1 }
|
||||
}
|
||||
|
||||
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
|
||||
|
||||
export type SessionMessagesResponse = {
|
||||
data: Array<SessionMessageInfo>
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
}
|
||||
|
||||
export type SessionEventDurable =
|
||||
| SessionCreated
|
||||
| SessionAgentSelected
|
||||
@@ -2074,6 +2178,7 @@ export type SessionEventDurable =
|
||||
| SessionShellStarted
|
||||
| SessionShellEnded
|
||||
| SessionStepStarted
|
||||
| SessionStepStreamed
|
||||
| SessionStepEnded
|
||||
| SessionStepFailed
|
||||
| SessionTextStarted
|
||||
@@ -2092,44 +2197,9 @@ export type SessionEventDurable =
|
||||
| SessionRevertStaged
|
||||
| SessionRevertCleared
|
||||
| SessionRevertCommitted
|
||||
| SessionMessageContentUpdated
|
||||
| SessionUsageRecorded
|
||||
|
||||
export type SessionMessageInfo =
|
||||
| SessionMessageAgentSelected
|
||||
| SessionMessageModelSelected
|
||||
| SessionMessageLocationSwitched
|
||||
| SessionMessageUser
|
||||
| SessionMessageSynthetic
|
||||
| SessionMessageSystem
|
||||
| SessionMessageSkill
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type IntegrationMethod =
|
||||
| IntegrationOAuthMethod
|
||||
| IntegrationCommandMethod
|
||||
| IntegrationKeyMethod
|
||||
| IntegrationEnvMethod
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "form.created"
|
||||
location?: LocationRef
|
||||
data: { form: FormInfo1 }
|
||||
}
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
|
||||
|
||||
export type SessionMessagesResponse = {
|
||||
data: Array<SessionMessageInfo>
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
}
|
||||
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -2168,6 +2238,7 @@ export type V2Event =
|
||||
| SessionShellStarted
|
||||
| SessionShellEnded
|
||||
| SessionStepStarted
|
||||
| SessionStepStreamed
|
||||
| SessionStepEnded
|
||||
| SessionStepFailed
|
||||
| SessionTextStarted
|
||||
@@ -2191,6 +2262,7 @@ export type V2Event =
|
||||
| SessionRevertStaged
|
||||
| SessionRevertCleared
|
||||
| SessionRevertCommitted
|
||||
| SessionMessageContentUpdated
|
||||
| FilesystemChanged
|
||||
| ReferenceUpdated
|
||||
| PermissionAsked
|
||||
@@ -2229,6 +2301,8 @@ export type V2Event =
|
||||
| McpResourcesChanged
|
||||
| V2EventServerConnected
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
|
||||
@@ -2789,7 +2863,7 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
@@ -3065,7 +3139,7 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
@@ -3341,7 +3415,7 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
@@ -4036,6 +4110,91 @@ export type SessionMessageInput = {
|
||||
|
||||
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
|
||||
|
||||
export type SessionMessageUpdateInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
|
||||
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]
|
||||
readonly content: {
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly text: string
|
||||
readonly state?: { readonly [x: string]: JsonValue }
|
||||
readonly time?: { readonly created: number; readonly completed?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly executed?: boolean
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly providerResultState?: { readonly [x: string]: JsonValue }
|
||||
readonly state:
|
||||
| { readonly status: "streaming"; readonly input: string }
|
||||
| {
|
||||
readonly status: "running"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly metadata: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly content: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
),
|
||||
...Array<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>,
|
||||
]
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
),
|
||||
...Array<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string | null
|
||||
}
|
||||
>,
|
||||
]
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
|
||||
}
|
||||
>
|
||||
}["content"]
|
||||
}
|
||||
|
||||
export type SessionMessageUpdateOutput = { data: SessionMessageAssistant }["data"]
|
||||
|
||||
export type SessionEnvironmentInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly variables: { readonly variables: { readonly [x: string]: string } }["variables"]
|
||||
|
||||
@@ -167,6 +167,10 @@ function createSync() {
|
||||
has(key: string) {
|
||||
return state.has(key)
|
||||
},
|
||||
pending(key: string) {
|
||||
const active = state.get(key)
|
||||
return active !== undefined && active !== true
|
||||
},
|
||||
invalidate(key?: string) {
|
||||
if (key) {
|
||||
const active = state.get(key)
|
||||
@@ -723,6 +727,17 @@ export function createData(config: CreateDataInput) {
|
||||
match.time.completed = event.created
|
||||
})
|
||||
return
|
||||
case "session.message.content.updated": {
|
||||
if (store.session.message[event.data.sessionID])
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const assistant = message.assistant(draft, index, event.data.messageID)
|
||||
if (assistant) assistant.content = [...event.data.content]
|
||||
})
|
||||
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
void result.session.message.sync(event.data.sessionID)
|
||||
return
|
||||
}
|
||||
case "session.step.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.assistantMessageID)
|
||||
@@ -735,6 +750,7 @@ export function createData(config: CreateDataInput) {
|
||||
existing.finish = undefined
|
||||
existing.rawFinish = undefined
|
||||
existing.providerState = undefined
|
||||
existing.time.streamed = undefined
|
||||
existing.time.completed = undefined
|
||||
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
|
||||
return
|
||||
@@ -756,6 +772,12 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
})
|
||||
return
|
||||
case "session.step.streamed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (currentAssistant) currentAssistant.time.streamed = event.created
|
||||
})
|
||||
return
|
||||
case "session.step.ended": {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenCodeClient, PtyConnectTokenInput } from "../promise"
|
||||
import type { ExperimentalPersistentPtyConnectTokenInput, OpenCodeClient, PtyConnectTokenInput } from "../promise"
|
||||
|
||||
export type PtyClientOptions = {
|
||||
readonly url: string
|
||||
@@ -11,6 +11,13 @@ export type PtyConnectInput = {
|
||||
readonly cursor?: number
|
||||
}
|
||||
|
||||
export type PersistentPtyConnectInput = {
|
||||
readonly ptyID: ExperimentalPersistentPtyConnectTokenInput["ptyID"]
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly takeover?: boolean
|
||||
}
|
||||
|
||||
export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
|
||||
return {
|
||||
async connect(input: PtyConnectInput) {
|
||||
@@ -32,3 +39,25 @@ export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createPersistentPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
|
||||
return {
|
||||
async connect(input: PersistentPtyConnectInput) {
|
||||
const token = await api.experimental.persistentPty.connectToken({
|
||||
ptyID: input.ptyID,
|
||||
"x-opencode-ticket": "1",
|
||||
})
|
||||
const url = new URL(`/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`, options.url)
|
||||
url.searchParams.set("ticket", token.ticket)
|
||||
url.searchParams.set("cursor", String(input.cursor))
|
||||
url.searchParams.set("attachment_id", input.attachmentID)
|
||||
url.searchParams.set("takeover", String(input.takeover ?? false))
|
||||
url.searchParams.set("input_protocol", "1")
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
const socket = options.openSocket?.(url) ?? new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
return socket
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +414,77 @@ test("loads bounded message pages", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves assistant content replacement events across an active message read", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
let requests = 0
|
||||
const content = [
|
||||
{ type: "text" as const, text: "replacement" },
|
||||
{ type: "reasoning" as const, text: "reasoning", time: { created: 3 } },
|
||||
]
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async () => {
|
||||
const current = ++requests
|
||||
if (current === 2) await release.promise
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: current === 3 ? content : [{ type: "text", text: "original" }],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
setup.data.session.message.invalidate("ses_refresh")
|
||||
const stale = setup.data.session.message.sync("ses_refresh")
|
||||
await wait(() => requests === 2)
|
||||
const updated: OpenCodeEvent = {
|
||||
id: "evt_message_updated",
|
||||
created: 3,
|
||||
type: "session.message.content.updated",
|
||||
durable: { aggregateID: "ses_refresh", seq: 3, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_refresh",
|
||||
messageID: "msg_assistant",
|
||||
content,
|
||||
},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
|
||||
|
||||
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
|
||||
release.resolve()
|
||||
await stale
|
||||
await wait(() => requests === 3)
|
||||
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function wait(check: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!check()) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenCode } from "../src/promise"
|
||||
import { createPtyClient } from "../src/solid"
|
||||
import { createPersistentPtyClient, createPtyClient } from "../src/solid"
|
||||
|
||||
describe("createPtyClient", () => {
|
||||
test("mints an authenticated ticket before opening the terminal socket", async () => {
|
||||
@@ -66,3 +66,36 @@ describe("createPtyClient", () => {
|
||||
expect(opened).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createPersistentPtyClient", () => {
|
||||
test("mints an authenticated ticket before opening the persistent terminal socket", async () => {
|
||||
let request: Request | undefined
|
||||
let socketURL: URL | undefined
|
||||
const socket = { binaryType: "blob" } as unknown as WebSocket
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "https://server.example/base",
|
||||
headers: { Authorization: "Basic credential" },
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ data: { ticket: "persistent-ticket", expires_in: 60 } })
|
||||
},
|
||||
})
|
||||
const pty = createPersistentPtyClient(api, {
|
||||
url: "https://server.example/base",
|
||||
openSocket(url) {
|
||||
socketURL = url
|
||||
return socket
|
||||
},
|
||||
})
|
||||
|
||||
expect(await pty.connect({ ptyID: "pty_1", cursor: 42, attachmentID: "attachment_1", takeover: true })).toBe(socket)
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("https://server.example/api/experimental/persistent-pty/pty_1/connect-token")
|
||||
expect(request?.headers.get("authorization")).toBe("Basic credential")
|
||||
expect(request?.headers.get("x-opencode-ticket")).toBe("1")
|
||||
expect(socketURL?.toString()).toBe(
|
||||
"wss://server.example/api/experimental/persistent-pty/pty_1/connect?ticket=persistent-ticket&cursor=42&attachment_id=attachment_1&takeover=true&input_protocol=1",
|
||||
)
|
||||
expect(socket.binaryType).toBe("arraybuffer")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -119,7 +118,7 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.8",
|
||||
"@opencode-ai/pty": "0.1.9",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -55,6 +55,7 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
}
|
||||
case "@ai-sdk/cerebras":
|
||||
case "@ai-sdk/deepinfra":
|
||||
case "@ai-sdk/groq":
|
||||
case "@ai-sdk/togetherai":
|
||||
return {
|
||||
package: `@opencode-ai/ai/providers/${input.packageName.slice("@ai-sdk/".length)}`,
|
||||
|
||||
@@ -337,6 +337,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@ai-sdk/deepinfra" ||
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/groq" ||
|
||||
name === "@ai-sdk/togetherai" ||
|
||||
name === "@ai-sdk/xai" ||
|
||||
name === "@openrouter/ai-sdk-provider" ||
|
||||
@@ -349,6 +350,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@opencode-ai/ai/providers/deepinfra" ||
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/groq" ||
|
||||
name === "@opencode-ai/ai/providers/togetherai" ||
|
||||
name === "@opencode-ai/ai/providers/xai" ||
|
||||
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||
|
||||
@@ -161,7 +161,7 @@ const layer = Layer.effect(
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Effect.gen(function* () {
|
||||
active.clear()
|
||||
yield* State.batch(Scope.close(scope, exit))
|
||||
yield* State.batch(Scope.close(scope, exit), { flush: false })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { GatewayPlugin } from "./provider/gateway.js"
|
||||
import { GithubCopilotPlugin } from "./provider/github-copilot.js"
|
||||
import { GitLabPlugin } from "./provider/gitlab.js"
|
||||
import { GoogleVertexPlugin } from "./provider/google-vertex.js"
|
||||
import { GroqPlugin } from "./provider/groq.js"
|
||||
import { KiloPlugin } from "./provider/kilo.js"
|
||||
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||
import { LMStudioPlugin } from "./provider/lmstudio.js"
|
||||
@@ -45,7 +44,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
GoogleVertexPlugin,
|
||||
GroqPlugin,
|
||||
KiloPlugin,
|
||||
LLMGatewayPlugin,
|
||||
LMStudioPlugin,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createProviderPlugin } from "./factory.js"
|
||||
|
||||
export const GroqPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.groq",
|
||||
package: "@ai-sdk/groq",
|
||||
load: async (options) => {
|
||||
const { createGroq } = await import("@ai-sdk/groq")
|
||||
return createGroq(options)
|
||||
},
|
||||
})
|
||||
@@ -62,6 +62,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
],
|
||||
["@opencode-ai/ai/providers/groq", () => import("@opencode-ai/ai/providers/groq")],
|
||||
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
|
||||
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
|
||||
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
||||
|
||||
@@ -139,6 +139,27 @@ export class CompactionConflictError extends Schema.TaggedError<CompactionConfli
|
||||
export class BusyError extends Schema.TaggedError<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export class MessageNotAssistantError extends Schema.TaggedError<MessageNotAssistantError>()(
|
||||
"Session.MessageNotAssistantError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
export class MessageIncompleteError extends Schema.TaggedError<MessageIncompleteError>()(
|
||||
"Session.MessageIncompleteError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
export class MessageToolIncompleteError extends Schema.TaggedError<MessageToolIncompleteError>()(
|
||||
"Session.MessageToolIncompleteError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()("Session.InboxConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
inboxID: SessionMessage.ID,
|
||||
@@ -193,6 +214,19 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
}) => Effect.Effect<SessionMessage.Info | undefined>
|
||||
readonly updateMessage: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly content: readonly SessionMessage.AssistantContent[]
|
||||
}) => Effect.Effect<
|
||||
SessionMessage.Assistant,
|
||||
| NotFoundError
|
||||
| MessageNotFoundError
|
||||
| BusyError
|
||||
| MessageNotAssistantError
|
||||
| MessageIncompleteError
|
||||
| MessageToolIncompleteError
|
||||
>
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
@@ -560,6 +594,29 @@ const layer = Layer.effect(
|
||||
const stored = yield* store.message(input.messageID)
|
||||
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
||||
}),
|
||||
updateMessage: Effect.fn("Session.updateMessage")(function* (input) {
|
||||
const ref = { sessionID: input.sessionID, messageID: input.messageID }
|
||||
yield* result.get(ref.sessionID)
|
||||
if ((yield* execution.active).has(ref.sessionID)) return yield* new BusyError({ sessionID: ref.sessionID })
|
||||
const message = yield* result.message(ref)
|
||||
if (!message) return yield* new MessageNotFoundError(ref)
|
||||
if (message.type !== "assistant") return yield* new MessageNotAssistantError(ref)
|
||||
if (!message.time.completed) return yield* new MessageIncompleteError(ref)
|
||||
if (
|
||||
input.content.some(
|
||||
(content) =>
|
||||
content.type === "tool" && (content.state.status === "streaming" || content.state.status === "running"),
|
||||
)
|
||||
)
|
||||
return yield* new MessageToolIncompleteError(ref)
|
||||
yield* bus.publish(SessionEvent.MessageContentUpdated, {
|
||||
...ref,
|
||||
content: Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))(input.content),
|
||||
})
|
||||
const updated = yield* result.message(ref)
|
||||
if (updated?.type !== "assistant") return yield* new MessageNotFoundError(ref)
|
||||
return updated
|
||||
}),
|
||||
context: Effect.fn("Session.context")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { castDraft, produce, type WritableDraft } from "immer"
|
||||
import { DateTime, Effect, Match, pipe } from "effect"
|
||||
import { DateTime, Effect, Match, pipe, Schema } from "effect"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
|
||||
@@ -71,6 +71,12 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
"session.created": () => Effect.void,
|
||||
"session.viewed": () => Effect.void,
|
||||
"session.message.content.updated": (event) =>
|
||||
updateOwnedAssistant(event.data.messageID, (draft) => {
|
||||
draft.content = castDraft(
|
||||
Schema.decodeUnknownSync(Schema.Array(SessionMessage.AssistantContent))(event.data.content),
|
||||
)
|
||||
}),
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -204,6 +210,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.finish = undefined
|
||||
draft.rawFinish = undefined
|
||||
draft.providerState = undefined
|
||||
draft.time.streamed = undefined
|
||||
draft.time.completed = undefined
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
|
||||
}),
|
||||
@@ -232,6 +239,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
}),
|
||||
)
|
||||
}),
|
||||
"session.step.streamed": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.streamed = created
|
||||
})
|
||||
},
|
||||
"session.step.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = created
|
||||
|
||||
@@ -581,6 +581,7 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
yield* bus.project(SessionEvent.MessageContentUpdated, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
|
||||
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
||||
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
|
||||
@@ -659,6 +660,7 @@ const layer = Layer.effectDiscard(
|
||||
yield* bus.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Step.Started, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Step.Streamed, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Step.Ended, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
|
||||
@@ -489,6 +489,7 @@ const layer = Layer.effect(
|
||||
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
|
||||
|
||||
// Join every owned tool run first: await all exits, not just the first failure.
|
||||
// Afterwards no fiber is alive, settlement is the only writer, and the record
|
||||
|
||||
@@ -103,6 +103,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
let outputStarted = false
|
||||
let stepStreamed = false
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement: StepRecord["finish"]
|
||||
|
||||
@@ -120,6 +121,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})
|
||||
const currentAssistantMessageID = () =>
|
||||
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
|
||||
const streamed = Effect.fnUntraced(function* () {
|
||||
if (stepStreamed) return
|
||||
stepStreamed = true
|
||||
yield* bus.publish(SessionEvent.Step.Streamed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
})
|
||||
})
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
|
||||
const fragments = (
|
||||
name: string,
|
||||
@@ -583,6 +592,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
publishStepFailure,
|
||||
failUnsettledTools,
|
||||
hasProviderError: () => providerFailed,
|
||||
hasStarted: () => stepStarted,
|
||||
/** Immutable snapshot of everything recorded for this step so far. */
|
||||
record: (): StepRecord => ({
|
||||
outputStarted,
|
||||
@@ -598,6 +608,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})),
|
||||
}),
|
||||
startAssistant,
|
||||
streamed,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface Transformable<DraftApi> {
|
||||
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly flush: boolean
|
||||
readonly reloads: Set<Reload>
|
||||
}
|
||||
|
||||
@@ -40,14 +41,15 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
|
||||
})
|
||||
const reloadDebounce = 500
|
||||
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active) return yield* effect
|
||||
const batch: Batch = { active: true, reloads: new Set() }
|
||||
if (current?.active && options.flush !== false) return yield* effect
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
|
||||
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
|
||||
batch.active = false
|
||||
yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
|
||||
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
|
||||
return yield* exit
|
||||
})
|
||||
}
|
||||
@@ -81,6 +83,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
let generation = 0
|
||||
let requestedAt = 0
|
||||
let running = false
|
||||
let closed = false
|
||||
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
|
||||
@@ -90,6 +93,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
})
|
||||
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms) {
|
||||
@@ -122,6 +126,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
})
|
||||
|
||||
const reload = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const done = Deferred.makeUnsafe<void>()
|
||||
const clock = yield* Clock.Clock
|
||||
generation++
|
||||
@@ -131,7 +136,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
running = true
|
||||
yield* rebuild().pipe(Effect.forkDetach)
|
||||
}
|
||||
return yield* Deferred.await(done)
|
||||
yield* Deferred.await(done)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -152,6 +157,11 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
return Effect.gen(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
// Detached debounced reloads must also stay quiet after teardown.
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.reloads.add(materializeReload)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Cerebras, DeepInfra, and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "deepinfra", "togetherai"]) {
|
||||
test("maps Cerebras, DeepInfra, Groq, and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "deepinfra", "groq", "togetherai"]) {
|
||||
expect(
|
||||
map(`@ai-sdk/${name}`, {
|
||||
apiKey: "secret",
|
||||
|
||||
@@ -915,6 +915,12 @@ describe("ModelResolver", () => {
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ reasoning: { effort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/groq",
|
||||
"@opencode-ai/ai/providers/groq",
|
||||
{ reasoningEffort: "high", parallelToolCalls: false },
|
||||
{ reasoningEffort: "high", parallelToolCalls: false },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/togetherai",
|
||||
"@opencode-ai/ai/providers/togetherai",
|
||||
@@ -973,6 +979,7 @@ describe("ModelResolver", () => {
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
|
||||
["@ai-sdk/groq", "@opencode-ai/ai/providers/groq", "api-model"],
|
||||
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
|
||||
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
|
||||
@@ -1102,6 +1109,11 @@ describe("ModelResolver", () => {
|
||||
const togetherai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/togetherai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
const groq = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/groq"), {
|
||||
settings: { reasoningEffort: "high", parallelToolCalls: false },
|
||||
}),
|
||||
)
|
||||
const xai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
@@ -1132,6 +1144,10 @@ describe("ModelResolver", () => {
|
||||
expect(togetherai.route.id).toBe("togetherai-chat")
|
||||
expect(togetherai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(String(togetherai.provider)).toBe("test-provider")
|
||||
expect(groq.route.id).toBe("groq-chat")
|
||||
expect(groq.route.protocol).toBe("groq-chat")
|
||||
expect(groq.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", parallelToolCalls: false })
|
||||
expect(String(groq.provider)).toBe("test-provider")
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
reasoningEffort: "high",
|
||||
|
||||
@@ -184,6 +184,39 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits rebuilt state when disabling one plugin while another remains enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const agents = yield* Agent.Service
|
||||
const bus = yield* Bus.Service
|
||||
const definitions = ["first", "second"].map((id) =>
|
||||
versioned(
|
||||
EffectPlugin.define({
|
||||
id,
|
||||
effect: (ctx) => ctx.agent.transform((draft) => draft.update(id, () => {})),
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* plugins.activate(definitions)
|
||||
|
||||
const observed: string[][] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Agent.Event.Updated.type
|
||||
? agents.list().pipe(
|
||||
Effect.flatMap((items) => Effect.sync(() => observed.push(items.map((item) => item.id)))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
yield* plugins.activate(definitions.slice(1))
|
||||
expect(yield* agents.get(Agent.ID.make("first"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("second"))).toBeDefined()
|
||||
expect(observed).toEqual([["second"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate IDs before replacing active plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
|
||||
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
|
||||
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
|
||||
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
|
||||
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
|
||||
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
|
||||
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
|
||||
@@ -21,7 +20,6 @@ const providers = [
|
||||
{ id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" },
|
||||
{ id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" },
|
||||
{ id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" },
|
||||
{ id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" },
|
||||
{ id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" },
|
||||
{ id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" },
|
||||
{ id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" },
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("Provider", () => {
|
||||
"@opencode-ai/ai/providers/google-vertex/chat",
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"@opencode-ai/ai/providers/groq",
|
||||
"@opencode-ai/ai/providers/togetherai",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const active = new Set<Session.ID>()
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[
|
||||
SessionExecution.node,
|
||||
Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.sync(() => active),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
|
||||
|
||||
const start = (bus: Bus.Interface, sessionID: Session.ID, messageID: SessionMessage.ID) =>
|
||||
bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
agent: Agent.defaultID,
|
||||
model,
|
||||
})
|
||||
|
||||
const complete = (bus: Bus.Interface, sessionID: Session.ID, messageID: SessionMessage.ID) =>
|
||||
bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.make(0),
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
describe("Session.updateMessage", () => {
|
||||
it.effect("replaces assistant content through a durable projected event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const created = yield* session.create({ location })
|
||||
const messageID = SessionMessage.ID.create()
|
||||
yield* start(bus, created.id, messageID)
|
||||
yield* complete(bus, created.id, messageID)
|
||||
|
||||
const content = [
|
||||
SessionMessage.AssistantText.make({ type: "text", text: "replacement" }),
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
text: "updated reasoning",
|
||||
time: { created: created.time.created },
|
||||
}),
|
||||
]
|
||||
const updated = yield* session.updateMessage({ sessionID: created.id, messageID, content })
|
||||
|
||||
expect(updated.content).toEqual(content)
|
||||
expect(yield* session.message({ sessionID: created.id, messageID })).toMatchObject({ content })
|
||||
expect((yield* session.messages({ sessionID: created.id }))[0]).toMatchObject({ id: messageID, content })
|
||||
|
||||
const events = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||
expect(events.at(-2)).toMatchObject({
|
||||
type: "session.message.content.updated",
|
||||
data: {
|
||||
sessionID: created.id,
|
||||
messageID,
|
||||
content: [
|
||||
{ type: "text", text: "replacement" },
|
||||
{ type: "reasoning", text: "updated reasoning", time: { created: expect.any(Number) } },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, Bus.versionedType(SessionEvent.MessageContentUpdated.type, 1)))
|
||||
.get(),
|
||||
).toMatchObject({ aggregate_id: created.id, data: { messageID } })
|
||||
|
||||
expect((yield* session.updateMessage({ sessionID: created.id, messageID, content: [] })).content).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays updated assistant content into a fresh projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const created = yield* session.create({ location })
|
||||
const messageID = SessionMessage.ID.create()
|
||||
yield* start(bus, created.id, messageID)
|
||||
yield* complete(bus, created.id, messageID)
|
||||
const content = [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
text: "replayed reasoning",
|
||||
time: { created: created.time.created },
|
||||
}),
|
||||
]
|
||||
yield* session.updateMessage({ sessionID: created.id, messageID, content })
|
||||
|
||||
const serialized = (yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((event) => ({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}))
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const target = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
],
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const database = (yield* Database.Service).db
|
||||
const replay = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
yield* database
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.forEach(serialized, (event) => replay.replay(event), { discard: true })
|
||||
expect((yield* store.message(messageID))?.message).toMatchObject({ content })
|
||||
}).pipe(Effect.provide(Layer.fresh(target)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects missing and cross-session messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const other = yield* session.create({ location })
|
||||
const messageID = SessionMessage.ID.create()
|
||||
yield* start(bus, created.id, messageID)
|
||||
yield* complete(bus, created.id, messageID)
|
||||
|
||||
expect(yield* Effect.flip(session.updateMessage({ sessionID: other.id, messageID, content: [] }))).toEqual(
|
||||
new Session.MessageNotFoundError({ sessionID: other.id, messageID }),
|
||||
)
|
||||
const missing = Session.ID.create()
|
||||
expect(yield* Effect.flip(session.updateMessage({ sessionID: missing, messageID, content: [] }))).toEqual(
|
||||
new Session.NotFoundError({ sessionID: missing }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-assistant messages, incomplete assistants, and unfinished tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const synthetic = yield* bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text: "synthetic" })
|
||||
const syntheticID = SessionMessage.ID.fromEvent(synthetic.id)
|
||||
|
||||
expect(
|
||||
yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID: syntheticID, content: [] })),
|
||||
).toEqual(new Session.MessageNotAssistantError({ sessionID: created.id, messageID: syntheticID }))
|
||||
|
||||
const messageID = SessionMessage.ID.create()
|
||||
yield* start(bus, created.id, messageID)
|
||||
expect(yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [] }))).toEqual(
|
||||
new Session.MessageIncompleteError({ sessionID: created.id, messageID }),
|
||||
)
|
||||
|
||||
yield* complete(bus, created.id, messageID)
|
||||
const unfinished = SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_unfinished",
|
||||
name: "read",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: created.time.created },
|
||||
})
|
||||
expect(
|
||||
yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [unfinished] })),
|
||||
).toEqual(new Session.MessageToolIncompleteError({ sessionID: created.id, messageID }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a completed assistant while its session is active", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const messageID = SessionMessage.ID.create()
|
||||
yield* start(bus, created.id, messageID)
|
||||
yield* complete(bus, created.id, messageID)
|
||||
active.add(created.id)
|
||||
const failure = yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [] }))
|
||||
active.delete(created.id)
|
||||
|
||||
expect(failure).toEqual(new Session.BusyError({ sessionID: created.id }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -784,6 +784,10 @@ describe("SessionProjector", () => {
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const service = yield* Bus.Service
|
||||
yield* service.publish(SessionEvent.Step.Streamed, {
|
||||
sessionID,
|
||||
assistantMessageID: endedID,
|
||||
})
|
||||
yield* service.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: endedID,
|
||||
@@ -824,7 +828,7 @@ describe("SessionProjector", () => {
|
||||
cost: Money.USD.make(1),
|
||||
tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
|
||||
snapshot: { end: "snap_ended", files: ["src/ended.ts"] },
|
||||
time: { completed: created },
|
||||
time: { streamed: created, completed: created },
|
||||
})
|
||||
expect(messages[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
|
||||
@@ -241,6 +241,7 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
"session.step.started.1",
|
||||
"session.text.started.1",
|
||||
"session.text.ended.1",
|
||||
"session.step.streamed.1",
|
||||
"session.step.ended.1",
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -2926,6 +2926,35 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("records the stream boundary before local tools complete", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "Echo this")
|
||||
yield* TestLLM.push(TestLLM.tool("call-streamed", "echo", { text: "hello" }), TestLLM.stop())
|
||||
const tools = yield* blockTools()
|
||||
const streamed = yield* bus
|
||||
.subscribe(SessionEvent.Step.Streamed)
|
||||
.pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const run = yield* Effect.forkChild(session.resume(sessionID))
|
||||
|
||||
yield* tools.started
|
||||
yield* Fiber.join(streamed)
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
expect(assistant.time.streamed).toBeDefined()
|
||||
expect(assistant.time.completed).toBeUndefined()
|
||||
expect(assistant.content).toMatchObject([{ type: "tool", state: { status: "running" } }])
|
||||
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restores durable reasoning provider metadata in the next request", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -133,6 +133,64 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
let disposed = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.addFinalizer(
|
||||
scope,
|
||||
Effect.sync(() => disposed++),
|
||||
)
|
||||
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
|
||||
expect(finalized).toBe(1)
|
||||
|
||||
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("250 millis")
|
||||
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
|
||||
expect(disposed).toBe(1)
|
||||
expect(finalized).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(pending)
|
||||
yield* registration.dispose
|
||||
yield* state.reload()
|
||||
expect(finalized).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps teardown suppression separate from an enclosing live batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const finalized: string[] = []
|
||||
const closing = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
finalize: () => Effect.sync(() => finalized.push("closing")),
|
||||
})
|
||||
const live = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
finalize: () => Effect.sync(() => finalized.push("live")),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
|
||||
finalized.length = 0
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* live.transform(() => {})
|
||||
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
|
||||
}),
|
||||
)
|
||||
expect(finalized).toEqual(["live"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("debounces reload bursts", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -3950,6 +3950,143 @@
|
||||
},
|
||||
"description": "Retrieve one projected message owned by the Session.",
|
||||
"summary": "Get session message"
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.messageUpdate",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Session.Message.Assistant"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | MessageNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "SessionBusyError | ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionBusyErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Replace the content of a completed assistant message in an idle session.",
|
||||
"summary": "Update assistant message content",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Assistant.Text"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Assistant.Reasoning"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Assistant.Tool"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/environment": {
|
||||
|
||||
@@ -710,6 +710,20 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.patch("session.messageUpdate", "/api/session/:sessionID/message/:messageID", {
|
||||
params: { sessionID: Session.ID, messageID: SessionMessage.ID },
|
||||
payload: Schema.Struct({ content: Schema.Array(SessionMessage.AssistantContent) }),
|
||||
success: Schema.Struct({ data: SessionMessage.Assistant }),
|
||||
error: [SessionNotFoundError, MessageNotFoundError, InvalidRequestError, SessionBusyError, ConflictError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.messageUpdate",
|
||||
summary: "Update assistant message content",
|
||||
description: "Replace the content of a completed assistant message in an idle session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("session.environment", "/api/session/:sessionID/environment", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -117,6 +117,18 @@ export const Viewed = Event.durable({
|
||||
})
|
||||
export type Viewed = typeof Viewed.Type
|
||||
|
||||
export const MessageContentUpdated = Event.durable({
|
||||
type: "session.message.content.updated",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessage.ID,
|
||||
// Public events are framed directly, so timestamps must already be encoded.
|
||||
content: Schema.Array(SessionMessage.AssistantContentEncoded),
|
||||
},
|
||||
})
|
||||
export type MessageContentUpdated = typeof MessageContentUpdated.Type
|
||||
|
||||
export const UsageRecorded = Event.durable({
|
||||
type: "session.usage.recorded",
|
||||
...options,
|
||||
@@ -304,6 +316,17 @@ export namespace Step {
|
||||
})
|
||||
export type Started = typeof Started.Type
|
||||
|
||||
/** Records the provider response-body boundary independently of tool settlement. */
|
||||
export const Streamed = Event.durable({
|
||||
type: "session.step.streamed",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
},
|
||||
})
|
||||
export type Streamed = typeof Streamed.Type
|
||||
|
||||
export const Ended = Event.durable({
|
||||
type: "session.step.ended",
|
||||
...options,
|
||||
@@ -616,6 +639,7 @@ export const Definitions = Event.inventory(
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
Step.Started,
|
||||
Step.Streamed,
|
||||
Step.Ended,
|
||||
Step.Failed,
|
||||
Text.Started,
|
||||
@@ -639,6 +663,7 @@ export const Definitions = Event.inventory(
|
||||
RevertEvent.Staged,
|
||||
RevertEvent.Cleared,
|
||||
RevertEvent.Committed,
|
||||
MessageContentUpdated,
|
||||
)
|
||||
|
||||
// UsageRecorded is durable but internal: excluded from Definitions so it never reaches the public manifest.
|
||||
|
||||
@@ -195,6 +195,11 @@ export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning,
|
||||
)
|
||||
export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool
|
||||
|
||||
export const AssistantContentEncoded = Schema.toEncoded(AssistantContent).annotate({
|
||||
identifier: "Session.Message.AssistantContent.Encoded",
|
||||
})
|
||||
export type AssistantContentEncoded = typeof AssistantContentEncoded.Type
|
||||
|
||||
export interface AssistantRetry extends Schema.Schema.Type<typeof AssistantRetry> {}
|
||||
export const AssistantRetry = Schema.Struct({
|
||||
attempt: PositiveInt,
|
||||
@@ -223,6 +228,8 @@ export const Assistant = Schema.Struct({
|
||||
retry: AssistantRetry.pipe(optional),
|
||||
time: Schema.Struct({
|
||||
created: DateTimeUtcFromMillis,
|
||||
/** When the provider response body ended, before tool settlement. */
|
||||
streamed: DateTimeUtcFromMillis.pipe(optional),
|
||||
completed: DateTimeUtcFromMillis.pipe(optional),
|
||||
}),
|
||||
}).annotate({ identifier: "Session.Message.Assistant" })
|
||||
|
||||
@@ -118,6 +118,7 @@ describe("public event manifest", () => {
|
||||
"session.moved.1",
|
||||
"session.renamed.1",
|
||||
"session.viewed.1",
|
||||
"session.message.content.updated.1",
|
||||
"session.usage.recorded.1",
|
||||
"session.forked.2",
|
||||
"session.inbox.delivered.1",
|
||||
@@ -134,6 +135,7 @@ describe("public event manifest", () => {
|
||||
"session.shell.started.1",
|
||||
"session.shell.ended.1",
|
||||
"session.step.started.1",
|
||||
"session.step.streamed.1",
|
||||
"session.step.ended.1",
|
||||
"session.step.failed.1",
|
||||
"session.text.started.1",
|
||||
|
||||
@@ -172,6 +172,87 @@ it.live(
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"evicts a Location without triggering connected client refetches",
|
||||
() =>
|
||||
withEmbedded("opencode-embedded-quiet-eviction-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create({
|
||||
config: { directory: fixture.directory, project: false, content: "{}" },
|
||||
})
|
||||
const ref = location(fixture)
|
||||
const connected = yield* Latch.make(false)
|
||||
const booted = yield* Deferred.make<void>()
|
||||
const boots = yield* Ref.make(0)
|
||||
const updates = yield* Ref.make<string[]>([])
|
||||
|
||||
yield* opencode.plugin({
|
||||
id: `quiet-eviction-${crypto.randomUUID()}`,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(boots, (count) => count + 1)
|
||||
yield* ctx.catalog.transform((catalog) => catalog.provider.update("eviction-test", () => {}))
|
||||
yield* ctx.agent.transform((agents) => agents.update("eviction-test", () => {}))
|
||||
yield* ctx.command.transform((commands) =>
|
||||
commands.add({ name: "eviction-test", execute: () => Effect.void }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const subscriber = yield* opencode.events.subscribe().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type === "server.connected") {
|
||||
yield* connected.open
|
||||
return
|
||||
}
|
||||
if (event.location?.directory !== fixture.directory) return
|
||||
if (event.type === "plugin.updated") {
|
||||
yield* Deferred.succeed(booted, undefined)
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.type !== "catalog.updated" &&
|
||||
event.type !== "agent.updated" &&
|
||||
event.type !== "command.updated"
|
||||
)
|
||||
return
|
||||
yield* Ref.update(updates, (types) => [...types, event.type])
|
||||
// A connected consumer re-reads invalidated resources through the real router.
|
||||
if (event.type === "catalog.updated") {
|
||||
yield* opencode.model.list({ location: ref })
|
||||
yield* opencode.provider.list({ location: ref })
|
||||
return
|
||||
}
|
||||
if (event.type === "agent.updated") {
|
||||
yield* opencode.agent.list({ location: ref })
|
||||
return
|
||||
}
|
||||
yield* opencode.command.list({ location: ref })
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* connected.await
|
||||
yield* opencode.plugin.list({ location: ref })
|
||||
yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds"))
|
||||
expect(yield* Ref.get(updates)).toEqual(
|
||||
expect.arrayContaining(["catalog.updated", "agent.updated", "command.updated"]),
|
||||
)
|
||||
yield* Ref.set(updates, [])
|
||||
|
||||
yield* opencode.debug.location.evict({ location: ref })
|
||||
// Allow the live event stream to deliver teardown notifications and any refetches.
|
||||
yield* Effect.sleep("200 millis")
|
||||
|
||||
expect(yield* Ref.get(updates)).toEqual([])
|
||||
expect(yield* Ref.get(boots)).toBe(1)
|
||||
expect(yield* opencode.debug.location.list()).toEqual([])
|
||||
expect(subscriber.pollUnsafe()).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"keeps SDK plugin registration isolated between embedded hosts",
|
||||
() =>
|
||||
|
||||
@@ -95,13 +95,6 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
.handleRaw(
|
||||
"persistentPty.connect",
|
||||
Effect.fn("PersistentPtyHandler.connect")(function* (ctx) {
|
||||
const exists = yield* pty.get(ctx.params.ptyID).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("PersistentPty.NotFoundError", () => Effect.succeed(false)),
|
||||
Effect.catchTag("PersistentPty.UnavailableError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const url = new URL(ctx.request.url, "http://localhost")
|
||||
const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
@@ -121,52 +114,62 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
const write = yield* socket.writer
|
||||
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
|
||||
const input = yield* Semaphore.make(1)
|
||||
const attachment = yield* pty
|
||||
.attach(ctx.params.ptyID, {
|
||||
cursor,
|
||||
attachmentID,
|
||||
role,
|
||||
takeover: url.searchParams.get("takeover") === "true",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "output") Queue.offerUnsafe(outbox, event.data)
|
||||
if (event.type === "resized")
|
||||
Queue.offerUnsafe(
|
||||
outbox,
|
||||
JSON.stringify({ ...event, checkpoint: Buffer.from(event.checkpoint).toString("base64") }),
|
||||
)
|
||||
if (event.type !== "output" && event.type !== "resized")
|
||||
Queue.offerUnsafe(outbox, JSON.stringify(event))
|
||||
},
|
||||
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"PersistentPty.NotFoundError": () => Effect.succeed(undefined),
|
||||
"PersistentPty.UnavailableError": () => Effect.succeed(undefined),
|
||||
let attachment: PersistentPty.Attachment | undefined
|
||||
// Bun's native ws upgrade must start before asynchronous daemon I/O.
|
||||
const onOpen = Effect.gen(function* () {
|
||||
attachment = yield* pty
|
||||
.attach(ctx.params.ptyID, {
|
||||
cursor,
|
||||
attachmentID,
|
||||
role,
|
||||
takeover: url.searchParams.get("takeover") === "true",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "output") Queue.offerUnsafe(outbox, event.data)
|
||||
if (event.type === "resized")
|
||||
Queue.offerUnsafe(
|
||||
outbox,
|
||||
JSON.stringify({ ...event, checkpoint: Buffer.from(event.checkpoint).toString("base64") }),
|
||||
)
|
||||
if (event.type !== "output" && event.type !== "resized")
|
||||
Queue.offerUnsafe(outbox, JSON.stringify(event))
|
||||
},
|
||||
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"PersistentPty.NotFoundError": () => Effect.succeed(undefined),
|
||||
"PersistentPty.UnavailableError": () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
if (!attachment) {
|
||||
Queue.offerUnsafe(outbox, new Socket.CloseEvent(4404, "terminal unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
Queue.offerUnsafe(
|
||||
outbox,
|
||||
JSON.stringify({
|
||||
type: "attached",
|
||||
attachmentID,
|
||||
inputProtocol: framedInput ? 1 : 0,
|
||||
info: attachment.info,
|
||||
role: attachment.role,
|
||||
generation: attachment.generation,
|
||||
replay: {
|
||||
requestedOffset: attachment.replay.requestedOffset,
|
||||
availableOffset: attachment.replay.availableOffset,
|
||||
endOffset: attachment.replay.endOffset,
|
||||
truncated: attachment.replay.truncated,
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (!attachment) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
Queue.offerUnsafe(
|
||||
outbox,
|
||||
JSON.stringify({
|
||||
type: "attached",
|
||||
attachmentID,
|
||||
inputProtocol: framedInput ? 1 : 0,
|
||||
info: attachment.info,
|
||||
role: attachment.role,
|
||||
generation: attachment.generation,
|
||||
replay: {
|
||||
requestedOffset: attachment.replay.requestedOffset,
|
||||
availableOffset: attachment.replay.availableOffset,
|
||||
endOffset: attachment.replay.endOffset,
|
||||
truncated: attachment.replay.truncated,
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (attachment.replay.data.length > 0) Queue.offerUnsafe(outbox, attachment.replay.data)
|
||||
Queue.offerUnsafe(outbox, JSON.stringify({ type: "replay_complete", endOffset: attachment.replay.endOffset }))
|
||||
attachment.activate()
|
||||
if (attachment.replay.data.length > 0) Queue.offerUnsafe(outbox, attachment.replay.data)
|
||||
Queue.offerUnsafe(
|
||||
outbox,
|
||||
JSON.stringify({ type: "replay_complete", endOffset: attachment.replay.endOffset }),
|
||||
)
|
||||
attachment.activate()
|
||||
})
|
||||
|
||||
const drain = Effect.gen(function* () {
|
||||
while (true) {
|
||||
@@ -178,28 +181,37 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
|
||||
yield* Effect.race(
|
||||
drain,
|
||||
socket.runRaw((message) =>
|
||||
input.withPermit(
|
||||
Effect.suspend(() => {
|
||||
const data = typeof message === "string" ? Buffer.from(message) : message
|
||||
if (!framedInput)
|
||||
return pty
|
||||
.input(ctx.params.ptyID, attachmentID, attachment.info.size.cols, attachment.info.size.rows, data)
|
||||
.pipe(Effect.ignore)
|
||||
if (data.byteLength < 5) return Effect.void
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
const type = data[0]
|
||||
const cols = view.getUint16(1)
|
||||
const rows = view.getUint16(3)
|
||||
if ((type !== 0 && type !== 1) || cols === 0 || rows === 0) return Effect.void
|
||||
if (type === 0) return pty.control(ctx.params.ptyID, attachmentID, cols, rows).pipe(Effect.ignore)
|
||||
return pty.input(ctx.params.ptyID, attachmentID, cols, rows, data.subarray(5)).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
socket.runRaw(
|
||||
(message) =>
|
||||
input.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (!attachment) return Effect.void
|
||||
const data = typeof message === "string" ? Buffer.from(message) : message
|
||||
if (!framedInput)
|
||||
return pty
|
||||
.input(
|
||||
ctx.params.ptyID,
|
||||
attachmentID,
|
||||
attachment.info.size.cols,
|
||||
attachment.info.size.rows,
|
||||
data,
|
||||
)
|
||||
.pipe(Effect.ignore)
|
||||
if (data.byteLength < 5) return Effect.void
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
const type = data[0]
|
||||
const cols = view.getUint16(1)
|
||||
const rows = view.getUint16(3)
|
||||
if ((type !== 0 && type !== 1) || cols === 0 || rows === 0) return Effect.void
|
||||
if (type === 0) return pty.control(ctx.params.ptyID, attachmentID, cols, rows).pipe(Effect.ignore)
|
||||
return pty.input(ctx.params.ptyID, attachmentID, cols, rows, data.subarray(5)).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
{ onOpen },
|
||||
),
|
||||
).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => attachment.detach())),
|
||||
Effect.ensuring(Effect.sync(() => attachment?.detach())),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
|
||||
@@ -635,5 +635,36 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
})
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.messageUpdate",
|
||||
Effect.fn(function* (ctx) {
|
||||
const message = yield* session.updateMessage({ ...ctx.params, content: ctx.payload.content }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotAssistantError",
|
||||
() => new InvalidRequestError({ message: "Only assistant messages can be updated", field: "messageID" }),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.MessageIncompleteError",
|
||||
(error) => new ConflictError({ message: "Assistant message is incomplete", resource: error.messageID }),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.MessageToolIncompleteError",
|
||||
() => new InvalidRequestError({ message: "Tool content must be completed", field: "content" }),
|
||||
),
|
||||
)
|
||||
return { data: message }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -100,7 +100,44 @@ smoke(
|
||||
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
|
||||
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
|
||||
|
||||
const ticket = yield* request(
|
||||
base,
|
||||
"POST",
|
||||
`/api/experimental/persistent-pty/${first.id}/connect-token`,
|
||||
undefined,
|
||||
{
|
||||
"x-opencode-ticket": "1",
|
||||
},
|
||||
)
|
||||
if (!isRecord(ticket.data) || typeof ticket.data.ticket !== "string")
|
||||
throw new Error("Invalid connect ticket")
|
||||
const connectTicket = ticket.data.ticket
|
||||
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${first.id}`)
|
||||
yield* Effect.promise(async () => {
|
||||
const url = new URL(`/api/experimental/persistent-pty/${first.id}/connect`, base)
|
||||
url.searchParams.set("ticket", "invalid")
|
||||
expect((await fetch(url)).status).toBe(403)
|
||||
url.protocol = "ws:"
|
||||
url.searchParams.set("ticket", connectTicket)
|
||||
const socket = new WebSocket(url)
|
||||
try {
|
||||
const closed = await new Promise<CloseEvent>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Removed terminal socket did not close")), 5_000)
|
||||
socket.addEventListener("close", (event) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
socket.addEventListener("error", () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error("Valid ticket should upgrade before the missing terminal is reported"))
|
||||
})
|
||||
})
|
||||
expect(closed.code).toBe(4404)
|
||||
expect(closed.reason).toBe("terminal unavailable")
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
})
|
||||
expect(yield* Effect.promise(() => events.next("persistent-pty.removed"))).toMatchObject({
|
||||
data: { sessionID, ptyID: first.id },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
it.live("updates completed assistant message content through the session HTTP API", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = {
|
||||
active: new Set<Session.ID>(),
|
||||
user: SessionMessage.ID.create(),
|
||||
assistant: SessionMessage.ID.create(),
|
||||
complete: true,
|
||||
}
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
return SessionExecution.Service.of({
|
||||
active: Effect.sync(() => state.active),
|
||||
resume: () => Effect.void,
|
||||
wake: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: state.user })
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: state.assistant,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
|
||||
})
|
||||
if (!state.complete) return
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: state.assistant,
|
||||
finish: "stop",
|
||||
cost: Money.USD.make(0),
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
}),
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{ app: { version: "test-version" }, database: { path: ":memory:" }, fs: { filewatcher: false } },
|
||||
{ overrides: [[SessionExecution.node, execution]] },
|
||||
)
|
||||
const created = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
}),
|
||||
).then((response) => response.json()),
|
||||
)
|
||||
const sessionID = Session.ID.make(created.data.id)
|
||||
const prompt = () =>
|
||||
Effect.promise(() =>
|
||||
handler(
|
||||
new Request(`http://opencode.local/api/session/${sessionID}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: state.user, text: "prompt" }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const update = (messageID: SessionMessage.ID, body: unknown, id = sessionID) =>
|
||||
Effect.promise(() =>
|
||||
handler(
|
||||
new Request(`http://opencode.local/api/session/${id}/message/${messageID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect((yield* prompt()).status).toBe(200)
|
||||
const content = [
|
||||
{ type: "text", text: "edited assistant response" },
|
||||
{ type: "reasoning", text: "edited reasoning", time: { created: 123 } },
|
||||
]
|
||||
const updated = yield* update(state.assistant, { content })
|
||||
expect(updated.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => updated.json())).toMatchObject({
|
||||
data: { id: state.assistant, type: "assistant", content },
|
||||
})
|
||||
|
||||
const projected = yield* Effect.promise(() =>
|
||||
handler(new Request(`http://opencode.local/api/session/${sessionID}/message/${state.assistant}`)).then(
|
||||
(response) => response.json(),
|
||||
),
|
||||
)
|
||||
expect(projected.data.content).toEqual(content)
|
||||
expect((yield* update(state.assistant, { text: "not a content array" })).status).toBe(400)
|
||||
const unfinished = yield* update(state.assistant, {
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_unfinished",
|
||||
name: "read",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 123 },
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(unfinished.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => unfinished.json())).toMatchObject({
|
||||
_tag: "InvalidRequestError",
|
||||
field: "content",
|
||||
})
|
||||
const nonAssistant = yield* update(state.user, { content: [] })
|
||||
expect(nonAssistant.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => nonAssistant.json())).toMatchObject({ _tag: "InvalidRequestError" })
|
||||
expect((yield* update(SessionMessage.ID.create(), { content: [] })).status).toBe(404)
|
||||
expect((yield* update(state.assistant, { content: [] }, Session.ID.create())).status).toBe(404)
|
||||
|
||||
state.active.add(sessionID)
|
||||
const busy = yield* update(state.assistant, { content: [] })
|
||||
state.active.delete(sessionID)
|
||||
expect(busy.status).toBe(409)
|
||||
expect(yield* Effect.promise(() => busy.json())).toMatchObject({ _tag: "SessionBusyError", sessionID })
|
||||
|
||||
state.user = SessionMessage.ID.create()
|
||||
state.assistant = SessionMessage.ID.create()
|
||||
state.complete = false
|
||||
expect((yield* prompt()).status).toBe(200)
|
||||
const incomplete = yield* update(state.assistant, { content: [] })
|
||||
expect(incomplete.status).toBe(409)
|
||||
expect(yield* Effect.promise(() => incomplete.json())).toMatchObject({
|
||||
_tag: "ConflictError",
|
||||
resource: state.assistant,
|
||||
})
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
+49
-42
@@ -31,7 +31,6 @@ import {
|
||||
batch,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
TuiLifecycleProvider,
|
||||
TuiAppProvider,
|
||||
@@ -77,7 +76,6 @@ import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./prompt/history"
|
||||
import { FrecencyProvider } from "./prompt/frecency"
|
||||
import { PromptStashProvider } from "./prompt/stash"
|
||||
@@ -100,6 +98,8 @@ import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
@@ -218,7 +218,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next) }
|
||||
return { api: OpenCode.make(next), url: endpoint.url }
|
||||
},
|
||||
restart: managed.restart,
|
||||
}
|
||||
@@ -373,48 +373,50 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<ClientProvider api={api} url={input.server.endpoint.url} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider
|
||||
mode={mode}
|
||||
source={createThemeSource(global.config)}
|
||||
>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
<SessionTerminalsProvider>
|
||||
<ThemeProvider
|
||||
mode={mode}
|
||||
source={createThemeSource(global.config)}
|
||||
>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTerminalsProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
@@ -1318,7 +1320,12 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
|
||||
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
|
||||
{(sessionID) => (
|
||||
<SessionFrame
|
||||
sessionID={sessionID}
|
||||
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={route.data.type === "plugin"}>
|
||||
|
||||
@@ -51,6 +51,15 @@ export const settings: Setting[] = [
|
||||
values: ["hide", "auto"],
|
||||
keywords: ["side panel"],
|
||||
},
|
||||
{
|
||||
title: "Terminal",
|
||||
category: "Session",
|
||||
path: ["session", "terminal"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["pty", "shell", "terminal pane"],
|
||||
},
|
||||
{
|
||||
title: "Scrollbar",
|
||||
category: "Session",
|
||||
@@ -93,6 +102,15 @@ export const settings: Setting[] = [
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "images", "tool output"],
|
||||
},
|
||||
{
|
||||
title: "TPS",
|
||||
category: "Session",
|
||||
path: ["session", "tps"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["tokens per second", "throughput"],
|
||||
},
|
||||
{
|
||||
title: "New session location",
|
||||
category: "Session",
|
||||
|
||||
@@ -4,12 +4,19 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useRoute } from "../context/route"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { emptyPrompt } from "../prompt/history"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
|
||||
export function DialogErrorDetails(props: { title: string; error: string; context?: string; onBack: () => void }) {
|
||||
const clipboard = useClipboard()
|
||||
const dialog = useDialog()
|
||||
const location = useLocation()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const overlayTheme = useTheme("overlay")
|
||||
@@ -49,11 +56,24 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
const investigate = () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location: location.ref,
|
||||
prompt: {
|
||||
...emptyPrompt(),
|
||||
text: `Investigate why this OpenCode component failed in the current project.\n\n${props.title}${props.context ? `\n${props.context}` : ""}\nError: ${props.error}\n\nInspect the relevant project and global OpenCode configuration, startup or loading behavior, required environment variables or credentials, dependencies, and logs. Identify the root cause and recommend a fix.`,
|
||||
},
|
||||
})
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack },
|
||||
{ bind: "c", title: "Copy details", group: "Dialog", run: copy },
|
||||
{ bind: "i", title: "Investigate error", group: "Dialog", run: investigate },
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -95,13 +115,19 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
</text>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text>
|
||||
<box flexDirection="row" gap={3} paddingLeft={2} paddingRight={2}>
|
||||
<text flexGrow={1}>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<b>{scrollable() ? "↑/↓" : ""}</b>
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{scrollable() ? " scroll" : ""}</span>
|
||||
</text>
|
||||
<text onMouseUp={investigate}>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<b>i</b>
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}> investigate</span>
|
||||
</text>
|
||||
<text onMouseUp={copy}>
|
||||
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
|
||||
<b>{copied() ? "✓ copied" : "c"}</b>
|
||||
|
||||
@@ -157,6 +157,9 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
|
||||
<DialogErrorDetails
|
||||
title={`MCP server: ${server().name}`}
|
||||
error={statusError(server().status) ?? "Unknown MCP connection error"}
|
||||
context={`Status: failed\nConfiguration: mcp.servers.${server().name}${
|
||||
server().integrationID ? `\nIntegration: ${server().integrationID}` : ""
|
||||
}`}
|
||||
onBack={() => {
|
||||
setDetail(undefined)
|
||||
dialog.setSize("medium")
|
||||
|
||||
@@ -79,6 +79,7 @@ export type PromptProps = {
|
||||
sessionID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
muted?: boolean
|
||||
onSubmit?: () => void
|
||||
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
@@ -196,6 +197,7 @@ export function Prompt(props: PromptProps) {
|
||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
||||
|
||||
const leader = Keymap.useLeaderActive()
|
||||
const muted = () => leader() || props.muted
|
||||
const local = useLocal()
|
||||
const args = useArgs()
|
||||
const paths = useTuiPaths()
|
||||
@@ -1614,7 +1616,7 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
)
|
||||
const highlight = createMemo(() => {
|
||||
if (leader()) return theme.border.default
|
||||
if (muted()) return theme.border.default
|
||||
if (store.mode === "shell") return theme.text.action.primary.selected
|
||||
return promptDisplay().agentColor ?? theme.border.default
|
||||
})
|
||||
@@ -1793,8 +1795,8 @@ export function Prompt(props: PromptProps) {
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
textColor={muted() ? theme.text.subdued : theme.text.default}
|
||||
focusedTextColor={muted() ? theme.text.subdued : theme.text.default}
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
cursorStyle={config.cursor}
|
||||
@@ -1891,7 +1893,7 @@ export function Prompt(props: PromptProps) {
|
||||
minWidth={0}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
|
||||
fg={fadeColor(muted() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
|
||||
>
|
||||
{promptDisplay().modelLabel}
|
||||
</text>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useSessionTerminals } from "../context/session-terminals"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { Session } from "../routes/session"
|
||||
import { Sidebar } from "../routes/session/sidebar"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { TerminalPane } from "./terminal-pane"
|
||||
|
||||
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
|
||||
const sessions = useSessionTerminals()
|
||||
const prompt = usePromptRef()
|
||||
const config = useConfig()
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [sessionWidth, setSessionWidth] = createSignal<number>()
|
||||
const [terminalFocused, setTerminalFocused] = createSignal(false)
|
||||
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
|
||||
let focusTerminal: (() => void) | undefined
|
||||
createResource(
|
||||
() => (config.data.session.terminal ? props.sessionID : undefined),
|
||||
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
|
||||
)
|
||||
const session = () => sessions.get(props.sessionID)
|
||||
const terminals = () => session()?.terminals ?? []
|
||||
const selectedTerminal = () => {
|
||||
if (!config.data.session.terminal) return
|
||||
const value = session()
|
||||
if (value?.hidden) return
|
||||
return value?.terminals.find((terminal) => terminal.id === value.selectedTerminalID) ?? value?.terminals.at(-1)
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => selectedTerminal()?.id,
|
||||
(id) => {
|
||||
if (id) setSidebarOpen(false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (data.session.get(props.sessionID)?.parentID) return false
|
||||
if (sidebarOpen()) return true
|
||||
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
|
||||
})
|
||||
const rightPane = createMemo(() => {
|
||||
if (sidebarOpen() && sidebarVisible()) return "sidebar"
|
||||
if (selectedTerminal()) return "terminal"
|
||||
if (sidebarVisible() && !session()?.hidden) return "sidebar"
|
||||
})
|
||||
const toggleSidebar = () => {
|
||||
batch(() => {
|
||||
const visible = rightPane() === "sidebar"
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, sidebar: visible ? "hide" : "auto" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
setSidebarOpen(!visible)
|
||||
if (!visible && selectedTerminal()) void sessions.hideTerminal(props.sessionID).catch(toast.error)
|
||||
})
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!restoreTerminalFocus() || terminals().length > 0) return
|
||||
setRestoreTerminalFocus(false)
|
||||
prompt.current?.focus()
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => config.data.session.terminal === true,
|
||||
commands: [
|
||||
{
|
||||
id: "pane.focus.left",
|
||||
title: "Focus session pane",
|
||||
run: () => {
|
||||
prompt.current?.focus()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pane.focus.right",
|
||||
title: "Focus terminal pane",
|
||||
run: () => {
|
||||
focusTerminal?.()
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="row" position="relative">
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexBasis={0}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
position="relative"
|
||||
onSizeChange={function () {
|
||||
setSessionWidth(this.width)
|
||||
}}
|
||||
>
|
||||
<Session
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={terminalFocused()}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
<Show when={terminalFocused()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width="100%"
|
||||
height="100%"
|
||||
zIndex={1}
|
||||
onMouseDown={() => prompt.current?.focus()}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
width={
|
||||
rightPane() === "terminal"
|
||||
? Math.max(1, Math.floor((dimensions().width - props.verticalTabsWidth) / 2))
|
||||
: SESSION_SIDEBAR_WIDTH
|
||||
}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
>
|
||||
<Show
|
||||
when={rightPane() === "sidebar"}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()}>
|
||||
{(terminal) => (
|
||||
<TerminalPane
|
||||
ptyID={terminal.id}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(terminal.id)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(terminal.id)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Sidebar sessionID={props.sessionID} />
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={rightPane() === "sidebar" && !wide()}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
alignItems="flex-end"
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||
>
|
||||
<Sidebar sessionID={props.sessionID} />
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
|
||||
import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
embeddedTerminal: typeof EmbeddedTerminalRenderable
|
||||
}
|
||||
}
|
||||
|
||||
extend({ embeddedTerminal: EmbeddedTerminalRenderable })
|
||||
|
||||
type TerminalSize = { cols: number; rows: number }
|
||||
type StreamItem =
|
||||
| { type: "output"; data: Uint8Array }
|
||||
| { type: "resize"; size: TerminalSize; checkpoint?: Uint8Array }
|
||||
| { type: "ready" }
|
||||
|
||||
export function TerminalPane(props: {
|
||||
ptyID: string
|
||||
autoFocus?: boolean
|
||||
onAutoFocus?: () => void
|
||||
onFocusRequest?: (focus: (() => void) | undefined) => void
|
||||
onDisconnect?: () => void
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
}) {
|
||||
const client = useClient()
|
||||
const keymap = Keymap.use()
|
||||
const leader = Keymap.useLeaderActive()
|
||||
const theme = useTheme("elevated")
|
||||
const themes = useThemes()
|
||||
const renderer = useRenderer()
|
||||
const [failure, setFailure] = createSignal<string>()
|
||||
const attachmentID = crypto.randomUUID()
|
||||
const stream: StreamItem[] = []
|
||||
const pendingInput: Uint8Array[] = []
|
||||
let terminal: EmbeddedTerminalRenderable | undefined
|
||||
let socket: WebSocket | undefined
|
||||
let attached = false
|
||||
let controller = false
|
||||
let restored = false
|
||||
let wantsControl = false
|
||||
let disposed = false
|
||||
let size: TerminalSize | undefined
|
||||
let canonicalSize: TerminalSize | undefined
|
||||
let terminalSize: TerminalSize | undefined
|
||||
let lastIntermediateRender = 0
|
||||
let terminalTheme: Uint8Array | undefined
|
||||
let waitingSize: { size: TerminalSize; resolve: () => void } | undefined
|
||||
|
||||
const setCanonicalSize = (value: TerminalSize) => {
|
||||
canonicalSize = value
|
||||
if (!terminal) return
|
||||
terminal.width = value.cols
|
||||
terminal.height = value.rows
|
||||
}
|
||||
|
||||
const applyTerminalTheme = () => {
|
||||
if (terminalTheme) terminal?.write(terminalTheme)
|
||||
}
|
||||
|
||||
const send = (data: Uint8Array) => {
|
||||
if (attached && socket?.readyState === WebSocket.OPEN) socket.send(data)
|
||||
}
|
||||
|
||||
const interact = () => {
|
||||
if (!restored) {
|
||||
wantsControl = true
|
||||
return
|
||||
}
|
||||
if (!size) return
|
||||
send(interactionFrame(size))
|
||||
}
|
||||
|
||||
const sendInput = (data: Uint8Array) => {
|
||||
if (!restored) {
|
||||
pendingInput.push(data)
|
||||
return
|
||||
}
|
||||
if (size) send(interactionFrame(size, data))
|
||||
}
|
||||
|
||||
const processStream = () => {
|
||||
if (disposed || !terminal || !sameSize(canonicalSize, terminalSize)) return
|
||||
while (stream.length > 0) {
|
||||
const item = stream[0]!
|
||||
if (item.type === "output") {
|
||||
stream.shift()
|
||||
const output = [item.data]
|
||||
while (true) {
|
||||
const next = stream[0]
|
||||
if (!next || next.type !== "output") break
|
||||
output.push(next.data)
|
||||
stream.shift()
|
||||
}
|
||||
terminal.write(output.length === 1 ? output[0] : Buffer.concat(output))
|
||||
continue
|
||||
}
|
||||
if (item.type === "resize") {
|
||||
setCanonicalSize(item.size)
|
||||
if (!sameSize(canonicalSize, terminalSize)) return
|
||||
stream.shift()
|
||||
if (item.checkpoint) {
|
||||
terminal.write(Buffer.concat([Buffer.from("\x1bc"), Buffer.from(item.checkpoint)]))
|
||||
applyTerminalTheme()
|
||||
}
|
||||
continue
|
||||
}
|
||||
stream.shift()
|
||||
restored = true
|
||||
const input = pendingInput.splice(0)
|
||||
if (input.length > 0) input.forEach(sendInput)
|
||||
if (input.length === 0 && (controller || wantsControl)) interact()
|
||||
wantsControl = false
|
||||
}
|
||||
}
|
||||
|
||||
const enqueue = (item: StreamItem) => {
|
||||
stream.push(item)
|
||||
processStream()
|
||||
}
|
||||
|
||||
const waitForTerminalSize = (value: TerminalSize) => {
|
||||
if (sameSize(value, terminalSize)) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => {
|
||||
waitingSize = { size: value, resolve }
|
||||
})
|
||||
}
|
||||
|
||||
const offKeys = keymap.intercept(
|
||||
"key",
|
||||
({ event }) => {
|
||||
if (!terminal?.focused) return
|
||||
if (keymap.isLeader(event) || leader()) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
terminal.handleKeyPress(event)
|
||||
},
|
||||
{ priority: 100 },
|
||||
)
|
||||
const onFocused = () => props.onFocusChange?.(terminal?.focused === true)
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
createEffect(() => {
|
||||
if (!props.autoFocus || !terminal) return
|
||||
terminal.focus()
|
||||
props.onAutoFocus?.()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const tokens = themes.currentTokens().contextual.elevated
|
||||
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default)
|
||||
applyTerminalTheme()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void connect().catch((error) => setFailure(errorMessage(error)))
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
waitingSize?.resolve()
|
||||
socket?.close()
|
||||
offKeys()
|
||||
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
props.onFocusChange?.(false)
|
||||
props.onFocusRequest?.(undefined)
|
||||
})
|
||||
|
||||
async function connect() {
|
||||
const snapshot = await client.api.experimental.persistentPty.snapshot({ ptyID: props.ptyID })
|
||||
if (disposed) return
|
||||
setCanonicalSize(snapshot.info.size)
|
||||
await waitForTerminalSize(snapshot.info.size)
|
||||
if (disposed) return
|
||||
terminal?.write(Buffer.from(snapshot.checkpoint, "base64"))
|
||||
applyTerminalTheme()
|
||||
const next = await client.persistentPty.connect({
|
||||
ptyID: props.ptyID,
|
||||
cursor: snapshot.info.output.tail,
|
||||
attachmentID,
|
||||
takeover: true,
|
||||
})
|
||||
if (disposed) {
|
||||
next.close()
|
||||
return
|
||||
}
|
||||
next.addEventListener("message", (event) => {
|
||||
if (disposed) return
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
enqueue({ type: "output", data: new Uint8Array(event.data) })
|
||||
const now = performance.now()
|
||||
if (now - lastIntermediateRender >= 16) {
|
||||
lastIntermediateRender = now
|
||||
renderer.intermediateRender()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (typeof event.data !== "string") return
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!message || typeof message !== "object" || !("type" in message)) return
|
||||
if (
|
||||
message.type === "resized" &&
|
||||
"cols" in message &&
|
||||
typeof message.cols === "number" &&
|
||||
"rows" in message &&
|
||||
typeof message.rows === "number" &&
|
||||
"checkpoint" in message &&
|
||||
typeof message.checkpoint === "string"
|
||||
) {
|
||||
enqueue({
|
||||
type: "resize",
|
||||
size: { cols: message.cols, rows: message.rows },
|
||||
checkpoint: Buffer.from(message.checkpoint, "base64"),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message.type === "replay_complete") {
|
||||
enqueue({ type: "ready" })
|
||||
return
|
||||
}
|
||||
if (
|
||||
message.type === "controller_changed" &&
|
||||
"attachmentID" in message &&
|
||||
(typeof message.attachmentID === "string" || message.attachmentID === undefined)
|
||||
) {
|
||||
const previous = controller
|
||||
controller = message.attachmentID === attachmentID
|
||||
if (controller && !previous && restored) interact()
|
||||
return
|
||||
}
|
||||
if (message.type !== "attached") return
|
||||
if (!("inputProtocol" in message) || message.inputProtocol !== 1) {
|
||||
setFailure("Persistent terminal server is out of date; restart OpenCode")
|
||||
next.close()
|
||||
return
|
||||
}
|
||||
if (
|
||||
"info" in message &&
|
||||
message.info &&
|
||||
typeof message.info === "object" &&
|
||||
"size" in message.info &&
|
||||
message.info.size &&
|
||||
typeof message.info.size === "object" &&
|
||||
"cols" in message.info.size &&
|
||||
typeof message.info.size.cols === "number" &&
|
||||
"rows" in message.info.size &&
|
||||
typeof message.info.size.rows === "number"
|
||||
)
|
||||
enqueue({ type: "resize", size: { cols: message.info.size.cols, rows: message.info.size.rows } })
|
||||
controller = "role" in message && message.role === "controller"
|
||||
attached = true
|
||||
})
|
||||
next.addEventListener("error", () => {
|
||||
if (disposed) return
|
||||
const focused = terminal?.focused
|
||||
terminal = undefined
|
||||
setFailure("Terminal connection failed")
|
||||
if (focused) props.onDisconnect?.()
|
||||
})
|
||||
next.addEventListener("close", () => {
|
||||
if (disposed) return
|
||||
const focused = terminal?.focused
|
||||
terminal = undefined
|
||||
setFailure("Terminal disconnected")
|
||||
if (focused) props.onDisconnect?.()
|
||||
})
|
||||
socket = next
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
overflow="hidden"
|
||||
backgroundColor={themes.currentTokens().contextual.elevated.background.default}
|
||||
onSizeChange={function () {
|
||||
size = { cols: Math.max(1, this.width - 2), rows: this.height }
|
||||
if (controller && restored) interact()
|
||||
}}
|
||||
// TODO: Revisit when embedded terminal mouse handlers can compose without replacing its internal focus handler.
|
||||
onMouseDown={() => interact()}
|
||||
>
|
||||
<Show when={!failure()} fallback={<text fg={theme.text.feedback.error.default}>{failure()}</text>}>
|
||||
<>
|
||||
<embeddedTerminal
|
||||
ref={(value) => {
|
||||
terminal = value
|
||||
props.onFocusRequest?.(() => {
|
||||
value.focus()
|
||||
interact()
|
||||
})
|
||||
terminalSize = { cols: 80, rows: 24 }
|
||||
if (canonicalSize) {
|
||||
value.width = canonicalSize.cols
|
||||
value.height = canonicalSize.rows
|
||||
}
|
||||
applyTerminalTheme()
|
||||
}}
|
||||
position="absolute"
|
||||
left={1}
|
||||
top={0}
|
||||
width={80}
|
||||
height={24}
|
||||
onData={(data, source) => {
|
||||
if (source === "input") sendInput(data)
|
||||
}}
|
||||
onTerminalResize={(cols, rows) => {
|
||||
terminalSize = { cols, rows }
|
||||
if (waitingSize && sameSize(waitingSize.size, terminalSize)) {
|
||||
waitingSize.resolve()
|
||||
waitingSize = undefined
|
||||
}
|
||||
processStream()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefined) {
|
||||
return !!first && !!second && first.cols === second.cols && first.rows === second.rows
|
||||
}
|
||||
|
||||
function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", background: RGBA) {
|
||||
const base = mode === "dark" ? 500 : 700
|
||||
const bright = mode === "dark" ? 300 : 500
|
||||
const colors = [
|
||||
background,
|
||||
theme.text.feedback.error.default,
|
||||
theme.text.feedback.success.default,
|
||||
theme.text.feedback.warning.default,
|
||||
theme.hue.blue[base],
|
||||
theme.hue.purple[base],
|
||||
theme.text.feedback.info.default,
|
||||
theme.text.default,
|
||||
theme.text.subdued,
|
||||
theme.text.feedback.error.subdued,
|
||||
theme.text.feedback.success.subdued,
|
||||
theme.text.feedback.warning.subdued,
|
||||
theme.hue.blue[bright],
|
||||
theme.hue.purple[bright],
|
||||
theme.hue.cyan[bright],
|
||||
theme.hue.neutral[mode === "dark" ? 100 : 900],
|
||||
]
|
||||
return Buffer.from(
|
||||
colors
|
||||
.map((color, index) => `\x1b]4;${index};${hex(color)}\x1b\\`)
|
||||
.concat(`\x1b]10;${hex(theme.text.default)}\x1b\\`, `\x1b]11;${hex(background)}\x1b\\`)
|
||||
.join(""),
|
||||
)
|
||||
}
|
||||
|
||||
function hex(color: RGBA) {
|
||||
return `#${color
|
||||
.toInts()
|
||||
.slice(0, 3)
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("")}`
|
||||
}
|
||||
|
||||
function interactionFrame(size: { cols: number; rows: number }, data?: Uint8Array) {
|
||||
const frame = new Uint8Array(5 + (data?.byteLength ?? 0))
|
||||
const view = new DataView(frame.buffer)
|
||||
frame[0] = data ? 1 : 0
|
||||
view.setUint16(1, size.cols)
|
||||
view.setUint16(3, size.rows)
|
||||
if (data) frame.set(data, 5)
|
||||
return frame
|
||||
}
|
||||
@@ -126,6 +126,7 @@ export const Info = Schema.Struct({
|
||||
sidebar: Schema.optional(Schema.Literals(["auto", "hide"])).annotate({
|
||||
description: "Session sidebar visibility; 'auto' shows it when space permits",
|
||||
}),
|
||||
terminal: Schema.optional(Schema.Boolean).annotate({ description: "Enable persistent session terminal panes" }),
|
||||
scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide model reasoning by default",
|
||||
@@ -136,6 +137,9 @@ export const Info = Schema.Struct({
|
||||
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show user attachment and tool-result images in the session transcript",
|
||||
}),
|
||||
tps: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show output tokens per second in assistant footers",
|
||||
}),
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
@@ -219,8 +223,9 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
||||
style: "block" | "underline" | "line" | "default"
|
||||
blinking: boolean
|
||||
}
|
||||
session: Omit<NonNullable<Info["session"]>, "new_location"> & {
|
||||
session: Omit<NonNullable<Info["session"]>, "new_location" | "tps"> & {
|
||||
new_location: "launch" | "inherit"
|
||||
tps: boolean
|
||||
}
|
||||
tabs: {
|
||||
enabled: boolean
|
||||
@@ -231,6 +236,12 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
||||
|
||||
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
|
||||
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
|
||||
if (input.session?.terminal) {
|
||||
if (input.keybinds?.["terminal.toggle"] === undefined && input.keybinds?.["theme.switch"] === undefined) {
|
||||
keybinds["terminal.toggle"] = "<leader>t"
|
||||
keybinds["theme.switch"] = "none"
|
||||
}
|
||||
}
|
||||
if (!options.terminalSuspend) {
|
||||
keybinds["terminal.suspend"] = "none"
|
||||
if (keybinds["input.undo"] === undefined) {
|
||||
@@ -265,6 +276,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
session: {
|
||||
...input.session,
|
||||
new_location: input.session?.new_location ?? "launch",
|
||||
tps: input.session?.tps ?? true,
|
||||
},
|
||||
tabs: {
|
||||
...input.tabs,
|
||||
|
||||
@@ -87,6 +87,11 @@ export const Definitions = {
|
||||
"theme.switch_mode": keybind("none", "Switch between light and dark theme mode"),
|
||||
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
|
||||
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
|
||||
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
|
||||
"terminal.select": keybind("<leader>down", "Select terminal"),
|
||||
"terminal.toggle": keybind("none", "Toggle terminal pane"),
|
||||
"terminal.close": keybind("<leader>up", "Close terminal pane"),
|
||||
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
|
||||
"opencode.status": keybind("<leader>s", "View status"),
|
||||
"opencode.debug": keybind("none", "View debug info"),
|
||||
@@ -234,6 +239,9 @@ export const Definitions = {
|
||||
"composer.shell.up": keybind("up", "Previous shell"),
|
||||
"composer.shell.down": keybind("down", "Next shell"),
|
||||
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
|
||||
"composer.terminal.up": keybind("up,k", "Previous terminal"),
|
||||
"composer.terminal.down": keybind("down,j", "Next terminal"),
|
||||
"composer.terminal.select": keybind("return", "Select terminal"),
|
||||
|
||||
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
|
||||
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { createClientConnection } from "@opencode-ai/client/solid"
|
||||
import { createClientConnection, createPersistentPtyClient } from "@opencode-ai/client/solid"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useLog } from "./log"
|
||||
|
||||
type ManagedService = {
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; url?: string }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -14,16 +14,21 @@ type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent,
|
||||
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
||||
init: (props: { api: OpenCodeClient; url?: string; service?: ManagedService }) => {
|
||||
const log = useLog({ component: "client" })
|
||||
const service = props.service
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
let api = props.api
|
||||
let url = props.url
|
||||
let persistentPty = url ? createPersistentPtyClient(api, { url }) : undefined
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
reconnect: service
|
||||
? async (signal) => {
|
||||
api = (await service.reconnect(signal)).api
|
||||
const next = await service.reconnect(signal)
|
||||
api = next.api
|
||||
if (next.url) url = next.url
|
||||
if (url) persistentPty = createPersistentPtyClient(api, { url })
|
||||
return api
|
||||
}
|
||||
: undefined,
|
||||
@@ -41,6 +46,10 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
get api() {
|
||||
return api
|
||||
},
|
||||
get persistentPty() {
|
||||
if (!persistentPty) throw new Error("Persistent terminal server endpoint is unavailable")
|
||||
return persistentPty
|
||||
},
|
||||
event: {
|
||||
on: events.on,
|
||||
listen: events.listen,
|
||||
|
||||
@@ -169,16 +169,21 @@ export interface Keymap {
|
||||
}
|
||||
/** Registers a low-level keymap interceptor. */
|
||||
intercept: OpenTuiKeymap["intercept"]
|
||||
/** Returns whether an event matches the configured leader key. */
|
||||
isLeader(event: KeyEvent): boolean
|
||||
}
|
||||
|
||||
function use(): Keymap {
|
||||
const value = useValue()
|
||||
const leader = value.config.keybinds.get("leader")?.[0]?.key
|
||||
const isLeader = leader ? value.keymap.createKeyMatcher(leader) : () => false
|
||||
return {
|
||||
dispatch(id, input) {
|
||||
value.dispatch(id, input)
|
||||
},
|
||||
mode: value.mode,
|
||||
intercept: value.keymap.intercept.bind(value.keymap),
|
||||
isLeader,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { PersistentPtyInfo } from "@opencode-ai/client"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "./data"
|
||||
import { useEvent } from "./event"
|
||||
import { useStorage } from "./storage"
|
||||
|
||||
type SessionTerminals = {
|
||||
terminals: PersistentPtyInfo[]
|
||||
selectedTerminalID?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
type SessionTerminalsState = {
|
||||
sessions: Record<string, SessionTerminals>
|
||||
}
|
||||
|
||||
export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = createSimpleContext({
|
||||
name: "SessionTerminals",
|
||||
init: () => {
|
||||
const client = useClient()
|
||||
const config = useConfig().data
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const [focus, setFocus] = createSignal<string>()
|
||||
const [store, update] = useStorage().store<SessionTerminalsState>("session-terminals-v1", {
|
||||
initial: { sessions: {} },
|
||||
})
|
||||
|
||||
const save = (sessionID: string, terminals: PersistentPtyInfo[], selectedTerminalID?: string) =>
|
||||
update((draft) => {
|
||||
const current = draft.sessions[sessionID]?.selectedTerminalID
|
||||
const selected = selectedTerminalID ?? current
|
||||
draft.sessions[sessionID] = {
|
||||
terminals,
|
||||
selectedTerminalID: terminals.some((terminal) => terminal.id === selected) ? selected : terminals.at(-1)?.id,
|
||||
...(selectedTerminalID === undefined && draft.sessions[sessionID]?.hidden ? { hidden: true } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async (sessionID: string) => {
|
||||
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }))
|
||||
}
|
||||
|
||||
for (const type of ["persistent-pty.added", "persistent-pty.removed"] as const) {
|
||||
onCleanup(
|
||||
event.on(type, (evt) => {
|
||||
if (!config.session.terminal || !store.sessions[evt.data.sessionID]) return
|
||||
void refresh(evt.data.sessionID).catch((error) =>
|
||||
console.error("Failed to refresh persistent terminal panes", error),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
get(sessionID: string) {
|
||||
return store.sessions[sessionID]
|
||||
},
|
||||
refresh,
|
||||
selectTerminal(sessionID: string, ptyID: string) {
|
||||
setFocus(ptyID)
|
||||
return update((draft) => {
|
||||
const session = draft.sessions[sessionID]
|
||||
if (!session?.terminals.some((terminal) => terminal.id === ptyID)) return
|
||||
session.selectedTerminalID = ptyID
|
||||
delete session.hidden
|
||||
})
|
||||
},
|
||||
hideTerminal(sessionID: string) {
|
||||
return update((draft) => {
|
||||
const session = draft.sessions[sessionID]
|
||||
if (session) session.hidden = true
|
||||
})
|
||||
},
|
||||
async newTerminal(sessionID: string): Promise<PersistentPtyInfo> {
|
||||
const session = data.session.get(sessionID)
|
||||
const terminal = await client.api.experimental.persistentPty.create({
|
||||
sessionID,
|
||||
command: process.env.SHELL || "/bin/sh",
|
||||
args: [],
|
||||
cwd: session?.location.directory ?? process.cwd(),
|
||||
title: "Terminal",
|
||||
env: {},
|
||||
})
|
||||
setFocus(terminal.id)
|
||||
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }), terminal.id)
|
||||
return terminal
|
||||
},
|
||||
shouldFocus(ptyID: string) {
|
||||
return focus() === ptyID
|
||||
},
|
||||
clearFocus(ptyID: string) {
|
||||
setFocus((current) => (current === ptyID ? undefined : current))
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -169,6 +169,7 @@ export function PluginsDialog(props: {
|
||||
<DialogErrorDetails
|
||||
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
|
||||
error={pluginError(entry()) ?? "Unknown plugin error"}
|
||||
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
|
||||
onBack={() => {
|
||||
setDetail()
|
||||
dialog.setSize("medium")
|
||||
@@ -185,6 +186,11 @@ function label(entry: Entry, context: Plugin.Context) {
|
||||
return entry.plugin.id ?? source(entry.plugin, context)
|
||||
}
|
||||
|
||||
function pluginSource(entry: Entry, context: Plugin.Context) {
|
||||
if (entry.runtime === "tui") return entry.target
|
||||
return source(entry.plugin, context)
|
||||
}
|
||||
|
||||
function source(plugin: PluginInfo, context: Plugin.Context) {
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return context.ui.format.path(plugin.source.path)
|
||||
|
||||
@@ -48,17 +48,18 @@ export function Home() {
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
setRef(r)
|
||||
promptRef.set(r)
|
||||
if (once || !r) return
|
||||
if (route.prompt) {
|
||||
r.set(route.prompt)
|
||||
once = true
|
||||
return
|
||||
}
|
||||
if (!args.prompt) return
|
||||
if (once || !r || route.prompt || !args.prompt) return
|
||||
r.set({ text: args.prompt, files: [], agents: [], pasted: [] })
|
||||
once = true
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const composer = ref()
|
||||
const prompt = route.prompt
|
||||
if (!composer || prompt?.text === undefined) return
|
||||
untrack(() => composer.set(prompt))
|
||||
})
|
||||
|
||||
// Wait for the model store to be ready before auto-submitting --prompt.
|
||||
createEffect(() => {
|
||||
const r = ref()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user