Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a47dbd58 | ||
|
|
5ef72e1101 | ||
|
|
d8060dc9ad | ||
|
|
f71fb18d3d | ||
|
|
5654dd2aad | ||
|
|
64dde0cb15 | ||
|
|
a658e43eeb | ||
|
|
ca8a42c973 | ||
|
|
8f5f75db12 | ||
|
|
4ff0d07b1d | ||
|
|
2a571b3cee | ||
|
|
0fffcdfe46 | ||
|
|
2ba2ee52e8 | ||
|
|
56d818fc34 | ||
|
|
9c8da69196 | ||
|
|
a78018697c | ||
|
|
e45b6ef1de | ||
|
|
b616543ac2 | ||
|
|
2bd3d9a696 | ||
|
|
fa15dbc5ec | ||
|
|
312e5c7a7c | ||
|
|
049502fac6 | ||
|
|
cc2915be16 | ||
|
|
ce061bf661 | ||
|
|
3b8790e034 | ||
|
|
a4f3cedcdf | ||
|
|
1c9a2eb239 | ||
|
|
4fb417d3b5 | ||
|
|
11030c627b | ||
|
|
c104098a66 | ||
|
|
49ee3ba85a | ||
|
|
4fc538378d | ||
|
|
d28b5ad2f4 | ||
|
|
6589a66822 | ||
|
|
5cf9abe743 | ||
|
|
903d81819d | ||
|
|
472f9e64a6 | ||
|
|
c04fa9e253 | ||
|
|
3a78fb1f42 | ||
|
|
85ce6a5f95 | ||
|
|
5217e6c1af | ||
|
|
3a7f617098 | ||
|
|
d9150413cb | ||
|
|
bcbc1dba22 | ||
|
|
ce3235e115 | ||
|
|
a9a2a597d5 | ||
|
|
3753601f87 |
@@ -9,6 +9,7 @@
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -329,7 +329,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -412,6 +412,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
@@ -495,7 +496,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -533,7 +534,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -548,7 +549,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -583,7 +584,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -632,7 +633,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -677,6 +678,7 @@
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
@@ -2035,6 +2037,8 @@
|
||||
|
||||
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
|
||||
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
|
||||
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
|
||||
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
|
||||
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
|
||||
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
|
||||
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
|
||||
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
|
||||
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { PartID } from "@/utils/id"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||
|
||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||
@@ -91,7 +91,7 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
|
||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
const requestParts: PromptRequestPart[] = [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
@@ -100,7 +100,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
return {
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
@@ -119,7 +119,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
|
||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
||||
return {
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "agent",
|
||||
name: attachment.name,
|
||||
source: {
|
||||
@@ -139,7 +139,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
used.add(url)
|
||||
|
||||
const filePart = {
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
@@ -154,7 +154,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
used.add(url)
|
||||
return [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
@@ -165,7 +165,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
|
||||
return [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
|
||||
synthetic: true,
|
||||
@@ -184,7 +184,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
|
||||
const images = input.images.map((attachment) => {
|
||||
return {
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { MessageID, PartID } from "@/utils/id"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
@@ -89,7 +89,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
@@ -103,7 +103,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const messageID = input.messageID ?? MessageID.ascending()
|
||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
@@ -467,7 +467,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: PartID.ascending(),
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
@@ -486,7 +486,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = MessageID.ascending()
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
sync.session.optimistic.remove({
|
||||
|
||||
@@ -58,7 +58,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { MessageID } from "@/utils/id"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
@@ -1575,7 +1575,10 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const queueFollowup = (draft: FollowupDraft) => {
|
||||
setFollowup("items", draft.sessionID, (items) => [...(items ?? []), { id: MessageID.ascending(), ...draft }])
|
||||
setFollowup("items", draft.sessionID, (items) => [
|
||||
...(items ?? []),
|
||||
{ id: Identifier.ascending("message"), ...draft },
|
||||
])
|
||||
setFollowup("failed", draft.sessionID, undefined)
|
||||
setFollowup("paused", draft.sessionID, undefined)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import z from "zod"
|
||||
|
||||
const prefixes = {
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
user: "usr",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
} as const
|
||||
|
||||
const LENGTH = 26
|
||||
let lastTimestamp = 0
|
||||
let counter = 0
|
||||
|
||||
type Prefix = "msg" | "prt"
|
||||
type Prefix = keyof typeof prefixes
|
||||
export namespace Identifier {
|
||||
export function schema(prefix: Prefix) {
|
||||
return z.string().startsWith(prefixes[prefix])
|
||||
}
|
||||
|
||||
export const MessageID = {
|
||||
ascending: (given?: string) => generateID("msg", false, given),
|
||||
}
|
||||
export function ascending(prefix: Prefix, given?: string) {
|
||||
return generateID(prefix, false, given)
|
||||
}
|
||||
|
||||
export const PartID = {
|
||||
ascending: (given?: string) => generateID("prt", false, given),
|
||||
export function descending(prefix: Prefix, given?: string) {
|
||||
return generateID(prefix, true, given)
|
||||
}
|
||||
}
|
||||
|
||||
function generateID(prefix: Prefix, descending: boolean, given?: string): string {
|
||||
@@ -17,8 +33,8 @@ function generateID(prefix: Prefix, descending: boolean, given?: string): string
|
||||
return create(prefix, descending)
|
||||
}
|
||||
|
||||
if (!given.startsWith(prefix)) {
|
||||
throw new Error(`ID ${given} does not start with ${prefix}`)
|
||||
if (!given.startsWith(prefixes[prefix])) {
|
||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||
}
|
||||
|
||||
return given
|
||||
@@ -45,7 +61,7 @@ function create(prefix: Prefix, descending: boolean, timestamp?: number): string
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||
}
|
||||
|
||||
return prefix + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
|
||||
return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -79,6 +80,7 @@
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UsageSection() {
|
||||
}
|
||||
|
||||
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
||||
return u.outputTokens + (u.reasoningTokens ?? 0)
|
||||
return u.outputTokens
|
||||
}
|
||||
|
||||
const goPrev = async () => {
|
||||
|
||||
@@ -889,10 +889,6 @@ export async function handler(
|
||||
|
||||
const inputCost = modelCost.input * inputTokens * 100
|
||||
const outputCost = modelCost.output * outputTokens * 100
|
||||
const reasoningCost = (() => {
|
||||
if (!reasoningTokens) return undefined
|
||||
return modelCost.output * reasoningTokens * 100
|
||||
})()
|
||||
const cacheReadCost = (() => {
|
||||
if (!cacheReadTokens) return undefined
|
||||
if (!modelCost.cacheRead) return undefined
|
||||
@@ -909,17 +905,11 @@ export async function handler(
|
||||
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
||||
})()
|
||||
const totalCostInCent =
|
||||
inputCost +
|
||||
outputCost +
|
||||
(reasoningCost ?? 0) +
|
||||
(cacheReadCost ?? 0) +
|
||||
(cacheWrite5mCost ?? 0) +
|
||||
(cacheWrite1hCost ?? 0)
|
||||
inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0)
|
||||
return {
|
||||
totalCostInCent,
|
||||
inputCost,
|
||||
outputCost,
|
||||
reasoningCost,
|
||||
cacheReadCost,
|
||||
cacheWrite5mCost,
|
||||
cacheWrite1hCost,
|
||||
@@ -941,8 +931,7 @@ export async function handler(
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
usageInfo
|
||||
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
|
||||
costInfo
|
||||
const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo
|
||||
|
||||
logger.metric({
|
||||
"tokens.input": inputTokens,
|
||||
@@ -953,14 +942,12 @@ export async function handler(
|
||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||
"cost.input.microcents": centsToMicroCents(inputCost),
|
||||
"cost.output.microcents": centsToMicroCents(outputCost),
|
||||
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
|
||||
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
|
||||
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
|
||||
"cost.total.microcents": centsToMicroCents(totalCostInCent),
|
||||
// deprecated - remove after May 20, 2026
|
||||
"cost.input": Math.round(inputCost),
|
||||
"cost.output": Math.round(outputCost),
|
||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
||||
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
||||
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
||||
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.46"
|
||||
version = "1.14.47"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# @opencode-ai/http-recorder
|
||||
|
||||
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
|
||||
exercise real request shapes against deterministic, version-controlled
|
||||
cassettes — no manual mocks, no flakes from upstream drift.
|
||||
|
||||
## Install
|
||||
|
||||
Internal package; depended on as `@opencode-ai/http-recorder` from another
|
||||
workspace package.
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
|
||||
By default the layer records on first run and replays on subsequent runs —
|
||||
no env-var ternary at the call site, and `CI=true` forces strict replay so
|
||||
missing cassettes fail loudly in CI rather than silently re-recording.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
|
||||
return yield* response.json
|
||||
})
|
||||
|
||||
// Records if the cassette is missing, replays if it exists.
|
||||
// In CI (CI=true) always replays — fails loudly on missing fixtures.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
|
||||
|
||||
// Force a refresh — always hits upstream and overwrites.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
|
||||
| `replay` | Strict — match the request to a recorded interaction; error if none. |
|
||||
| `record` | Execute upstream, append the interaction, write the cassette. |
|
||||
| `passthrough` | Bypass the recorder entirely — just call upstream. |
|
||||
|
||||
## Cassette format
|
||||
|
||||
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
|
||||
"response": { "status": 200, "headers": {...}, "body": "..." }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Cassettes are normal source files — review them, diff them, commit them.
|
||||
|
||||
## Request matching
|
||||
|
||||
By default, requests match on canonicalized method, URL, headers, and JSON
|
||||
body (object keys sorted). Two dispatch strategies are available:
|
||||
|
||||
- **`match`** (default) — find the first recorded interaction whose request
|
||||
matches the incoming request. Same request twice returns the same response.
|
||||
- **`sequential`** — return interactions in the order they were recorded,
|
||||
validating each one matches as the cursor advances. Use for ordered flows
|
||||
where the same URL is hit multiple times with meaningful state changes
|
||||
(pagination, retries, polling).
|
||||
|
||||
```ts
|
||||
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
|
||||
```
|
||||
|
||||
Supply your own matcher via `match: (incoming, recorded) => boolean` for
|
||||
custom equivalence (e.g. ignoring a timestamp field in the body).
|
||||
|
||||
## Redaction & secret safety
|
||||
|
||||
Cassettes get checked in, so the recorder is aggressive about not letting
|
||||
secrets escape. Redaction is configured by composing a `Redactor`:
|
||||
|
||||
```ts
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
|
||||
HttpRecorder.cassetteLayer("anthropic/messages", {
|
||||
redactor: Redactor.defaults({
|
||||
requestHeaders: { allow: ["content-type", "anthropic-version"] },
|
||||
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
|
||||
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
`Redactor.defaults({ … })` composes the four built-in redactors with your
|
||||
overrides. For full control, build the stack yourself:
|
||||
|
||||
```ts
|
||||
const redactor = Redactor.compose(
|
||||
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
|
||||
Redactor.responseHeaders(),
|
||||
Redactor.url({ query: ["session-id"] }),
|
||||
Redactor.body((parsed) => /* … */),
|
||||
)
|
||||
```
|
||||
|
||||
What each layer does:
|
||||
|
||||
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
|
||||
allow-list (request default: `content-type`, `accept`, `openai-beta`;
|
||||
response default: `content-type`). Sensitive headers within the
|
||||
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
|
||||
…) are replaced with `[REDACTED]`.
|
||||
- **`url`** — query parameters matching common secret names (`api_key`,
|
||||
`token`, `signature`, AWS signing params, …) are replaced with
|
||||
`[REDACTED]`. URL user/password are replaced. `transform` runs after
|
||||
built-in redaction for path-level scrubbing.
|
||||
- **`body`** — receives the parsed JSON request body and returns a redacted
|
||||
version. No-op for non-JSON bodies.
|
||||
|
||||
After assembling the cassette, the recorder scans every string for known
|
||||
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
|
||||
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
|
||||
environment variable named like a credential. If anything is found, the
|
||||
cassette is **not written** and the request fails with `UnsafeCassetteError`
|
||||
listing what was detected.
|
||||
|
||||
## WebSocket recording
|
||||
|
||||
WebSocket support records the open frame plus client/server message
|
||||
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
|
||||
can live in the same cassette.
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "ws/subscribe",
|
||||
cassette,
|
||||
live: liveExecutor,
|
||||
})
|
||||
// use executor.open(...)
|
||||
})
|
||||
```
|
||||
|
||||
## Inspecting cassettes programmatically
|
||||
|
||||
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
|
||||
returns the recorded interactions for a name; the file format is hidden
|
||||
behind the seam. Useful for CI checks:
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const audit = Effect.gen(function* () {
|
||||
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||
const entries = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(entries, (entry) =>
|
||||
cassettes
|
||||
.read(entry.name)
|
||||
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
)
|
||||
return issues.filter((i) => i.findings.length > 0)
|
||||
})
|
||||
```
|
||||
|
||||
`cassetteLayer` is the batteries-included entry point — it provides
|
||||
`Cassette.fileSystem({ directory })` automatically. If you want to provide
|
||||
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
|
||||
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
|
||||
`Cassette.memory` yourself.
|
||||
|
||||
## Options reference
|
||||
|
||||
```ts
|
||||
type RecordReplayOptions = {
|
||||
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
|
||||
directory?: string // default: <cwd>/test/fixtures/recordings
|
||||
metadata?: Record<string, unknown> // merged into cassette.metadata
|
||||
redactor?: Redactor // default: Redactor.defaults()
|
||||
dispatch?: "match" | "sequential" // default: "match"
|
||||
match?: (incoming, recorded) => boolean // custom matcher
|
||||
}
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
| -------------- | -------------------------------------------------------------------------------- |
|
||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
|
||||
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
|
||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
|
||||
| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,54 +1,76 @@
|
||||
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
|
||||
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
||||
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
|
||||
import { secretFindings, type SecretFinding } from "./redaction"
|
||||
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
export interface Entry {
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
|
||||
cassetteName: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Cassette "${this.cassetteName}" not found`
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppendResult {
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly path: (name: string) => string
|
||||
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
|
||||
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
|
||||
readonly append: (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
) => Effect.Effect<
|
||||
{
|
||||
readonly cassette: Cassette
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
},
|
||||
PlatformError.PlatformError
|
||||
>
|
||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
|
||||
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
||||
|
||||
export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
||||
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
|
||||
|
||||
const buildCassette = (
|
||||
name: string,
|
||||
interactions: ReadonlyArray<Interaction>,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
|
||||
interactions,
|
||||
})
|
||||
|
||||
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
export const fileSystem = (
|
||||
options: { readonly directory?: string } = {},
|
||||
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
||||
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
|
||||
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
||||
const directoriesEnsured = new Set<string>()
|
||||
|
||||
const pathFor = (name: string) => cassettePath(name, directory)
|
||||
const cassettePath = (name: string) => path.join(directory, `${name}.json`)
|
||||
|
||||
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
|
||||
const ensureDirectory = (name: string) =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fileSystem
|
||||
.readDirectory(directory)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const dir = path.dirname(cassettePath(name))
|
||||
if (directoriesEnsured.has(dir)) return
|
||||
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||
directoriesEnsured.add(dir)
|
||||
})
|
||||
|
||||
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const nested = yield* Effect.forEach(entries, (entry) => {
|
||||
const full = path.join(directory, entry)
|
||||
return fileSystem.stat(full).pipe(
|
||||
const full = path.join(current, entry)
|
||||
return fs.stat(full).pipe(
|
||||
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
@@ -56,53 +78,73 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
return nested.flat()
|
||||
})
|
||||
|
||||
const read = Effect.fn("Cassette.read")(function* (name: string) {
|
||||
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
fs.readFileString(cassettePath(name)).pipe(
|
||||
Effect.map((raw) => parseCassette(raw).interactions),
|
||||
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
||||
),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.gen(function* () {
|
||||
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
||||
if (!recorded.has(name)) recorded.set(name, entry)
|
||||
entry.interactions.push(interaction)
|
||||
entry.findings.push(...secretFindings(interaction))
|
||||
const cassette = buildCassette(name, entry.interactions, metadata)
|
||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||
if (findings.length === 0) {
|
||||
yield* ensureDirectory(name)
|
||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||
}
|
||||
return { findings }
|
||||
}),
|
||||
exists: (name) =>
|
||||
fs.access(cassettePath(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
),
|
||||
list: () =>
|
||||
walk(directory).pipe(
|
||||
Effect.map((files) =>
|
||||
files
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) =>
|
||||
path
|
||||
.relative(directory, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.json$/, ""),
|
||||
)
|
||||
.toSorted((a, b) => a.localeCompare(b)),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
|
||||
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
|
||||
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
|
||||
})
|
||||
|
||||
const append = Effect.fn("Cassette.append")(function* (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
) {
|
||||
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
|
||||
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
|
||||
)
|
||||
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
|
||||
const findings = cassetteSecretFindings(cassette)
|
||||
if (findings.length === 0) yield* write(name, cassette)
|
||||
return { cassette, findings }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
|
||||
return yield* fileSystem.access(pathFor(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Cassette.list")(function* () {
|
||||
return (yield* walk(directory))
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => ({
|
||||
name: path
|
||||
.relative(directory, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.json$/, ""),
|
||||
path: file,
|
||||
}))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer()
|
||||
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
|
||||
Layer.sync(Service, () => {
|
||||
const stored = new Map<string, Interaction[]>(
|
||||
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
|
||||
)
|
||||
const accumulatedFindings = new Map<string, SecretFinding[]>()
|
||||
|
||||
export * as Cassette from "./cassette"
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
stored.has(name)
|
||||
? Effect.succeed(stored.get(name) ?? [])
|
||||
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.sync(() => {
|
||||
const existing = stored.get(name)
|
||||
if (existing) existing.push(interaction)
|
||||
else stored.set(name, [interaction])
|
||||
const findings = accumulatedFindings.get(name)
|
||||
if (findings) findings.push(...secretFindings(interaction))
|
||||
else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
||||
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
||||
return { findings: accumulatedFindings.get(name) ?? [] }
|
||||
}),
|
||||
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Option } from "effect"
|
||||
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
|
||||
import { decodeJson } from "./matching"
|
||||
import { REDACTED, redactUrl, secretFindings } from "./redaction"
|
||||
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
|
||||
|
||||
const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
|
||||
if (!text) return String(value)
|
||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||
}
|
||||
|
||||
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||
|
||||
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
||||
if (Object.is(expected, received)) return []
|
||||
if (
|
||||
expected &&
|
||||
received &&
|
||||
typeof expected === "object" &&
|
||||
typeof received === "object" &&
|
||||
!Array.isArray(expected) &&
|
||||
!Array.isArray(received)
|
||||
) {
|
||||
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
||||
.toSorted()
|
||||
.flatMap((key) =>
|
||||
valueDiffs(
|
||||
(expected as Record<string, unknown>)[key],
|
||||
(received as Record<string, unknown>)[key],
|
||||
`${base}.${key}`,
|
||||
limit,
|
||||
),
|
||||
)
|
||||
.slice(0, limit)
|
||||
}
|
||||
if (Array.isArray(expected) && Array.isArray(received)) {
|
||||
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
||||
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
||||
}
|
||||
|
||||
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
||||
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
||||
if (expected[key] === received[key]) return []
|
||||
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
||||
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
||||
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
||||
})
|
||||
|
||||
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
|
||||
const lines = []
|
||||
if (expected.method !== received.method) {
|
||||
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
||||
}
|
||||
if (expected.url !== received.url) {
|
||||
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||
}
|
||||
const headers = headerDiffs(expected.headers, received.headers)
|
||||
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
||||
const expectedBody = jsonBody(expected.body)
|
||||
const receivedBody = jsonBody(received.body)
|
||||
const body =
|
||||
expectedBody !== undefined && receivedBody !== undefined
|
||||
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
||||
: expected.body === received.body
|
||||
? []
|
||||
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
||||
if (body.length > 0) lines.push("body:", ...body)
|
||||
return lines
|
||||
}
|
||||
|
||||
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
|
||||
const interactions = httpInteractions(cassette)
|
||||
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
|
||||
const ranked = interactions
|
||||
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
|
||||
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
|
||||
const best = ranked[0]
|
||||
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
|
||||
}
|
||||
|
||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
HttpClientRequest.makeWith(
|
||||
request.method,
|
||||
redactUrl(request.url),
|
||||
UrlParams.empty,
|
||||
Option.none(),
|
||||
Headers.empty,
|
||||
HttpBody.empty,
|
||||
)
|
||||
@@ -1,62 +1,37 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Option, Ref } from "effect"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
HttpBody,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
UrlParams,
|
||||
} from "effect/unstable/http"
|
||||
import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff"
|
||||
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
|
||||
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
||||
import {
|
||||
httpInteractions,
|
||||
type Cassette,
|
||||
type CassetteMetadata,
|
||||
type HttpInteraction,
|
||||
type ResponseSnapshot,
|
||||
} from "./schema"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { redactUrl } from "./redaction"
|
||||
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
||||
|
||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
|
||||
export type RecordReplayMode = "record" | "replay" | "passthrough"
|
||||
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
|
||||
|
||||
export interface RecordReplayOptions {
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly directory?: string
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly redact?: {
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly url?: (url: string) => string
|
||||
}
|
||||
readonly requestHeaders?: ReadonlyArray<string>
|
||||
readonly responseHeaders?: ReadonlyArray<string>
|
||||
readonly redactBody?: (body: unknown) => unknown
|
||||
readonly redactor?: Redactor
|
||||
readonly dispatch?: "match" | "sequential"
|
||||
readonly match?: RequestMatcher
|
||||
}
|
||||
|
||||
const responseHeaders = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
allow: ReadonlyArray<string>,
|
||||
redact: ReadonlyArray<string> | undefined,
|
||||
) => {
|
||||
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
|
||||
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
|
||||
return merged
|
||||
}
|
||||
|
||||
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
|
||||
|
||||
const isBinaryContentType = (contentType: string | undefined) => {
|
||||
if (!contentType) return false
|
||||
const lower = contentType.toLowerCase()
|
||||
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
|
||||
}
|
||||
const isBinaryContentType = (contentType: string | undefined) =>
|
||||
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token))
|
||||
|
||||
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
||||
isBinaryContentType(contentType)
|
||||
@@ -68,34 +43,19 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
|
||||
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||
|
||||
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
|
||||
}),
|
||||
})
|
||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
HttpClientRequest.makeWith(
|
||||
request.method,
|
||||
redactUrl(request.url),
|
||||
UrlParams.empty,
|
||||
Option.none(),
|
||||
Headers.empty,
|
||||
HttpBody.empty,
|
||||
)
|
||||
|
||||
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
|
||||
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
|
||||
}),
|
||||
})
|
||||
|
||||
const unsafeCassette = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
name: string,
|
||||
findings: ReadonlyArray<SecretFinding>,
|
||||
) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
|
||||
.map((item) => `${item.path} (${item.reason})`)
|
||||
.join(", ")}`,
|
||||
}),
|
||||
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
|
||||
})
|
||||
|
||||
export const recordingLayer = (
|
||||
@@ -107,61 +67,22 @@ export const recordingLayer = (
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* HttpClient.HttpClient
|
||||
const cassetteService = yield* CassetteService.Service
|
||||
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
|
||||
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
|
||||
const redactor = options.redactor ?? defaults()
|
||||
const match = options.match ?? defaultMatcher
|
||||
const mode = options.mode ?? "replay"
|
||||
const requested = options.mode ?? "auto"
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
|
||||
const sequential = options.dispatch === "sequential"
|
||||
const replay = yield* Ref.make<Cassette | undefined>(undefined)
|
||||
const cursor = yield* Ref.make(0)
|
||||
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
||||
|
||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||
const raw = yield* Effect.promise(() => web.text())
|
||||
const body = options.redactBody
|
||||
? Option.match(decodeJson(raw), {
|
||||
onNone: () => raw,
|
||||
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
|
||||
})
|
||||
: raw
|
||||
return {
|
||||
return redactor.request({
|
||||
method: web.method,
|
||||
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
|
||||
headers: redactHeaders(
|
||||
Object.fromEntries(web.headers.entries()),
|
||||
requestHeadersAllow,
|
||||
options.redact?.headers,
|
||||
),
|
||||
body,
|
||||
}
|
||||
})
|
||||
|
||||
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
|
||||
Effect.gen(function* () {
|
||||
const interactions = httpInteractions(cassette)
|
||||
if (sequential) {
|
||||
const index = yield* Ref.get(cursor)
|
||||
const interaction = interactions[index]
|
||||
if (!interaction)
|
||||
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
||||
if (!match(incoming, interaction.request)) {
|
||||
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
||||
}
|
||||
yield* Ref.update(cursor, (n) => n + 1)
|
||||
return { interaction, detail: "" }
|
||||
}
|
||||
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
|
||||
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
|
||||
})
|
||||
|
||||
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* Ref.get(replay)
|
||||
if (cached) return cached
|
||||
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
|
||||
yield* Ref.set(replay, cassette)
|
||||
return cassette
|
||||
url: web.url,
|
||||
headers: Object.fromEntries(web.headers.entries()),
|
||||
body: yield* Effect.promise(() => web.text()),
|
||||
})
|
||||
})
|
||||
|
||||
return HttpClient.make((request) => {
|
||||
@@ -169,18 +90,21 @@ export const recordingLayer = (
|
||||
|
||||
if (mode === "record") {
|
||||
return Effect.gen(function* () {
|
||||
const currentRequest = yield* snapshotRequest(request)
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const response = yield* upstream.execute(request)
|
||||
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
|
||||
const captured = yield* captureResponseBody(response, headers["content-type"])
|
||||
const captured = yield* captureResponseBody(response, response.headers["content-type"])
|
||||
const interaction: HttpInteraction = {
|
||||
transport: "http",
|
||||
request: currentRequest,
|
||||
response: { status: response.status, headers, ...captured },
|
||||
request: incoming,
|
||||
response: redactor.response({
|
||||
status: response.status,
|
||||
headers: response.headers as Record<string, string>,
|
||||
...captured,
|
||||
}),
|
||||
}
|
||||
const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
|
||||
const findings = result.findings
|
||||
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
|
||||
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||
)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
@@ -189,14 +113,23 @@ export const recordingLayer = (
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const cassette = yield* loadReplay(request)
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
|
||||
if (!interaction) return yield* fixtureMismatch(request, name, detail)
|
||||
|
||||
const interactions = yield* replay.load.pipe(
|
||||
Effect.mapError(() =>
|
||||
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
|
||||
),
|
||||
)
|
||||
const result = sequential
|
||||
? selectSequential(interactions, incoming, match, yield* replay.cursor)
|
||||
: selectMatch(interactions, incoming, match)
|
||||
if (!result.interaction)
|
||||
return yield* Effect.fail(
|
||||
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
|
||||
)
|
||||
if (sequential) yield* replay.advance
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -205,7 +138,7 @@ export const recordingLayer = (
|
||||
|
||||
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||
recordingLayer(name, options).pipe(
|
||||
Layer.provide(CassetteService.layer({ directory: options.directory })),
|
||||
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
export * from "./schema"
|
||||
export * from "./redaction"
|
||||
export * from "./matching"
|
||||
export * from "./diff"
|
||||
export * from "./storage"
|
||||
export * from "./websocket"
|
||||
export * from "./effect"
|
||||
export type {
|
||||
CassetteMetadata,
|
||||
HttpInteraction,
|
||||
Interaction,
|
||||
RequestSnapshot,
|
||||
ResponseSnapshot,
|
||||
WebSocketFrame,
|
||||
WebSocketInteraction,
|
||||
} from "./schema"
|
||||
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
|
||||
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
||||
export { UnsafeCassetteError } from "./recorder"
|
||||
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
||||
export {
|
||||
makeWebSocketExecutor,
|
||||
type WebSocketConnection,
|
||||
type WebSocketExecutor,
|
||||
type WebSocketRecordReplayOptions,
|
||||
type WebSocketRequest,
|
||||
} from "./websocket"
|
||||
|
||||
export * as Cassette from "./cassette"
|
||||
export * as Redactor from "./redactor"
|
||||
|
||||
export * as HttpRecorder from "."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import type { RequestSnapshot } from "./schema"
|
||||
import { REDACTED, secretFindings } from "./redaction"
|
||||
import type { HttpInteraction, RequestSnapshot } from "./schema"
|
||||
|
||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||
@@ -34,3 +35,90 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
|
||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||
|
||||
const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = JSON.stringify(value)
|
||||
if (!text) return String(value)
|
||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||
}
|
||||
|
||||
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||
|
||||
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
||||
if (Object.is(expected, received)) return []
|
||||
if (isRecord(expected) && isRecord(received)) {
|
||||
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
||||
.toSorted()
|
||||
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
if (Array.isArray(expected) && Array.isArray(received)) {
|
||||
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
||||
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
||||
}
|
||||
|
||||
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
||||
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
||||
if (expected[key] === received[key]) return []
|
||||
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
||||
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
||||
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
||||
})
|
||||
|
||||
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
|
||||
const lines: string[] = []
|
||||
if (expected.method !== received.method) {
|
||||
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
||||
}
|
||||
if (expected.url !== received.url) {
|
||||
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||
}
|
||||
const headers = headerDiffs(expected.headers, received.headers)
|
||||
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
||||
const expectedBody = jsonBody(expected.body)
|
||||
const receivedBody = jsonBody(received.body)
|
||||
const body =
|
||||
expectedBody !== undefined && receivedBody !== undefined
|
||||
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
||||
: expected.body === received.body
|
||||
? []
|
||||
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
||||
if (body.length > 0) lines.push("body:", ...body)
|
||||
return lines
|
||||
}
|
||||
|
||||
export const mismatchDetail = (interactions: ReadonlyArray<HttpInteraction>, incoming: RequestSnapshot): string => {
|
||||
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
|
||||
const ranked = interactions
|
||||
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
|
||||
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
|
||||
const best = ranked[0]
|
||||
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
|
||||
}
|
||||
|
||||
export const selectMatch = (
|
||||
interactions: ReadonlyArray<HttpInteraction>,
|
||||
incoming: RequestSnapshot,
|
||||
match: RequestMatcher,
|
||||
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
|
||||
return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) }
|
||||
}
|
||||
|
||||
export const selectSequential = (
|
||||
interactions: ReadonlyArray<HttpInteraction>,
|
||||
incoming: RequestSnapshot,
|
||||
match: RequestMatcher,
|
||||
index: number,
|
||||
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||
const interaction = interactions[index]
|
||||
if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
||||
if (!match(incoming, interaction.request))
|
||||
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
||||
return { interaction, detail: "" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Effect, Ref, Schema, Scope } from "effect"
|
||||
import type * as CassetteService from "./cassette"
|
||||
import type { CassetteNotFoundError } from "./cassette"
|
||||
import { SecretFindingSchema } from "./redaction"
|
||||
import type { CassetteMetadata, Interaction } from "./schema"
|
||||
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedMode = "record" | "replay" | "passthrough"
|
||||
|
||||
const isCI = () => {
|
||||
const value = process.env.CI
|
||||
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||
}
|
||||
|
||||
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
|
||||
Effect.gen(function* () {
|
||||
if (isCI()) return "replay"
|
||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||
})
|
||||
|
||||
export const appendOrFail = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Effect.Effect<void, UnsafeCassetteError> =>
|
||||
cassette
|
||||
.append(name, interaction, metadata)
|
||||
.pipe(
|
||||
Effect.flatMap(({ findings }) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
||||
readonly cursor: Effect.Effect<number>
|
||||
readonly advance: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const makeReplayState = <T>(
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
||||
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
|
||||
const position = yield* Ref.make(0)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
const used = yield* Ref.get(position)
|
||||
if (used === 0) return
|
||||
const interactions = yield* load.pipe(Effect.orDie)
|
||||
if (used < interactions.length)
|
||||
yield* Effect.die(
|
||||
new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) }
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Cassette } from "./schema"
|
||||
|
||||
export const REDACTED = "[REDACTED]"
|
||||
|
||||
const DEFAULT_REDACT_HEADERS = [
|
||||
@@ -97,10 +95,13 @@ export const redactHeaders = (
|
||||
)
|
||||
}
|
||||
|
||||
export type SecretFinding = {
|
||||
readonly path: string
|
||||
readonly reason: string
|
||||
}
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const SecretFindingSchema = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
})
|
||||
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
|
||||
|
||||
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||
stringEntries(value).flatMap((entry) => [
|
||||
@@ -112,5 +113,3 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||
.filter((item) => entry.value.includes(item.value))
|
||||
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
||||
])
|
||||
|
||||
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Option } from "effect"
|
||||
import { decodeJson } from "./matching"
|
||||
import { redactHeaders, redactUrl } from "./redaction"
|
||||
import type { RequestSnapshot, ResponseSnapshot } from "./schema"
|
||||
|
||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export interface Redactor {
|
||||
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
|
||||
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
|
||||
}
|
||||
|
||||
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
|
||||
const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
|
||||
const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
|
||||
return {
|
||||
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
|
||||
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeaderOptions {
|
||||
readonly allow?: ReadonlyArray<string>
|
||||
readonly redact?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
response: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface UrlOptions {
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly transform?: (url: string) => string
|
||||
}
|
||||
|
||||
export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
|
||||
})
|
||||
|
||||
export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
body: Option.match(decodeJson(snapshot.body), {
|
||||
onNone: () => snapshot.body,
|
||||
onSome: (parsed) => JSON.stringify(transform(parsed)),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface DefaultRedactorOverrides {
|
||||
readonly requestHeaders?: HeaderOptions
|
||||
readonly responseHeaders?: HeaderOptions
|
||||
readonly url?: UrlOptions
|
||||
readonly body?: (parsed: unknown) => unknown
|
||||
}
|
||||
|
||||
export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
|
||||
compose(
|
||||
requestHeaders(overrides.requestHeaders),
|
||||
responseHeaders(overrides.responseHeaders),
|
||||
url(overrides.url),
|
||||
...(overrides.body ? [body(overrides.body)] : []),
|
||||
)
|
||||
@@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http
|
||||
|
||||
export const isWebSocketInteraction = InteractionSchema.guards.websocket
|
||||
|
||||
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
|
||||
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
|
||||
|
||||
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
|
||||
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
|
||||
interactions.filter(isWebSocketInteraction)
|
||||
|
||||
export const CassetteSchema = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Option } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
|
||||
|
||||
export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({
|
||||
name,
|
||||
recordedAt: new Date().toISOString(),
|
||||
...(metadata ?? {}),
|
||||
})
|
||||
|
||||
export const cassetteFor = (
|
||||
name: string,
|
||||
interactions: ReadonlyArray<Interaction>,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: metadataFor(name, metadata),
|
||||
interactions,
|
||||
})
|
||||
|
||||
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
|
||||
const file = cassettePath(name, options.directory)
|
||||
if (!fs.existsSync(file)) return false
|
||||
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { canonicalizeJson, decodeJson } from "./matching"
|
||||
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
|
||||
|
||||
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import type { RecordReplayMode } from "./effect"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||
|
||||
export interface WebSocketRequest {
|
||||
readonly url: string
|
||||
@@ -24,67 +24,36 @@ export interface WebSocketExecutor<E> {
|
||||
|
||||
export interface WebSocketRecordReplayOptions<E> {
|
||||
readonly name: string
|
||||
readonly mode?: "record" | "replay" | "passthrough"
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly cassette: CassetteService.Interface
|
||||
readonly live: WebSocketExecutor<E>
|
||||
readonly redact?: {
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly url?: (url: string) => string
|
||||
}
|
||||
readonly requestHeaders?: ReadonlyArray<string>
|
||||
readonly redactor?: Redactor
|
||||
readonly compareClientMessagesAsJson?: boolean
|
||||
}
|
||||
|
||||
const headersRecord = (headers: Headers.Headers) =>
|
||||
const headersRecord = (headers: Headers.Headers): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(headers as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
Object.entries(headers as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
)
|
||||
|
||||
const openSnapshot = (
|
||||
request: WebSocketRequest,
|
||||
options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
|
||||
) => ({
|
||||
url: redactUrl(request.url, options.redact?.query, options.redact?.url),
|
||||
headers: redactHeaders(
|
||||
headersRecord(request.headers),
|
||||
options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
|
||||
options.redact?.headers,
|
||||
),
|
||||
})
|
||||
|
||||
const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
|
||||
|
||||
const frameText = (frame: WebSocketFrame) => {
|
||||
if (frame.kind === "text") return frame.body
|
||||
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
||||
}
|
||||
|
||||
const frameMessage = (frame: WebSocketFrame) =>
|
||||
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
|
||||
|
||||
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
|
||||
const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
|
||||
typeof message === "string"
|
||||
? textFrame(message)
|
||||
? { kind: "text", body: message }
|
||||
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
||||
|
||||
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
||||
new Error(
|
||||
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
|
||||
.map((item) => `${item.path} (${item.reason})`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
|
||||
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
|
||||
|
||||
const mismatch = (message: string, actual: unknown, expected: unknown) =>
|
||||
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||
const decodeFrameText = (frame: WebSocketFrame) =>
|
||||
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
||||
|
||||
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
||||
Effect.sync(() => {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
||||
throw mismatch(message, actual, expected)
|
||||
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||
})
|
||||
|
||||
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
|
||||
@@ -94,7 +63,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin
|
||||
return Effect.sync(() => {
|
||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
|
||||
})
|
||||
const expectedText = frameText(expected)
|
||||
const expectedText = decodeFrameText(expected)
|
||||
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
||||
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
|
||||
}
|
||||
@@ -103,7 +72,18 @@ export const makeWebSocketExecutor = <E>(
|
||||
options: WebSocketRecordReplayOptions<E>,
|
||||
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const mode = options.mode ?? "replay"
|
||||
const requested = options.mode ?? "auto"
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
|
||||
const redactor = options.redactor ?? defaults()
|
||||
const openSnapshot = (request: WebSocketRequest) => {
|
||||
const redacted = redactor.request({
|
||||
method: "GET",
|
||||
url: request.url,
|
||||
headers: headersRecord(request.headers),
|
||||
body: "",
|
||||
})
|
||||
return { url: redacted.url, headers: redacted.headers }
|
||||
}
|
||||
|
||||
if (mode === "passthrough") return options.live
|
||||
|
||||
@@ -118,21 +98,21 @@ export const makeWebSocketExecutor = <E>(
|
||||
const closeOnce = Effect.gen(function* () {
|
||||
if (yield* Ref.getAndSet(closed, true)) return
|
||||
yield* connection.close
|
||||
const result = yield* options.cassette
|
||||
.append(
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request, options), client, server },
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
|
||||
yield* appendOrFail(
|
||||
options.cassette,
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||
options.metadata,
|
||||
).pipe(Effect.orDie)
|
||||
})
|
||||
return {
|
||||
sendText: (message: string) =>
|
||||
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
|
||||
sendText: (message) =>
|
||||
connection
|
||||
.sendText(message)
|
||||
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
||||
messages: connection.messages.pipe(
|
||||
Stream.map((message) => {
|
||||
server.push(receivedFrame(message))
|
||||
server.push(encodeFrame(message))
|
||||
return message
|
||||
}),
|
||||
),
|
||||
@@ -142,44 +122,20 @@ export const makeWebSocketExecutor = <E>(
|
||||
}
|
||||
}
|
||||
|
||||
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const cursor = yield* Ref.make(0)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* Ref.get(replay)
|
||||
if (!input) return
|
||||
yield* assertEqual(
|
||||
`Unused recorded WebSocket interactions in ${options.name}`,
|
||||
yield* Ref.get(cursor),
|
||||
input.interactions.length,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
|
||||
const cached = yield* Ref.get(replay)
|
||||
if (cached) return cached
|
||||
const input = {
|
||||
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
|
||||
}
|
||||
yield* Ref.set(replay, input)
|
||||
return input
|
||||
})
|
||||
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
|
||||
|
||||
return {
|
||||
open: (request) => {
|
||||
return Effect.gen(function* () {
|
||||
const input = yield* loadReplay()
|
||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
||||
const interaction = input.interactions[index]
|
||||
open: (request) =>
|
||||
Effect.gen(function* () {
|
||||
const interactions = yield* replay.load.pipe(Effect.orDie)
|
||||
const index = yield* replay.cursor
|
||||
const interaction = interactions[index]
|
||||
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
|
||||
yield* replay.advance
|
||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
|
||||
const messageIndex = yield* Ref.make(0)
|
||||
return {
|
||||
sendText: (message: string) =>
|
||||
sendText: (message) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
||||
yield* compareClientMessage(
|
||||
@@ -189,7 +145,7 @@ export const makeWebSocketExecutor = <E>(
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
}),
|
||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
|
||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
|
||||
close: Effect.gen(function* () {
|
||||
yield* assertEqual(
|
||||
`WebSocket client frame count for interaction ${index + 1}`,
|
||||
@@ -198,7 +154,6 @@ export const makeWebSocketExecutor = <E>(
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -6,7 +6,16 @@ import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { HttpRecorder } from "../src"
|
||||
import { redactedErrorRequest } from "../src/diff"
|
||||
import { redactedErrorRequest } from "../src/effect"
|
||||
import type { Interaction } from "../src/schema"
|
||||
|
||||
const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
|
||||
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
const post = (url: string, body: object) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -33,7 +42,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
|
||||
Effect.scoped(
|
||||
effect.pipe(
|
||||
Effect.provide(
|
||||
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
|
||||
HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
|
||||
),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
@@ -108,7 +117,7 @@ describe("http-recorder", () => {
|
||||
|
||||
test("detects secret-looking values without returning the secret", () => {
|
||||
expect(
|
||||
HttpRecorder.cassetteSecretFindings({
|
||||
HttpRecorder.secretFindings({
|
||||
version: 1,
|
||||
interactions: [
|
||||
{
|
||||
@@ -136,7 +145,7 @@ describe("http-recorder", () => {
|
||||
|
||||
test("detects secret-looking values inside metadata", () => {
|
||||
expect(
|
||||
HttpRecorder.cassetteSecretFindings({
|
||||
HttpRecorder.secretFindings({
|
||||
version: 1,
|
||||
metadata: { token: "sk-123456789012345678901234" },
|
||||
interactions: [],
|
||||
@@ -144,60 +153,42 @@ describe("http-recorder", () => {
|
||||
).toEqual([{ path: "metadata.token", reason: "API key" }])
|
||||
})
|
||||
|
||||
test("formats websocket cassettes with shared metadata", () => {
|
||||
const cassette = HttpRecorder.cassetteFor(
|
||||
"websocket/basic",
|
||||
[
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
{ provider: "openai" },
|
||||
)
|
||||
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "websocket/replay",
|
||||
cassette,
|
||||
compareClientMessagesAsJson: true,
|
||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||
})
|
||||
const connection = yield* executor.open({
|
||||
url: "wss://example.test/realtime",
|
||||
headers: Headers.fromInput({ "content-type": "application/json" }),
|
||||
})
|
||||
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
|
||||
const messages: Array<string | Uint8Array> = []
|
||||
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
|
||||
yield* connection.close
|
||||
|
||||
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
|
||||
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
|
||||
})
|
||||
|
||||
test("replays websocket interactions from the shared cassette service", async () => {
|
||||
await runRecorder(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
yield* cassette.write(
|
||||
"websocket/replay",
|
||||
HttpRecorder.cassetteFor(
|
||||
"websocket/replay",
|
||||
[
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
HttpRecorder.Cassette.memory({
|
||||
"websocket/replay": [
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "websocket/replay",
|
||||
cassette,
|
||||
compareClientMessagesAsJson: true,
|
||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||
})
|
||||
const connection = yield* executor.open({
|
||||
url: "wss://example.test/realtime",
|
||||
headers: Headers.fromInput({ "content-type": "application/json" }),
|
||||
})
|
||||
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
|
||||
const messages: Array<string | Uint8Array> = []
|
||||
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
|
||||
yield* connection.close
|
||||
|
||||
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -227,17 +218,14 @@ describe("http-recorder", () => {
|
||||
yield* connection.messages.pipe(Stream.runDrain)
|
||||
yield* connection.close
|
||||
|
||||
expect(yield* cassette.read("websocket/record")).toMatchObject({
|
||||
metadata: { name: "websocket/record", provider: "test" },
|
||||
interactions: [
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(yield* cassette.read("websocket/record")).toMatchObject([
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -300,6 +288,49 @@ describe("http-recorder", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("auto mode replays when the cassette exists", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
|
||||
await seedCassetteDirectory(directory, "auto-replay", [
|
||||
{
|
||||
transport: "http",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://example.test/echo",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ step: 1 }),
|
||||
},
|
||||
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
|
||||
},
|
||||
])
|
||||
|
||||
const result = await runWith(
|
||||
"auto-replay",
|
||||
{ directory, mode: "auto" },
|
||||
post("https://example.test/echo", { step: 1 }),
|
||||
)
|
||||
expect(result).toBe('{"reply":"hi"}')
|
||||
})
|
||||
|
||||
test("auto mode forces replay when CI=true even if cassette is missing", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
|
||||
const previous = process.env.CI
|
||||
process.env.CI = "true"
|
||||
try {
|
||||
const exit = await Effect.runPromise(
|
||||
Effect.exit(
|
||||
post("https://example.test/echo", { step: 1 }).pipe(
|
||||
Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CI
|
||||
else process.env.CI = previous
|
||||
}
|
||||
})
|
||||
|
||||
test("mismatch diagnostics show closest redacted request differences", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
stream: {
|
||||
event: Schema.String,
|
||||
initial: () => undefined,
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const),
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||
onHalt: () => [{ type: "request-finish", reason: "stop" }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
@@ -415,14 +415,13 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
||||
? String((block.content as Record<string, unknown>).type)
|
||||
: ""
|
||||
const isError = errorPayload.endsWith("_tool_result_error")
|
||||
return {
|
||||
type: "tool-result",
|
||||
return LLMEvent.toolResult({
|
||||
id: block.tool_use_id ?? "",
|
||||
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
||||
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
||||
providerExecuted: true,
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
@@ -453,18 +452,17 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
}
|
||||
|
||||
if (block.type === "text" && block.text) {
|
||||
return [state, [{ type: "text-delta", text: block.text }]]
|
||||
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]]
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
return [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "reasoning-delta",
|
||||
LLMEvent.reasoningDelta({
|
||||
id: `reasoning-${event.index ?? 0}`,
|
||||
text: block.thinking,
|
||||
...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}),
|
||||
},
|
||||
}),
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -480,17 +478,25 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
const delta = event.delta
|
||||
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult
|
||||
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult
|
||||
return [
|
||||
state,
|
||||
[LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })],
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
return [
|
||||
state,
|
||||
[{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }],
|
||||
[
|
||||
LLMEvent.reasoningEnd({
|
||||
id: `reasoning-${event.index ?? 0}`,
|
||||
providerMetadata: anthropicMetadata({ signature: delta.signature }),
|
||||
}),
|
||||
],
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -524,21 +530,20 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
return [
|
||||
{ ...state, usage },
|
||||
[
|
||||
{
|
||||
type: "request-finish",
|
||||
LLMEvent.requestFinish({
|
||||
reason: mapFinishReason(event.delta?.stop_reason),
|
||||
usage,
|
||||
...(event.delta?.stop_sequence
|
||||
? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) }
|
||||
: {}),
|
||||
},
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }],
|
||||
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
@@ -400,13 +400,26 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.text) {
|
||||
return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.textDelta({
|
||||
id: `text-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
text: event.contentBlockDelta.delta.text,
|
||||
}),
|
||||
],
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
return [
|
||||
state,
|
||||
[{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }],
|
||||
[
|
||||
LLMEvent.reasoningDelta({
|
||||
id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
text: event.contentBlockDelta.delta.reasoningContent.text,
|
||||
}),
|
||||
],
|
||||
] as const
|
||||
}
|
||||
|
||||
@@ -449,16 +462,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
event.modelStreamErrorException?.message ??
|
||||
event.serviceUnavailableException?.message ??
|
||||
"Bedrock Converse stream error"
|
||||
return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const
|
||||
return [state, [LLMEvent.providerError({ message, retryable: true })]] as const
|
||||
}
|
||||
|
||||
if (event.validationException || event.throttlingException) {
|
||||
const message =
|
||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||
return [
|
||||
state,
|
||||
[{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }],
|
||||
] as const
|
||||
return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const
|
||||
}
|
||||
|
||||
return [state, []] as const
|
||||
@@ -468,7 +478,7 @@ const framing = BedrockEventStream.framing(ADAPTER)
|
||||
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.pendingFinish
|
||||
? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }]
|
||||
? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })]
|
||||
: []
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type TextPart,
|
||||
@@ -311,7 +311,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.finishReason || state.usage
|
||||
? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }]
|
||||
? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })]
|
||||
: []
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
@@ -332,14 +332,18 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text })
|
||||
events.push(
|
||||
part.thought
|
||||
? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
|
||||
: LLMEvent.textDelta({ id: "text-0", text: part.text }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
events.push({ type: "tool-call", id, name: part.functionCall.name, input })
|
||||
events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
|
||||
hasToolCalls = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
@@ -312,7 +312,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
|
||||
if (delta?.content) events.push({ type: "text-delta", text: delta.content })
|
||||
if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content }))
|
||||
|
||||
for (const tool of toolDeltas) {
|
||||
const result = ToolStream.appendOrStart(
|
||||
@@ -348,10 +348,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||
return [
|
||||
...state.toolCallEvents,
|
||||
...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray<LLMEvent>) : []),
|
||||
]
|
||||
return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
||||
import { HttpTransport, WebSocketTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type TextPart,
|
||||
@@ -348,22 +348,20 @@ const hostedToolEvents = (
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
return [
|
||||
{
|
||||
type: "tool-call",
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -379,17 +377,7 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
return [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "text-delta",
|
||||
id: event.item_id,
|
||||
text: event.delta,
|
||||
...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}),
|
||||
},
|
||||
],
|
||||
]
|
||||
return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]]
|
||||
}
|
||||
|
||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
@@ -458,30 +446,28 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "request-finish",
|
||||
LLMEvent.requestFinish({
|
||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||
usage: mapUsage(event.response?.usage),
|
||||
...(event.response?.id || event.response?.service_tier
|
||||
? {
|
||||
providerMetadata: openaiMetadata({
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
? openaiMetadata({
|
||||
responseId: event.response.id,
|
||||
serviceTier: event.response.service_tier,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
|
||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }],
|
||||
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
|
||||
]
|
||||
|
||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }],
|
||||
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -49,34 +49,24 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
||||
return next
|
||||
}
|
||||
|
||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({
|
||||
type: "tool-input-delta",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
})
|
||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
})
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
||||
Effect.map(
|
||||
(input): ToolCall =>
|
||||
tool.providerExecuted
|
||||
? {
|
||||
type: "tool-call",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: true,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
}
|
||||
: {
|
||||
type: "tool-call",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
},
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelRef } from "./options"
|
||||
import { ToolResultValue } from "./messages"
|
||||
|
||||
@@ -14,60 +14,87 @@ export class Usage extends Schema.Class<Usage>("LLM.Usage")({
|
||||
}) {}
|
||||
|
||||
export const RequestStart = Schema.Struct({
|
||||
type: Schema.Literal("request-start"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("request-start"),
|
||||
id: ResponseID,
|
||||
model: ModelRef,
|
||||
}).annotate({ identifier: "LLM.Event.RequestStart" })
|
||||
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.Literal("step-start"),
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
}).annotate({ identifier: "LLM.Event.StepStart" })
|
||||
export type StepStart = Schema.Schema.Type<typeof StepStart>
|
||||
|
||||
export const TextStart = Schema.Struct({
|
||||
type: Schema.Literal("text-start"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("text-start"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextStart" })
|
||||
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
||||
|
||||
export const TextDelta = Schema.Struct({
|
||||
type: Schema.Literal("text-delta"),
|
||||
id: Schema.optional(Schema.String),
|
||||
type: Schema.tag("text-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||
|
||||
export const TextEnd = Schema.Struct({
|
||||
type: Schema.Literal("text-end"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("text-end"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||
|
||||
export const ReasoningDelta = Schema.Struct({
|
||||
type: Schema.Literal("reasoning-delta"),
|
||||
id: Schema.optional(Schema.String),
|
||||
text: Schema.String,
|
||||
export const ReasoningStart = Schema.Struct({
|
||||
type: Schema.tag("reasoning-start"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
|
||||
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
|
||||
|
||||
export const ReasoningDelta = Schema.Struct({
|
||||
type: Schema.tag("reasoning-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||
|
||||
export const ReasoningEnd = Schema.Struct({
|
||||
type: Schema.tag("reasoning-end"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||
|
||||
export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||
|
||||
export const ToolInputDelta = Schema.Struct({
|
||||
type: Schema.Literal("tool-input-delta"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-input-delta"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
|
||||
|
||||
export const ToolInputEnd = Schema.Struct({
|
||||
type: Schema.tag("tool-input-end"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||
|
||||
export const ToolCall = Schema.Struct({
|
||||
type: Schema.Literal("tool-call"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-call"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -76,8 +103,8 @@ export const ToolCall = Schema.Struct({
|
||||
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||
|
||||
export const ToolResult = Schema.Struct({
|
||||
type: Schema.Literal("tool-result"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-result"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -86,8 +113,8 @@ export const ToolResult = Schema.Struct({
|
||||
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||
|
||||
export const ToolError = Schema.Struct({
|
||||
type: Schema.Literal("tool-error"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-error"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -95,7 +122,7 @@ export const ToolError = Schema.Struct({
|
||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
|
||||
export const StepFinish = Schema.Struct({
|
||||
type: Schema.Literal("step-finish"),
|
||||
type: Schema.tag("step-finish"),
|
||||
index: Schema.Number,
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
@@ -104,7 +131,7 @@ export const StepFinish = Schema.Struct({
|
||||
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||
|
||||
export const RequestFinish = Schema.Struct({
|
||||
type: Schema.Literal("request-finish"),
|
||||
type: Schema.tag("request-finish"),
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -112,7 +139,7 @@ export const RequestFinish = Schema.Struct({
|
||||
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
|
||||
|
||||
export const ProviderErrorEvent = Schema.Struct({
|
||||
type: Schema.Literal("provider-error"),
|
||||
type: Schema.tag("provider-error"),
|
||||
message: Schema.String,
|
||||
retryable: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -125,8 +152,12 @@ const llmEventTagged = Schema.Union([
|
||||
TextStart,
|
||||
TextDelta,
|
||||
TextEnd,
|
||||
ReasoningStart,
|
||||
ReasoningDelta,
|
||||
ReasoningEnd,
|
||||
ToolInputStart,
|
||||
ToolInputDelta,
|
||||
ToolInputEnd,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
ToolError,
|
||||
@@ -135,20 +166,52 @@ const llmEventTagged = Schema.Union([
|
||||
ProviderErrorEvent,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
||||
|
||||
const responseID = (value: ResponseID | string) => ResponseID.make(value)
|
||||
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
|
||||
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
|
||||
|
||||
/**
|
||||
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
|
||||
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
|
||||
* `events.filter(LLMEvent.guards["tool-call"])`.
|
||||
*/
|
||||
export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
requestStart: (input: WithID<RequestStart, ResponseID>) => RequestStart.make({ ...input, id: responseID(input.id) }),
|
||||
stepStart: StepStart.make,
|
||||
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textEnd: (input: WithID<TextEnd, ContentBlockID>) => TextEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningStart: (input: WithID<ReasoningStart, ContentBlockID>) =>
|
||||
ReasoningStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningDelta: (input: WithID<ReasoningDelta, ContentBlockID>) =>
|
||||
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
|
||||
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
|
||||
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolResult: (input: WithID<ToolResult, ToolCallID>) => ToolResult.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolError: (input: WithID<ToolError, ToolCallID>) => ToolError.make({ ...input, id: toolCallID(input.id) }),
|
||||
stepFinish: StepFinish.make,
|
||||
requestFinish: RequestFinish.make,
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
requestStart: llmEventTagged.guards["request-start"],
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
textDelta: llmEventTagged.guards["text-delta"],
|
||||
textEnd: llmEventTagged.guards["text-end"],
|
||||
reasoningStart: llmEventTagged.guards["reasoning-start"],
|
||||
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
|
||||
reasoningEnd: llmEventTagged.guards["reasoning-end"],
|
||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||
toolCall: llmEventTagged.guards["tool-call"],
|
||||
toolResult: llmEventTagged.guards["tool-result"],
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
|
||||
@@ -14,6 +14,15 @@ export type ModelID = typeof ModelID.Type
|
||||
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
|
||||
export type ProviderID = typeof ProviderID.Type
|
||||
|
||||
export const ResponseID = Schema.String
|
||||
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
|
||||
|
||||
export const ContentBlockID = Schema.String
|
||||
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
|
||||
|
||||
export const ToolCallID = Schema.String
|
||||
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
|
||||
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
||||
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ContentPart,
|
||||
type FinishReason,
|
||||
type LLMError,
|
||||
type LLMEvent,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ProviderMetadata,
|
||||
@@ -115,11 +115,19 @@ interface StepState {
|
||||
|
||||
const accumulate = (state: StepState, event: LLMEvent) => {
|
||||
if (event.type === "text-delta") {
|
||||
appendStreamingText(state, "text", event.text, event.providerMetadata)
|
||||
appendStreamingText(state, "text", event.text, undefined)
|
||||
return
|
||||
}
|
||||
if (event.type === "reasoning-delta") {
|
||||
appendStreamingText(state, "reasoning", event.text, event.providerMetadata)
|
||||
appendStreamingText(state, "reasoning", event.text, undefined)
|
||||
return
|
||||
}
|
||||
if (event.type === "reasoning-end") {
|
||||
appendStreamingText(state, "reasoning", "", event.providerMetadata)
|
||||
return
|
||||
}
|
||||
if (event.type === "text-end") {
|
||||
appendStreamingText(state, "text", "", event.providerMetadata)
|
||||
return
|
||||
}
|
||||
if (event.type === "tool-call") {
|
||||
@@ -219,10 +227,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
|
||||
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
|
||||
result.type === "error"
|
||||
? [
|
||||
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
|
||||
{ type: "tool-result", id: call.id, name: call.name, result },
|
||||
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
|
||||
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
|
||||
]
|
||||
: [{ type: "tool-result", id: call.id, name: call.name, result }]
|
||||
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
|
||||
|
||||
const followUpRequest = (
|
||||
request: LLMRequest,
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -50,7 +50,9 @@ const request = LLM.request({
|
||||
})
|
||||
|
||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||
event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text }
|
||||
event.type === "finish"
|
||||
? { type: "request-finish", reason: event.reason }
|
||||
: { type: "text-delta", id: "text-0", text: event.text }
|
||||
|
||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||
id: "fake",
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("llm constructors", () => {
|
||||
expect(
|
||||
LLMResponse.text({
|
||||
events: [
|
||||
{ type: "text-delta", text: "hi" },
|
||||
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||
{ type: "request-finish", reason: "stop" },
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Redactor } from "@opencode-ai/http-recorder"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
@@ -30,7 +31,7 @@ const recorded = recordedTests({
|
||||
provider: "anthropic",
|
||||
protocol: "anthropic-messages",
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
})
|
||||
|
||||
describe("Anthropic Messages sad-path recorded", () => {
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("Anthropic Messages route", () => {
|
||||
cacheReadInputTokens: 1,
|
||||
totalTokens: 7,
|
||||
})
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
|
||||
@@ -204,9 +204,9 @@ describe("Gemini route", () => {
|
||||
totalTokens: 7,
|
||||
})
|
||||
expect(response.events).toEqual([
|
||||
{ type: "reasoning-delta", text: "thinking" },
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: "!" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Redactor } from "@opencode-ai/http-recorder"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
@@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) =>
|
||||
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
||||
|
||||
const cloudflareOptions = {
|
||||
redact: { url: redactCloudflareURL },
|
||||
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
|
||||
}
|
||||
|
||||
describeRecordedGoldenScenarios([
|
||||
@@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([
|
||||
prefix: "anthropic-messages",
|
||||
model: anthropicHaiku,
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
scenarios: ["text", "tool-call"],
|
||||
},
|
||||
{
|
||||
@@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([
|
||||
model: anthropicOpus,
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
tags: ["flagship"],
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
scenarios: [{ id: "tool-loop", temperature: false }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -225,8 +225,8 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: "!" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
|
||||
@@ -336,8 +336,8 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
@@ -394,14 +394,12 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query"',
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
|
||||
@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
...metadata,
|
||||
}
|
||||
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
|
||||
const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe(
|
||||
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
const requestExecutor = RequestExecutor.layer.pipe(
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder"
|
||||
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { WebSocketExecutor } from "../src/route"
|
||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
||||
|
||||
const liveWebSocket = WebSocketExecutor.open
|
||||
type Mode = "record" | "replay" | "passthrough"
|
||||
|
||||
export const webSocketCassetteLayer = (
|
||||
cassette: string,
|
||||
input: { readonly metadata?: Record<string, unknown>; readonly mode: Mode },
|
||||
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
|
||||
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
|
||||
Layer.effect(
|
||||
WebSocketExecutor.Service,
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||
|
||||
## Development server
|
||||
|
||||
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
|
||||
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
|
||||
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
|
||||
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
|
||||
|
||||
# Module shape
|
||||
|
||||
Do not use `export namespace Foo { ... }` for module organization. It is not
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.47",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -119,6 +119,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
|
||||
@@ -26,9 +26,7 @@ import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
||||
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
@@ -303,69 +301,70 @@ export const layer = Layer.effect(
|
||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
function referencePath(value: string) {
|
||||
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
|
||||
return path.isAbsolute(value)
|
||||
? value
|
||||
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
|
||||
}
|
||||
|
||||
function resolveReference(reference: ReferenceEntry): ResolvedReference {
|
||||
if (typeof reference === "string") {
|
||||
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
|
||||
return { kind: "local", path: referencePath(reference) }
|
||||
}
|
||||
return { kind: "git", repository: reference }
|
||||
}
|
||||
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
|
||||
return { kind: "git", repository: reference.repository, branch: reference.branch }
|
||||
}
|
||||
|
||||
function referencePrompt(name: string, reference: ResolvedReference) {
|
||||
function referencePrompt(reference: Reference.Resolved) {
|
||||
if (reference.kind === "local") {
|
||||
return [
|
||||
PROMPT_SCOUT,
|
||||
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
|
||||
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||
`Local directory: ${reference.path}`,
|
||||
`When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
||||
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
||||
`Return exact absolute file paths for findings whenever possible.`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
if (reference.kind === "invalid") {
|
||||
return [
|
||||
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
|
||||
`Configured repository: ${reference.repository}`,
|
||||
`Problem: ${reference.message}`,
|
||||
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
return [
|
||||
PROMPT_SCOUT,
|
||||
`You are Scout reference @${name}. This reference points to a git repository.`,
|
||||
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||
`Repository: ${reference.repository}`,
|
||||
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
|
||||
`Cached directory: ${reference.path}`,
|
||||
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
|
||||
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
|
||||
`Return exact absolute file paths for findings whenever possible.`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
function referenceDescription(reference: Reference.Resolved) {
|
||||
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
|
||||
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
|
||||
return `Invalid Scout reference for repository ${reference.repository}`
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
||||
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
|
||||
if (agents[name]) continue
|
||||
const resolved = resolveReference(reference)
|
||||
const localPath = resolved.kind === "local" ? resolved.path : undefined
|
||||
agents[name] = {
|
||||
name,
|
||||
description:
|
||||
resolved.kind === "local"
|
||||
? `Scout reference for local directory ${resolved.path}`
|
||||
: `Scout reference for repository ${resolved.repository}`,
|
||||
const resolvedReferences = Reference.resolveAll({
|
||||
references: cfg.reference ?? {},
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
})
|
||||
for (const resolved of resolvedReferences) {
|
||||
if (agents[resolved.name]) continue
|
||||
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
|
||||
agents[resolved.name] = {
|
||||
name: resolved.name,
|
||||
description: referenceDescription(resolved),
|
||||
permission: Permission.merge(
|
||||
agents.scout.permission,
|
||||
Permission.fromConfig(
|
||||
localPath
|
||||
Permission.fromConfig({
|
||||
repo_clone: "deny",
|
||||
...(localPath
|
||||
? {
|
||||
external_directory: {
|
||||
[localPath]: "allow",
|
||||
[path.join(localPath, "*")]: "allow",
|
||||
},
|
||||
}
|
||||
: {},
|
||||
),
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
prompt: referencePrompt(name, resolved),
|
||||
options: { reference },
|
||||
prompt: referencePrompt(resolved),
|
||||
options: { reference: cfg.reference?.[resolved.name], resolved },
|
||||
mode: "subagent",
|
||||
native: false,
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -2,3 +2,8 @@ declare module "*.wav" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
|
||||
declare module "*.wasm" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
|
||||
@@ -93,7 +93,6 @@ const appBindingCommands = [
|
||||
"theme.mode.lock",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"app.exit",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
@@ -648,11 +647,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
title: "Exit the app",
|
||||
slashName: "exit",
|
||||
slashAliases: ["quit", "q"],
|
||||
enabled: () => {
|
||||
const current = promptRef.current
|
||||
if (!current?.focused) return true
|
||||
return current.current.input === ""
|
||||
},
|
||||
run: () => exit(),
|
||||
category: "System",
|
||||
},
|
||||
@@ -785,6 +779,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => {
|
||||
const ok = command.matcher.get()
|
||||
if (!ok) return false
|
||||
const current = promptRef.current
|
||||
if (!current?.focused) return true
|
||||
return current.current.input === ""
|
||||
},
|
||||
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
|
||||
}))
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.run(evt.properties.command)
|
||||
})
|
||||
|
||||
@@ -712,7 +712,6 @@ export function Prompt(props: PromptProps) {
|
||||
...input.traits,
|
||||
...computePromptTraits({
|
||||
mode: store.mode,
|
||||
disabled: !!props.disabled,
|
||||
autocompleteVisible: !!auto()?.visible,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ export type PromptMode = "normal" | "shell"
|
||||
|
||||
export interface PromptTraitsInput {
|
||||
mode: PromptMode
|
||||
disabled: boolean
|
||||
autocompleteVisible: boolean
|
||||
}
|
||||
|
||||
@@ -16,10 +15,9 @@ export type PromptTraits = EditorTraits & {
|
||||
/**
|
||||
* Compute the textarea editor traits for the prompt.
|
||||
*
|
||||
* `traits.suspend` gates the textarea's keybinding actions (backspace,
|
||||
* delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
|
||||
* editing mode — only `disabled` should suspend the textarea, otherwise
|
||||
* users can type in shell mode but cannot delete or move the cursor.
|
||||
* The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits
|
||||
* only expose capture/status metadata so focus changes cannot unsuspend the
|
||||
* keymap-managed editor mappings.
|
||||
*/
|
||||
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
||||
const capture =
|
||||
@@ -30,7 +28,6 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
||||
: undefined
|
||||
return {
|
||||
capture,
|
||||
suspend: input.disabled,
|
||||
status: input.mode === "shell" ? "SHELL" : undefined,
|
||||
owner: "opencode",
|
||||
role: "prompt",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import path from "path"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
const context = createContext<{
|
||||
path: () => string
|
||||
format: (input?: string) => string
|
||||
}>()
|
||||
|
||||
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
|
||||
return (
|
||||
<context.Provider
|
||||
value={{ path: () => props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}
|
||||
>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePathFormatter() {
|
||||
const value = useContext(context)
|
||||
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
function formatPath(input: string | undefined, base: string | undefined) {
|
||||
if (!input) return ""
|
||||
|
||||
const root = base || process.cwd()
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(root, input)
|
||||
const relative = path.relative(root, absolute)
|
||||
|
||||
if (!relative) return "."
|
||||
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
|
||||
if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) {
|
||||
return absolute.replace(Global.Path.home, "~")
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
import {
|
||||
KeymapProvider,
|
||||
reactiveMatcherFromSignal,
|
||||
useBindings,
|
||||
useKeymap,
|
||||
useKeymapSelector,
|
||||
useBindings,
|
||||
} from "@opentui/keymap/solid"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { TuiConfig } from "./config/tui"
|
||||
@@ -26,6 +26,28 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
|
||||
|
||||
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
||||
|
||||
const KEY_ALIASES = {
|
||||
enter: "return",
|
||||
esc: "escape",
|
||||
} as const
|
||||
|
||||
function expandKeyAliases(input: string) {
|
||||
const result = Object.entries(KEY_ALIASES).reduce(
|
||||
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
|
||||
input,
|
||||
)
|
||||
if (result === input) return
|
||||
return result
|
||||
}
|
||||
|
||||
function registerKeyAliases(keymap: OpenTuiKeymap) {
|
||||
return keymap.appendBindingExpander((ctx) => {
|
||||
const key = expandKeyAliases(ctx.input)
|
||||
if (!key) return
|
||||
return [{ key, displays: ctx.displays }]
|
||||
})
|
||||
}
|
||||
|
||||
const inputCommands = [
|
||||
"input.move.left",
|
||||
"input.move.right",
|
||||
@@ -98,8 +120,13 @@ export function formatKeyBindings(
|
||||
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
||||
}
|
||||
|
||||
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
|
||||
export function registerOpencodeKeymap(
|
||||
keymap: OpenTuiKeymap,
|
||||
renderer: CliRenderer,
|
||||
config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
|
||||
) {
|
||||
const offCommaBindings = addons.registerCommaBindings(keymap)
|
||||
const offAliasExpander = registerKeyAliases(keymap)
|
||||
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
||||
const offLeader = addons.registerTimedLeader(keymap, {
|
||||
trigger: config.keybinds.get(LEADER_TOKEN),
|
||||
@@ -108,20 +135,17 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
|
||||
})
|
||||
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
|
||||
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
|
||||
const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
|
||||
const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
|
||||
const offInputBindings = keymap.registerLayer({
|
||||
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
|
||||
enabled: () => renderer.currentFocusedEditor !== null,
|
||||
bindings: config.keybinds.gather("input", inputCommands),
|
||||
})
|
||||
|
||||
return () => {
|
||||
offInputBindings()
|
||||
offInputSuspension()
|
||||
offInputCommands()
|
||||
offBackspace()
|
||||
offEscape()
|
||||
offLeader()
|
||||
offAliasExpander()
|
||||
offBaseLayout()
|
||||
offCommaBindings()
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
@@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry"
|
||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||
import { useCommandPalette } from "../../context/command-palette"
|
||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
||||
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -1078,199 +1078,201 @@ export function Session() {
|
||||
createEffect(on(() => route.sessionID, toBottom))
|
||||
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
get width() {
|
||||
return contentWidth()
|
||||
},
|
||||
sessionID: route.sessionID,
|
||||
conceal,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
showDetails,
|
||||
showGenericToolOutput,
|
||||
diffWrapMode,
|
||||
providers,
|
||||
sync,
|
||||
tui: tuiConfig,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Show when={session()}>
|
||||
<scrollbox
|
||||
ref={(r) => (scroll = r)}
|
||||
viewportOptions={{
|
||||
paddingRight: showScrollbar() ? 1 : 0,
|
||||
}}
|
||||
verticalScrollbarOptions={{
|
||||
paddingLeft: 1,
|
||||
visible: showScrollbar(),
|
||||
trackOptions: {
|
||||
backgroundColor: theme.backgroundElement,
|
||||
foregroundColor: theme.border,
|
||||
},
|
||||
}}
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<box height={1} />
|
||||
<For each={messages()}>
|
||||
{(message, index) => (
|
||||
<Switch>
|
||||
<Match when={message.id === revert()?.messageID}>
|
||||
{(function () {
|
||||
const command = useCommandPalette()
|
||||
const redoShortcut = useCommandShortcut("session.redo")
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const dialog = useDialog()
|
||||
|
||||
const handleUnrevert = async () => {
|
||||
const confirmed = await DialogConfirm.show(
|
||||
dialog,
|
||||
"Confirm Redo",
|
||||
"Are you sure you want to restore the reverted messages?",
|
||||
)
|
||||
if (confirmed) {
|
||||
command.run("session.redo")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={handleUnrevert}
|
||||
marginTop={1}
|
||||
flexShrink={0}
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundPanel}
|
||||
>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
|
||||
</text>
|
||||
<Show when={revert()!.diffFiles?.length}>
|
||||
<box marginTop={1}>
|
||||
<For each={revert()!.diffFiles}>
|
||||
{(file) => (
|
||||
<text fg={theme.text}>
|
||||
{file.filename}
|
||||
<Show when={file.additions > 0}>
|
||||
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
|
||||
</Show>
|
||||
<Show when={file.deletions > 0}>
|
||||
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
|
||||
</Show>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
|
||||
<></>
|
||||
</Match>
|
||||
<Match when={message.role === "user"}>
|
||||
<UserMessage
|
||||
index={index()}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={message.id}
|
||||
sessionID={route.sessionID}
|
||||
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
message={message as UserMessage}
|
||||
parts={sync.data.part[message.id] ?? []}
|
||||
pending={pending()}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<AssistantMessage
|
||||
last={lastAssistant()?.id === message.id}
|
||||
message={message as AssistantMessage}
|
||||
parts={sync.data.part[message.id] ?? []}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Show when={permissions().length > 0}>
|
||||
<PermissionPrompt request={permissions()[0]} />
|
||||
</Show>
|
||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||
<QuestionPrompt request={questions()[0]} />
|
||||
</Show>
|
||||
<Show when={session()?.parentID}>
|
||||
<SubagentFooter />
|
||||
</Show>
|
||||
<Show when={visible()}>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="session_prompt"
|
||||
mode="replace"
|
||||
session_id={route.sessionID}
|
||||
visible={visible()}
|
||||
disabled={disabled()}
|
||||
on_submit={toBottom}
|
||||
ref={bind}
|
||||
>
|
||||
<Prompt
|
||||
visible={visible()}
|
||||
ref={bind}
|
||||
disabled={disabled()}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Switch>
|
||||
<Match when={wide()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</Match>
|
||||
<Match when={!wide()}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
alignItems="flex-end"
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||
<PathFormatterProvider path={session()?.directory}>
|
||||
<context.Provider
|
||||
value={{
|
||||
get width() {
|
||||
return contentWidth()
|
||||
},
|
||||
sessionID: route.sessionID,
|
||||
conceal,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
showDetails,
|
||||
showGenericToolOutput,
|
||||
diffWrapMode,
|
||||
providers,
|
||||
sync,
|
||||
tui: tuiConfig,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Show when={session()}>
|
||||
<scrollbox
|
||||
ref={(r) => (scroll = r)}
|
||||
viewportOptions={{
|
||||
paddingRight: showScrollbar() ? 1 : 0,
|
||||
}}
|
||||
verticalScrollbarOptions={{
|
||||
paddingLeft: 1,
|
||||
visible: showScrollbar(),
|
||||
trackOptions: {
|
||||
backgroundColor: theme.backgroundElement,
|
||||
foregroundColor: theme.border,
|
||||
},
|
||||
}}
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
<box height={1} />
|
||||
<For each={messages()}>
|
||||
{(message, index) => (
|
||||
<Switch>
|
||||
<Match when={message.id === revert()?.messageID}>
|
||||
{(function () {
|
||||
const command = useCommandPalette()
|
||||
const redoShortcut = useCommandShortcut("session.redo")
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const dialog = useDialog()
|
||||
|
||||
const handleUnrevert = async () => {
|
||||
const confirmed = await DialogConfirm.show(
|
||||
dialog,
|
||||
"Confirm Redo",
|
||||
"Are you sure you want to restore the reverted messages?",
|
||||
)
|
||||
if (confirmed) {
|
||||
command.run("session.redo")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={handleUnrevert}
|
||||
marginTop={1}
|
||||
flexShrink={0}
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundPanel}
|
||||
>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
|
||||
</text>
|
||||
<Show when={revert()!.diffFiles?.length}>
|
||||
<box marginTop={1}>
|
||||
<For each={revert()!.diffFiles}>
|
||||
{(file) => (
|
||||
<text fg={theme.text}>
|
||||
{file.filename}
|
||||
<Show when={file.additions > 0}>
|
||||
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
|
||||
</Show>
|
||||
<Show when={file.deletions > 0}>
|
||||
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
|
||||
</Show>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
|
||||
<></>
|
||||
</Match>
|
||||
<Match when={message.role === "user"}>
|
||||
<UserMessage
|
||||
index={index()}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={message.id}
|
||||
sessionID={route.sessionID}
|
||||
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
message={message as UserMessage}
|
||||
parts={sync.data.part[message.id] ?? []}
|
||||
pending={pending()}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<AssistantMessage
|
||||
last={lastAssistant()?.id === message.id}
|
||||
message={message as AssistantMessage}
|
||||
parts={sync.data.part[message.id] ?? []}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Show when={permissions().length > 0}>
|
||||
<PermissionPrompt request={permissions()[0]} />
|
||||
</Show>
|
||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||
<QuestionPrompt request={questions()[0]} />
|
||||
</Show>
|
||||
<Show when={session()?.parentID}>
|
||||
<SubagentFooter />
|
||||
</Show>
|
||||
<Show when={visible()}>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="session_prompt"
|
||||
mode="replace"
|
||||
session_id={route.sessionID}
|
||||
visible={visible()}
|
||||
disabled={disabled()}
|
||||
on_submit={toBottom}
|
||||
ref={bind}
|
||||
>
|
||||
<Prompt
|
||||
visible={visible()}
|
||||
ref={bind}
|
||||
disabled={disabled()}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
</Show>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</box>
|
||||
</context.Provider>
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Switch>
|
||||
<Match when={wide()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</Match>
|
||||
<Match when={!wide()}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
alignItems="flex-end"
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||
>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</box>
|
||||
</context.Provider>
|
||||
</PathFormatterProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1827,7 +1829,7 @@ function BlockTool(props: {
|
||||
|
||||
function Shell(props: ToolProps<typeof ShellTool>) {
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
@@ -1841,18 +1843,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
||||
const workdirDisplay = createMemo(() => {
|
||||
const workdir = props.input.workdir
|
||||
if (!workdir || workdir === ".") return undefined
|
||||
|
||||
const base = sync.path.directory
|
||||
if (!base) return undefined
|
||||
|
||||
const absolute = path.resolve(base, workdir)
|
||||
if (absolute === base) return undefined
|
||||
|
||||
const home = Global.Path.home
|
||||
if (!home) return absolute
|
||||
|
||||
const match = absolute === home || absolute.startsWith(home + path.sep)
|
||||
return match ? absolute.replace(home, "~") : absolute
|
||||
return pathFormatter.format(workdir)
|
||||
})
|
||||
|
||||
const title = createMemo(() => {
|
||||
@@ -1894,6 +1885,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
||||
|
||||
function Write(props: ToolProps<typeof WriteTool>) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const code = createMemo(() => {
|
||||
if (!props.input.content) return ""
|
||||
return props.input.content
|
||||
@@ -1902,7 +1894,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
|
||||
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
@@ -1917,7 +1909,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
||||
Write {normalizePath(props.input.filePath!)}
|
||||
Write {pathFormatter.format(props.input.filePath)}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1925,9 +1917,10 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
}
|
||||
|
||||
function Glob(props: ToolProps<typeof GlobTool>) {
|
||||
const pathFormatter = usePathFormatter()
|
||||
return (
|
||||
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
|
||||
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
Glob "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
|
||||
<Show when={props.metadata.count}>
|
||||
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
@@ -1937,6 +1930,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
|
||||
|
||||
function Read(props: ToolProps<typeof ReadTool>) {
|
||||
const { theme } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
const loaded = createMemo(() => {
|
||||
if (props.part.state.status !== "completed") return []
|
||||
@@ -1954,13 +1948,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
||||
spinner={isRunning()}
|
||||
part={props.part}
|
||||
>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
<For each={loaded()}>
|
||||
{(filepath) => (
|
||||
<box paddingLeft={3}>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
↳ Loaded {normalizePath(filepath)}
|
||||
↳ Loaded {pathFormatter.format(filepath)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -1970,9 +1964,10 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
||||
}
|
||||
|
||||
function Grep(props: ToolProps<typeof GrepTool>) {
|
||||
const pathFormatter = usePathFormatter()
|
||||
return (
|
||||
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
|
||||
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
Grep "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
|
||||
<Show when={props.metadata.matches}>
|
||||
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
@@ -2071,6 +2066,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
function Edit(props: ToolProps<typeof EditTool>) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = ctx.tui.diff_style
|
||||
@@ -2086,7 +2082,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diff !== undefined}>
|
||||
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
|
||||
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={diffContent()}
|
||||
@@ -2113,7 +2109,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
|
||||
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -2123,6 +2119,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
|
||||
const files = createMemo(() => props.metadata.files ?? [])
|
||||
|
||||
@@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
||||
if (file.type === "delete") return "# Deleted " + file.relativePath
|
||||
if (file.type === "add") return "# Created " + file.relativePath
|
||||
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
|
||||
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
|
||||
return "← Patched " + file.relativePath
|
||||
}
|
||||
|
||||
@@ -2281,20 +2278,6 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
|
||||
const cwd = process.cwd()
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
||||
const relative = path.relative(cwd, absolute)
|
||||
|
||||
if (!relative) return "."
|
||||
if (!relative.startsWith("..")) return relative
|
||||
|
||||
// outside cwd - use absolute
|
||||
return absolute
|
||||
}
|
||||
|
||||
function input(input: Record<string, any>, omit?: string[]): string {
|
||||
const primitives = Object.entries(input).filter(([key, value]) => {
|
||||
if (omit?.includes(key)) return false
|
||||
|
||||
@@ -11,34 +11,16 @@ import { useProject } from "../../context/project"
|
||||
import path from "path"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { webSearchProviderLabel } from "@/tool/websearch"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
|
||||
const cwd = process.cwd()
|
||||
const home = Global.Path.home
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
||||
const relative = path.relative(cwd, absolute)
|
||||
|
||||
if (!relative) return "."
|
||||
if (!relative.startsWith("..")) return relative
|
||||
|
||||
// outside cwd - use ~ or absolute
|
||||
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
|
||||
return absolute.replace(home, "~")
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
function filetype(input?: string) {
|
||||
if (!input) return "none"
|
||||
const ext = path.extname(input)
|
||||
@@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const [store, setStore] = createStore({
|
||||
stage: "permission" as PermissionStage,
|
||||
})
|
||||
const pathFormatter = usePathFormatter()
|
||||
|
||||
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
|
||||
|
||||
@@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const filepath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${normalizePath(filepath)}`,
|
||||
title: `Edit ${pathFormatter.format(filepath)}`,
|
||||
body: <EditBody request={props.request} />,
|
||||
}
|
||||
}
|
||||
@@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const filePath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${normalizePath(filePath)}`,
|
||||
title: `Read ${pathFormatter.format(filePath)}`,
|
||||
body: (
|
||||
<Show when={filePath}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
|
||||
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
@@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const dir = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `List ${normalizePath(dir)}`,
|
||||
title: `List ${pathFormatter.format(dir)}`,
|
||||
body: (
|
||||
<Show when={dir}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
|
||||
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
@@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = normalizePath(raw)
|
||||
const dir = pathFormatter.format(raw)
|
||||
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const decodeSessionID = Schema.decodeUnknownSync(SessionID)
|
||||
|
||||
export async function validateSession(input: {
|
||||
url: string
|
||||
@@ -10,9 +13,11 @@ export async function validateSession(input: {
|
||||
}) {
|
||||
if (!input.sessionID) return
|
||||
|
||||
const result = SessionID.zod.safeParse(input.sessionID)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
|
||||
let sessionID: SessionID
|
||||
try {
|
||||
sessionID = decodeSessionID(input.sessionID)
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error })
|
||||
}
|
||||
|
||||
await createOpencodeClient({
|
||||
@@ -20,5 +25,5 @@ export async function validateSession(input: {
|
||||
directory: input.directory,
|
||||
fetch: input.fetch,
|
||||
headers: input.headers,
|
||||
}).session.get({ sessionID: result.data }, { throwOnError: true })
|
||||
}).session.get({ sessionID }, { throwOnError: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export * as ConfigAttachment from "./attachment"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Image = Schema.Struct({
|
||||
auto_resize: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
|
||||
}),
|
||||
max_width: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
|
||||
}),
|
||||
max_height: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
|
||||
}),
|
||||
max_base64_bytes: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
|
||||
}),
|
||||
})
|
||||
.annotate({ identifier: "ImageAttachmentConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Image = Schema.Schema.Type<typeof Image>
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
|
||||
})
|
||||
.annotate({ identifier: "AttachmentConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
@@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { ConfigAgent } from "./agent"
|
||||
import { ConfigAttachment } from "./attachment"
|
||||
import { ConfigCommand } from "./command"
|
||||
import { ConfigFormatter } from "./formatter"
|
||||
import { ConfigLayout } from "./layout"
|
||||
@@ -241,6 +242,9 @@ export const Info = Schema.Struct({
|
||||
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||
permission: Schema.optional(ConfigPermission.Info),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
attachment: Schema.optional(ConfigAttachment.Info).annotate({
|
||||
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||
}),
|
||||
enterprise: Schema.optional(
|
||||
Schema.Struct({
|
||||
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const workspacePrefix = "wrk"
|
||||
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith(workspacePrefix)).pipe(Schema.brand("WorkspaceID"))
|
||||
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
|
||||
|
||||
export type WorkspaceID = typeof workspaceIdSchema.Type
|
||||
|
||||
export const WorkspaceID = workspaceIdSchema.pipe(
|
||||
withStatics((schema: typeof workspaceIdSchema) => ({
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending(workspacePrefix, id)),
|
||||
zod: zod(schema),
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ import { Format } from "@/format"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
@@ -96,6 +97,7 @@ export const AppLayer = Layer.mergeAll(
|
||||
Format.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Pty.defaultLayer,
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
export type Prefix = "evt" | "ses" | "msg" | "per" | "que" | "prt" | "pty" | "tool" | "wrk"
|
||||
const prefixes = {
|
||||
event: "evt",
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
question: "que",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
} as const
|
||||
|
||||
const LENGTH = 26
|
||||
|
||||
@@ -8,21 +18,21 @@ const LENGTH = 26
|
||||
let lastTimestamp = 0
|
||||
let counter = 0
|
||||
|
||||
export function ascending(prefix: Prefix, given?: string) {
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, "ascending", given)
|
||||
}
|
||||
|
||||
export function descending(prefix: Prefix, given?: string) {
|
||||
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, "descending", given)
|
||||
}
|
||||
|
||||
function generateID(prefix: Prefix, direction: "descending" | "ascending", given?: string): string {
|
||||
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
|
||||
if (!given) {
|
||||
return create(prefix, direction)
|
||||
return create(prefixes[prefix], direction)
|
||||
}
|
||||
|
||||
if (!given.startsWith(prefix)) {
|
||||
throw new Error(`ID ${given} does not start with ${prefix}`)
|
||||
if (!given.startsWith(prefixes[prefix])) {
|
||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||
}
|
||||
return given
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Config } from "@/config/config"
|
||||
import type { MessageV2 } from "@/session/message-v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
|
||||
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
|
||||
const MAX_WIDTH = 2000
|
||||
const MAX_HEIGHT = 2000
|
||||
const AUTO_RESIZE = true
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
const log = Log.create({ service: "image" })
|
||||
|
||||
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
|
||||
"ImagePhotonUnavailableError",
|
||||
{},
|
||||
) {
|
||||
override get message() {
|
||||
return "Photon image processor is unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidDataUrlError extends Schema.TaggedErrorClass<InvalidDataUrlError>()("ImageInvalidDataUrlError", {
|
||||
url: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return "Image URL must be a base64 data URL"
|
||||
}
|
||||
}
|
||||
|
||||
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("ImageDecodeError", {}) {
|
||||
override get message() {
|
||||
return "Image could not be decoded"
|
||||
}
|
||||
}
|
||||
|
||||
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeError", {
|
||||
bytes: Schema.Number,
|
||||
max: Schema.Number,
|
||||
width: Schema.Number,
|
||||
height: Schema.Number,
|
||||
max_width: Schema.Number,
|
||||
max_height: Schema.Number,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
|
||||
|
||||
export interface Interface {
|
||||
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const loadPhoton = yield* Effect.cached(
|
||||
Effect.promise(async () => {
|
||||
try {
|
||||
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } }))
|
||||
.default
|
||||
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
|
||||
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
|
||||
photonWasm
|
||||
return await import("@silvia-odwyer/photon-node")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
|
||||
const image = (yield* config.get()).attachment?.image
|
||||
const info = {
|
||||
autoResize: image?.auto_resize ?? AUTO_RESIZE,
|
||||
maxWidth: image?.max_width ?? MAX_WIDTH,
|
||||
maxHeight: image?.max_height ?? MAX_HEIGHT,
|
||||
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
|
||||
}
|
||||
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
|
||||
return yield* new InvalidDataUrlError({ url: input.url })
|
||||
|
||||
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
|
||||
const photon = yield* loadPhoton
|
||||
if (!photon) return yield* new PhotonUnavailableError()
|
||||
|
||||
const decoded = yield* Effect.sync(() => {
|
||||
try {
|
||||
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
if (!decoded) return yield* new DecodeError()
|
||||
|
||||
try {
|
||||
const originalWidth = decoded.get_width()
|
||||
const originalHeight = decoded.get_height()
|
||||
if (
|
||||
originalWidth <= info.maxWidth &&
|
||||
originalHeight <= info.maxHeight &&
|
||||
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
|
||||
)
|
||||
return input
|
||||
if (!info.autoResize)
|
||||
return yield* new SizeError({
|
||||
bytes: Buffer.byteLength(base64, "utf8"),
|
||||
max: info.maxBase64Bytes,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
max_width: info.maxWidth,
|
||||
max_height: info.maxHeight,
|
||||
})
|
||||
|
||||
const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight)
|
||||
for (const size of Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
|
||||
const previous = acc.at(-1) ?? {
|
||||
width: Math.max(1, Math.round(originalWidth * scale)),
|
||||
height: Math.max(1, Math.round(originalHeight * scale)),
|
||||
}
|
||||
const next =
|
||||
acc.length === 0
|
||||
? previous
|
||||
: {
|
||||
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
|
||||
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
|
||||
}
|
||||
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
|
||||
}, [])) {
|
||||
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
|
||||
const candidate = [
|
||||
{ data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
|
||||
...JPEG_QUALITIES.map((quality) => ({
|
||||
data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
|
||||
mime: "image/jpeg",
|
||||
})),
|
||||
]
|
||||
.map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") }))
|
||||
.find((item) => item.bytes <= info.maxBase64Bytes)
|
||||
resized.free()
|
||||
|
||||
if (candidate) {
|
||||
log.info("using resized image", {
|
||||
from_mime: input.mime,
|
||||
to_mime: candidate.mime,
|
||||
from: `${originalWidth}x${originalHeight}`,
|
||||
to: `${size.width}x${size.height}`,
|
||||
})
|
||||
return {
|
||||
...input,
|
||||
mime: candidate.mime,
|
||||
url: `data:${candidate.mime};base64,${candidate.data}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return yield* new SizeError({
|
||||
bytes: Buffer.byteLength(base64, "utf8"),
|
||||
max: info.maxBase64Bytes,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
max_width: info.maxWidth,
|
||||
max_height: info.maxHeight,
|
||||
})
|
||||
} finally {
|
||||
decoded.free()
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ normalize })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
export * as Image from "./image"
|
||||
@@ -4,14 +4,12 @@ import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { Newtype } from "@opencode-ai/core/schema"
|
||||
|
||||
const permissionPrefix = "per"
|
||||
|
||||
export class PermissionID extends Newtype<PermissionID>()(
|
||||
"PermissionID",
|
||||
Schema.String.check(Schema.isStartsWith(permissionPrefix)),
|
||||
Schema.String.check(Schema.isStartsWith("per")),
|
||||
) {
|
||||
static ascending(id?: string): PermissionID {
|
||||
return this.make(Identifier.ascending(permissionPrefix, id))
|
||||
return this.make(Identifier.ascending("permission", id))
|
||||
}
|
||||
|
||||
static readonly zod = zod(this)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ShareNext } from "@/share/share-next"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { Service } from "./bootstrap-service"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
export { Service } from "./bootstrap-service"
|
||||
export type { Interface } from "./bootstrap-service"
|
||||
@@ -29,6 +30,7 @@ export const layer = Layer.effect(
|
||||
const lsp = yield* LSP.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const project = yield* Project.Service
|
||||
const reference = yield* Reference.Service
|
||||
const shareNext = yield* ShareNext.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
@@ -43,7 +45,7 @@ export const layer = Layer.effect(
|
||||
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||
// its per-instance state scope. We just await materialization here.
|
||||
yield* Effect.forEach(
|
||||
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
||||
[reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
||||
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||
@@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
LSP.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
|
||||
@@ -4,14 +4,13 @@ import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const ptyPrefix = "pty"
|
||||
const ptyIdSchema = Schema.String.check(Schema.isStartsWith(ptyPrefix)).pipe(Schema.brand("PtyID"))
|
||||
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
|
||||
|
||||
export type PtyID = typeof ptyIdSchema.Type
|
||||
|
||||
export const PtyID = ptyIdSchema.pipe(
|
||||
withStatics((schema: typeof ptyIdSchema) => ({
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending(ptyPrefix, id)),
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
|
||||
zod: zod(schema),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -4,14 +4,9 @@ import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { Newtype } from "@opencode-ai/core/schema"
|
||||
|
||||
const questionPrefix = "que"
|
||||
|
||||
export class QuestionID extends Newtype<QuestionID>()(
|
||||
"QuestionID",
|
||||
Schema.String.check(Schema.isStartsWith(questionPrefix)),
|
||||
) {
|
||||
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) {
|
||||
static ascending(id?: string): QuestionID {
|
||||
return this.make(Identifier.ascending(questionPrefix, id))
|
||||
return this.make(Identifier.ascending("question", id))
|
||||
}
|
||||
|
||||
static readonly zod = zod(this)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import path from "path"
|
||||
import { Effect, Context, Layer, Scope } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Git } from "@/git"
|
||||
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
|
||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
||||
|
||||
export type Resolved =
|
||||
| {
|
||||
name: string
|
||||
kind: "local"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
kind: "git"
|
||||
repository: string
|
||||
reference: RepositoryReference
|
||||
path: string
|
||||
branch?: string
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
kind: "invalid"
|
||||
repository: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
references: Resolved[]
|
||||
materializeAll: Effect.Effect<void>
|
||||
materializeByPath: { path: string; run: Effect.Effect<void> }[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Resolved[]>
|
||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||
readonly ensure: (target?: string) => Effect.Effect<void>
|
||||
readonly contains: (target?: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
|
||||
|
||||
export function referencePath(input: { directory: string; worktree: string; value: string }) {
|
||||
if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2))
|
||||
return path.isAbsolute(input.value)
|
||||
? input.value
|
||||
: path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value)
|
||||
}
|
||||
|
||||
function resolveGit(
|
||||
input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined },
|
||||
): Resolved {
|
||||
const parsed = parseRepositoryReference(input.repository)
|
||||
if (!parsed || parsed.protocol === "file:") {
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "invalid",
|
||||
repository: input.repository,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "git",
|
||||
repository: input.repository,
|
||||
reference: parsed,
|
||||
path: repositoryCachePath(parsed),
|
||||
...("branch" in input ? { branch: input.branch } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function branchLabel(branch: string | undefined) {
|
||||
return branch ?? "default branch"
|
||||
}
|
||||
|
||||
function normalizedTarget(target?: string) {
|
||||
if (!target) return
|
||||
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
||||
}
|
||||
|
||||
function containsReferencePath(referencePath: string, target: string) {
|
||||
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||
}
|
||||
|
||||
export function resolve(input: {
|
||||
name: string
|
||||
reference: ReferenceEntry
|
||||
directory: string
|
||||
worktree: string
|
||||
}): Resolved {
|
||||
if (typeof input.reference === "string") {
|
||||
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
|
||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
|
||||
}
|
||||
return resolveGit({ name: input.name, repository: input.reference })
|
||||
}
|
||||
|
||||
if ("path" in input.reference) {
|
||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
|
||||
}
|
||||
|
||||
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
|
||||
}
|
||||
|
||||
export function resolveAll(input: {
|
||||
references: NonNullable<Config.Info["reference"]>
|
||||
directory: string
|
||||
worktree: string
|
||||
}) {
|
||||
const seen = new Map<string, { name: string; branch?: string }>()
|
||||
return Object.entries(input.references).map(([name, reference]) => {
|
||||
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
|
||||
if (resolved.kind !== "git") return resolved
|
||||
|
||||
const existing = seen.get(resolved.path)
|
||||
if (!existing) {
|
||||
seen.set(resolved.path, { name, branch: resolved.branch })
|
||||
return resolved
|
||||
}
|
||||
if (existing.branch === resolved.branch) return resolved
|
||||
|
||||
return {
|
||||
name,
|
||||
kind: "invalid" as const,
|
||||
repository: resolved.repository,
|
||||
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Reference.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const references = resolveAll({
|
||||
references: cfg.reference ?? {},
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
})
|
||||
const seenPath = new Set<string>()
|
||||
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||
if (reference.kind !== "git") return false
|
||||
if (seenPath.has(reference.path)) return false
|
||||
seenPath.add(reference.path)
|
||||
return true
|
||||
})
|
||||
const materializeByPath = yield* Effect.forEach(
|
||||
gitReferences,
|
||||
Effect.fnUntraced(function* (reference) {
|
||||
const run = yield* Effect.cached(
|
||||
RepositoryCache.ensure(
|
||||
{ reference: reference.reference, branch: reference.branch, refresh: true },
|
||||
{ fs, git },
|
||||
).pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
|
||||
),
|
||||
),
|
||||
)
|
||||
return { path: reference.path, run }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const materializeAll = yield* Effect.cached(
|
||||
Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||
? Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
materializeByPath,
|
||||
Effect.fnUntraced(function* (item) {
|
||||
yield* item.run
|
||||
}),
|
||||
{ concurrency: 4, discard: true },
|
||||
)
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
return { references, materializeAll, materializeByPath }
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Reference.init")(function* () {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
|
||||
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
}),
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.references)
|
||||
}),
|
||||
get: Effect.fn("Reference.get")(function* (name: string) {
|
||||
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
|
||||
}),
|
||||
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
|
||||
const full = normalizedTarget(target)
|
||||
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||
return yield* InstanceState.useEffect(
|
||||
state,
|
||||
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
|
||||
)
|
||||
}),
|
||||
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false
|
||||
const full = normalizedTarget(target)
|
||||
if (!full) return false
|
||||
return yield* InstanceState.use(state, (s) =>
|
||||
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Reference from "./reference"
|
||||
@@ -0,0 +1,147 @@
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Git } from "@/git"
|
||||
import {
|
||||
repositoryCachePath,
|
||||
sameRepositoryReference,
|
||||
parseRepositoryReference,
|
||||
validateRepositoryBranch,
|
||||
type Reference as RepositoryReference,
|
||||
} from "@/util/repository"
|
||||
|
||||
export type Result = {
|
||||
repository: string
|
||||
host: string
|
||||
remote: string
|
||||
localPath: string
|
||||
status: "cached" | "cloned" | "refreshed"
|
||||
head?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||
if (!input.reuse) return "cloned" as const
|
||||
if (input.branchMatches === false) return "refreshed" as const
|
||||
if (input.refresh) return "refreshed" as const
|
||||
return "cached" as const
|
||||
}
|
||||
|
||||
function resetTarget(input: {
|
||||
requestedBranch?: string
|
||||
remoteHead: { code: number; stdout: string }
|
||||
branch: { code: number; stdout: string }
|
||||
}) {
|
||||
if (input.requestedBranch) return `origin/${input.requestedBranch}`
|
||||
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
|
||||
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
|
||||
}
|
||||
if (input.branch.code === 0 && input.branch.stdout) {
|
||||
return `origin/${input.branch.stdout}`
|
||||
}
|
||||
return "HEAD"
|
||||
}
|
||||
|
||||
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
||||
input: {
|
||||
reference: RepositoryReference
|
||||
refresh?: boolean
|
||||
branch?: string
|
||||
},
|
||||
services: {
|
||||
fs: AppFileSystem.Interface
|
||||
git: Git.Interface
|
||||
},
|
||||
) {
|
||||
if (input.branch) validateRepositoryBranch(input.branch)
|
||||
|
||||
const repository = input.reference.label
|
||||
const remote = input.reference.remote
|
||||
const localPath = repositoryCachePath(input.reference)
|
||||
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
||||
|
||||
const exists = yield* services.fs.existsSafe(localPath)
|
||||
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
|
||||
const origin = hasGitDir
|
||||
? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
|
||||
: undefined
|
||||
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||
if (exists && !reuse) {
|
||||
yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
|
||||
const status = statusForRepository({
|
||||
reuse,
|
||||
refresh: input.refresh,
|
||||
branchMatches: input.branch ? currentBranch === input.branch : undefined,
|
||||
})
|
||||
|
||||
if (status === "cloned") {
|
||||
const clone = yield* services.git.run(
|
||||
["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath],
|
||||
{ cwd: path.dirname(localPath) },
|
||||
)
|
||||
if (clone.exitCode !== 0) {
|
||||
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "refreshed") {
|
||||
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
|
||||
}
|
||||
|
||||
if (input.branch) {
|
||||
const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], {
|
||||
cwd: localPath,
|
||||
})
|
||||
if (checkout.exitCode !== 0) {
|
||||
throw new Error(
|
||||
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
|
||||
const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
|
||||
const target = resetTarget({
|
||||
requestedBranch: input.branch,
|
||||
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
|
||||
branch: { code: branch.exitCode, stdout: branch.text().trim() },
|
||||
})
|
||||
|
||||
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
|
||||
if (reset.exitCode !== 0) {
|
||||
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
|
||||
}
|
||||
}
|
||||
|
||||
const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath })
|
||||
const branch = yield* services.git.branch(localPath)
|
||||
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||
|
||||
return {
|
||||
repository,
|
||||
host: input.reference.host,
|
||||
remote,
|
||||
localPath,
|
||||
status,
|
||||
head: headText,
|
||||
branch,
|
||||
} satisfies Result
|
||||
}),
|
||||
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
|
||||
export * as RepositoryCache from "./repository-cache"
|
||||
@@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui"
|
||||
import { WorkspaceApi } from "./groups/workspace"
|
||||
import { V2Api } from "./groups/v2"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
import { SchemaErrorMiddleware } from "./middleware/schema-error"
|
||||
|
||||
// SSE event schemas built from the BusEvent/SyncEvent registries.
|
||||
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
||||
@@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads()
|
||||
export const RootHttpApi = HttpApi.make("opencode-root")
|
||||
.addHttpApi(ControlApi)
|
||||
.addHttpApi(GlobalApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
.middleware(Authorization)
|
||||
|
||||
export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
@@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(V2Api)
|
||||
.addHttpApi(TuiApi)
|
||||
.addHttpApi(WorkspaceApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
|
||||
export const OpenCodeHttpApi = HttpApi.make("opencode")
|
||||
.addHttpApi(RootHttpApi)
|
||||
|
||||
@@ -6,3 +6,7 @@ export const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
|
||||
export const QueryBooleanOpenApi = {
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
}
|
||||
|
||||
@@ -203,13 +203,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof InitPayload.Type
|
||||
}) {
|
||||
yield* promptSvc.command({
|
||||
sessionID: ctx.params.sessionID,
|
||||
messageID: ctx.payload.messageID,
|
||||
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
yield* promptSvc
|
||||
.command({
|
||||
sessionID: ctx.params.sessionID,
|
||||
messageID: ctx.payload.messageID,
|
||||
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -258,20 +260,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
promptSvc
|
||||
.prompt({
|
||||
...ctx.payload,
|
||||
sessionID: ctx.params.sessionID,
|
||||
})
|
||||
.pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)),
|
||||
).pipe(
|
||||
Stream.map((message) => JSON.stringify(message)),
|
||||
Stream.encodeText,
|
||||
),
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
const message = yield* promptSvc
|
||||
.prompt({
|
||||
...ctx.payload,
|
||||
sessionID: ctx.params.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.provideService(InstanceRef, instance),
|
||||
Effect.provideService(WorkspaceRef, workspace),
|
||||
Effect.mapError(() => new HttpApiError.BadRequest({})),
|
||||
)
|
||||
return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {
|
||||
contentType: "application/json",
|
||||
})
|
||||
})
|
||||
|
||||
const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {
|
||||
@@ -297,7 +298,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof CommandPayload.Type
|
||||
}) {
|
||||
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
|
||||
return yield* promptSvc
|
||||
.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
})
|
||||
|
||||
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
// Effect's Issue formatter recursively dumps the rejected `actual` value with
|
||||
// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep
|
||||
// 4xx responses small and avoid mirroring entire request payloads (which may
|
||||
// contain secrets) into the response body and log file.
|
||||
const REASON_LIMIT = 1024
|
||||
function truncateReason(reason: string) {
|
||||
if (reason.length <= REASON_LIMIT) return reason
|
||||
return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)`
|
||||
}
|
||||
|
||||
// Default Respondable returns an empty 400 body. Match the NamedError shape
|
||||
// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`.
|
||||
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
|
||||
"@opencode/HttpApiSchemaError",
|
||||
) {}
|
||||
|
||||
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
|
||||
const reason = truncateReason(error.cause.message)
|
||||
log.warn("schema rejection", { kind: error.kind, reason })
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }),
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { OpenCodeHttpApi } from "./api"
|
||||
import { QueryBooleanOpenApi } from "./groups/query"
|
||||
|
||||
type OpenApiParameter = {
|
||||
name: string
|
||||
@@ -54,18 +55,20 @@ type OpenApiResponse = {
|
||||
// Query schemas describe decoded Effect values, but the generated SDK needs the
|
||||
// public call shape. These keep SDK callers passing numbers/booleans while the
|
||||
// server still decodes string query params at runtime.
|
||||
const QueryBooleanParameters = new Set(["roots", "archived"])
|
||||
const QueryParameterSchemas: Record<string, OpenApiSchema> = {
|
||||
"GET /experimental/session start": { type: "number" },
|
||||
"GET /experimental/session roots": QueryBooleanOpenApi,
|
||||
"GET /experimental/session archived": QueryBooleanOpenApi,
|
||||
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
|
||||
"GET /experimental/session cursor": { type: "number" },
|
||||
"GET /experimental/session limit": { type: "number" },
|
||||
"GET /session start": { type: "number" },
|
||||
"GET /session roots": QueryBooleanOpenApi,
|
||||
"GET /session limit": { type: "number" },
|
||||
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
|
||||
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
"GET /api/session limit": { type: "number" },
|
||||
"GET /api/session start": { type: "number" },
|
||||
"GET /api/session roots": QueryBooleanOpenApi,
|
||||
"GET /api/session/{sessionID}/message limit": { type: "number" },
|
||||
}
|
||||
|
||||
@@ -175,17 +178,20 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) {
|
||||
if (!spec.components?.schemas) return
|
||||
spec.components.schemas.BadRequestError = {
|
||||
type: "object",
|
||||
required: ["data", "errors", "success"],
|
||||
required: ["name", "data"],
|
||||
properties: {
|
||||
data: {},
|
||||
errors: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: {},
|
||||
name: { type: "string", enum: ["BadRequest"] },
|
||||
data: {
|
||||
type: "object",
|
||||
required: ["message"],
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["Params", "Headers", "Query", "Body", "Payload"],
|
||||
},
|
||||
},
|
||||
},
|
||||
success: { type: "boolean", enum: [false] },
|
||||
},
|
||||
}
|
||||
spec.components.schemas.NotFoundError = {
|
||||
@@ -487,12 +493,6 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
param.schema = override
|
||||
return
|
||||
}
|
||||
if (QueryBooleanParameters.has(param.name)) {
|
||||
param.schema = {
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
param.schema = stripOptionalNull(param.schema)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression"
|
||||
import { corsVaryFix } from "./middleware/cors-vary"
|
||||
import { errorLayer } from "./middleware/error"
|
||||
import { fenceLayer } from "./middleware/fence"
|
||||
import { schemaErrorLayer } from "./middleware/schema-error"
|
||||
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
|
||||
@@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi
|
||||
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(httpApiAuthLayer),
|
||||
)
|
||||
const instanceRouterLayer = authorizationRouterMiddleware
|
||||
@@ -150,6 +152,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
|
||||
httpApiAuthLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
schemaErrorLayer,
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as Session from "./session"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import z from "zod"
|
||||
import { Token } from "@/util/token"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { SessionProcessor } from "./processor"
|
||||
@@ -18,7 +17,7 @@ import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { fn } from "@/util/fn"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
|
||||
@@ -208,6 +207,8 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
@@ -638,15 +639,4 @@ export async function prune(input: { sessionID: SessionID }) {
|
||||
return runPromise((svc) => svc.prune(input))
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
agent: z.string(),
|
||||
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
|
||||
auto: z.boolean(),
|
||||
overflow: z.boolean().optional(),
|
||||
}),
|
||||
(input) => runPromise((svc) => svc.create(input)),
|
||||
)
|
||||
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
@@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
|
||||
import * as Session from "./session"
|
||||
import { LLM } from "./llm"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Image } from "@/image/image"
|
||||
import { isOverflow } from "./overflow"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID } from "./schema"
|
||||
@@ -92,6 +93,7 @@ export const layer: Layer.Layer<
|
||||
| LLM.Service
|
||||
| Permission.Service
|
||||
| Plugin.Service
|
||||
| Image.Service
|
||||
| SessionSummary.Service
|
||||
| SessionStatus.Service
|
||||
> = Layer.effect(
|
||||
@@ -108,6 +110,7 @@ export const layer: Layer.Layer<
|
||||
const summary = yield* SessionSummary.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const status = yield* SessionStatus.Service
|
||||
const image = yield* Image.Service
|
||||
|
||||
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
||||
// Pre-capture snapshot before the LLM stream starts. The AI SDK
|
||||
@@ -377,17 +380,41 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "tool-result": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
const toolAttachments: MessageV2.FilePart[] = (
|
||||
Array.isArray(value.output.attachments) ? value.output.attachments : []
|
||||
).filter(
|
||||
(attachment: unknown): attachment is MessageV2.FilePart =>
|
||||
isRecord(attachment) &&
|
||||
attachment.type === "file" &&
|
||||
typeof attachment.mime === "string" &&
|
||||
typeof attachment.url === "string",
|
||||
)
|
||||
const normalized = yield* Effect.forEach(toolAttachments, (attachment) =>
|
||||
attachment.mime.startsWith("image/")
|
||||
? image.normalize(attachment).pipe(Effect.exit)
|
||||
: Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),
|
||||
)
|
||||
const omitted = normalized.filter(Exit.isFailure).length
|
||||
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
||||
const output = {
|
||||
...value.output,
|
||||
output:
|
||||
omitted === 0
|
||||
? value.output.output
|
||||
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Success.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
structured: value.output.metadata,
|
||||
structured: output.metadata,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: value.output.output,
|
||||
text: output.output,
|
||||
},
|
||||
...(value.output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file",
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
@@ -399,7 +426,7 @@ export const layer: Layer.Layer<
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* completeToolCall(value.toolCallId, value.output)
|
||||
yield* completeToolCall(value.toolCallId, output)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -758,6 +785,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ import { Shell } from "@/shell/shell"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Image } from "@/image/image"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
|
||||
@@ -80,10 +81,10 @@ const elog = EffectLogger.create({ service: "session.prompt" })
|
||||
|
||||
export interface Interface {
|
||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||
readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
|
||||
}
|
||||
|
||||
@@ -108,6 +109,7 @@ export const layer = Layer.effect(
|
||||
const lsp = yield* LSP.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const image = yield* Image.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const scope = yield* Scope.Scope
|
||||
const instruction = yield* Instruction.Service
|
||||
@@ -123,7 +125,7 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||
resolvePromptParts: (template: string) => resolvePromptParts(template),
|
||||
prompt: (input: PromptInput) => prompt(input),
|
||||
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
|
||||
} satisfies TaskPromptOps
|
||||
})
|
||||
|
||||
@@ -756,7 +758,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
throw error
|
||||
}
|
||||
const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID))
|
||||
const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID))
|
||||
const userMsg: MessageV2.User = {
|
||||
id: input.messageID ?? MessageID.ascending(),
|
||||
sessionID: input.sessionID,
|
||||
@@ -914,7 +916,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
})
|
||||
|
||||
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const current = Database.use((db) =>
|
||||
db.select({ model: SessionTable.model }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get(),
|
||||
)
|
||||
if (current?.model) {
|
||||
return {
|
||||
providerID: ProviderID.make(current.model.providerID),
|
||||
modelID: ModelID.make(current.model.id),
|
||||
...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}),
|
||||
}
|
||||
}
|
||||
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
|
||||
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
|
||||
return yield* provider.defaultModel()
|
||||
@@ -931,7 +943,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
throw error
|
||||
}
|
||||
|
||||
const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
|
||||
const current = Database.use((db) =>
|
||||
db
|
||||
.select({ agent: SessionTable.agent, model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get(),
|
||||
)
|
||||
const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))
|
||||
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
|
||||
const full =
|
||||
!input.variant && ag.variant && same
|
||||
@@ -955,34 +974,35 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
format: input.format,
|
||||
}
|
||||
|
||||
const current = Database.use((db) =>
|
||||
db
|
||||
.select({ agent: SessionTable.agent, model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get(),
|
||||
)
|
||||
if (current?.agent !== info.agent) {
|
||||
EventV2.run(SessionEvent.AgentSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
})
|
||||
EventV2.run(
|
||||
SessionEvent.AgentSwitched.Sync,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
},
|
||||
{ bypassExperimentalEventSystem: true },
|
||||
)
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== info.model.providerID ||
|
||||
current.model.id !== info.model.modelID ||
|
||||
current.model.variant !== info.model.variant
|
||||
(current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant
|
||||
) {
|
||||
EventV2.run(SessionEvent.ModelSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
model: {
|
||||
id: Modelv2.ID.make(info.model.modelID),
|
||||
providerID: Modelv2.ProviderID.make(info.model.providerID),
|
||||
variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
|
||||
EventV2.run(
|
||||
SessionEvent.ModelSwitched.Sync,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
model: {
|
||||
id: Modelv2.ID.make(info.model.modelID),
|
||||
providerID: Modelv2.ProviderID.make(info.model.providerID),
|
||||
variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
|
||||
},
|
||||
},
|
||||
})
|
||||
{ bypassExperimentalEventSystem: true },
|
||||
)
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() => instruction.clear(info.id))
|
||||
@@ -1259,7 +1279,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
|
||||
})
|
||||
|
||||
const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
|
||||
const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
|
||||
Effect.map((x) => x.flat().map(assign)),
|
||||
)
|
||||
|
||||
@@ -1272,7 +1292,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
messageID: input.messageID,
|
||||
variant: input.variant,
|
||||
},
|
||||
{ message: info, parts },
|
||||
{ message: info, parts: resolvedParts },
|
||||
)
|
||||
|
||||
const parts = yield* Effect.forEach(resolvedParts, (part) =>
|
||||
part.type === "file" && part.mime.startsWith("image/") ? image.normalize(part) : Effect.succeed(part),
|
||||
)
|
||||
|
||||
const parsed = MessageV2.Info.zod.safeParse(info)
|
||||
@@ -1368,26 +1392,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return { info, parts }
|
||||
}, Effect.scoped)
|
||||
|
||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
||||
function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
yield* revert.cleanup(session)
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(
|
||||
"SessionPrompt.prompt",
|
||||
)(function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
yield* revert.cleanup(session)
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
session.permission = permissions
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
session.permission = permissions
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
},
|
||||
)
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
})
|
||||
|
||||
const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user")
|
||||
@@ -1698,7 +1722,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (cmdAgent?.model) return cmdAgent.model
|
||||
}
|
||||
if (input.model) return Provider.parseModel(input.model)
|
||||
return yield* lastModel(input.sessionID)
|
||||
return yield* currentModel(input.sessionID)
|
||||
})
|
||||
|
||||
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
|
||||
@@ -1731,7 +1755,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const userModel = isSubtask
|
||||
? input.model
|
||||
? Provider.parseModel(input.model)
|
||||
: yield* lastModel(input.sessionID)
|
||||
: yield* currentModel(input.sessionID)
|
||||
: taskModel
|
||||
|
||||
yield* plugin.trigger(
|
||||
@@ -1788,6 +1812,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const sessionPrefix = "ses"
|
||||
const messagePrefix = "msg"
|
||||
const partPrefix = "prt"
|
||||
|
||||
export const SessionID = Schema.String.check(Schema.isStartsWith(sessionPrefix)).pipe(
|
||||
export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
withStatics((s) => ({
|
||||
descending: (id?: string) => s.make(Identifier.descending(sessionPrefix, id)),
|
||||
zod: zod(s),
|
||||
descending: (id?: string) => s.make(Identifier.descending("session", id)),
|
||||
})),
|
||||
)
|
||||
|
||||
export type SessionID = Schema.Schema.Type<typeof SessionID>
|
||||
|
||||
export const MessageID = Schema.String.check(Schema.isStartsWith(messagePrefix)).pipe(
|
||||
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
|
||||
Schema.brand("MessageID"),
|
||||
withStatics((s) => ({
|
||||
ascending: (id?: string) => s.make(Identifier.ascending(messagePrefix, id)),
|
||||
zod: zod(s),
|
||||
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
|
||||
})),
|
||||
)
|
||||
|
||||
export type MessageID = Schema.Schema.Type<typeof MessageID>
|
||||
|
||||
export const PartID = Schema.String.check(Schema.isStartsWith(partPrefix)).pipe(
|
||||
export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
|
||||
Schema.brand("PartID"),
|
||||
withStatics((s) => ({
|
||||
ascending: (id?: string) => s.make(Identifier.ascending(partPrefix, id)),
|
||||
zod: zod(s),
|
||||
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,12 +4,10 @@ import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const eventPrefix = "evt"
|
||||
|
||||
export const EventID = Schema.String.check(Schema.isStartsWith(eventPrefix)).pipe(
|
||||
export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe(
|
||||
Schema.brand("EventID"),
|
||||
withStatics((s) => ({
|
||||
ascending: (id?: string) => s.make(Identifier.ascending(eventPrefix, id)),
|
||||
ascending: (id?: string) => s.make(Identifier.ascending("event", id)),
|
||||
zod: zod(s),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
||||
@@ -20,6 +21,7 @@ export const GlobTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -39,11 +41,15 @@ export const GlobTool = Tool.define(
|
||||
|
||||
let search = params.path ?? ins.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
|
||||
yield* reference.ensure(search)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (info?.type === "File") {
|
||||
throw new Error(`glob path must be a directory: ${search}`)
|
||||
}
|
||||
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
bypass: yield* reference.contains(search),
|
||||
kind: "directory",
|
||||
})
|
||||
|
||||
const limit = 100
|
||||
let truncated = false
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
@@ -25,6 +26,7 @@ export const GrepTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -57,10 +59,12 @@ export const GrepTool = Tool.define(
|
||||
? (params.path ?? ins.directory)
|
||||
: path.join(ins.directory, params.path ?? "."),
|
||||
)
|
||||
yield* reference.ensure(search)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
bypass: yield* reference.contains(search),
|
||||
kind: info?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -41,6 +42,7 @@ export const ReadTool = Tool.define(
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const instruction = yield* Instruction.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const reference = yield* Reference.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
||||
@@ -162,6 +164,7 @@ export const ReadTool = Tool.define(
|
||||
if (process.platform === "win32") {
|
||||
filepath = AppFileSystem.normalizePath(filepath)
|
||||
}
|
||||
yield* reference.ensure(filepath)
|
||||
const title = path.relative(instance.worktree, filepath)
|
||||
|
||||
const stat = yield* fs.stat(filepath).pipe(
|
||||
@@ -172,7 +175,7 @@ export const ReadTool = Tool.define(
|
||||
)
|
||||
|
||||
yield* assertExternalDirectoryEffect(ctx, filepath, {
|
||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)),
|
||||
kind: stat?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Agent } from "../agent/agent"
|
||||
import { Git } from "@/git"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
@@ -91,6 +92,7 @@ export const layer: Layer.Layer<
|
||||
| Session.Service
|
||||
| Provider.Service
|
||||
| Git.Service
|
||||
| Reference.Service
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
@@ -361,6 +363,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Git } from "@/git"
|
||||
import DESCRIPTION from "./repo_clone.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository"
|
||||
import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository"
|
||||
import { RepositoryCache } from "@/reference/repository-cache"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
repository: Schema.String.annotate({
|
||||
@@ -29,36 +28,6 @@ type Metadata = {
|
||||
branch?: string
|
||||
}
|
||||
|
||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||
if (!input.reuse) return "cloned" as const
|
||||
if (input.branchMatches === false) return "refreshed" as const
|
||||
if (input.refresh) return "refreshed" as const
|
||||
return "cached" as const
|
||||
}
|
||||
|
||||
function resetTarget(input: {
|
||||
requestedBranch?: string
|
||||
remoteHead: { code: number; stdout: string }
|
||||
branch: { code: number; stdout: string }
|
||||
}) {
|
||||
if (input.requestedBranch) return `origin/${input.requestedBranch}`
|
||||
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
|
||||
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
|
||||
}
|
||||
if (input.branch.code === 0 && input.branch.stdout) {
|
||||
return `origin/${input.branch.stdout}`
|
||||
}
|
||||
return "HEAD"
|
||||
}
|
||||
|
||||
function validateBranch(branch: string) {
|
||||
if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) {
|
||||
throw new Error(
|
||||
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
|
||||
"repo_clone",
|
||||
Effect.gen(function* () {
|
||||
@@ -70,16 +39,12 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const reference = parseRepositoryReference(params.repository)
|
||||
if (!reference)
|
||||
throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||
if (reference.protocol === "file:") throw new Error("Local file repositories are not supported")
|
||||
if (params.branch) validateBranch(params.branch)
|
||||
const reference = parseRemoteRepositoryReference(params.repository)
|
||||
if (params.branch) validateRepositoryBranch(params.branch)
|
||||
|
||||
const repository = reference.label
|
||||
const remote = reference.remote
|
||||
const localPath = repositoryCachePath(reference)
|
||||
const cloneTarget = parseRepositoryReference(remote) ?? reference
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "repo_clone",
|
||||
@@ -94,115 +59,21 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
|
||||
},
|
||||
})
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
||||
|
||||
const exists = yield* fs.existsSafe(localPath)
|
||||
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
|
||||
const origin = hasGitDir
|
||||
? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
|
||||
: undefined
|
||||
const originReference =
|
||||
origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||
const reuse =
|
||||
hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||
if (exists && !reuse) {
|
||||
yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined
|
||||
const status = statusForRepository({
|
||||
reuse,
|
||||
refresh: params.refresh,
|
||||
branchMatches: params.branch ? currentBranch === params.branch : undefined,
|
||||
})
|
||||
|
||||
if (status === "cloned") {
|
||||
const clone = yield* git.run(
|
||||
[
|
||||
"clone",
|
||||
"--depth",
|
||||
"100",
|
||||
...(params.branch ? ["--branch", params.branch] : []),
|
||||
"--",
|
||||
remote,
|
||||
localPath,
|
||||
],
|
||||
{ cwd: path.dirname(localPath) },
|
||||
)
|
||||
if (clone.exitCode !== 0) {
|
||||
throw new Error(
|
||||
clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "refreshed") {
|
||||
const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new Error(
|
||||
fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (params.branch) {
|
||||
const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], {
|
||||
cwd: localPath,
|
||||
})
|
||||
if (checkout.exitCode !== 0) {
|
||||
throw new Error(
|
||||
checkout.stderr.toString().trim() ||
|
||||
checkout.text().trim() ||
|
||||
`Failed to checkout ${params.branch}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
|
||||
const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
|
||||
const target = resetTarget({
|
||||
requestedBranch: params.branch,
|
||||
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
|
||||
branch: { code: branch.exitCode, stdout: branch.text().trim() },
|
||||
})
|
||||
|
||||
const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath })
|
||||
if (reset.exitCode !== 0) {
|
||||
throw new Error(
|
||||
reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath })
|
||||
const branch = yield* git.branch(localPath)
|
||||
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||
|
||||
return {
|
||||
title: repository,
|
||||
metadata: {
|
||||
repository,
|
||||
host: reference.host,
|
||||
remote,
|
||||
localPath,
|
||||
status,
|
||||
head: headText,
|
||||
branch,
|
||||
},
|
||||
output: [
|
||||
`Repository ready: ${repository}`,
|
||||
`Status: ${status}`,
|
||||
`Local path: ${localPath}`,
|
||||
...(branch ? [`Branch: ${branch}`] : []),
|
||||
...(headText ? [`HEAD: ${headText}`] : []),
|
||||
].join("\n"),
|
||||
}
|
||||
}),
|
||||
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
|
||||
const result = yield* RepositoryCache.ensure(
|
||||
{ reference, refresh: params.refresh, branch: params.branch },
|
||||
{ fs, git },
|
||||
)
|
||||
return {
|
||||
title: repository,
|
||||
metadata: result,
|
||||
output: [
|
||||
`Repository ready: ${repository}`,
|
||||
`Status: ${result.status}`,
|
||||
`Local path: ${localPath}`,
|
||||
...(result.branch ? [`Branch: ${result.branch}`] : []),
|
||||
...(result.head ? [`HEAD: ${result.head}`] : []),
|
||||
].join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||
}),
|
||||
|
||||
@@ -4,14 +4,13 @@ import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const toolPrefix = "tool"
|
||||
const toolIdSchema = Schema.String.check(Schema.isStartsWith(toolPrefix)).pipe(Schema.brand("ToolID"))
|
||||
const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID"))
|
||||
|
||||
export type ToolID = typeof toolIdSchema.Type
|
||||
|
||||
export const ToolID = toolIdSchema.pipe(
|
||||
withStatics((schema: typeof toolIdSchema) => ({
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending(toolPrefix, id)),
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)),
|
||||
zod: zod(schema),
|
||||
})),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user