Compare commits

..
Author SHA1 Message Date
Kit Langton 1c059ae0d5 fix(effect): preserve logger context in prompt runs 2026-04-14 16:15:01 -04:00
45 changed files with 370 additions and 3203 deletions
+1 -2
View File
@@ -3,5 +3,4 @@ plans
package.json
bun.lock
.gitignore
package-lock.json
references/
package-lock.json
-21
View File
@@ -1,21 +0,0 @@
---
name: effect
description: Answer questions about the Effect framework
---
# Effect
This codebase uses Effect, a framework for writing typescript.
## How to Answer Effect Questions
1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
`.opencode/references/effect-smol` in this project NOT the skill folder.
2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
3. Provide responses based on the actual Effect source code and documentation
## Guidelines
- Always use the explore agent with the cloned repository when answering Effect-related questions
- Reference specific files and patterns found in the Effect codebase
- Do not answer from memory - always verify against the source
+14 -30
View File
@@ -356,11 +356,10 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -387,7 +386,6 @@
"hono": "catalog:",
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
@@ -458,16 +456,16 @@
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.99",
"@opentui/solid": ">=0.1.99",
"@opentui/core": ">=0.1.97",
"@opentui/solid": ">=0.1.97",
},
"optionalPeers": [
"@opentui/core",
@@ -499,16 +497,6 @@
"typescript": "catalog:",
},
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.4.3",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"typescript": "catalog:",
},
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.3",
@@ -1544,8 +1532,6 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
@@ -1560,21 +1546,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.99", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.99", "@opentui/core-darwin-x64": "0.1.99", "@opentui/core-linux-arm64": "0.1.99", "@opentui/core-linux-x64": "0.1.99", "@opentui/core-win32-arm64": "0.1.99", "@opentui/core-win32-x64": "0.1.99", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-I3+AEgGzqNWIpWX9g2WOscSPwtQDNOm4KlBjxBWCZjLxkF07u77heWXF7OiAdhKLtNUW6TFiyt6yznqAZPdG3A=="],
"@opentui/core": ["@opentui/core@0.1.97", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.97", "@opentui/core-darwin-x64": "0.1.97", "@opentui/core-linux-arm64": "0.1.97", "@opentui/core-linux-x64": "0.1.97", "@opentui/core-win32-arm64": "0.1.97", "@opentui/core-win32-x64": "0.1.97", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2ENH0Dc4NUAeHeeQCQhF1lg68RuyntOUP68UvortvDqTz/hqLG0tIwF+DboCKtWi8Nmao4SAQEJ7lfmyQNEDOQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.99", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bzVrqeX2vb5iWrc/ftOUOqeUY8XO+qSgoTwj5TXHuwagavgwD3Hpeyjx8+icnTTeM4pao0som1WR9xfye6/X5Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-t7oMGEfMPQsqLEx7/rPqv/UGJ+vqhe4RWHRRQRYcuHuLKssZ2S8P9mSS7MBPtDqGcxg4PosCrh5nHYeZ94EXUw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.99", "", { "os": "darwin", "cpu": "x64" }, "sha512-VE4FrXBYpkxnvkqcCV1a8aN9jyyMJMihVW+V2NLCtp+4yQsj0AapG5TiUSN76XnmSZRptxDy5rBmEempeoIZbg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.97", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZuPWAawlVat6ZHb8vaH/CVUeGwI0pI4vd+6zz1ZocZn95ZWJztfyhzNZOJrq1WjHmUROieJ7cOuYUZfvYNuLrg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.99", "", { "os": "linux", "cpu": "arm64" }, "sha512-viXQsbpS7yHjYkl7+am32JdvG96QU9lvHh1UiZtpOxcNUUqiYmA2ZwZFPD2Bi54jNyj5l2hjH6YkD3DzE2FEWA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-QXxhz654vXgEu2wrFFFFnrSWbyk6/r6nXNnDTcMRWofdMZQLx87NhbcsErNmz9KmFdzoPiQSmlpYubLflKKzqQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.99", "", { "os": "linux", "cpu": "x64" }, "sha512-WLoEFINOSp0tZSR9y4LUuGc7n4Y7H1wcpjUPzQ9vChkYDXrfZltEanzoDWbDcQ4kZQW5tHVC7LrZHpAsRLwFZg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-v3z0QWpRS3p8blE/A7pTu15hcFMtSndeiYhRxhrjp6zAhQ+UlruQs9DAG1ifSuVO1RJJ0pUKklFivdbu0pMzuw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.99", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWMOLWCEO8HdrctU1dMkgZC8qGkiO4Dwr4/e11tTvVpRmYhDsP/IR89ZjEEtOwnKwFOFuB/MxvflqaEWVQ2g5Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.97", "", { "os": "win32", "cpu": "arm64" }, "sha512-o/m9mD1dvOCwkxOUUyoEILl+d6tzh/85foJc4uqjXYi71NNcwg8u+Eq3/gdHuSKnlT1pusCPKoS1IDuBvZE24A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.99", "", { "os": "win32", "cpu": "x64" }, "sha512-aYRlsL2w8YRL6vPd7/hrqlNVkXU3QowWb01TOvAcHS8UAsXaGFUr47kSDyjxDi1wg1MzmVduCfsC7T3NoThV1w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-Rwp7JOwrYm4wtzPHY2vv+2l91LXmKSI7CtbmWN1sSUGhBPtPGSvfwux3W5xaAZQa2KPEXicPjaKJZc+pob3YRg=="],
"@opentui/solid": ["@opentui/solid@0.1.99", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.99", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-DrqqO4h2V88FmeIP2cErYkMU0ZK5MrUsZw3w6IzZpoXyyiL4/9qpWzUq+CXx+r16VP2iGxDJwGKUmtFAzUch2Q=="],
"@opentui/solid": ["@opentui/solid@0.1.97", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.97", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-ma/uihG38F+6oLJVD8yR7z82FWmR8QhfesNV5SBXbN74riMCRyy6kyQ6SI4xs4ykt9BbZOjrKLq+Xt/0Pd0SJQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -3350,8 +3336,6 @@
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
"immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2p0WOk7qE2zC8S5mIDmpefjhJv8zhsgT33crGFWl6LI=",
"aarch64-linux": "sha256-sMW7pXoFtV6r4ySoYB8ISqKFHFeAMmiCUvHtiplwxak=",
"aarch64-darwin": "sha256-/4g2e39t9huLXOObdolDPmImGNhndOsxeAGJjw+bE8g=",
"x86_64-darwin": "sha256-SJ9y58ZwQnXhMtus0ITQo3sfHzHfOSPkJRK24n5pnBw="
"x86_64-linux": "sha256-StHGWSONcykyo5kk3aEdfTu4BcwPEA5PbtJN2W2rIhs=",
"aarch64-linux": "sha256-W0nu5L0W4YiGx15nmzi+JInV0Bh04JOPQxp0lRRgMWQ=",
"aarch64-darwin": "sha256-k1hYWg3s5eK7qHFJPBvJtt1IllRL969vNUdXe8ITR4Y=",
"x86_64-darwin": "sha256-Mpsgkm5uAREitA9zeG9XtQ8hUL+umLJLENUww4mhWes="
}
}
+18 -17
View File
@@ -7,6 +7,7 @@
sysctl,
makeBinaryWrapper,
models-dev,
ripgrep,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
@@ -51,25 +52,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook postBuild
'';
installPhase =
''
runHook preInstall
installPhase = ''
runHook preInstall
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
''
# bun runs sysctl to detect if dunning on rosetta2
+ lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath [
sysctl
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath (
[
ripgrep
]
}
''
+ ''
runHook postInstall
'';
# bun runs sysctl to detect if dunning on rosetta2
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
)
}
runHook postInstall
'';
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
@@ -106,7 +106,7 @@ export async function handler(
const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip)
const trialProviders = await trialLimiter?.check()
const rateLimiter = createRateLimiter(
modelInfo.id,
@@ -392,7 +392,7 @@ export async function handler(
function validateModel(zenData: ZenData, reqModel: string) {
if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel }))
const modelId = reqModel
const modelId = reqModel as keyof typeof zenData.models
const modelData = Array.isArray(zenData.models[modelId])
? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
: zenData.models[modelId]
@@ -6,14 +6,12 @@ type Usage = {
total_tokens?: number
// used by moonshot
cached_tokens?: number
// used by xai & alibaba
// used by xai
prompt_tokens_details?: {
text_tokens?: number
audio_tokens?: number
image_tokens?: number
cached_tokens?: number
// used by alibaba
cache_creation_input_tokens?: number
}
completion_tokens_details?: {
reasoning_tokens?: number
@@ -64,7 +62,6 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
const outputTokens = usage.completion_tokens ?? 0
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
const cacheWriteTokens = usage.prompt_tokens_details?.cache_creation_input_tokens ?? undefined
if (adjustCacheUsage && !cacheReadTokens) {
cacheReadTokens = Math.floor(inputTokens * 0.9)
@@ -75,7 +72,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: cacheWriteTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
+6 -65
View File
@@ -26,7 +26,7 @@ export namespace ZenData {
allowAnonymous: z.boolean().optional(),
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(),
trialProvider: z.string().optional(),
trialProviders: z.array(z.string()).optional(),
trialEnded: z.boolean().optional(),
fallbackProvider: z.string().optional(),
rateLimit: z.number().optional(),
@@ -45,7 +45,7 @@ export namespace ZenData {
const ProviderSchema = z.object({
api: z.string(),
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
apiKey: z.string(),
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
@@ -54,10 +54,7 @@ export namespace ZenData {
})
const ModelsSchema = z.object({
zenModels: z.record(
z.string(),
z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
),
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
liteModels: z.record(
z.string(),
z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
@@ -102,66 +99,10 @@ export namespace ZenData {
Resource.ZEN_MODELS29.value +
Resource.ZEN_MODELS30.value,
)
const { zenModels, liteModels, providers } = ModelsSchema.parse(json)
const compositeProviders = Object.fromEntries(
Object.entries(providers).map(([id, provider]) => [
id,
typeof provider.apiKey === "string"
? [{ id: id, key: provider.apiKey }]
: Object.entries(provider.apiKey).map(([kid, key]) => ({
id: `${id}.${kid}`,
key,
})),
]),
)
const { models, liteModels, providers } = ModelsSchema.parse(json)
return {
providers: Object.fromEntries(
Object.entries(providers).flatMap(([providerId, provider]) =>
compositeProviders[providerId].map((p) => [p.id, { ...provider, apiKey: p.key }]),
),
),
models: (() => {
const normalize = (model: z.infer<typeof ModelSchema>) => {
const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
if (!composite)
return {
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
}
const weightMulti = compositeProviders[composite.id].length
return {
trialProvider: (() => {
if (!model.trialProvider) return undefined
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
return [model.trialProvider]
})(),
providers: model.providers.flatMap((p) =>
p.id === composite.id
? compositeProviders[p.id].map((sub) => ({
...p,
id: sub.id,
weight: p.weight ?? 1,
}))
: [
{
...p,
weight: (p.weight ?? 1) * weightMulti,
},
],
),
}
}
return Object.fromEntries(
Object.entries(modelList === "lite" ? liteModels : zenModels).map(([modelId, model]) => {
const n = Array.isArray(model)
? model.map((m) => ({ ...m, ...normalize(m) }))
: { ...model, ...normalize(model) }
return [modelId, n]
}),
)
})(),
models: modelList === "lite" ? liteModels : models,
providers,
}
})
}
+2 -4
View File
@@ -111,13 +111,12 @@
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -144,7 +143,6 @@
"hono": "catalog:",
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
+6 -263
View File
@@ -104,19 +104,6 @@ Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
Recommended first slice:
- start with `question`
- start with `GET /question`
- start with `POST /question/:requestID/reply`
Why `question` first:
- already JSON-only
- already delegates into an Effect service
- proves list + mutation + params + payload + OpenAPI in one small slice
- avoids the harder streaming and middleware cases
### 3. Reuse existing services
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
@@ -134,257 +121,13 @@ Prefer mounting an experimental `HttpApi` surface alongside the existing Hono ro
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Schema rule for HttpApi work
## Proposed first steps
Every `HttpApi` slice should follow `specs/effect/schema.md` and the Schema -> Zod interop rule in `specs/effect/migration.md`.
Default rule:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- do not introduce a new hand-written Zod schema for a type that is already migrating to Effect Schema
Practical implication for `HttpApi` migration:
- if a route boundary already depends on a shared DTO, ID, input, output, or tagged error, migrate that model to Effect Schema first or in the same change
- if an existing Hono route or tool still needs Zod, derive it with `@/util/effect-zod`
- avoid maintaining parallel Zod and Effect definitions for the same request or response type
Ordering for a route-group migration:
1. move implicated shared `schema.ts` leaf types to Effect Schema first
2. move exported `Info` / `Input` / `Output` route DTOs to Effect Schema
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
4. switch existing Zod boundary validators to derived `.zod`
5. define the `HttpApi` contract from the canonical Effect schemas
Temporary exception:
- it is acceptable to keep a route-local Zod schema for the first spike only when the type is boundary-local and migrating it would create unrelated churn
- if that happens, leave a short note so the type does not become a permanent second source of truth
## First vertical slice
The first `HttpApi` spike should be intentionally small and repeatable.
Chosen slice:
- group: `question`
- endpoints: `GET /question` and `POST /question/:requestID/reply`
Non-goals:
- no `session` routes
- no SSE or websocket routes
- no auth redesign
- no broad service refactor
Behavior rule:
- preserve current runtime behavior first
- treat semantic changes such as introducing new `404` behavior as a separate follow-up unless they are required to make the contract honest
Add `POST /question/:requestID/reject` only after the first two endpoints work cleanly.
## Repeatable slice template
Use the same sequence for each route group.
1. Pick one JSON-only route group that already mostly delegates into services.
2. Identify the shared DTOs, IDs, and errors implicated by that slice.
3. Apply the schema migration ordering above so those types are Effect Schema-first.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel under an experimental prefix.
7. Add one end-to-end test and one OpenAPI-focused test.
8. Compare ergonomics before migrating the next endpoint.
Rule of thumb:
- migrate one route group at a time
- migrate one or two endpoints first, not the whole file
- keep business logic in the existing service
- keep the first spike easy to delete if the experiment is not worth continuing
## Example structure
Placement rule:
- keep `HttpApi` code under `src/server`, not `src/effect`
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
- place each `HttpApi` slice next to the HTTP boundary it serves
- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
Suggested file layout for a repeatable spike:
- `src/server/instance/httpapi/question.ts`
- `src/server/instance/httpapi/index.ts`
- `test/server/question-httpapi.test.ts`
- `test/server/question-httpapi-openapi.test.ts`
Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice
- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer
- `question-httpapi.test.ts` proves the route works end-to-end against the real service
- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints
## Example migration shape
Each route-group spike should follow the same shape.
### 1. Contract
- define an experimental `HttpApi`
- define one `HttpApiGroup`
- define endpoint params, payload, success, and error schemas from canonical Effect schemas
- annotate summary, description, and operation ids explicitly so generated docs are stable
### 2. Handler layer
- implement with `HttpApiBuilder.group(api, groupName, ...)`
- yield the existing Effect service from context
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
### 3. Mounting
- mount under an experimental prefix such as `/experimental/httpapi`
- keep existing Hono routes unchanged
- expose separate OpenAPI output for the experimental slice first
### 4. Verification
- seed real state through the existing service
- call the experimental endpoints
- assert that the service behavior is unchanged
- assert that the generated OpenAPI contains the migrated paths and schemas
## Boundary composition
The first slices should keep the existing outer server composition and only replace the route contract and handler layer.
### Auth
- keep `AuthMiddleware` at the outer Hono app level
- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices
- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler
Practical rule:
- if a route is currently protected by the shared server middleware stack, the experimental `HttpApi` route should stay mounted behind that same stack
### Instance and workspace lookup
- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context
- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler
- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them
Practical rule:
- `HttpApi` handlers should yield services from context and assume the correct instance has already been provided
- only move instance lookup into the `HttpApi` layer if we later decide to migrate the outer middleware boundary itself
### Error mapping
- keep domain and service errors typed in the service layer
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
- prefer explicit endpoint-level error schemas over relying on the outer Hono `ErrorMiddleware` for expected route behavior
Practical rule:
- request decoding failures should remain transport-level `400`s
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
- unexpected defects can still fall through to the outer error middleware while the slice is experimental
For the current parallel slices, this means:
- auth still composes outside `HttpApi`
- instance selection still composes outside `HttpApi`
- success payloads should be schema-defined from canonical Effect schemas
- known route errors should be modeled at the endpoint boundary incrementally instead of all at once
## Exit criteria for the spike
The first slice is successful if:
- the endpoints run in parallel with the current Hono routes
- the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
- OpenAPI is generated from the `HttpApi` contract
- the tests are straightforward enough that the next slice feels mechanical
## Learnings from the question slice
The first parallel `question` spike gave us a concrete pattern to reuse.
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
- the experimental slice should stay mounted in parallel and keep calling the existing service layer unchanged.
- compare generated OpenAPI semantically at the route and schema level; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix.
## Route inventory
Status legend:
- `done` - parallel `HttpApi` slice exists
- `next` - good near-term candidate
- `later` - possible, but not first wave
- `defer` - not a good early `HttpApi` target
Current instance route inventory:
- `question` - `done`
endpoints in slice: `GET /question`, `POST /question/:requestID/reply`
- `permission` - `done`
endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `next`
best next endpoint: `GET /provider/auth`
later endpoint: `GET /provider`
defer first-wave OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
later endpoint: `GET /config`
defer `PATCH /config` for now
- `project` - `later`
best small reads: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `later`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
good JSON-only candidate set, but larger than the current first-wave slices
- `mcp` - `later`
has JSON-only endpoints, but interactive OAuth/auth flows make it a worse early fit
- `session` - `defer`
large, stateful, mixes CRUD with prompt/shell/command/share/revert flows and a streaming route
- `event` - `defer`
SSE only
- `global` - `defer`
mixed bag with SSE and process-level side effects
- `pty` - `defer`
websocket-heavy route surface
- `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence after the first spike:
1. `provider` auth read endpoint
2. `config` providers read endpoint
3. `project` read endpoints
4. `workspace` read endpoints
## Checklist
- [x] add one small spike that defines an `HttpApi` group for a simple JSON route set
- [x] use Effect Schema request / response types for that slice
- [x] keep the underlying service calls identical to the current handlers
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] add one small spike that defines an `HttpApi` group for a simple JSON route set
- [ ] use Effect Schema request / response types for that slice
- [ ] keep the underlying service calls identical to the current handlers
- [ ] compare generated OpenAPI against the current Hono/OpenAPI setup
- [ ] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
## Rule of thumb
@@ -1,666 +0,0 @@
# Server package extraction
Practical reference for extracting a future `packages/server` from the current `packages/opencode` monolith while `packages/core` is still being migrated to Effect.
This document is intentionally execution-oriented.
It should give an agent enough context to land one incremental PR at a time without needing to rediscover the package strategy, route migration rules, or current constraints.
## Goal
Create `packages/server` as the home for:
- HTTP contract definitions
- HTTP handler implementations
- OpenAPI generation
- eventual embeddable server APIs for Node apps
Do this without blocking on the full `packages/core` extraction.
## Future state
Target package layout:
- `packages/core` - all opencode services, Effect-first source of truth
- `packages/server` - opencode server, with separate contract and implementation, still producing `openapi.json`
- `packages/cli` - TUI + CLI entrypoints
- `packages/sdk` - generated from the server OpenAPI spec, may add higher-level wrappers
- `packages/plugin` - generated or semi-hand-rolled non-Effect package built from core plugin definitions
Desired user stories:
- import from `core` and build a custom agent or app-specific runtime
- import from `server` and embed the full opencode server into an existing Node app
- spawn the CLI and talk to the server through that boundary
## Current state
Everything still lives in `packages/opencode`.
Important current facts:
- there is no `packages/core` or `packages/cli` workspace yet
- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
- the main host server is still Hono-based in `src/server/server.ts`
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
- that experimental slice is mounted under `/experimental/httpapi/question`
- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
This means the package split should start from an extraction path, not from greenfield package ownership.
## Structural reference
Use `anomalyco/opentunnel` as the structural reference for `packages/server`.
The important pattern there is:
- `packages/core` owns services and domain schemas
- `packages/server/src/definition/*` owns pure `HttpApi` contracts
- `packages/server/src/api/*` owns `HttpApiBuilder.group(...)` implementations and server-side middleware wiring
- `packages/server/src/index.ts` becomes the composition root only after the server package really owns runtime hosting
Relevant `opentunnel` files:
- `packages/server/src/definition/index.ts`
- `packages/server/src/definition/tunnel.ts`
- `packages/server/src/api/index.ts`
- `packages/server/src/api/tunnel.ts`
- `packages/server/src/api/client.ts`
- `packages/server/src/index.ts`
The intended direction here is the same, but the current `opencode` package split is earlier in the migration.
That means:
- we should follow the same `definition` and `api` naming
- we should keep contract and implementation as separate modules from the start
- we should postpone the runtime composition root until `packages/core` exists enough to support it cleanly
## Key decision
Start `packages/server` as a contract and implementation package only.
Do not make it the runtime host yet.
Why:
- `packages/core` does not exist yet
- the current server host still lives in `packages/opencode`
- moving host ownership immediately would force a large package and runtime shuffle while Effect service extraction is still in flight
- if `packages/server` imports services from `packages/opencode` while `packages/opencode` imports `packages/server` to host routes, we create a package cycle immediately
Short version:
1. create `packages/server`
2. move pure `HttpApi` contracts there
3. move handler factories there
4. keep `packages/opencode` as the temporary Hono host
5. merge `packages/server` OpenAPI with the legacy Hono OpenAPI during the transition
6. move server hosting later, after `packages/core` exists enough
## Dependency rule
Phase 1 rule:
- `packages/server` must not import from `packages/opencode`
Allowed in phase 1:
- `packages/opencode` imports `packages/server`
- `packages/server` accepts host-provided services, layers, or callbacks as inputs
- `packages/server` may temporarily own transport-local placeholder schemas when a canonical shared schema does not exist yet
Future rule after `packages/core` exists:
- `packages/server` imports from `packages/core`
- `packages/cli` imports from `packages/server` and `packages/core`
- `packages/opencode` shrinks or disappears as package responsibilities are fully split
## HttpApi model
Use Effect v4 `HttpApi` as the source of truth for migrated HTTP routes.
Important properties from the current `effect` / `effect-smol` model:
- `HttpApi`, `HttpApiGroup`, and `HttpApiEndpoint` are pure contract definitions
- handlers are implemented separately with `HttpApiBuilder.group(...)`
- OpenAPI can be generated from the contract alone
- auth and middleware can later be modeled with `HttpApiMiddleware.Service`
- SSE and websocket routes are not good first-wave `HttpApi` targets
This package split should preserve that separation explicitly.
Default shape for migrated routes:
- contract lives in `packages/server/src/definition/*`
- implementation lives in `packages/server/src/api/*`
- host mounting stays outside for now
## OpenAPI rule
During the transition there is still one spec artifact.
Default rule:
- `packages/server` generates OpenAPI from `HttpApi` contract
- `packages/opencode` keeps generating legacy OpenAPI from Hono routes
- the temporary exported server spec is a merged document
- `packages/sdk` continues consuming one `openapi.json`
Merge safety rules:
- fail on duplicate `path + method`
- fail on duplicate `operationId`
- prefer explicit summary, description, and operation ids on all new `HttpApi` endpoints
Practical implication:
- do not make the SDK consume two specs
- do not switch SDK generation to `packages/server` only until enough of the route surface has moved
## Package shape
Minimum viable `packages/server`:
- `src/index.ts`
- `src/definition/index.ts`
- `src/definition/api.ts`
- `src/definition/question.ts`
- `src/api/index.ts`
- `src/api/question.ts`
- `src/openapi.ts`
- `src/bridge/hono.ts`
- `src/types.ts`
Later additions, once there is enough real contract surface:
- `src/api/client.ts`
- runtime composition in `src/index.ts`
Suggested initial exports:
- `api`
- `openapi`
- `questionApi`
- `makeQuestionHandler`
Phase 1 responsibilities:
- own pure API contracts
- own handler factories for migrated slices
- own contract-generated OpenAPI
- expose host adapters needed by `packages/opencode`
Phase 1 non-goals:
- do not own `listen()`
- do not own adapter selection
- do not own global server middleware
- do not own websocket or SSE transport
- do not own process bootstrapping for CLI entrypoints
## Current source inventory
These files matter for the first phase.
Current host and route composition:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/index.ts`
- `src/server/middleware.ts`
- `src/server/adapter.bun.ts`
- `src/server/adapter.node.ts`
Current experimental `HttpApi` slice:
- `src/server/instance/httpapi/question.ts`
- `src/server/instance/httpapi/index.ts`
- `src/server/instance/experimental.ts`
- `test/server/question-httpapi.test.ts`
Current OpenAPI flow:
- `src/server/server.ts` via `Server.openapi()`
- `src/cli/cmd/generate.ts`
- `packages/sdk/js/script/build.ts`
Current runtime and service layer:
- `src/effect/app-runtime.ts`
- `src/effect/run-service.ts`
## Ownership rules
Move first into `packages/server`:
- the experimental `question` `HttpApi` slice
- future `provider` and `config` JSON read slices
- any new `HttpApi` route groups
- transport-local OpenAPI generation for migrated routes
Keep in `packages/opencode` for now:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/*.ts`
- `src/server/middleware.ts`
- `src/server/adapter.*.ts`
- `src/effect/app-runtime.ts`
- `src/effect/run-service.ts`
- all Effect services until they move to `packages/core`
## Placeholder schema rule
`packages/core` is allowed to lag behind.
Until shared canonical schemas move to `packages/core`:
- prefer importing existing Effect Schema DTOs from current locations when practical
- if a route only needs a transport-local type and moving the canonical schema would create unrelated churn, allow a temporary server-local placeholder schema
- if a placeholder is introduced, leave a short note so it does not become permanent
The default rule from `schema.md` still applies:
- Effect Schema owns the type
- `.zod` is compatibility only
- avoid parallel hand-written Zod and Effect definitions for the same migrated route shape
## Host boundary rule
Until host ownership moves:
- auth stays at the outer Hono app level
- compression stays at the outer Hono app level
- CORS stays at the outer Hono app level
- instance and workspace lookup stay at the current middleware layer
- `packages/server` handlers should assume the host already provided the right request context
- do not redesign host middleware just to land the package split
This matches the current guidance in `http-api.md`:
- keep auth outside the first parallel `HttpApi` slices
- keep instance lookup outside the first parallel `HttpApi` slices
- keep the first migrations transport-focused and semantics-preserving
## Route selection rules
Good early migration targets:
- `question`
- `provider` auth read endpoint
- `config` providers read endpoint
- small read-only instance routes
Bad early migration targets:
- `session`
- `event`
- `pty`
- most `global` streaming or process-heavy routes
- anything requiring websocket upgrade handling
- anything that mixes many mutations and streaming in one file
## First vertical slice
The first slice for the package split is the existing experimental `question` group.
Why `question` first:
- it already exists as an experimental `HttpApi` slice
- it already follows the desired contract and implementation split in one file
- it is already mounted through the current Hono host
- it already has an end-to-end test
- it is JSON-only
- it has low blast radius
Use the first slice to prove:
- package boundary
- contract and implementation split
- host mounting from `packages/opencode`
- merged OpenAPI output
- test ergonomics for future slices
Do not broaden scope in the first slice.
## Incremental migration order
Use small PRs.
Each PR should be easy to review, easy to revert, and should not mix extraction work with unrelated service refactors.
### PR 1. Create `packages/server`
Scope:
- add the new workspace package
- add package manifest and tsconfig
- add empty `src/index.ts`, `src/definition/api.ts`, `src/definition/index.ts`, `src/api/index.ts`, `src/openapi.ts`, and supporting scaffolding
Rules:
- no production behavior changes
- no host server changes yet
- no imports from `packages/opencode` inside `packages/server`
- prefer `opentunnel`-style naming from the start: `definition` for contracts, `api` for implementations
Done means:
- `packages/server` typechecks
- the workspace can import it
- the package boundary is in place for follow-up PRs
### PR 2. Move the experimental question contract
Scope:
- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
- place it in `packages/server/src/definition/question.ts`
- aggregate it in `packages/server/src/definition/api.ts`
- generate OpenAPI in `packages/server/src/openapi.ts`
Rules:
- contract only in this PR
- no handler movement yet if that keeps the diff simpler
- keep operation ids and docs metadata stable
Done means:
- question contract lives in `packages/server`
- OpenAPI can be generated from contract alone
- no runtime behavior changes yet
### PR 3. Move the experimental question handler factory
Scope:
- extract the question `HttpApiBuilder.group(...)` implementation into `packages/server/src/api/question.ts`
- expose it as a factory that accepts host-provided dependencies or wiring
- add a small Hono bridge in `packages/server/src/bridge/hono.ts` if needed
Rules:
- `packages/server` must still not import from `packages/opencode`
- handler code should stay thin and service-delegating
- do not redesign the question service itself in this PR
Done means:
- `packages/server` can produce the experimental question handler
- the package still stays cycle-free
### PR 4. Mount `packages/server` question from `packages/opencode`
Scope:
- replace local experimental question route wiring in `packages/opencode`
- keep the same mount path:
- `/experimental/httpapi/question`
- `/experimental/httpapi/question/doc`
Rules:
- no behavior change
- preserve existing docs path
- preserve current request and response shapes
Done means:
- existing question `HttpApi` test still passes
- runtime behavior is unchanged
- the current host server is now consuming `packages/server`
### PR 5. Merge legacy and contract OpenAPI
Scope:
- keep `Server.openapi()` as the temporary spec entrypoint
- generate legacy Hono spec
- generate `packages/server` contract spec
- merge them into one document
- keep `cli/cmd/generate.ts` and `packages/sdk/js/script/build.ts` consuming one spec
Rules:
- fail loudly on duplicate `path + method`
- fail loudly on duplicate `operationId`
- do not silently overwrite one source with the other
Done means:
- one merged spec is produced
- migrated question paths can come from `packages/server`
- existing SDK generation path still works
### PR 6. Add merged OpenAPI coverage
Scope:
- add one test for merged OpenAPI
- assert both a legacy Hono route and a migrated `HttpApi` route exist
Rules:
- test the merged document, not just the `packages/server` contract spec in isolation
- pick one stable legacy route and one stable migrated route
Done means:
- the merged-spec path is covered
- future route migrations have a guardrail
### PR 7. Migrate `GET /provider/auth`
Scope:
- add `GET /provider/auth` as the next `HttpApi` slice in `packages/server`
- mount it in parallel from `packages/opencode`
Why this route:
- JSON-only
- simple service delegation
- small response shape
- already listed as the best next `provider` candidate in `http-api.md`
Done means:
- route works through the current host
- route appears in merged OpenAPI
- no semantic change to provider auth behavior
### PR 8. Migrate `GET /config/providers`
Scope:
- add `GET /config/providers` as a `HttpApi` slice in `packages/server`
- mount it in parallel from `packages/opencode`
Why this route:
- JSON-only
- read-only
- low transport complexity
- already listed as the best next `config` candidate in `http-api.md`
Done means:
- route works unchanged
- route appears in merged OpenAPI
### PR 9+. Migrate small read-only instance routes
Candidate order:
1. `GET /path`
2. `GET /vcs`
3. `GET /vcs/diff`
4. `GET /command`
5. `GET /agent`
6. `GET /skill`
Rules:
- one or two endpoints per PR
- prefer read-only routes first
- keep outer middleware unchanged
- keep business logic in the existing service layer
Done means for each PR:
- contract lives in `packages/server`
- handler lives in `packages/server`
- route is mounted from the current host
- route appears in merged OpenAPI
- behavior remains unchanged
### Later PR. Move host ownership into `packages/server`
Only start this after there is enough `packages/core` surface to depend on directly.
Scope:
- move server composition into `packages/server`
- add embeddable APIs such as `createServer(...)`, `listen(...)`, or `createApp(...)`
- move adapter selection and server startup out of `packages/opencode`
Rules:
- do not start this while `packages/server` still depends on `packages/opencode`
- do not mix this with route migration PRs
Done means:
- `packages/server` can be embedded in another Node app
- `packages/cli` can depend on `packages/server`
- host logic no longer lives in `packages/opencode`
## PR sizing rule
Every migration PR should satisfy all of these:
- one route group or one to two endpoints
- no unrelated service refactor
- no auth redesign
- no middleware redesign
- OpenAPI updated
- at least one route test or spec test added or updated
## Done means for a migrated route group
A route group migration is complete only when:
1. the `HttpApi` contract lives in `packages/server`
2. handler implementation lives in `packages/server`
3. the route is mounted from the current host in `packages/opencode`
4. the route appears in merged OpenAPI
5. request and response schemas are Effect Schema-first or clearly temporary placeholders
6. existing behavior remains unchanged
7. the route has straightforward test coverage
## Validation expectations
For package-split PRs, validate the smallest useful thing.
Typical validation for the first waves:
- `bun typecheck` in the touched package directory or directories
- the relevant route test, especially `test/server/question-httpapi.test.ts`
- merged OpenAPI coverage if the PR touches spec generation
Do not run tests from repo root.
## Main risks
### Package cycle
This is the biggest risk.
Bad state:
- `packages/server` imports services or runtime from `packages/opencode`
- `packages/opencode` imports route definitions or handlers from `packages/server`
Avoid by:
- keeping phase-1 `packages/server` free of `packages/opencode` imports
- using factories and host-provided wiring instead of direct service imports
### Spec drift
During the transition there are two route-definition sources.
Avoid by:
- one merged spec
- collision checks
- explicit `operationId`s
- merged OpenAPI tests
### Middleware mismatch
Current auth, compression, CORS, and instance selection are Hono-centered.
Avoid by:
- leaving them where they are during the first wave
- not trying to solve `HttpApiMiddleware.Service` globally in the package-split PRs
### Core lag
`packages/core` will not be ready everywhere.
Avoid by:
- allowing small transport-local placeholder schemas where necessary
- keeping those placeholders clearly temporary
- not blocking the server extraction on full schema movement
### Scope creep
The first vertical slice is easy to overload.
Avoid by:
- proving the package boundary first
- not mixing package creation, route migration, host redesign, and core extraction in the same change
## Non-goals for the first wave
- do not replace all Hono routes at once
- do not migrate SSE or websocket routes first
- do not redesign auth
- do not redesign instance lookup
- do not wait for full `packages/core` before starting `packages/server`
- do not change SDK generation to consume multiple specs
## Checklist
- [x] create `packages/server`
- [x] add package-level exports for contract and OpenAPI
- [ ] extract `question` contract into `packages/server`
- [ ] extract `question` handler factory into `packages/server`
- [ ] mount `question` from `packages/opencode`
- [ ] merge legacy and contract OpenAPI into one document
- [ ] add merged-spec coverage
- [ ] migrate `GET /provider/auth`
- [ ] migrate `GET /config/providers`
- [ ] migrate small read-only instance routes one or two at a time
- [ ] move host ownership into `packages/server` only after `packages/core` is ready enough
- [ ] split `packages/cli` after server and core boundaries are stable
## Rule of thumb
The fastest correct path is:
1. establish `packages/server` as the contract-first boundary
2. keep `packages/opencode` as the temporary host
3. migrate a few safe JSON routes
4. keep one merged OpenAPI document
5. move actual host ownership only after `packages/core` can support it cleanly
If a proposed PR would make `packages/server` import from `packages/opencode`, stop and restructure the boundary first.
+19 -226
View File
@@ -1,6 +1,5 @@
import type { Argv } from "yargs"
import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
import { SessionID } from "../../session/schema"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
@@ -8,231 +7,16 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
function redact(kind: string, id: string, value: string) {
return value.trim() ? `[redacted:${kind}:${id}]` : value
}
function data(kind: string, id: string, value: Record<string, unknown> | undefined) {
if (!value) return value
return Object.keys(value).length ? { redacted: `${kind}:${id}` } : value
}
function span(id: string, value: { value: string; start: number; end: number }) {
return {
...value,
value: redact("file-text", id, value.value),
}
}
function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) {
return diffs?.map((item, i) => ({
...item,
file: redact(`${kind}-file`, String(i), item.file),
patch: redact(`${kind}-patch`, String(i), item.patch),
}))
}
function source(part: MessageV2.FilePart) {
if (!part.source) return part.source
if (part.source.type === "symbol") {
return {
...part.source,
path: redact("file-path", part.id, part.source.path),
name: redact("file-symbol", part.id, part.source.name),
text: span(part.id, part.source.text),
}
}
if (part.source.type === "resource") {
return {
...part.source,
clientName: redact("file-client", part.id, part.source.clientName),
uri: redact("file-uri", part.id, part.source.uri),
text: span(part.id, part.source.text),
}
}
return {
...part.source,
path: redact("file-path", part.id, part.source.path),
text: span(part.id, part.source.text),
}
}
function filepart(part: MessageV2.FilePart): MessageV2.FilePart {
return {
...part,
url: redact("file-url", part.id, part.url),
filename: part.filename === undefined ? undefined : redact("file-name", part.id, part.filename),
source: source(part),
}
}
function part(part: MessageV2.Part): MessageV2.Part {
switch (part.type) {
case "text":
return {
...part,
text: redact("text", part.id, part.text),
metadata: data("text-metadata", part.id, part.metadata),
}
case "reasoning":
return {
...part,
text: redact("reasoning", part.id, part.text),
metadata: data("reasoning-metadata", part.id, part.metadata),
}
case "file":
return filepart(part)
case "subtask":
return {
...part,
prompt: redact("subtask-prompt", part.id, part.prompt),
description: redact("subtask-description", part.id, part.description),
command: part.command === undefined ? undefined : redact("subtask-command", part.id, part.command),
}
case "tool":
return {
...part,
metadata: data("tool-metadata", part.id, part.metadata),
state:
part.state.status === "pending"
? {
...part.state,
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
raw: redact("tool-raw", part.id, part.state.raw),
}
: part.state.status === "running"
? {
...part.state,
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
title: part.state.title === undefined ? undefined : redact("tool-title", part.id, part.state.title),
metadata: data("tool-state-metadata", part.id, part.state.metadata),
}
: part.state.status === "completed"
? {
...part.state,
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
output: redact("tool-output", part.id, part.state.output),
title: redact("tool-title", part.id, part.state.title),
metadata: data("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata,
attachments: part.state.attachments?.map(filepart),
}
: {
...part.state,
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
metadata: data("tool-state-metadata", part.id, part.state.metadata),
},
}
case "patch":
return {
...part,
hash: redact("patch", part.id, part.hash),
files: part.files.map((item: string, i: number) => redact("patch-file", `${part.id}-${i}`, item)),
}
case "snapshot":
return {
...part,
snapshot: redact("snapshot", part.id, part.snapshot),
}
case "step-start":
return {
...part,
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
}
case "step-finish":
return {
...part,
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
}
case "agent":
return {
...part,
source: !part.source
? part.source
: {
...part.source,
value: redact("agent-source", part.id, part.source.value),
},
}
default:
return part
}
}
const partFn = part
function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) {
return {
info: {
...data.info,
title: redact("session-title", data.info.id, data.info.title),
directory: redact("session-directory", data.info.id, data.info.directory),
summary: !data.info.summary
? data.info.summary
: {
...data.info.summary,
diffs: diff("session-diff", data.info.summary.diffs),
},
revert: !data.info.revert
? data.info.revert
: {
...data.info.revert,
snapshot:
data.info.revert.snapshot === undefined
? undefined
: redact("revert-snapshot", data.info.id, data.info.revert.snapshot),
diff:
data.info.revert.diff === undefined
? undefined
: redact("revert-diff", data.info.id, data.info.revert.diff),
},
},
messages: data.messages.map((msg) => ({
info:
msg.info.role === "user"
? {
...msg.info,
system: msg.info.system === undefined ? undefined : redact("system", msg.info.id, msg.info.system),
summary: !msg.info.summary
? msg.info.summary
: {
...msg.info.summary,
title:
msg.info.summary.title === undefined
? undefined
: redact("summary-title", msg.info.id, msg.info.summary.title),
body:
msg.info.summary.body === undefined
? undefined
: redact("summary-body", msg.info.id, msg.info.summary.body),
diffs: diff("message-diff", msg.info.summary.diffs),
},
}
: {
...msg.info,
path: {
cwd: redact("cwd", msg.info.id, msg.info.path.cwd),
root: redact("root", msg.info.id, msg.info.path.root),
},
},
parts: msg.parts.map(partFn),
})),
}
}
import { Effect } from "effect"
export const ExportCommand = cmd({
command: "export [sessionID]",
describe: "export session data as JSON",
builder: (yargs: Argv) => {
return yargs
.positional("sessionID", {
describe: "session id to export",
type: "string",
})
.option("sanitize", {
describe: "redact sensitive transcript and file data",
type: "boolean",
})
return yargs.positional("sessionID", {
describe: "session id to export",
type: "string",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
@@ -285,17 +69,26 @@ export const ExportCommand = cmd({
}
try {
const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!)))
const messages = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })),
const { sessionInfo, messages } = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const sessionInfo = yield* session.get(sessionID!)
return {
sessionInfo,
messages: yield* session.messages({ sessionID: sessionInfo.id }),
}
}),
)
const exportData = {
info: sessionInfo,
messages,
messages: messages.map((msg) => ({
info: msg.info,
parts: msg.parts,
})),
}
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(JSON.stringify(exportData, null, 2))
process.stdout.write(EOL)
} catch (error) {
UI.error(`Session not found: ${sessionID!}`)
@@ -1183,26 +1183,28 @@ export function Session() {
<Show when={session()?.parentID}>
<SubagentFooter />
</Show>
<TuiPluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
visible={visible()}
disabled={disabled()}
on_submit={toBottom}
ref={bind}
>
<Prompt
<Show when={visible()}>
<TuiPluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
visible={visible()}
ref={bind}
disabled={disabled()}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</TuiPluginRuntime.Slot>
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 />
@@ -2193,7 +2195,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
const { theme } = useTheme()
const count = createMemo(() => props.input.questions?.length ?? 0)
function format(answer?: ReadonlyArray<string>) {
function format(answer?: string[]) {
if (!answer?.length) return "(no answer)"
return answer.join(", ")
}
+2 -23
View File
@@ -1,5 +1,5 @@
import { Layer, ManagedRuntime } from "effect"
import { attach, memoMap } from "./run-service"
import { memoMap } from "./run-service"
import { Observability } from "./oltp"
import { AppFileSystem } from "@/filesystem"
@@ -97,25 +97,4 @@ export const AppLayer = Layer.mergeAll(
SessionShare.defaultLayer,
)
const rt = ManagedRuntime.make(AppLayer, { memoMap })
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never
export const AppRuntime: Runtime = {
runSync(effect) {
return rt.runSync(wrap(effect))
},
runPromise(effect, options) {
return rt.runPromise(wrap(effect), options)
},
runPromiseExit(effect, options) {
return rt.runPromiseExit(wrap(effect), options)
},
runFork(effect) {
return rt.runFork(wrap(effect))
},
runCallback(effect) {
return rt.runCallback(wrap(effect))
},
dispose: () => rt.dispose(),
}
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
+7 -7
View File
@@ -27,16 +27,16 @@ export namespace Identifier {
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
return generateID(prefix, false, given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "descending", given)
return generateID(prefix, true, given)
}
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
if (!given) {
return create(prefixes[prefix], direction)
return create(prefix, descending)
}
if (!given.startsWith(prefixes[prefix])) {
@@ -55,7 +55,7 @@ export namespace Identifier {
return result
}
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
@@ -66,14 +66,14 @@ export namespace Identifier {
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = direction === "descending" ? ~now : now
now = descending ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
+68 -96
View File
@@ -3,9 +3,8 @@ import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { SessionID, MessageID } from "@/session/schema"
import { zod } from "@/util/effect-zod"
import { Log } from "@/util/log"
import { withStatics } from "@/util/schema"
import z from "zod"
import { QuestionID } from "./schema"
export namespace Question {
@@ -13,91 +12,67 @@ export namespace Question {
// Schemas
export class Option extends Schema.Class<Option>("QuestionOption")({
label: Schema.String.annotate({
description: "Display text (1-5 words, concise)",
}),
description: Schema.String.annotate({
description: "Explanation of choice",
}),
}) {
static readonly zod = zod(this)
}
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
const base = {
question: Schema.String.annotate({
description: "Complete question",
}),
header: Schema.String.annotate({
description: "Very short label (max 30 chars)",
}),
options: Schema.Array(Option).annotate({
description: "Available choices",
}),
multiple: Schema.optional(Schema.Boolean).annotate({
description: "Allow selecting multiple choices",
}),
}
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export class Info extends Schema.Class<Info>("QuestionInfo")({
...base,
custom: Schema.optional(Schema.Boolean).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}) {
static readonly zod = zod(this)
}
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {
static readonly zod = zod(this)
}
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export class Tool extends Schema.Class<Tool>("QuestionTool")({
messageID: MessageID,
callID: Schema.String,
}) {
static readonly zod = zod(this)
}
export class Request extends Schema.Class<Request>("QuestionRequest")({
id: QuestionID,
sessionID: SessionID,
questions: Schema.Array(Info).annotate({
description: "Questions to ask",
}),
tool: Schema.optional(Tool),
}) {
static readonly zod = zod(this)
}
export const Answer = Schema.Array(Schema.String)
.annotate({ identifier: "QuestionAnswer" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Answer = Schema.Schema.Type<typeof Answer>
export class Reply extends Schema.Class<Reply>("QuestionReply")({
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}) {
static readonly zod = zod(this)
}
class Replied extends Schema.Class<Replied>("QuestionReplied")({
sessionID: SessionID,
requestID: QuestionID,
answers: Schema.Array(Answer),
}) {}
class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
sessionID: SessionID,
requestID: QuestionID,
}) {}
export const Reply = z.object({
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request.zod),
Replied: BusEvent.define("question.replied", zod(Replied)),
Rejected: BusEvent.define("question.rejected", zod(Rejected)),
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
@@ -108,7 +83,7 @@ export namespace Question {
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
deferred: Deferred.Deferred<Answer[], RejectedError>
}
interface State {
@@ -120,12 +95,12 @@ export namespace Question {
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
readonly list: () => Effect.Effect<Request[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
@@ -155,20 +130,20 @@ export namespace Question {
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const pending = (yield* InstanceState.get(state)).pending
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
const info = Schema.decodeUnknownSync(Request)({
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
})
}
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
@@ -180,10 +155,7 @@ export namespace Question {
)
})
const reply = Effect.fn("Question.reply")(function* (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
}) {
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID)
if (!existing) {
@@ -18,7 +18,6 @@ import { lazy } from "../../util/lazy"
import { Effect, Option } from "effect"
import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent"
import { HttpApiRoutes } from "./httpapi"
const ConsoleOrgOption = z.object({
accountID: z.string(),
@@ -40,7 +39,6 @@ const ConsoleSwitchBody = z.object({
export const ExperimentalRoutes = lazy(() =>
new Hono()
.route("/httpapi", HttpApiRoutes())
.get(
"/console",
describeRoute({
@@ -1,7 +0,0 @@
import { lazy } from "@/util/lazy"
import { Hono } from "hono"
import { QuestionHttpApiHandler } from "./question"
export const HttpApiRoutes = lazy(() =>
new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
)
@@ -1,56 +0,0 @@
import { AppLayer } from "@/effect/app-runtime"
import { memoMap } from "@/effect/run-service"
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { lazy } from "@/util/lazy"
import { QuestionReply, QuestionRequest, questionApi } from "@opencode-ai/server"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import type { Handler } from "hono"
const root = "/experimental/httpapi/question"
const QuestionLive = HttpApiBuilder.group(
questionApi,
"question",
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
const svc = yield* Question.Service
const decode = Schema.decodeUnknownSync(Schema.Array(QuestionRequest))
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return decode(yield* svc.list())
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: string }
payload: Schema.Schema.Type<typeof QuestionReply>
}) {
yield* svc.reply({
requestID: QuestionID.make(ctx.params.requestID),
answers: ctx.payload.answers,
})
return true
})
return handlers.handle("list", list).handle("reply", reply)
}),
).pipe(Layer.provide(Question.defaultLayer))
const web = lazy(() =>
HttpRouter.toWebHandler(
Layer.mergeAll(
AppLayer,
HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe(
Layer.provide(QuestionLive),
Layer.provide(HttpServer.layerServices),
),
),
{
disableLogger: true,
memoMap,
},
),
)
export const QuestionHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)
@@ -8,12 +8,6 @@ import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
const Reply = z.object({
answers: Question.Answer.zod
.array()
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export const QuestionRoutes = lazy(() =>
new Hono()
.get(
@@ -27,7 +21,7 @@ export const QuestionRoutes = lazy(() =>
description: "List of pending questions",
content: {
"application/json": {
schema: resolver(Question.Request.zod.array()),
schema: resolver(Question.Request.array()),
},
},
},
@@ -62,7 +56,7 @@ export const QuestionRoutes = lazy(() =>
requestID: QuestionID.zod,
}),
),
validator("json", Reply),
validator("json", Question.Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
+1 -5
View File
@@ -205,11 +205,7 @@ export namespace LLM {
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
+12 -19
View File
@@ -104,21 +104,12 @@ export namespace SessionPrompt {
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
const runner = Effect.fn("SessionPrompt.runner")(function* () {
const ctx = yield* Effect.context()
return {
promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
}
})
const ops = Effect.fn("SessionPrompt.ops")(function* () {
const run = yield* runner()
return {
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
resolvePromptParts: (template: string) => resolvePromptParts(template),
prompt: (input: PromptInput) => prompt(input),
} satisfies TaskPromptOps
})
const ctx = yield* Effect.context<any>()
const run = {
promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
}
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
yield* elog.info("cancel", { sessionID })
@@ -368,8 +359,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
const run = yield* runner()
const promptOps = yield* ops()
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id,
@@ -539,7 +528,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const promptOps = yield* ops()
const { task: taskTool } = yield* registry.named()
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
@@ -724,7 +712,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
const ctx = yield* InstanceState.context
const run = yield* runner()
const session = yield* sessions.get(input.sessionID)
if (session.revert) {
yield* revert.cleanup(session)
@@ -1672,6 +1659,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return result
})
const promptOps: TaskPromptOps = {
cancel: (sessionID) => run.fork(cancel(sessionID)),
resolvePromptParts: (template) => resolvePromptParts(template),
prompt: (input) => prompt(input),
}
return Service.of({
cancel,
prompt,
+2 -2
View File
@@ -5,11 +5,11 @@ import { Question } from "../question"
import DESCRIPTION from "./question.txt"
const parameters = z.object({
questions: z.array(Question.Prompt.zod).describe("Questions to ask"),
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
})
type Metadata = {
answers: ReadonlyArray<Question.Answer>
answers: Question.Answer[]
}
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
+1 -3
View File
@@ -48,9 +48,7 @@ export namespace Truncate {
const fs = yield* AppFileSystem.Service
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
)
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
Effect.catch(() => Effect.succeed([])),
@@ -1 +0,0 @@
export namespace SessionCommon {}
+113 -203
View File
@@ -1,51 +1,114 @@
import { Schema } from "effect"
import { SessionEvent } from "./session-event"
import { produce } from "immer"
import { Identifier } from "@/id/id"
import { Database } from "@/node"
import type { SessionID } from "@/session/schema"
import { SessionEntryTable } from "@/session/session.sql"
import { withStatics } from "@/util/schema"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { eq } from "../storage/db"
export namespace SessionEntry {
export const ID = SessionEvent.ID
export const ID = Schema.String.pipe(Schema.brand("Session.Entry.ID")).pipe(
withStatics((s) => ({
create: () => s.make(Identifier.ascending("entry")),
prefix: "ent",
})),
)
export type ID = Schema.Schema.Type<typeof ID>
const Base = {
id: SessionEvent.ID,
id: ID,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}
export class User extends Schema.Class<User>("Session.Entry.User")({
...Base,
text: SessionEvent.Prompt.fields.text,
files: SessionEvent.Prompt.fields.files,
agents: SessionEvent.Prompt.fields.agents,
type: Schema.Literal("user"),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
export class Source extends Schema.Class<Source>("Session.Entry.Source")({
start: Schema.Number,
end: Schema.Number,
text: Schema.String,
}) {}
export class FileAttachment extends Schema.Class<FileAttachment>("Session.Entry.File.Attachment")({
uri: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {
static fromEvent(event: SessionEvent.Prompt) {
return new User({
id: event.id,
type: "user",
metadata: event.metadata,
text: event.text,
files: event.files,
agents: event.agents,
time: { created: event.timestamp },
static create(url: string) {
return new FileAttachment({
uri: url,
mime: "text/plain",
})
}
}
export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Entry.Agent.Attachment")({
name: Schema.String,
source: Source.pipe(Schema.optional),
}) {}
export class User extends Schema.Class<User>("Session.Entry.User")({
...Base,
type: Schema.Literal("user"),
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
}) {
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
const msg = new User({
id: ID.create(),
type: "user",
...input,
time: {
created: Effect.runSync(DateTime.now),
},
})
return msg
}
}
export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
...SessionEvent.Synthetic.fields,
...Base,
type: Schema.Literal("synthetic"),
text: Schema.String,
}) {}
export class Request extends Schema.Class<Request>("Session.Entry.Request")({
...Base,
type: Schema.Literal("start"),
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
}) {}
export class Text extends Schema.Class<Text>("Session.Entry.Text")({
...Base,
type: Schema.Literal("text"),
text: Schema.String,
time: Schema.Struct({
...Base.time.fields,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class Reasoning extends Schema.Class<Reasoning>("Session.Entry.Reasoning")({
...Base,
type: Schema.Literal("reasoning"),
text: Schema.String,
time: Schema.Struct({
...Base.time.fields,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
status: Schema.Literal("pending"),
input: Schema.String,
input: Schema.Record(Schema.String, Schema.Unknown),
raw: Schema.String,
}) {}
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Entry.ToolState.Running")({
@@ -61,7 +124,7 @@ export namespace SessionEntry {
output: Schema.String,
title: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Unknown),
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
}) {}
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.ToolState.Error")({
@@ -69,42 +132,34 @@ export namespace SessionEntry {
input: Schema.Record(Schema.String, Schema.Unknown),
error: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
time: Schema.Struct({
start: Schema.Number,
end: Schema.Number,
}),
}) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
export type ToolState = Schema.Schema.Type<typeof ToolState>
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
export class Tool extends Schema.Class<Tool>("Session.Entry.Tool")({
...Base,
type: Schema.Literal("tool"),
callID: Schema.String,
name: Schema.String,
state: ToolState,
time: Schema.Struct({
created: Schema.DateTimeUtc,
...Base.time.fields,
ran: Schema.DateTimeUtc.pipe(Schema.optional),
completed: Schema.DateTimeUtc.pipe(Schema.optional),
pruned: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class AssistantText extends Schema.Class<AssistantText>("Session.Entry.Assistant.Text")({
type: Schema.Literal("text"),
text: Schema.String,
}) {}
export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Entry.Assistant.Reasoning")({
type: Schema.Literal("reasoning"),
text: Schema.String,
}) {}
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool])
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
export class Complete extends Schema.Class<Complete>("Session.Entry.Complete")({
...Base,
type: Schema.Literal("assistant"),
content: AssistantContent.pipe(Schema.Array),
cost: Schema.Number.pipe(Schema.optional),
type: Schema.Literal("complete"),
cost: Schema.Number,
reason: Schema.String,
tokens: Schema.Struct({
input: Schema.Number,
output: Schema.Number,
@@ -113,174 +168,30 @@ export namespace SessionEntry {
read: Schema.Number,
write: Schema.Number,
}),
}).pipe(Schema.optional),
error: Schema.String.pipe(Schema.optional),
time: Schema.Struct({
created: Schema.DateTimeUtc,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
...SessionEvent.Compacted.fields,
type: Schema.Literal("compaction"),
export class Retry extends Schema.Class<Retry>("Session.Entry.Retry")({
...Base,
type: Schema.Literal("retry"),
attempt: Schema.Number,
error: Schema.String,
}) {}
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction])
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
...Base,
type: Schema.Literal("compaction"),
auto: Schema.Boolean,
overflow: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction], {
mode: "oneOf",
})
export type Entry = Schema.Schema.Type<typeof Entry>
export type Type = Entry["type"]
export type History = {
entries: Entry[]
pending: Entry[]
}
export function step(old: History, event: SessionEvent.Event): History {
return produce(old, (draft) => {
const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
switch (event.type) {
case "prompt": {
if (pendingAssistant) {
// @ts-expect-error
draft.pending.push(User.fromEvent(event))
break
}
// @ts-expect-error
draft.entries.push(User.fromEvent(event))
break
}
case "step.started": {
if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
draft.entries.push({
id: event.id,
type: "assistant",
time: {
created: event.timestamp,
},
content: [],
})
break
}
case "text.started": {
if (!pendingAssistant) break
pendingAssistant.content.push({
type: "text",
text: "",
})
break
}
case "text.delta": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "text")
if (match) match.text += event.delta
break
}
case "text.ended": {
break
}
case "tool.input.started": {
if (!pendingAssistant) break
pendingAssistant.content.push({
type: "tool",
callID: event.callID,
name: event.name,
time: {
created: event.timestamp,
},
state: {
status: "pending",
input: "",
},
})
break
}
case "tool.input.delta": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
if (match) match.state.input += event.delta
break
}
case "tool.input.ended": {
break
}
case "tool.called": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
if (match) {
match.time.ran = event.timestamp
match.state = {
status: "running",
input: event.input,
}
}
break
}
case "tool.success": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
if (match && match.state.status === "running") {
match.state = {
status: "completed",
input: match.state.input,
output: event.output ?? "",
title: event.title,
metadata: event.metadata ?? {},
// @ts-expect-error
attachments: event.attachments ?? [],
}
}
break
}
case "tool.error": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
if (match && match.state.status === "running") {
match.state = {
status: "error",
error: event.error,
input: match.state.input,
metadata: event.metadata ?? {},
}
}
break
}
case "reasoning.started": {
if (!pendingAssistant) break
pendingAssistant.content.push({
type: "reasoning",
text: "",
})
break
}
case "reasoning.delta": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
if (match) match.text += event.delta
break
}
case "reasoning.ended": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
if (match) match.text = event.text
break
}
case "step.ended": {
if (!pendingAssistant) break
pendingAssistant.time.completed = event.timestamp
pendingAssistant.cost = event.cost
pendingAssistant.tokens = event.tokens
break
}
}
})
}
/*
export interface Interface {
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
readonly fromSession: (sessionID: SessionID) => Effect.Effect<Entry[], never>
@@ -313,5 +224,4 @@ export namespace SessionEntry {
})
}),
)
*/
}
-443
View File
@@ -1,443 +0,0 @@
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import * as DateTime from "effect/DateTime"
import { Schema } from "effect"
export namespace SessionEvent {
export const ID = Schema.String.pipe(
Schema.brand("Session.Event.ID"),
withStatics((s) => ({
create: () => s.make(Identifier.create("evt", "ascending")),
})),
)
export type ID = Schema.Schema.Type<typeof ID>
type Stamp = Schema.Schema.Type<typeof Schema.DateTimeUtc>
type BaseInput = {
id?: ID
metadata?: Record<string, unknown>
timestamp?: Stamp
}
const Base = {
id: ID,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
timestamp: Schema.DateTimeUtc,
}
export class Source extends Schema.Class<Source>("Session.Event.Source")({
start: Schema.Number,
end: Schema.Number,
text: Schema.String,
}) {}
export class FileAttachment extends Schema.Class<FileAttachment>("Session.Event.FileAttachment")({
uri: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {
static create(input: FileAttachment) {
return new FileAttachment({
...input,
})
}
}
export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Event.AgentAttachment")({
name: Schema.String,
source: Source.pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
...Base,
type: Schema.Literal("prompt"),
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
}) {
static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) {
return new Prompt({
id: input.id ?? ID.create(),
type: "prompt",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
text: input.text,
files: input.files,
agents: input.agents,
})
}
}
export class Synthetic extends Schema.Class<Synthetic>("Session.Event.Synthetic")({
...Base,
type: Schema.Literal("synthetic"),
text: Schema.String,
}) {
static create(input: BaseInput & { text: string }) {
return new Synthetic({
id: input.id ?? ID.create(),
type: "synthetic",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
text: input.text,
})
}
}
export namespace Step {
export class Started extends Schema.Class<Started>("Session.Event.Step.Started")({
...Base,
type: Schema.Literal("step.started"),
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
}) {
static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) {
return new Started({
id: input.id ?? ID.create(),
type: "step.started",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
model: input.model,
})
}
}
export class Ended extends Schema.Class<Ended>("Session.Event.Step.Ended")({
...Base,
type: Schema.Literal("step.ended"),
reason: Schema.String,
cost: Schema.Number,
tokens: Schema.Struct({
input: Schema.Number,
output: Schema.Number,
reasoning: Schema.Number,
cache: Schema.Struct({
read: Schema.Number,
write: Schema.Number,
}),
}),
}) {
static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) {
return new Ended({
id: input.id ?? ID.create(),
type: "step.ended",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
reason: input.reason,
cost: input.cost,
tokens: input.tokens,
})
}
}
}
export namespace Text {
export class Started extends Schema.Class<Started>("Session.Event.Text.Started")({
...Base,
type: Schema.Literal("text.started"),
}) {
static create(input: BaseInput = {}) {
return new Started({
id: input.id ?? ID.create(),
type: "text.started",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
})
}
}
export class Delta extends Schema.Class<Delta>("Session.Event.Text.Delta")({
...Base,
type: Schema.Literal("text.delta"),
delta: Schema.String,
}) {
static create(input: BaseInput & { delta: string }) {
return new Delta({
id: input.id ?? ID.create(),
type: "text.delta",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
delta: input.delta,
})
}
}
export class Ended extends Schema.Class<Ended>("Session.Event.Text.Ended")({
...Base,
type: Schema.Literal("text.ended"),
text: Schema.String,
}) {
static create(input: BaseInput & { text: string }) {
return new Ended({
id: input.id ?? ID.create(),
type: "text.ended",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
text: input.text,
})
}
}
}
export namespace Reasoning {
export class Started extends Schema.Class<Started>("Session.Event.Reasoning.Started")({
...Base,
type: Schema.Literal("reasoning.started"),
}) {
static create(input: BaseInput = {}) {
return new Started({
id: input.id ?? ID.create(),
type: "reasoning.started",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
})
}
}
export class Delta extends Schema.Class<Delta>("Session.Event.Reasoning.Delta")({
...Base,
type: Schema.Literal("reasoning.delta"),
delta: Schema.String,
}) {
static create(input: BaseInput & { delta: string }) {
return new Delta({
id: input.id ?? ID.create(),
type: "reasoning.delta",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
delta: input.delta,
})
}
}
export class Ended extends Schema.Class<Ended>("Session.Event.Reasoning.Ended")({
...Base,
type: Schema.Literal("reasoning.ended"),
text: Schema.String,
}) {
static create(input: BaseInput & { text: string }) {
return new Ended({
id: input.id ?? ID.create(),
type: "reasoning.ended",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
text: input.text,
})
}
}
}
export namespace Tool {
export namespace Input {
export class Started extends Schema.Class<Started>("Session.Event.Tool.Input.Started")({
...Base,
callID: Schema.String,
name: Schema.String,
type: Schema.Literal("tool.input.started"),
}) {
static create(input: BaseInput & { callID: string; name: string }) {
return new Started({
id: input.id ?? ID.create(),
type: "tool.input.started",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
callID: input.callID,
name: input.name,
})
}
}
export class Delta extends Schema.Class<Delta>("Session.Event.Tool.Input.Delta")({
...Base,
callID: Schema.String,
type: Schema.Literal("tool.input.delta"),
delta: Schema.String,
}) {
static create(input: BaseInput & { callID: string; delta: string }) {
return new Delta({
id: input.id ?? ID.create(),
type: "tool.input.delta",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
callID: input.callID,
delta: input.delta,
})
}
}
export class Ended extends Schema.Class<Ended>("Session.Event.Tool.Input.Ended")({
...Base,
callID: Schema.String,
type: Schema.Literal("tool.input.ended"),
text: Schema.String,
}) {
static create(input: BaseInput & { callID: string; text: string }) {
return new Ended({
id: input.id ?? ID.create(),
type: "tool.input.ended",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
callID: input.callID,
text: input.text,
})
}
}
}
export class Called extends Schema.Class<Called>("Session.Event.Tool.Called")({
...Base,
type: Schema.Literal("tool.called"),
callID: Schema.String,
tool: Schema.String,
input: Schema.Record(Schema.String, Schema.Unknown),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}),
}) {
static create(
input: BaseInput & {
callID: string
tool: string
input: Record<string, unknown>
provider: Called["provider"]
},
) {
return new Called({
id: input.id ?? ID.create(),
type: "tool.called",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
callID: input.callID,
tool: input.tool,
input: input.input,
provider: input.provider,
})
}
}
export class Success extends Schema.Class<Success>("Session.Event.Tool.Success")({
...Base,
type: Schema.Literal("tool.success"),
callID: Schema.String,
title: Schema.String,
output: Schema.String.pipe(Schema.optional),
attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}),
}) {
static create(
input: BaseInput & {
callID: string
title: string
output?: string
attachments?: FileAttachment[]
provider: Success["provider"]
},
) {
return new Success({
id: input.id ?? ID.create(),
type: "tool.success",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
callID: input.callID,
title: input.title,
output: input.output,
attachments: input.attachments,
provider: input.provider,
})
}
}
export class Error extends Schema.Class<Error>("Session.Event.Tool.Error")({
...Base,
type: Schema.Literal("tool.error"),
callID: Schema.String,
error: Schema.String,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}),
}) {
static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) {
return new Error({
id: input.id ?? ID.create(),
type: "tool.error",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
callID: input.callID,
error: input.error,
provider: input.provider,
})
}
}
}
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
...Base,
type: Schema.Literal("retried"),
error: Schema.String,
}) {
static create(input: BaseInput & { error: string }) {
return new Retried({
id: input.id ?? ID.create(),
type: "retried",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
error: input.error,
})
}
}
export class Compacted extends Schema.Class<Compacted>("Session.Event.Compated")({
...Base,
type: Schema.Literal("compacted"),
auto: Schema.Boolean,
overflow: Schema.Boolean.pipe(Schema.optional),
}) {
static create(input: BaseInput & { auto: boolean; overflow?: boolean }) {
return new Compacted({
id: input.id ?? ID.create(),
type: "compacted",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
auto: input.auto,
overflow: input.overflow,
})
}
}
export const Event = Schema.Union(
[
Prompt,
Synthetic,
Step.Started,
Step.Ended,
Text.Started,
Text.Delta,
Text.Ended,
Tool.Input.Started,
Tool.Input.Delta,
Tool.Input.Ended,
Tool.Called,
Tool.Success,
Tool.Error,
Reasoning.Started,
Reasoning.Delta,
Reasoning.Ended,
Retried,
Compacted,
],
{
mode: "oneOf",
},
)
export type Event = Schema.Schema.Type<typeof Event>
export type Type = Event["type"]
}
@@ -1,11 +1,8 @@
import { expect, test } from "bun:test"
import { Context, Effect, Layer, Logger } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { EffectLogger } from "../../src/effect/logger"
import { makeRuntime } from "../../src/effect/run-service"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { EffectLogger } from "../../src/effect/logger"
function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
return {
@@ -43,19 +40,3 @@ test("AppRuntime also installs EffectLogger through Observability.layer", async
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
})
test("AppRuntime attaches InstanceRef from ALS", async () => {
await using tmp = await tmpdir({ git: true })
const dir = await Instance.provide({
directory: tmp.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
),
})
expect(dir).toBe(tmp.path)
})
@@ -6,12 +6,12 @@ import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info>; tool?: Question.Tool }) =>
const ask = (input: { sessionID: SessionID; questions: Question.Info[]; tool?: { messageID: any; callID: string } }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
const reply = (input: { requestID: QuestionID; answers: ReadonlyArray<Question.Answer> }) =>
const reply = (input: { requestID: QuestionID; answers: Question.Answer[] }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id)))
@@ -1,78 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Instance } from "../../src/project/instance"
import { Question } from "../../src/question"
import { Server } from "../../src/server/server"
import { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info> }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
afterEach(async () => {
await Instance.disposeAll()
})
describe("experimental question httpapi", () => {
test("lists pending questions, replies, and serves docs", async () => {
await using tmp = await tmpdir({ git: true })
const app = Server.Default().app
const headers = {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
}
const questions: ReadonlyArray<Question.Info> = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
]
let pending!: ReturnType<typeof ask>
await Instance.provide({
directory: tmp.path,
fn: async () => {
pending = ask({
sessionID: SessionID.make("ses_test"),
questions,
})
},
})
const list = await app.request("/experimental/httpapi/question", {
headers,
})
expect(list.status).toBe(200)
const items = await list.json()
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({ questions })
const doc = await app.request("/experimental/httpapi/question/doc", {
headers,
})
expect(doc.status).toBe(200)
const spec = await doc.json()
expect(spec.paths["/experimental/httpapi/question"]?.get?.operationId).toBe("question.list")
expect(spec.paths["/experimental/httpapi/question/{requestID}/reply"]?.post?.operationId).toBe("question.reply")
const reply = await app.request(`/experimental/httpapi/question/${items[0].id}/reply`, {
method: "POST",
headers,
body: JSON.stringify({ answers: [["Option 1"]] }),
})
expect(reply.status).toBe(200)
expect(await reply.json()).toBe(true)
expect(await pending).toEqual([["Option 1"]])
})
})
@@ -483,48 +483,6 @@ it.live("loop continues when finish is tool-calls", () =>
),
)
it.live("glob tool keeps instance context during prompt runs", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Glob context",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
const file = path.join(dir, "probe.txt")
yield* Effect.promise(() => Bun.write(file, "probe"))
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "find text files" }],
})
yield* llm.tool("glob", { pattern: "**/*.txt" })
yield* llm.text("done")
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
const tool = msgs
.flatMap((msg) => msg.parts)
.find(
(part): part is CompletedToolPart =>
part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
)
if (!tool) return
expect(tool.state.output).toContain(file)
expect(tool.state.output).not.toContain("No context found for instance")
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
}),
{ git: true, config: providerCfg },
),
)
it.live("loop continues when finish is stop but assistant has tool parts", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
@@ -1,624 +0,0 @@
import { describe, expect, test } from "bun:test"
import * as DateTime from "effect/DateTime"
import * as FastCheck from "effect/testing/FastCheck"
import { SessionEntry } from "../../src/v2/session-entry"
import { SessionEvent } from "../../src/v2/session-event"
const time = (n: number) => DateTime.makeUnsafe(n)
const word = FastCheck.string({ minLength: 1, maxLength: 8 })
const text = FastCheck.string({ maxLength: 16 })
const texts = FastCheck.array(text, { maxLength: 8 })
const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
const files = FastCheck.array(
word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
{ maxLength: 2 },
)
function maybe<A>(arb: FastCheck.Arbitrary<A>) {
return FastCheck.oneof(FastCheck.constant(undefined), arb)
}
function assistant() {
return new SessionEntry.Assistant({
id: SessionEvent.ID.create(),
type: "assistant",
time: { created: time(0) },
content: [],
})
}
function history() {
const state: SessionEntry.History = {
entries: [],
pending: [],
}
return state
}
function active() {
const state: SessionEntry.History = {
entries: [assistant()],
pending: [],
}
return state
}
function run(events: SessionEvent.Event[], state = history()) {
return events.reduce<SessionEntry.History>((state, event) => SessionEntry.step(state, event), state)
}
function last(state: SessionEntry.History) {
const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
expect(entry?.type).toBe("assistant")
return entry?.type === "assistant" ? entry : undefined
}
function texts_of(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
}
function reasons(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
}
function tools(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
}
function tool(state: SessionEntry.History, callID: string) {
return tools(state).find((x) => x.callID === callID)
}
describe("session-entry step", () => {
describe("seeded pending assistant", () => {
test("stores prompts in entries when no assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(history(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("user")
if (next.entries[0]?.type !== "user") return
expect(next.entries[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("stores prompts in pending when an assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(next.pending).toHaveLength(1)
expect(next.pending[0]?.type).toBe("user")
if (next.pending[0]?.type !== "user") return
expect(next.pending[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("accumulates text deltas on the latest text part", () => {
FastCheck.assert(
FastCheck.property(texts, (parts) => {
const next = parts.reduce(
(state, part, i) =>
SessionEntry.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })),
SessionEntry.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
)
expect(texts_of(next)).toEqual([
{
type: "text",
text: parts.join(""),
},
])
}),
{ numRuns: 100 },
)
})
test("routes later text deltas to the latest text segment", () => {
FastCheck.assert(
FastCheck.property(texts, texts, (a, b) => {
const next = run(
[
SessionEvent.Text.Started.create({ timestamp: time(1) }),
...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
],
active(),
)
expect(texts_of(next)).toEqual([
{ type: "text", text: a.join("") },
{ type: "text", text: b.join("") },
])
}),
{ numRuns: 50 },
)
})
test("reasoning.ended replaces buffered reasoning text", () => {
FastCheck.assert(
FastCheck.property(texts, text, (parts, end) => {
const next = run(
[
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
],
active(),
)
expect(reasons(next)).toEqual([
{
type: "reasoning",
text: end,
},
])
}),
{ numRuns: 100 },
)
})
test("tool.success completes the latest running tool", () => {
FastCheck.assert(
FastCheck.property(
word,
word,
dict,
maybe(text),
maybe(dict),
maybe(files),
texts,
(callID, title, input, output, metadata, attachments, parts) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
...parts.map((x, i) =>
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(parts.length + 2),
}),
SessionEvent.Tool.Success.create({
callID,
title,
output,
metadata,
attachments,
provider: { executed: true },
timestamp: time(parts.length + 3),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state.status).toBe("completed")
if (match?.state.status !== "completed") return
expect(match.time.ran).toEqual(time(parts.length + 2))
expect(match.state.input).toEqual(input)
expect(match.state.output).toBe(output ?? "")
expect(match.state.title).toBe(title)
expect(match.state.metadata).toEqual(metadata ?? {})
expect(match.state.attachments).toEqual(attachments ?? [])
},
),
{ numRuns: 50 },
)
})
test("tool.error completes the latest running tool with an error", () => {
FastCheck.assert(
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(2),
}),
SessionEvent.Tool.Error.create({
callID,
error,
metadata,
provider: { executed: true },
timestamp: time(3),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state.status).toBe("error")
if (match?.state.status !== "error") return
expect(match.time.ran).toEqual(time(2))
expect(match.state.input).toEqual(input)
expect(match.state.error).toBe(error)
expect(match.state.metadata).toEqual(metadata ?? {})
}),
{ numRuns: 50 },
)
})
test("tool.success is ignored before tool.called promotes the tool to running", () => {
FastCheck.assert(
FastCheck.property(word, word, (callID, title) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Success.create({
callID,
title,
provider: { executed: true },
timestamp: time(2),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state).toEqual({
status: "pending",
input: "",
})
}),
{ numRuns: 50 },
)
})
test("step.ended copies completion fields onto the pending assistant", () => {
FastCheck.assert(
FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
const event = SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(n),
})
const next = SessionEntry.step(active(), event)
const entry = last(next)
expect(entry).toBeDefined()
if (!entry) return
expect(entry.time.completed).toEqual(event.timestamp)
expect(entry.cost).toBe(event.cost)
expect(entry.tokens).toEqual(event.tokens)
}),
{ numRuns: 50 },
)
})
})
describe("known reducer gaps", () => {
test("prompt appends immutably when no assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const old = history()
const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(old).not.toBe(next)
expect(old.entries).toHaveLength(0)
expect(next.entries).toHaveLength(1)
}),
{ numRuns: 50 },
)
})
test("prompt appends immutably when an assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const old = active()
const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(old).not.toBe(next)
expect(old.pending).toHaveLength(0)
expect(next.pending).toHaveLength(1)
}),
{ numRuns: 50 },
)
})
test("step.started creates an assistant consumed by follow-up events", () => {
FastCheck.assert(
FastCheck.property(texts, (parts) => {
const next = run([
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Text.Started.create({ timestamp: time(2) }),
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(parts.length + 3),
}),
])
const entry = last(next)
expect(entry).toBeDefined()
if (!entry) return
expect(entry.content).toEqual([
{
type: "text",
text: parts.join(""),
},
])
expect(entry.time.completed).toEqual(time(parts.length + 3))
}),
{ numRuns: 100 },
)
})
test("replays prompt -> step -> text -> step.ended", () => {
FastCheck.assert(
FastCheck.property(word, texts, (body, parts) => {
const next = run([
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Text.Started.create({ timestamp: time(2) }),
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(parts.length + 3),
}),
])
expect(next.entries).toHaveLength(2)
expect(next.entries[0]?.type).toBe("user")
expect(next.entries[1]?.type).toBe("assistant")
if (next.entries[1]?.type !== "assistant") return
expect(next.entries[1].content).toEqual([
{
type: "text",
text: parts.join(""),
},
])
expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
}),
{ numRuns: 50 },
)
})
test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
FastCheck.assert(
FastCheck.property(
word,
texts,
text,
dict,
word,
maybe(text),
maybe(dict),
maybe(files),
(body, reason, end, input, title, output, metadata, attachments) => {
const callID = "call"
const next = run([
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(reason.length + 5),
}),
SessionEvent.Tool.Success.create({
callID,
title,
output,
metadata,
attachments,
provider: { executed: true },
timestamp: time(reason.length + 6),
}),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(reason.length + 7),
}),
])
expect(next.entries.at(-1)?.type).toBe("assistant")
const entry = next.entries.at(-1)
if (entry?.type !== "assistant") return
expect(entry.content).toHaveLength(2)
expect(entry.content[0]).toEqual({
type: "reasoning",
text: end,
})
expect(entry.content[1]?.type).toBe("tool")
if (entry.content[1]?.type !== "tool") return
expect(entry.content[1].state.status).toBe("completed")
expect(entry.time.completed).toEqual(time(reason.length + 7))
},
),
{ numRuns: 50 },
)
})
test("starting a new step completes the old assistant and appends a new active assistant", () => {
const next = run(
[
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
],
active(),
)
expect(next.entries).toHaveLength(2)
expect(next.entries[0]?.type).toBe("assistant")
expect(next.entries[1]?.type).toBe("assistant")
if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
expect(next.entries[0].time.completed).toEqual(time(1))
expect(next.entries[1].time.created).toEqual(time(1))
expect(next.entries[1].time.completed).toBeUndefined()
})
test("handles sequential tools independently", () => {
FastCheck.assert(
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
input: a,
provider: { executed: true },
timestamp: time(2),
}),
SessionEvent.Tool.Success.create({
callID: "a",
title,
output: "done",
provider: { executed: true },
timestamp: time(3),
}),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "bash",
input: b,
provider: { executed: true },
timestamp: time(5),
}),
SessionEvent.Tool.Error.create({
callID: "b",
error,
provider: { executed: true },
timestamp: time(6),
}),
],
active(),
)
const first = tool(next, "a")
const second = tool(next, "b")
expect(first?.state.status).toBe("completed")
if (first?.state.status !== "completed") return
expect(first.state.input).toEqual(a)
expect(first.state.output).toBe("done")
expect(first.state.title).toBe(title)
expect(second?.state.status).toBe("error")
if (second?.state.status !== "error") return
expect(second.state.input).toEqual(b)
expect(second.state.error).toBe(error)
}),
{ numRuns: 50 },
)
})
test.failing("records synthetic events", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("synthetic")
if (next.entries[0]?.type !== "synthetic") return
expect(next.entries[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test.failing("records compaction events", () => {
FastCheck.assert(
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
const next = SessionEntry.step(
history(),
SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
)
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("compaction")
if (next.entries[0]?.type !== "compaction") return
expect(next.entries[0].auto).toBe(auto)
expect(next.entries[0].overflow).toBe(overflow)
}),
{ numRuns: 50 },
)
})
})
})
@@ -181,8 +181,8 @@ describe("Truncate", () => {
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 10 * DAY_MS))
const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 3 * DAY_MS))
const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content")
+4 -4
View File
@@ -22,8 +22,8 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.1.99",
"@opentui/solid": ">=0.1.99"
"@opentui/core": ">=0.1.97",
"@opentui/solid": ">=0.1.97"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -34,8 +34,8 @@
}
},
"devDependencies": {
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
+13 -19
View File
@@ -264,11 +264,6 @@ export type QuestionInfo = {
custom?: boolean
}
export type QuestionTool = {
messageID: string
callID: string
}
export type QuestionRequest = {
id: string
sessionID: string
@@ -276,7 +271,10 @@ export type QuestionRequest = {
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: QuestionTool
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
@@ -286,25 +284,21 @@ export type EventQuestionAsked = {
export type QuestionAnswer = Array<string>
export type QuestionReplied = {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
export type EventQuestionReplied = {
type: "question.replied"
properties: QuestionReplied
}
export type QuestionRejected = {
sessionID: string
requestID: string
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: QuestionRejected
properties: {
sessionID: string
requestID: string
}
}
export type Todo = {
+45 -47
View File
@@ -7859,26 +7859,16 @@
},
"required": ["question", "header", "options"]
},
"QuestionTool": {
"type": "object",
"properties": {
"messageID": {
"type": "string"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
},
"QuestionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string"
"type": "string",
"pattern": "^que.*"
},
"sessionID": {
"type": "string"
"type": "string",
"pattern": "^ses.*"
},
"questions": {
"description": "Questions to ask",
@@ -7888,7 +7878,17 @@
}
},
"tool": {
"$ref": "#/components/schemas/QuestionTool"
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
}
},
"required": ["id", "sessionID", "questions"]
@@ -7912,24 +7912,6 @@
"type": "string"
}
},
"QuestionReplied": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
},
"requestID": {
"type": "string"
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
"required": ["sessionID", "requestID", "answers"]
},
"Event.question.replied": {
"type": "object",
"properties": {
@@ -7938,23 +7920,28 @@
"const": "question.replied"
},
"properties": {
"$ref": "#/components/schemas/QuestionReplied"
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
"required": ["sessionID", "requestID", "answers"]
}
},
"required": ["type", "properties"]
},
"QuestionRejected": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
},
"requestID": {
"type": "string"
}
},
"required": ["sessionID", "requestID"]
},
"Event.question.rejected": {
"type": "object",
"properties": {
@@ -7963,7 +7950,18 @@
"const": "question.rejected"
},
"properties": {
"$ref": "#/components/schemas/QuestionRejected"
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
}
},
"required": ["sessionID", "requestID"]
}
},
"required": ["type", "properties"]
-28
View File
@@ -1,28 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.4.3",
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./openapi": "./src/openapi.ts",
"./definition": "./src/definition/index.ts",
"./definition/api": "./src/definition/api.ts",
"./definition/question": "./src/definition/question.ts",
"./api": "./src/api/index.ts"
},
"files": [
"dist"
],
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"devDependencies": {
"typescript": "catalog:"
},
"dependencies": {
"effect": "catalog:"
}
}
-1
View File
@@ -1 +0,0 @@
export {}
-12
View File
@@ -1,12 +0,0 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { questionApi } from "./question.js"
export const api = HttpApi.make("opencode")
.addHttpApi(questionApi)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
-2
View File
@@ -1,2 +0,0 @@
export { api } from "./api.js"
export { questionApi, QuestionReply, QuestionRequest } from "./question.js"
@@ -1,94 +0,0 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/httpapi/question"
// Temporary transport-local schemas until canonical question schemas move into packages/core.
export const QuestionID = Schema.String.annotate({ identifier: "QuestionID" })
export const SessionID = Schema.String.annotate({ identifier: "SessionID" })
export const MessageID = Schema.String.annotate({ identifier: "MessageID" })
export class QuestionOption extends Schema.Class<QuestionOption>("QuestionOption")({
label: Schema.String.annotate({
description: "Display text (1-5 words, concise)",
}),
description: Schema.String.annotate({
description: "Explanation of choice",
}),
}) {}
const base = {
question: Schema.String.annotate({
description: "Complete question",
}),
header: Schema.String.annotate({
description: "Very short label (max 30 chars)",
}),
options: Schema.Array(QuestionOption).annotate({
description: "Available choices",
}),
multiple: Schema.optional(Schema.Boolean).annotate({
description: "Allow selecting multiple choices",
}),
}
export class QuestionInfo extends Schema.Class<QuestionInfo>("QuestionInfo")({
...base,
custom: Schema.optional(Schema.Boolean).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}) {}
export class QuestionTool extends Schema.Class<QuestionTool>("QuestionTool")({
messageID: MessageID,
callID: Schema.String,
}) {}
export class QuestionRequest extends Schema.Class<QuestionRequest>("QuestionRequest")({
id: QuestionID,
sessionID: SessionID,
questions: Schema.Array(QuestionInfo).annotate({
description: "Questions to ask",
}),
tool: Schema.optional(QuestionTool),
}) {}
export const QuestionAnswer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
export class QuestionReply extends Schema.Class<QuestionReply>("QuestionReply")({
answers: Schema.Array(QuestionAnswer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}) {}
export const questionApi = HttpApi.make("question").add(
HttpApiGroup.make("question")
.add(
HttpApiEndpoint.get("list", root, {
success: Schema.Array(QuestionRequest),
}).annotateMerge(
OpenApi.annotations({
identifier: "question.list",
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
payload: QuestionReply,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reply",
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "question",
description: "Experimental HttpApi question routes.",
}),
),
)
-4
View File
@@ -1,4 +0,0 @@
export { openapi } from "./openapi.js"
export { api } from "./definition/api.js"
export { questionApi, QuestionReply, QuestionRequest } from "./definition/question.js"
export type { OpenApiSpec, ServerApi } from "./types.js"
-5
View File
@@ -1,5 +0,0 @@
import { OpenApi } from "effect/unstable/httpapi"
import { api } from "./definition/api.js"
import type { OpenApiSpec } from "./types.js"
export const openapi = (): OpenApiSpec => OpenApi.fromApi(api)
-5
View File
@@ -1,5 +0,0 @@
import type { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export type ServerApi = HttpApi.HttpApi<string, HttpApiGroup.Any>
export type OpenApiSpec = OpenApi.OpenAPISpec
-15
View File
@@ -1,15 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"rootDir": "src",
"outDir": "dist",
"module": "nodenext",
"declaration": true,
"moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}