Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c059ae0d5 | ||
|
|
a53fae1511 | ||
|
|
4626458175 | ||
|
|
9a5178e4ac | ||
|
|
68384613be | ||
|
|
4f967d5bc0 | ||
|
|
ff60859e36 | ||
|
|
020c47a055 | ||
|
|
64171db173 | ||
|
|
ad265797ab | ||
|
|
b1312a3181 | ||
|
|
a8f9f6b705 | ||
|
|
d312c677c5 |
@@ -116,8 +116,8 @@
|
||||
"light": "nord5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "nord2",
|
||||
"light": "nord4"
|
||||
"dark": "#abafb7",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#3B4252",
|
||||
|
||||
@@ -342,7 +342,6 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.5.2",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
@@ -1153,24 +1152,6 @@
|
||||
|
||||
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
|
||||
|
||||
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6hkXiB5R5n0eDxibVTDFFXKca1PVpVysWOwgFLyXYr6b0BLI5UcuDSz4vZ1fj4eoH+9rgqIqd65YMZ7e7s3J5g=="],
|
||||
|
||||
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-I7q1T9Iw/qnCzGT5dY7nxEdqS18vhTbXwWR2LKNPgxUpoGGv7sMe57QhEGAXT8eKvPh8hgZneU8VLt49wmFYRw=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-iputHhH4bpOegz+j14JcypuelNPdUwwx3oJavln02jNY1oouPNhC2tNMJKSXgkUgAbuGRn26iXSlUPNwIl0ftQ=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5ySavoc0Q5Cr1qKIBj2s8roQEFhCgJM8ndCM6AcPv5tuFirBHJe9iT3OG0x0/u9Mm70MosIYbd3dnbC3QwIWzw=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-lRMcoeNlGsqN1jVup95TfWfz74Funn8nuzVVxZUzmZbQOWEDkhfULUB8jpaz9Q7sDq5hqhMa5+zJf29sKuw0hw=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QvNTGvZNKj8h/ZuCY/g+/WMQagK6E8U0Zv3vCbZgUsegvMtGpVNW42w8jiUC21136DnmfF8rG0mz8SsR/99qHw=="],
|
||||
|
||||
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mT0A0FsZRgVPDsg4czksGYUuMqXodlrg97ss6jEs60upvHWGTWpgkoiRfm80u2ixVzx0z8yqhbquol1C/mvKGA=="],
|
||||
|
||||
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aca29wsv0XcTTwKd0GOpDBZPgMrvs0osqGSqp2cYrznH7/wQZySHYDlXni7b4SY6oqxmuQQYXXwQzAn8CoqLsQ=="],
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.5.2", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.5.2", "@ff-labs/fff-bin-darwin-x64": "0.5.2", "@ff-labs/fff-bin-linux-arm64-gnu": "0.5.2", "@ff-labs/fff-bin-linux-arm64-musl": "0.5.2", "@ff-labs/fff-bin-linux-x64-gnu": "0.5.2", "@ff-labs/fff-bin-linux-x64-musl": "0.5.2", "@ff-labs/fff-bin-win32-arm64": "0.5.2", "@ff-labs/fff-bin-win32-x64": "0.5.2" }, "peerDependencies": { "bun": ">=1.0.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "fff-demo": "examples/search.ts", "fff-grep": "examples/grep.ts" } }, "sha512-puwgVLi7RfjzqB4biVB5ZCvtLbTf34yEorJ3PhBpwaM0pB8hFM+9VRXdNhGF+I3F0LU6twuNsS43AomgyrdDvQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
@@ -1591,30 +1572,6 @@
|
||||
|
||||
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
|
||||
|
||||
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b6CQgT28Jx7uDwMTcGo7WFqUd1+wWTdp8XyPi/4LRcL/R4deKT7cLx/Q2ZCWAiK6ZU7yexoCaIaKun6azjRLVA=="],
|
||||
|
||||
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-//6W21c+GinAMMmxD2hFrFmJH+ZlEwJYbLzAGqp0mLFTli9y74RMtDgI2n9pCupXSpU1Kr1sSylVW9yNbAG9Xg=="],
|
||||
|
||||
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9jKJNOc9ID3BxPBPR4r1Mp1Wqde89Twi5zo2LoEMLMKbqpvEM/WUGdJ0Vv7OX1QPEqVblFO6NMky5yY7rjDI2w=="],
|
||||
|
||||
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-eTru6tk3K4Ya3SSkUqq/LbdEjwPqLlfINmIhRORrCExBdB1tQbk+WYYflaymO61fkrjnMAjmLTGqk/K37RMIGA=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HWIwFzm5fALd9Lli0CgaKb6xOGqODYyHpUTgkn/IHHuS/f3XDCu71+GgkyvfgCYbPoBSgBOfp5TzhRehPcgxow=="],
|
||||
|
||||
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-H75bcEn46lMDxd+P+R6Q/jlIKl/YO0ZXaalSyWhQHr7qNmFhQt3rOHurFoCxuwQeqFoToh0JpWVyMVzByZqgBQ=="],
|
||||
|
||||
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-0y+lUiQsPvSGsyM/10KtxhVAQ20p6/D+vj01l6vo9gHpYUpyc1L9pSgaPa7SC9TuaiGASlM3Cb62bmSKW0E/3Q=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Zb7T3JxWlArSe44ATO5mtjLCBCt7kenWPl9CYD+zeqq9kHswMv8Cd3h/9uzdv2PA4Flrq57J5XBSuRdStTCXCw=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jdsnuFD3H0l4AHtf1nInRHYWIMTWqok0aW8WysjzN5Isn6rBTBGK/ZWX6XjdTgDgcuVbVOYHiLUHHrvT9N6psA=="],
|
||||
|
||||
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-Oq0FIcCgL3JWf/4qRuxI5fxsOGyWJ1j904PDx/1TxxSCWWAu0Hh2o8ck4TcaPVv/3BMc1k6UxqQQKBrdP7a+qQ=="],
|
||||
|
||||
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-veSntY7pDLDh4XmxZMwTqxfoEVp0BDdeqCBoWL46/TigtniPtDFSTIWBxa6l/RcGzklUA/uqLqmsK/9cBZAm8Q=="],
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-rV21md7QWnu3r/shev7IFMh6hX8BJHwofxESAofUT4yH866oCIbcNbzp6+fxrj4oGD8uisP6WoaTCboijv9yYg=="],
|
||||
|
||||
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lzeIEMu/v6Y+La5JSesq4hvyKtKBq84cgQpKYTYM/yGuNk2tfd5Ha31hnC+mTh48lp/5vZH+WBfjVUjjINCfug=="],
|
||||
|
||||
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i0LkJAUXb4BeBFrJQbMKQPoxf8+cFEffDyLSb7NEzzKuPcH8qrVsnEItoOzeAdYam8Sr6qCHVwmBNEQzl7PWpw=="],
|
||||
@@ -2627,8 +2584,6 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
|
||||
|
||||
"bun": ["bun@1.3.12", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.12", "@oven/bun-darwin-x64": "1.3.12", "@oven/bun-darwin-x64-baseline": "1.3.12", "@oven/bun-linux-aarch64": "1.3.12", "@oven/bun-linux-aarch64-musl": "1.3.12", "@oven/bun-linux-x64": "1.3.12", "@oven/bun-linux-x64-baseline": "1.3.12", "@oven/bun-linux-x64-musl": "1.3.12", "@oven/bun-linux-x64-musl-baseline": "1.3.12", "@oven/bun-windows-aarch64": "1.3.12", "@oven/bun-windows-x64": "1.3.12", "@oven/bun-windows-x64-baseline": "1.3.12" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-KLwDUqs5WIny/94F4xZ4QfaAE6YWyjR+s79pt/ItQhk2CG+PJQ5xL6VuOWhiyN2eP3fryZK95vog9CTLCaYV2Q=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -28,11 +28,6 @@
|
||||
"node": "./src/storage/db.node.ts",
|
||||
"default": "./src/storage/db.bun.ts"
|
||||
},
|
||||
"#fff": {
|
||||
"bun": "./src/file/fff.bun.ts",
|
||||
"node": "./src/file/fff.node.ts",
|
||||
"default": "./src/file/fff.bun.ts"
|
||||
},
|
||||
"#pty": {
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
@@ -104,7 +99,6 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.5.2",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
|
||||
@@ -33,31 +33,38 @@ const seed = async () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
await Session.updatePart(part)
|
||||
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const result = yield* session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: result.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: result.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
yield* session.updateMessage(message)
|
||||
yield* session.updatePart(part)
|
||||
}),
|
||||
)
|
||||
await AppRuntime.runPromise(
|
||||
Project.Service.use((svc) => svc.update({ projectID: Instance.project.id, name: "E2E Project" })),
|
||||
)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -453,19 +453,12 @@ export namespace ACP {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ACP clients already know the prompt they just submitted, so replaying
|
||||
// live user parts duplicates the message. We still replay user history in
|
||||
// loadSession() and forkSession() via processMessage().
|
||||
if (part.type !== "text" && part.type !== "file") return
|
||||
const msg = await this.sdk.session
|
||||
.message(
|
||||
{ sessionID: part.sessionID, messageID: part.messageID, directory: session.cwd },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
log.error("failed to fetch message for user chunk", { error: err })
|
||||
return undefined
|
||||
})
|
||||
if (!msg || msg.info.role !== "user") return
|
||||
await this.processMessage({ info: msg.info, parts: [part] })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -123,45 +123,49 @@ function parseToolParams(input?: string) {
|
||||
}
|
||||
|
||||
async function createToolContext(agent: Agent.Info) {
|
||||
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model =
|
||||
agent.model ??
|
||||
(await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
}),
|
||||
))
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
const { session, messageID } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model
|
||||
? agent.model
|
||||
: yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: result.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* session.updateMessage(message)
|
||||
return { session: result, messageID }
|
||||
}),
|
||||
)
|
||||
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Search } from "@/file/search"
|
||||
import { File } from "../../../file"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
|
||||
const FileSearchCommand = cmd({
|
||||
command: "search <query>",
|
||||
@@ -95,7 +95,7 @@ const FileTreeCommand = cmd({
|
||||
default: process.cwd(),
|
||||
}),
|
||||
async handler(args) {
|
||||
const files = await Search.tree({ cwd: args.dir, limit: 200 })
|
||||
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
|
||||
console.log(JSON.stringify(files, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Search } from "../../../file/search"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -21,7 +21,7 @@ const TreeCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
process.stdout.write((await Search.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
|
||||
process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -46,7 +46,7 @@ const FilesCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files: string[] = []
|
||||
for await (const file of await Search.files({
|
||||
for await (const file of await Ripgrep.files({
|
||||
cwd: Instance.directory,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})) {
|
||||
@@ -79,7 +79,7 @@ const SearchCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(
|
||||
Search.Service.use((svc) =>
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: Instance.directory,
|
||||
pattern: args.pattern,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { bootstrap } from "../bootstrap"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const ExportCommand = cmd({
|
||||
command: "export [sessionID]",
|
||||
@@ -67,8 +69,16 @@ export const ExportCommand = cmd({
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionInfo = await Session.get(sessionID!)
|
||||
const messages = await Session.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,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
import { Effect } from "effect"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -551,20 +552,24 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
session = await Session.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
})
|
||||
session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
subscribeSessionEvents()
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
if (!share && repoData.data.private) return
|
||||
await SessionShare.share(session.id)
|
||||
await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
|
||||
return session.id.slice(-8)
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
@@ -937,96 +942,86 @@ export const GithubRunCommand = cmd({
|
||||
async function chat(message: string, files: PromptFiles = []) {
|
||||
console.log("Sending message to opencode...")
|
||||
|
||||
const result = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
variant,
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
// agent is omitted - server will use default_agent from config or fall back to "build"
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
...files.flatMap((f) => [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "file" as const,
|
||||
mime: f.mime,
|
||||
url: `data:${f.mime};base64,${f.content}`,
|
||||
filename: f.filename,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: f.replacement,
|
||||
start: f.start,
|
||||
end: f.end,
|
||||
},
|
||||
path: f.filename,
|
||||
},
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
variant,
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
// agent is omitted - server will use default_agent from config or fall back to "build"
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
...files.flatMap((f) => [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "file" as const,
|
||||
mime: f.mime,
|
||||
url: `data:${f.mime};base64,${f.content}`,
|
||||
filename: f.filename,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: f.replacement,
|
||||
start: f.start,
|
||||
end: f.end,
|
||||
},
|
||||
path: f.filename,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
// result should always be assistant just satisfying type checker
|
||||
if (result.info.role === "assistant" && result.info.error) {
|
||||
const err = result.info.error
|
||||
console.error("Agent error:", err)
|
||||
if (result.info.role === "assistant" && result.info.error) {
|
||||
const err = result.info.error
|
||||
console.error("Agent error:", err)
|
||||
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
|
||||
throw new Error(`${err.name}: ${err.data?.message || ""}`)
|
||||
}
|
||||
|
||||
if (err.name === "ContextOverflowError") {
|
||||
throw new Error(formatPromptTooLargeError(files))
|
||||
}
|
||||
const text = extractResponseText(result.parts)
|
||||
if (text) return text
|
||||
|
||||
const errorMsg = err.data?.message || ""
|
||||
throw new Error(`${err.name}: ${errorMsg}`)
|
||||
}
|
||||
console.log("Requesting summary from agent...")
|
||||
const summary = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
variant,
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
tools: { "*": false },
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const text = extractResponseText(result.parts)
|
||||
if (text) return text
|
||||
if (summary.info.role === "assistant" && summary.info.error) {
|
||||
const err = summary.info.error
|
||||
console.error("Summary agent error:", err)
|
||||
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
|
||||
throw new Error(`${err.name}: ${err.data?.message || ""}`)
|
||||
}
|
||||
|
||||
// No text part (tool-only or reasoning-only) - ask agent to summarize
|
||||
console.log("Requesting summary from agent...")
|
||||
const summary = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
variant,
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
tools: { "*": false }, // Disable all tools to force text response
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (summary.info.role === "assistant" && summary.info.error) {
|
||||
const err = summary.info.error
|
||||
console.error("Summary agent error:", err)
|
||||
|
||||
if (err.name === "ContextOverflowError") {
|
||||
throw new Error(formatPromptTooLargeError(files))
|
||||
}
|
||||
|
||||
const errorMsg = err.data?.message || ""
|
||||
throw new Error(`${err.name}: ${errorMsg}`)
|
||||
}
|
||||
|
||||
const summaryText = extractResponseText(summary.parts)
|
||||
if (!summaryText) {
|
||||
throw new Error("Failed to get summary from agent")
|
||||
}
|
||||
|
||||
return summaryText
|
||||
const summaryText = extractResponseText(summary.parts)
|
||||
if (!summaryText) throw new Error("Failed to get summary from agent")
|
||||
return summaryText
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function getOidcToken() {
|
||||
|
||||
@@ -158,13 +158,13 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
|
||||
if (method.type === "api") {
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
|
||||
if (method.authorize) {
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Process } from "../../util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -60,12 +61,12 @@ export const SessionDeleteCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
try {
|
||||
await Session.get(sessionID)
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
} catch {
|
||||
UI.error(`Session not found: ${args.sessionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
await Session.remove(sessionID)
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Database } from "../../storage/db"
|
||||
import { SessionTable } from "../../session/session.sql"
|
||||
import { Project } from "../../project/project"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
interface SessionStats {
|
||||
totalSessions: number
|
||||
@@ -167,7 +168,9 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
|
||||
|
||||
const batchPromises = batch.map(async (session) => {
|
||||
const messages = await Session.messages({ sessionID: session.id })
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
|
||||
)
|
||||
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
@@ -542,8 +542,10 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
|
||||
const diffAlpha = isDark ? 0.22 : 0.14
|
||||
const diffAddedBg = tint(bg, ansiColors.green, diffAlpha)
|
||||
const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha)
|
||||
const diffAddedLineNumberBg = tint(grays[3], ansiColors.green, diffAlpha)
|
||||
const diffRemovedLineNumberBg = tint(grays[3], ansiColors.red, diffAlpha)
|
||||
const diffContextBg = grays[2]
|
||||
const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
|
||||
const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
|
||||
const diffLineNumber = textMuted
|
||||
|
||||
return {
|
||||
theme: {
|
||||
@@ -583,8 +585,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
|
||||
diffHighlightRemoved: ansiColors.redBright,
|
||||
diffAddedBg,
|
||||
diffRemovedBg,
|
||||
diffContextBg: grays[1],
|
||||
diffLineNumber: grays[6],
|
||||
diffContextBg,
|
||||
diffLineNumber,
|
||||
diffAddedLineNumberBg,
|
||||
diffRemovedLineNumberBg,
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"diffAddedBg": "#354933",
|
||||
"diffRemovedBg": "#3f191a",
|
||||
"diffContextBg": "darkBgPanel",
|
||||
"diffLineNumber": "darkBorder",
|
||||
"diffLineNumber": "#898989",
|
||||
"diffAddedLineNumberBg": "#162620",
|
||||
"diffRemovedLineNumberBg": "#26161a",
|
||||
"markdownText": "darkFg",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"diffAddedBg": "#20303b",
|
||||
"diffRemovedBg": "#37222c",
|
||||
"diffContextBg": "darkPanel",
|
||||
"diffLineNumber": "darkGutter",
|
||||
"diffLineNumber": "diffContext",
|
||||
"diffAddedLineNumberBg": "#1b2b34",
|
||||
"diffRemovedLineNumberBg": "#2d1f26",
|
||||
"markdownText": "darkFg",
|
||||
|
||||
@@ -141,8 +141,8 @@
|
||||
"light": "lbg1"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "fg3",
|
||||
"light": "lfg3"
|
||||
"dark": "#808792",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "diffGreenBg",
|
||||
|
||||
@@ -125,10 +125,7 @@
|
||||
"dark": "frappeMantle",
|
||||
"light": "frappeMantle"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "frappeSurface1",
|
||||
"light": "frappeSurface1"
|
||||
},
|
||||
"diffLineNumber": "textMuted",
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#223025",
|
||||
"light": "#223025"
|
||||
|
||||
@@ -125,10 +125,7 @@
|
||||
"dark": "macMantle",
|
||||
"light": "macMantle"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "macSurface1",
|
||||
"light": "macSurface1"
|
||||
},
|
||||
"diffLineNumber": "textMuted",
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#223025",
|
||||
"light": "#223025"
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"diffAddedBg": { "dark": "#24312b", "light": "#d6f0d9" },
|
||||
"diffRemovedBg": { "dark": "#3c2a32", "light": "#f6dfe2" },
|
||||
"diffContextBg": { "dark": "darkMantle", "light": "lightMantle" },
|
||||
"diffLineNumber": { "dark": "darkSurface1", "light": "lightSurface1" },
|
||||
"diffLineNumber": { "dark": "textMuted", "light": "#5b5d63" },
|
||||
"diffAddedLineNumberBg": { "dark": "#1e2a25", "light": "#c9e3cb" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#32232a", "light": "#e9d3d6" },
|
||||
"markdownText": { "dark": "darkText", "light": "lightText" },
|
||||
|
||||
@@ -120,10 +120,7 @@
|
||||
"dark": "#122738",
|
||||
"light": "#f5f7fa"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#2d5a7b",
|
||||
"light": "#b0bec5"
|
||||
},
|
||||
"diffLineNumber": "textMuted",
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1a3a2a",
|
||||
"light": "#e8f5e9"
|
||||
|
||||
@@ -142,8 +142,8 @@
|
||||
"light": "lightPanel"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#e4e4e442",
|
||||
"light": "#1414147a"
|
||||
"dark": "#eeeeee87",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#3fa26633",
|
||||
|
||||
@@ -112,8 +112,8 @@
|
||||
"light": "#e8e8e2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "currentLine",
|
||||
"light": "#c8c8c2"
|
||||
"dark": "#989aa4",
|
||||
"light": "#686865"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1a3a1a",
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
"light": "lightStep2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkStep3",
|
||||
"light": "lightStep3"
|
||||
"dark": "#a0a5a7",
|
||||
"light": "#5b5951"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1b2b34",
|
||||
|
||||
@@ -130,8 +130,8 @@
|
||||
"light": "base50"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "base600",
|
||||
"light": "base600"
|
||||
"dark": "#888883",
|
||||
"light": "#5a5955"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#152515",
|
||||
|
||||
@@ -126,8 +126,8 @@
|
||||
"light": "lightBgAlt"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#484f58",
|
||||
"light": "#afb8c1"
|
||||
"dark": "#95999e",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#033a16",
|
||||
|
||||
@@ -135,8 +135,8 @@
|
||||
"light": "lightBg1"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkBg3",
|
||||
"light": "lightBg3"
|
||||
"dark": "#a8a29e",
|
||||
"light": "#564f43"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#2a2827",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"diffAddedBg": { "dark": "#252E25", "light": "#EAF3E4" },
|
||||
"diffRemovedBg": { "dark": "#362020", "light": "#FBE6E6" },
|
||||
"diffContextBg": { "dark": "sumiInk1", "light": "lightPaper" },
|
||||
"diffLineNumber": { "dark": "sumiInk3", "light": "#C7BEB4" },
|
||||
"diffLineNumber": { "dark": "#9090a0", "light": "#65615c" },
|
||||
"diffAddedLineNumberBg": { "dark": "#202820", "light": "#DDE8D6" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#2D1C1C", "light": "#F2DADA" },
|
||||
"markdownText": { "dark": "fujiWhite", "light": "lightText" },
|
||||
|
||||
@@ -129,10 +129,7 @@
|
||||
"dark": "transparent",
|
||||
"light": "transparent"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#666666",
|
||||
"light": "#999999"
|
||||
},
|
||||
"diffLineNumber": "textMuted",
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "transparent",
|
||||
"light": "transparent"
|
||||
|
||||
@@ -128,8 +128,8 @@
|
||||
"light": "lightBgAlt"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#37474f",
|
||||
"light": "#cfd8dc"
|
||||
"dark": "#9aa2a6",
|
||||
"light": "#6a6e70"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#2e3c2b",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"diffAddedBg": { "dark": "#132616", "light": "#e0efde" },
|
||||
"diffRemovedBg": { "dark": "#261212", "light": "#f9e5e5" },
|
||||
"diffContextBg": { "dark": "matrixInk1", "light": "lightPaper" },
|
||||
"diffLineNumber": { "dark": "matrixInk3", "light": "lightGray" },
|
||||
"diffLineNumber": { "dark": "textMuted", "light": "#556156" },
|
||||
"diffAddedLineNumberBg": { "dark": "#0f1b11", "light": "#d6e7d2" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#1b1414", "light": "#f2d2d2" },
|
||||
"markdownText": { "dark": "rainGreenHi", "light": "lightText" },
|
||||
|
||||
@@ -114,8 +114,8 @@
|
||||
"light": "#f0f0f0"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#3e3d32",
|
||||
"light": "#d0d0d0"
|
||||
"dark": "#9b9b95",
|
||||
"light": "#686868"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1a3a1a",
|
||||
|
||||
@@ -114,8 +114,8 @@
|
||||
"light": "nightOwlPanel"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "nightOwlMuted",
|
||||
"light": "nightOwlMuted"
|
||||
"dark": "#7791a6",
|
||||
"light": "#7791a6"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#0a2e1a",
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
"light": "nord5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "nord2",
|
||||
"light": "nord4"
|
||||
"dark": "#a9aeb6",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#3B4252",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"diffAddedBg": { "dark": "#2c382b", "light": "#eafbe9" },
|
||||
"diffRemovedBg": { "dark": "#3a2d2f", "light": "#fce9e8" },
|
||||
"diffContextBg": { "dark": "darkBgAlt", "light": "lightBgAlt" },
|
||||
"diffLineNumber": { "dark": "#495162", "light": "#c9c9ca" },
|
||||
"diffLineNumber": { "dark": "#9398a2", "light": "#666666" },
|
||||
"diffAddedLineNumberBg": { "dark": "#283427", "light": "#e1f3df" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#36292b", "light": "#f5e2e1" },
|
||||
"markdownText": { "dark": "darkFg", "light": "lightFg" },
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
"light": "lightStep2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkStep3",
|
||||
"light": "lightStep3"
|
||||
"dark": "#8f8f8f",
|
||||
"light": "#595959"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1b2b34",
|
||||
|
||||
@@ -142,8 +142,8 @@
|
||||
"light": "lightStep2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkStep3",
|
||||
"light": "lightStep3"
|
||||
"dark": "diffContext",
|
||||
"light": "#595755"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#162535",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"diffAddedBg": { "dark": "#15241c", "light": "#e0eee5" },
|
||||
"diffRemovedBg": { "dark": "#241515", "light": "#eee0e0" },
|
||||
"diffContextBg": { "dark": "darkBg1", "light": "lightBg1" },
|
||||
"diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" },
|
||||
"diffLineNumber": { "dark": "#828b87", "light": "#5f5e4f" },
|
||||
"diffAddedLineNumberBg": { "dark": "#121f18", "light": "#d5e5da" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#1f1212", "light": "#e5d5d5" },
|
||||
"markdownText": { "dark": "darkFg0", "light": "lightFg0" },
|
||||
|
||||
@@ -115,8 +115,8 @@
|
||||
"light": "#f5f5f5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#444760",
|
||||
"light": "#cfd8dc"
|
||||
"dark": "#a0a2af",
|
||||
"light": "#6a6e70"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#2e3c2b",
|
||||
|
||||
@@ -127,8 +127,8 @@
|
||||
"light": "dawnSurface"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "muted",
|
||||
"light": "dawnMuted"
|
||||
"dark": "#9491a6",
|
||||
"light": "#6c6875"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1f2d3a",
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
"light": "base2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "base01",
|
||||
"light": "base1"
|
||||
"dark": "#8b9b9f",
|
||||
"light": "#5f6969"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#073642",
|
||||
|
||||
@@ -119,8 +119,8 @@
|
||||
"light": "#f5f5f5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#495495",
|
||||
"light": "#b0b0b0"
|
||||
"dark": "#959bc1",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1a3a2a",
|
||||
|
||||
@@ -136,8 +136,8 @@
|
||||
"light": "lightStep2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkStep3",
|
||||
"light": "lightStep3"
|
||||
"dark": "#8f909a",
|
||||
"light": "#59595b"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1b2b34",
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
"light": "lightBackground"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "gray600",
|
||||
"light": "lightGray600"
|
||||
"dark": "#8a8a8a",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#0F2613",
|
||||
|
||||
@@ -111,8 +111,8 @@
|
||||
"light": "#F8F8F8"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#505050",
|
||||
"light": "#808080"
|
||||
"dark": "textMuted",
|
||||
"light": "#6a6a6a"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#0d2818",
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
"light": "#f5f5e5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#6f6f6f",
|
||||
"light": "#b0b0a0"
|
||||
"dark": "#d2d2d2",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#4f5f4f",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Auth } from "@/auth"
|
||||
import { Account } from "@/account"
|
||||
import { Config } from "@/config/config"
|
||||
import { Git } from "@/git"
|
||||
import { Search } from "@/file/search"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
@@ -49,14 +49,14 @@ import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
// Observability.layer,
|
||||
Observability.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Search.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
).pipe(Layer.provide(Observability.layer))
|
||||
)
|
||||
|
||||
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import {
|
||||
FileFinder,
|
||||
type FileItem,
|
||||
type GrepCursor,
|
||||
type GrepMatch,
|
||||
type GrepResult,
|
||||
type SearchResult,
|
||||
} from "@ff-labs/fff-bun"
|
||||
|
||||
export namespace Fff {
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export interface Init {
|
||||
basePath: string
|
||||
frecencyDbPath?: string
|
||||
historyDbPath?: string
|
||||
aiMode?: boolean
|
||||
}
|
||||
|
||||
export interface Search {
|
||||
items: FileItem[]
|
||||
scores: SearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
export type File = FileItem
|
||||
export type Cursor = GrepCursor | null
|
||||
export type Hit = GrepMatch
|
||||
|
||||
export interface Grep {
|
||||
items: GrepResult["items"]
|
||||
totalMatched: number
|
||||
totalFilesSearched: number
|
||||
totalFiles: number
|
||||
filteredFileCount: number
|
||||
nextCursor: Cursor
|
||||
regexFallbackError?: string
|
||||
}
|
||||
|
||||
export interface Picker {
|
||||
destroy(): void
|
||||
waitForScan(timeout?: number): Result<boolean>
|
||||
refreshGitStatus(): Result<number>
|
||||
fileSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
grep(
|
||||
query: string,
|
||||
opts?: {
|
||||
mode?: "plain" | "regex" | "fuzzy"
|
||||
maxMatchesPerFile?: number
|
||||
timeBudgetMs?: number
|
||||
beforeContext?: number
|
||||
afterContext?: number
|
||||
cursor?: Cursor
|
||||
},
|
||||
): Result<Grep>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return FileFinder.isAvailable()
|
||||
}
|
||||
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
const made = FileFinder.create(opts)
|
||||
if (!made.ok) return made
|
||||
const pick = made.value
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
destroy: () => pick.destroy(),
|
||||
waitForScan: (timeout) => pick.waitForScan(timeout),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
grep: (query, next) => pick.grep(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
export namespace Fff {
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export interface Init {
|
||||
basePath: string
|
||||
frecencyDbPath?: string
|
||||
historyDbPath?: string
|
||||
aiMode?: boolean
|
||||
}
|
||||
|
||||
export interface File {
|
||||
path: string
|
||||
relativePath: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export interface Search {
|
||||
items: File[]
|
||||
scores: unknown[]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
export type Cursor = null
|
||||
|
||||
export interface Hit {
|
||||
path: string
|
||||
relativePath: string
|
||||
fileName: string
|
||||
lineNumber: number
|
||||
byteOffset: number
|
||||
lineContent: string
|
||||
matchRanges: [number, number][]
|
||||
contextBefore?: string[]
|
||||
contextAfter?: string[]
|
||||
}
|
||||
|
||||
export interface Grep {
|
||||
items: Hit[]
|
||||
totalMatched: number
|
||||
totalFilesSearched: number
|
||||
totalFiles: number
|
||||
filteredFileCount: number
|
||||
nextCursor: Cursor
|
||||
regexFallbackError?: string
|
||||
}
|
||||
|
||||
export interface Picker {
|
||||
destroy(): void
|
||||
waitForScan(timeout?: number): Result<boolean>
|
||||
refreshGitStatus(): Result<number>
|
||||
fileSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
grep(
|
||||
query: string,
|
||||
opts?: {
|
||||
mode?: "plain" | "regex" | "fuzzy"
|
||||
maxMatchesPerFile?: number
|
||||
timeBudgetMs?: number
|
||||
beforeContext?: number
|
||||
afterContext?: number
|
||||
cursor?: Cursor
|
||||
},
|
||||
): Result<Grep>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return false
|
||||
}
|
||||
|
||||
export function create(): Result<Picker> {
|
||||
return { ok: false, error: "fff unavailable" }
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Search } from "./search"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
|
||||
export namespace File {
|
||||
export const Info = z
|
||||
@@ -344,7 +344,7 @@ export namespace File {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
@@ -384,7 +384,7 @@ export namespace File {
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const files = yield* searchSvc.files({ cwd: Instance.directory }).pipe(
|
||||
const files = yield* rg.files({ cwd: Instance.directory }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
@@ -512,8 +512,6 @@ export namespace File {
|
||||
|
||||
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
|
||||
|
||||
yield* searchSvc.open({ cwd: Instance.directory, file }).pipe(Effect.ignore)
|
||||
|
||||
if (isImageByExtension(file)) {
|
||||
const exists = yield* appFs.existsSafe(full)
|
||||
if (exists) {
|
||||
@@ -619,26 +617,14 @@ export namespace File {
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) {
|
||||
yield* ensure()
|
||||
const { cache } = yield* InstanceState.get(state)
|
||||
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
if (query && kind === "file") {
|
||||
const files = yield* searchSvc.file({
|
||||
cwd: Instance.directory,
|
||||
query,
|
||||
limit,
|
||||
})
|
||||
if (files.length) {
|
||||
log.info("search", { query, kind, results: files.length, mode: "fff" })
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
yield* ensure()
|
||||
const { cache } = yield* InstanceState.get(state)
|
||||
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
|
||||
if (!query) {
|
||||
@@ -663,7 +649,7 @@ export namespace File {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Search.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Context, Deferred, Effect, Layer, Option } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Fff } from "#fff"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Global } from "@/global"
|
||||
import { Glob } from "@/util/glob"
|
||||
import { Log } from "@/util/log"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
|
||||
export namespace Search {
|
||||
const log = Log.create({ service: "file.search" })
|
||||
const root = path.join(Global.Path.cache, "fff")
|
||||
|
||||
export const Match = z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
lines: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
line_number: z.number(),
|
||||
absolute_offset: z.number(),
|
||||
submatches: z.array(
|
||||
z.object({
|
||||
match: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export type Item = z.infer<typeof Match>
|
||||
|
||||
export interface Result {
|
||||
readonly items: Item[]
|
||||
readonly partial: boolean
|
||||
readonly engine: "fff" | "ripgrep"
|
||||
readonly regexFallbackError?: string
|
||||
}
|
||||
|
||||
export interface FileInput {
|
||||
readonly cwd: string
|
||||
readonly query: string
|
||||
readonly limit?: number
|
||||
readonly current?: string
|
||||
}
|
||||
|
||||
export interface GlobInput {
|
||||
readonly cwd: string
|
||||
readonly pattern: string
|
||||
readonly limit?: number
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface Query {
|
||||
readonly dir: string
|
||||
readonly text: string
|
||||
readonly files: string[]
|
||||
}
|
||||
|
||||
interface State {
|
||||
readonly pick: Map<string, Fff.Picker>
|
||||
readonly wait: Map<string, Deferred.Deferred<Fff.Picker, Error>>
|
||||
readonly recent: Query[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: Ripgrep.FilesInput) => Stream.Stream<string, Error>
|
||||
readonly tree: (input: Ripgrep.TreeInput) => Effect.Effect<string, Error>
|
||||
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, Error>
|
||||
readonly file: (input: FileInput) => Effect.Effect<string[]>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, Error>
|
||||
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
|
||||
|
||||
function key(dir: string) {
|
||||
return Buffer.from(dir).toString("base64url")
|
||||
}
|
||||
|
||||
function norm(text: string) {
|
||||
return text.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function blocked(rel: string) {
|
||||
return norm(rel).split("/").includes(".git")
|
||||
}
|
||||
|
||||
function base(file: string) {
|
||||
return norm(file).split("/").at(-1) ?? file
|
||||
}
|
||||
|
||||
function allow(glob: string[] | undefined, rel: string, file: string) {
|
||||
if (!glob?.length) return true
|
||||
const yes = glob.filter((item) => !item.startsWith("!"))
|
||||
const no = glob.filter((item) => item.startsWith("!")).map((item) => item.slice(1))
|
||||
if (yes.length > 0 && !yes.some((item) => Glob.match(item, rel) || Glob.match(item, file))) return false
|
||||
if (no.some((item) => Glob.match(item, rel) || Glob.match(item, file))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function include(pattern: string) {
|
||||
const val = pattern.trim().replaceAll("\\", "/")
|
||||
if (!val) return "*"
|
||||
const flat = val.replaceAll("**/", "").replaceAll("/**", "/")
|
||||
const idx = flat.lastIndexOf("/")
|
||||
if (idx < 0) return flat
|
||||
const dir = flat.slice(0, idx + 1)
|
||||
const glob = flat.slice(idx + 1)
|
||||
if (!glob) return dir
|
||||
return `${dir} ${glob}`
|
||||
}
|
||||
|
||||
function remember(st: State, dir: string, text: string, files: string[]) {
|
||||
if (!files.length) return
|
||||
const next = Array.from(new Set(files.map(AppFileSystem.resolve))).slice(0, 64)
|
||||
if (!next.length) return
|
||||
const old = st.recent.findIndex((item) => item.dir === dir && item.text === text)
|
||||
if (old >= 0) st.recent.splice(old, 1)
|
||||
st.recent.unshift({ dir, text, files: next })
|
||||
if (st.recent.length > 32) st.recent.length = 32
|
||||
}
|
||||
|
||||
function item(hit: Fff.Hit): Item {
|
||||
return {
|
||||
path: { text: norm(hit.relativePath) },
|
||||
lines: { text: hit.lineContent },
|
||||
line_number: hit.lineNumber,
|
||||
absolute_offset: hit.byteOffset,
|
||||
submatches: hit.matchRanges
|
||||
.map(([start, end]) => {
|
||||
const text = hit.lineContent.slice(start, end)
|
||||
if (!text) return undefined
|
||||
return {
|
||||
match: { text },
|
||||
start,
|
||||
end,
|
||||
}
|
||||
})
|
||||
.filter((row): row is Item["submatches"][number] => Boolean(row)),
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Search.state")(() =>
|
||||
Effect.gen(function* () {
|
||||
const next: State = {
|
||||
pick: new Map(),
|
||||
wait: new Map(),
|
||||
recent: [],
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const pick of next.pick.values()) pick.destroy()
|
||||
}),
|
||||
)
|
||||
return next
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
|
||||
const out = yield* rg.search(input)
|
||||
return {
|
||||
items: out.items,
|
||||
partial: out.partial,
|
||||
engine: "ripgrep" as const,
|
||||
}
|
||||
})
|
||||
|
||||
const picker = Effect.fn("Search.picker")(function* (cwd: string) {
|
||||
if (!Fff.available()) return undefined
|
||||
const dir = AppFileSystem.resolve(cwd)
|
||||
const st = yield* InstanceState.get(state)
|
||||
const old = st.pick.get(dir)
|
||||
if (old) return old
|
||||
|
||||
const wait = st.wait.get(dir)
|
||||
if (wait) return yield* Deferred.await(wait)
|
||||
|
||||
const gate = yield* Deferred.make<Fff.Picker, Error>()
|
||||
st.wait.set(dir, gate)
|
||||
try {
|
||||
yield* fs.ensureDir(root)
|
||||
const id = key(dir)
|
||||
const made = yield* Effect.sync(() =>
|
||||
Fff.create({
|
||||
basePath: dir,
|
||||
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
|
||||
historyDbPath: path.join(root, `${id}.history.mdb`),
|
||||
aiMode: true,
|
||||
}),
|
||||
)
|
||||
if (!made.ok) {
|
||||
const err = new Error(made.error)
|
||||
yield* Deferred.fail(gate, err)
|
||||
return yield* Effect.fail(err)
|
||||
}
|
||||
|
||||
const pick = made.value
|
||||
const done = yield* Effect.sync(() => pick.waitForScan(5_000))
|
||||
if (!done.ok) {
|
||||
pick.destroy()
|
||||
const err = new Error(done.error)
|
||||
yield* Deferred.fail(gate, err)
|
||||
return yield* Effect.fail(err)
|
||||
}
|
||||
if (!done.value) {
|
||||
pick.destroy()
|
||||
const err = new Error("fff scan timed out")
|
||||
yield* Deferred.fail(gate, err)
|
||||
return yield* Effect.fail(err)
|
||||
}
|
||||
|
||||
const git = yield* Effect.sync(() => pick.refreshGitStatus())
|
||||
if (!git.ok) {
|
||||
log.warn("git refresh failed", { dir, error: git.error })
|
||||
}
|
||||
|
||||
st.pick.set(dir, pick)
|
||||
yield* Deferred.succeed(gate, pick)
|
||||
return pick
|
||||
} finally {
|
||||
if (st.wait.get(dir) === gate) st.wait.delete(dir)
|
||||
}
|
||||
})
|
||||
|
||||
const files: Interface["files"] = (input) => rg.files(input)
|
||||
const tree: Interface["tree"] = (input) => rg.tree(input)
|
||||
|
||||
const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
|
||||
const query = input.query.trim()
|
||||
if (!query) return []
|
||||
const pick = yield* picker(input.cwd).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!pick) return []
|
||||
|
||||
const dir = AppFileSystem.resolve(input.cwd)
|
||||
const out = yield* Effect.sync(() =>
|
||||
pick.fileSearch(query, {
|
||||
currentFile: input.current
|
||||
? path.isAbsolute(input.current)
|
||||
? input.current
|
||||
: path.join(dir, input.current)
|
||||
: undefined,
|
||||
pageIndex: 0,
|
||||
pageSize: Math.max(input.limit ?? 100, 100),
|
||||
}),
|
||||
)
|
||||
if (!out.ok) {
|
||||
log.warn("fff file search failed", { dir, query, error: out.error })
|
||||
return []
|
||||
}
|
||||
|
||||
const min = query.length * 10
|
||||
const rows = Array.from(
|
||||
new Set(
|
||||
out.value.items.flatMap((item, idx) => {
|
||||
const score = out.value.scores[idx]
|
||||
if (!score || score.total < min) return []
|
||||
return [norm(item.relativePath)]
|
||||
}),
|
||||
),
|
||||
)
|
||||
remember(
|
||||
yield* InstanceState.get(state),
|
||||
dir,
|
||||
query,
|
||||
rows.map((row) => path.join(dir, row)),
|
||||
)
|
||||
return rows.slice(0, input.limit ?? 100)
|
||||
})
|
||||
|
||||
const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
|
||||
input.signal?.throwIfAborted()
|
||||
if (input.file?.length) return yield* rip(input)
|
||||
|
||||
const pick = yield* picker(input.cwd).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!pick) return yield* rip(input)
|
||||
|
||||
const dir = AppFileSystem.resolve(input.cwd)
|
||||
const limit = input.limit ?? 100
|
||||
const rows: Item[] = []
|
||||
const seen = new Set<string>()
|
||||
let cur: Fff.Cursor = null
|
||||
let err: string | undefined
|
||||
|
||||
while (rows.length < limit) {
|
||||
input.signal?.throwIfAborted()
|
||||
const out = yield* Effect.sync(() =>
|
||||
pick.grep(input.pattern, {
|
||||
mode: "regex",
|
||||
cursor: cur,
|
||||
maxMatchesPerFile: limit,
|
||||
timeBudgetMs: 1_500,
|
||||
}),
|
||||
)
|
||||
if (!out.ok) {
|
||||
log.warn("fff grep failed", { dir, pattern: input.pattern, error: out.error })
|
||||
return yield* rip(input)
|
||||
}
|
||||
|
||||
err = err ?? out.value.regexFallbackError
|
||||
for (const hit of out.value.items) {
|
||||
const rel = norm(hit.relativePath)
|
||||
if (!allow(input.glob, rel, norm(hit.fileName))) continue
|
||||
const id = `${rel}:${hit.lineNumber}:${hit.byteOffset}`
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
rows.push(item(hit))
|
||||
if (rows.length >= limit) break
|
||||
}
|
||||
|
||||
if (!out.value.nextCursor) break
|
||||
cur = out.value.nextCursor
|
||||
}
|
||||
|
||||
if (!rows.length && input.glob?.length) return yield* rip(input)
|
||||
|
||||
remember(
|
||||
yield* InstanceState.get(state),
|
||||
dir,
|
||||
input.pattern,
|
||||
Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))),
|
||||
)
|
||||
|
||||
return {
|
||||
items: rows,
|
||||
partial: false,
|
||||
engine: "fff" as const,
|
||||
regexFallbackError: err,
|
||||
}
|
||||
})
|
||||
|
||||
const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
|
||||
input.signal?.throwIfAborted()
|
||||
const dir = AppFileSystem.resolve(input.cwd)
|
||||
const limit = input.limit ?? 100
|
||||
const pick = yield* picker(dir).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
|
||||
if (pick) {
|
||||
const out = yield* Effect.sync(() =>
|
||||
pick.fileSearch(include(input.pattern), {
|
||||
currentFile: path.join(dir, ".opencode"),
|
||||
pageIndex: 0,
|
||||
pageSize: Math.max(limit * 4, 200),
|
||||
}),
|
||||
)
|
||||
if (out.ok) {
|
||||
const rows = Array.from(
|
||||
new Set(
|
||||
out.value.items
|
||||
.map((item) => norm(item.relativePath))
|
||||
.filter((item) => !blocked(item))
|
||||
.filter((item) => Glob.match(input.pattern, item) || Glob.match(input.pattern, base(item))),
|
||||
),
|
||||
)
|
||||
if (rows.length > 0) {
|
||||
remember(
|
||||
yield* InstanceState.get(state),
|
||||
dir,
|
||||
input.pattern,
|
||||
rows.map((row) => path.join(dir, row)),
|
||||
)
|
||||
return {
|
||||
files: rows.slice(0, limit).map((row) => path.join(dir, row)),
|
||||
truncated: rows.length > limit,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("fff glob search failed", { dir, pattern: input.pattern, error: out.error })
|
||||
}
|
||||
}
|
||||
|
||||
const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
|
||||
Stream.take(limit + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
const cut = rows.length > limit
|
||||
if (cut) rows.length = limit
|
||||
|
||||
const out = yield* Effect.forEach(
|
||||
rows,
|
||||
Effect.fnUntraced(function* (file) {
|
||||
const full = path.join(dir, file)
|
||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const time =
|
||||
info?.mtime.pipe(
|
||||
Option.map((item) => item.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
) ?? 0
|
||||
return { file: full, time }
|
||||
}),
|
||||
{ concurrency: 16 },
|
||||
)
|
||||
out.sort((a, b) => b.time - a.time)
|
||||
return {
|
||||
files: out.map((item) => item.file),
|
||||
truncated: cut,
|
||||
}
|
||||
})
|
||||
|
||||
const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
|
||||
const st = yield* InstanceState.get(state)
|
||||
const file = input.cwd
|
||||
? AppFileSystem.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
|
||||
: AppFileSystem.resolve(input.file)
|
||||
const idx = st.recent.findIndex((item) => item.files.includes(file))
|
||||
if (idx < 0) return
|
||||
|
||||
const row = st.recent[idx]
|
||||
st.recent.splice(idx, 1)
|
||||
const pick = st.pick.get(row.dir)
|
||||
if (!pick) return
|
||||
|
||||
const out = yield* Effect.sync(() => pick.trackQuery(row.text, file))
|
||||
if (!out.ok) {
|
||||
log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ files, tree, search, file, glob, open })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Ripgrep.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function files(input: Ripgrep.FilesInput) {
|
||||
return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input)))
|
||||
}
|
||||
|
||||
export function tree(input: Ripgrep.TreeInput) {
|
||||
return runPromise((svc) => svc.tree(input))
|
||||
}
|
||||
|
||||
export function search(input: Ripgrep.SearchInput) {
|
||||
return runPromise((svc) => svc.search(input))
|
||||
}
|
||||
|
||||
export function file(input: FileInput) {
|
||||
return runPromise((svc) => svc.file(input))
|
||||
}
|
||||
|
||||
export function glob(input: GlobInput) {
|
||||
return runPromise((svc) => svc.glob(input))
|
||||
}
|
||||
|
||||
export function open(input: { cwd?: string; file: string }) {
|
||||
return runPromise((svc) => svc.open(input))
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,12 @@ export namespace MCP {
|
||||
defs?: MCPToolDef[]
|
||||
}
|
||||
|
||||
interface AuthResult {
|
||||
authorizationUrl: string
|
||||
oauthState: string
|
||||
client?: MCPClient
|
||||
}
|
||||
|
||||
// --- Effect Service ---
|
||||
|
||||
interface State {
|
||||
@@ -552,6 +558,21 @@ export namespace MCP {
|
||||
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
const storeClient = Effect.fnUntraced(function* (
|
||||
s: State,
|
||||
name: string,
|
||||
client: MCPClient,
|
||||
listed: MCPToolDef[],
|
||||
timeout?: number,
|
||||
) {
|
||||
yield* closeClient(s, name)
|
||||
s.status[name] = { status: "connected" }
|
||||
s.clients[name] = client
|
||||
s.defs[name] = listed
|
||||
watch(s, name, client, timeout)
|
||||
return s.status[name]
|
||||
})
|
||||
|
||||
const status = Effect.fn("MCP.status")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
@@ -583,11 +604,7 @@ export namespace MCP {
|
||||
return result.status
|
||||
}
|
||||
|
||||
yield* closeClient(s, name)
|
||||
s.clients[name] = result.mcpClient
|
||||
s.defs[name] = result.defs!
|
||||
watch(s, name, result.mcpClient, mcp.timeout)
|
||||
return result.status
|
||||
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
|
||||
})
|
||||
|
||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
|
||||
@@ -753,14 +770,16 @@ export namespace MCP {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = new Client({ name: "opencode", version: Installation.VERSION })
|
||||
return client.connect(transport).then(() => ({ authorizationUrl: "", oauthState }))
|
||||
return client
|
||||
.connect(transport)
|
||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
||||
},
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (error instanceof UnauthorizedError && capturedUrl) {
|
||||
pendingOAuthTransports.set(mcpName, transport)
|
||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState })
|
||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
|
||||
}
|
||||
return Effect.die(error)
|
||||
}),
|
||||
@@ -768,14 +787,31 @@ export namespace MCP {
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
|
||||
const { authorizationUrl, oauthState } = yield* startAuth(mcpName)
|
||||
if (!authorizationUrl) return { status: "connected" } as Status
|
||||
const result = yield* startAuth(mcpName)
|
||||
if (!result.authorizationUrl) {
|
||||
const client = "client" in result ? result.client : undefined
|
||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||
if (!mcpConfig) {
|
||||
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
||||
return { status: "failed", error: "MCP config not found after auth" } as Status
|
||||
}
|
||||
|
||||
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
|
||||
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
|
||||
if (!client || !listed) {
|
||||
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
||||
return { status: "failed", error: "Failed to get tools" } as Status
|
||||
}
|
||||
|
||||
const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName)
|
||||
const s = yield* InstanceState.get(state)
|
||||
yield* auth.clearOAuthState(mcpName)
|
||||
return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout)
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise(() => open(authorizationUrl)).pipe(
|
||||
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: result.oauthState })
|
||||
|
||||
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
|
||||
|
||||
yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
|
||||
Effect.flatMap((subprocess) =>
|
||||
Effect.callback<void, Error>((resume) => {
|
||||
const timer = setTimeout(() => resume(Effect.void), 500)
|
||||
@@ -793,14 +829,14 @@ export namespace MCP {
|
||||
),
|
||||
Effect.catch(() => {
|
||||
log.warn("failed to open browser, user must open URL manually", { mcpName })
|
||||
return bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }).pipe(Effect.ignore)
|
||||
return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
|
||||
const code = yield* Effect.promise(() => callbackPromise)
|
||||
|
||||
const storedState = yield* auth.getOAuthState(mcpName)
|
||||
if (storedState !== oauthState) {
|
||||
if (storedState !== result.oauthState) {
|
||||
yield* auth.clearOAuthState(mcpName)
|
||||
throw new Error("OAuth state mismatch - potential CSRF attack")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
@@ -15,6 +16,7 @@ export interface InstanceContext {
|
||||
|
||||
const context = LocalContext.create<InstanceContext>("instance")
|
||||
const cache = new Map<string, Promise<InstanceContext>>()
|
||||
const project = makeRuntime(Project.Service, Project.defaultLayer)
|
||||
|
||||
const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
@@ -29,11 +31,13 @@ function boot(input: { directory: string; init?: () => Promise<any>; worktree?:
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}))
|
||||
: await project
|
||||
.runPromise((svc) => svc.fromDirectory(input.directory))
|
||||
.then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}))
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.()
|
||||
})
|
||||
|
||||
@@ -10,8 +10,7 @@ import { which } from "../util/which"
|
||||
import { ProjectID } from "./schema"
|
||||
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -463,19 +462,6 @@ export namespace Project {
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Promise-based API (delegates to Effect service via runPromise)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fromDirectory(directory: string) {
|
||||
return runPromise((svc) => svc.fromDirectory(directory))
|
||||
}
|
||||
|
||||
export function discover(input: Info) {
|
||||
return runPromise((svc) => svc.discover(input))
|
||||
}
|
||||
|
||||
export function list() {
|
||||
return Database.use((db) =>
|
||||
@@ -498,24 +484,4 @@ export namespace Project {
|
||||
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
}
|
||||
|
||||
export function initGit(input: { directory: string; project: Info }) {
|
||||
return runPromise((svc) => svc.initGit(input))
|
||||
}
|
||||
|
||||
export function update(input: UpdateInput) {
|
||||
return runPromise((svc) => svc.update(input))
|
||||
}
|
||||
|
||||
export function sandboxes(id: ProjectID) {
|
||||
return runPromise((svc) => svc.sandboxes(id))
|
||||
}
|
||||
|
||||
export function addSandbox(id: ProjectID, directory: string) {
|
||||
return runPromise((svc) => svc.addSandbox(id, directory))
|
||||
}
|
||||
|
||||
export function removeSandbox(id: ProjectID, directory: string) {
|
||||
return runPromise((svc) => svc.removeSandbox(id, directory))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const sandboxes = await Project.sandboxes(Instance.project.id)
|
||||
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
|
||||
return c.json(sandboxes)
|
||||
},
|
||||
)
|
||||
@@ -302,7 +302,9 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
|
||||
await Project.removeSandbox(Instance.project.id, body.directory)
|
||||
await AppRuntime.runPromise(
|
||||
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { File } from "../../file"
|
||||
import { Search } from "../../file/search"
|
||||
import { Ripgrep } from "../../file/ripgrep"
|
||||
import { LSP } from "../../lsp"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -15,14 +15,14 @@ export const FileRoutes = lazy(() =>
|
||||
"/find",
|
||||
describeRoute({
|
||||
summary: "Find text",
|
||||
description: "Search for text patterns across files in the project.",
|
||||
description: "Search for text patterns across files in the project using ripgrep.",
|
||||
operationId: "find.text",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Matches",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Search.Match.array()),
|
||||
schema: resolver(Ripgrep.Match.shape.data.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -37,7 +37,7 @@ export const FileRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const result = await AppRuntime.runPromise(
|
||||
Search.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
|
||||
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
|
||||
)
|
||||
return c.json(result.items)
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ async function getSessionWorkspace(url: URL) {
|
||||
const id = getSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await Session.get(id).catch(() => undefined)
|
||||
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
}
|
||||
|
||||
|
||||
@@ -75,10 +75,9 @@ export const ProjectRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const dir = Instance.directory
|
||||
const prev = Instance.project
|
||||
const next = await Project.initGit({
|
||||
directory: dir,
|
||||
project: prev,
|
||||
})
|
||||
const next = await AppRuntime.runPromise(
|
||||
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
|
||||
)
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
await Instance.reload({
|
||||
directory: dir,
|
||||
@@ -112,7 +111,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const project = await Project.update({ ...body, projectID })
|
||||
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
|
||||
return c.json(project)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -121,12 +121,12 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.get.schema,
|
||||
sessionID: Session.GetInput,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await Session.get(sessionID)
|
||||
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -152,12 +152,12 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.children.schema,
|
||||
sessionID: Session.ChildrenInput,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await Session.children(sessionID)
|
||||
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID)))
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -209,10 +209,10 @@ export const SessionRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", Session.create.schema),
|
||||
validator("json", Session.CreateInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const session = await SessionShare.create(body)
|
||||
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -237,12 +237,12 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.remove.schema,
|
||||
sessionID: Session.RemoveInput,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await Session.remove(sessionID)
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -285,22 +285,27 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
const current = await Session.get(sessionID)
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const current = yield* session.get(sessionID)
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
await Session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
await Session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
yield* session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
yield* session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
yield* session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
|
||||
const session = await Session.get(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -341,13 +346,17 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
await SessionPrompt.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -371,14 +380,14 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.fork.schema.shape.sessionID,
|
||||
sessionID: Session.ForkInput.shape.sessionID,
|
||||
}),
|
||||
),
|
||||
validator("json", Session.fork.schema.omit({ sessionID: true })),
|
||||
validator("json", Session.ForkInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const result = await Session.fork({ ...body, sessionID })
|
||||
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
@@ -407,7 +416,7 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
await SessionPrompt.cancel(c.req.valid("param").sessionID)
|
||||
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -437,8 +446,14 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await SessionShare.share(sessionID)
|
||||
const session = await Session.get(sessionID)
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.share(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -511,8 +526,14 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await SessionShare.unshare(sessionID)
|
||||
const session = await Session.get(sessionID)
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.unshare(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -645,15 +666,14 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
if (query.limit === undefined) {
|
||||
await Session.get(sessionID)
|
||||
const messages = await Session.messages({ sessionID })
|
||||
return c.json(messages)
|
||||
}
|
||||
|
||||
if (query.limit === 0) {
|
||||
await Session.get(sessionID)
|
||||
const messages = await Session.messages({ sessionID })
|
||||
if (query.limit === undefined || query.limit === 0) {
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
yield* session.get(sessionID)
|
||||
return yield* session.messages({ sessionID })
|
||||
}),
|
||||
)
|
||||
return c.json(messages)
|
||||
}
|
||||
|
||||
@@ -781,11 +801,15 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await Session.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -823,7 +847,7 @@ export const SessionRoutes = lazy(() =>
|
||||
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
|
||||
)
|
||||
}
|
||||
const part = await Session.updatePart(body)
|
||||
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
|
||||
return c.json(part)
|
||||
},
|
||||
)
|
||||
@@ -863,7 +887,9 @@ export const SessionRoutes = lazy(() =>
|
||||
return stream(c, async (stream) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.prompt({ ...body, sessionID })
|
||||
const msg = await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
|
||||
)
|
||||
stream.write(JSON.stringify(msg))
|
||||
})
|
||||
},
|
||||
@@ -892,7 +918,7 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
|
||||
AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
@@ -936,7 +962,7 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.command({ ...body, sessionID })
|
||||
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
@@ -968,7 +994,7 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.shell({ ...body, sessionID })
|
||||
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import z from "zod"
|
||||
import { Bus } from "../../bus"
|
||||
import { Session } from "../../session"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "../../util/queue"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -370,7 +371,7 @@ export const TuiRoutes = lazy(() =>
|
||||
validator("json", TuiEvent.SessionSelect.properties),
|
||||
async (c) => {
|
||||
const { sessionID } = c.req.valid("json")
|
||||
await Session.get(sessionID)
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ import { updateSchema } from "../util/update-schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectID } from "../project/schema"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
@@ -29,7 +28,6 @@ import type { Provider } from "@/provider/provider"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import { Effect, Layer, Option, Context } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
@@ -179,6 +177,30 @@ export namespace Session {
|
||||
})
|
||||
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||
|
||||
export const CreateInput = z
|
||||
.object({
|
||||
parentID: SessionID.zod.optional(),
|
||||
title: z.string().optional(),
|
||||
permission: Info.shape.permission,
|
||||
workspaceID: WorkspaceID.zod.optional(),
|
||||
})
|
||||
.optional()
|
||||
export type CreateInput = z.output<typeof CreateInput>
|
||||
|
||||
export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() })
|
||||
export const GetInput = SessionID.zod
|
||||
export const ChildrenInput = SessionID.zod
|
||||
export const RemoveInput = SessionID.zod
|
||||
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
|
||||
export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
|
||||
export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset })
|
||||
export const SetRevertInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
revert: Info.shape.revert,
|
||||
summary: Info.shape.summary,
|
||||
})
|
||||
export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() })
|
||||
|
||||
export const Event = {
|
||||
Created: SyncEvent.define({
|
||||
type: "session.created",
|
||||
@@ -682,48 +704,6 @@ export namespace Session {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const create = fn(
|
||||
z
|
||||
.object({
|
||||
parentID: SessionID.zod.optional(),
|
||||
title: z.string().optional(),
|
||||
permission: Info.shape.permission,
|
||||
workspaceID: WorkspaceID.zod.optional(),
|
||||
})
|
||||
.optional(),
|
||||
(input) => runPromise((svc) => svc.create(input)),
|
||||
)
|
||||
|
||||
export const fork = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), (input) =>
|
||||
runPromise((svc) => svc.fork(input)),
|
||||
)
|
||||
|
||||
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
|
||||
|
||||
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
|
||||
runPromise((svc) => svc.setTitle(input)),
|
||||
)
|
||||
|
||||
export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) =>
|
||||
runPromise((svc) => svc.setArchived(input)),
|
||||
)
|
||||
|
||||
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
||||
runPromise((svc) => svc.setPermission(input)),
|
||||
)
|
||||
|
||||
export const setRevert = fn(
|
||||
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
||||
(input) =>
|
||||
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
|
||||
)
|
||||
|
||||
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
|
||||
runPromise((svc) => svc.messages(input)),
|
||||
)
|
||||
|
||||
export function* list(input?: {
|
||||
directory?: string
|
||||
workspaceID?: WorkspaceID
|
||||
@@ -835,25 +815,4 @@ export namespace Session {
|
||||
yield { ...fromRow(row), project }
|
||||
}
|
||||
}
|
||||
|
||||
export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id)))
|
||||
export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id)))
|
||||
export async function updateMessage<T extends MessageV2.Info>(msg: T): Promise<T> {
|
||||
MessageV2.Info.parse(msg)
|
||||
return runPromise((svc) => svc.updateMessage(msg))
|
||||
}
|
||||
|
||||
export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) =>
|
||||
runPromise((svc) => svc.removeMessage(input)),
|
||||
)
|
||||
|
||||
export const removePart = fn(
|
||||
z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod }),
|
||||
(input) => runPromise((svc) => svc.removePart(input)),
|
||||
)
|
||||
|
||||
export async function updatePart<T extends MessageV2.Part>(part: T): Promise<T> {
|
||||
MessageV2.Part.parse(part)
|
||||
return runPromise((svc) => svc.updatePart(part))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Config } from "@/config/config"
|
||||
import { Permission } from "@/permission"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { Session } from "."
|
||||
import { LLM } from "./llm"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -19,11 +18,12 @@ import { SessionSummary } from "./summary"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
export namespace SessionProcessor {
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = EffectLogger.create({ service: "session.processor" })
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
|
||||
export type Result = "compact" | "stop" | "continue"
|
||||
|
||||
@@ -124,7 +124,7 @@ export namespace SessionProcessor {
|
||||
reasoningMap: {},
|
||||
}
|
||||
let aborted = false
|
||||
const slog = log.with({ sessionID: input.sessionID, messageID: input.assistantMessage.id })
|
||||
const slog = log.clone().tag("sessionID", input.sessionID).tag("messageID", input.assistantMessage.id)
|
||||
|
||||
const parse = (e: unknown) =>
|
||||
MessageV2.fromError(e, {
|
||||
@@ -454,7 +454,7 @@ export namespace SessionProcessor {
|
||||
return
|
||||
|
||||
default:
|
||||
yield* slog.info("unhandled", { event: value.type, value })
|
||||
slog.info("unhandled", { event: value.type, value })
|
||||
return
|
||||
}
|
||||
})
|
||||
@@ -520,7 +520,7 @@ export namespace SessionProcessor {
|
||||
})
|
||||
|
||||
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
|
||||
yield* slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
|
||||
slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
|
||||
const error = parse(e)
|
||||
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
||||
ctx.needsCompaction = true
|
||||
@@ -536,7 +536,7 @@ export namespace SessionProcessor {
|
||||
})
|
||||
|
||||
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
|
||||
yield* slog.info("process")
|
||||
slog.info("process")
|
||||
ctx.needsCompaction = false
|
||||
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { attach, makeRuntime } from "@/effect/run-service"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
|
||||
@@ -105,12 +104,11 @@ export namespace SessionPrompt {
|
||||
const summary = yield* SessionSummary.Service
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
const ctx = yield* Effect.context<any>()
|
||||
|
||||
const run = {
|
||||
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(attach(effect).pipe(Effect.provide(EffectLogger.layer))),
|
||||
fork: <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runFork(attach(effect).pipe(Effect.provide(EffectLogger.layer))),
|
||||
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) {
|
||||
@@ -1709,8 +1707,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
),
|
||||
),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const PromptInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
@@ -1778,26 +1774,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
export type PromptInput = z.infer<typeof PromptInput>
|
||||
|
||||
export async function prompt(input: PromptInput) {
|
||||
return runPromise((svc) => svc.prompt(PromptInput.parse(input)))
|
||||
}
|
||||
|
||||
export async function resolvePromptParts(template: string) {
|
||||
return runPromise((svc) => svc.resolvePromptParts(z.string().parse(template)))
|
||||
}
|
||||
|
||||
export async function cancel(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.cancel(SessionID.zod.parse(sessionID)))
|
||||
}
|
||||
|
||||
export const LoopInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
})
|
||||
|
||||
export async function loop(input: z.infer<typeof LoopInput>) {
|
||||
return runPromise((svc) => svc.loop(LoopInput.parse(input)))
|
||||
}
|
||||
|
||||
export const ShellInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
@@ -1812,10 +1792,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
export type ShellInput = z.infer<typeof ShellInput>
|
||||
|
||||
export async function shell(input: ShellInput) {
|
||||
return runPromise((svc) => svc.shell(ShellInput.parse(input)))
|
||||
}
|
||||
|
||||
export const CommandInput = z.object({
|
||||
messageID: MessageID.zod.optional(),
|
||||
sessionID: SessionID.zod,
|
||||
@@ -1839,10 +1815,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
export type CommandInput = z.infer<typeof CommandInput>
|
||||
|
||||
export async function command(input: CommandInput) {
|
||||
return runPromise((svc) => svc.command(CommandInput.parse(input)))
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function createStructuredOutputTool(input: {
|
||||
schema: Record<string, any>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Effect, Layer, Scope, Context } from "effect"
|
||||
import { Config } from "../config/config"
|
||||
import { Flag } from "../flag/flag"
|
||||
@@ -10,7 +8,7 @@ import { ShareNext } from "./share-next"
|
||||
|
||||
export namespace SessionShare {
|
||||
export interface Interface {
|
||||
readonly create: (input?: Parameters<typeof Session.create>[0]) => Effect.Effect<Session.Info>
|
||||
readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info>
|
||||
readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown>
|
||||
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
|
||||
}
|
||||
@@ -40,7 +38,7 @@ export namespace SessionShare {
|
||||
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
|
||||
})
|
||||
|
||||
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
|
||||
const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) {
|
||||
const result = yield* session.create(input)
|
||||
if (result.parentID) return result
|
||||
const conf = yield* cfg.get()
|
||||
@@ -58,10 +56,4 @@ export namespace SessionShare {
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input)))
|
||||
export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID)))
|
||||
export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID)))
|
||||
}
|
||||
|
||||
@@ -437,7 +437,11 @@ export const BashTool = Tool.define(
|
||||
).pipe(Effect.orDie)
|
||||
|
||||
const meta: string[] = []
|
||||
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||
if (expired) {
|
||||
meta.push(
|
||||
`bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`,
|
||||
)
|
||||
}
|
||||
if (aborted) meta.push("User aborted the command")
|
||||
if (meta.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Search } from "../file/search"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import { Tool } from "./tool"
|
||||
@@ -11,8 +12,8 @@ import { Tool } from "./tool"
|
||||
export const GlobTool = Tool.define(
|
||||
"glob",
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -47,18 +48,36 @@ export const GlobTool = Tool.define(
|
||||
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
||||
|
||||
const limit = 100
|
||||
const files = yield* searchSvc.glob({
|
||||
cwd: search,
|
||||
pattern: params.pattern,
|
||||
limit,
|
||||
signal: ctx.abort,
|
||||
})
|
||||
let truncated = false
|
||||
const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe(
|
||||
Stream.mapEffect((file) =>
|
||||
Effect.gen(function* () {
|
||||
const full = path.resolve(search, file)
|
||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const mtime =
|
||||
info?.mtime.pipe(
|
||||
Option.map((date) => date.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
) ?? 0
|
||||
return { path: full, mtime }
|
||||
}),
|
||||
),
|
||||
Stream.take(limit + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
|
||||
if (files.length > limit) {
|
||||
truncated = true
|
||||
files.length = limit
|
||||
}
|
||||
files.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
const output = []
|
||||
if (files.files.length === 0) output.push("No files found")
|
||||
if (files.files.length > 0) {
|
||||
output.push(...files.files)
|
||||
if (files.truncated) {
|
||||
if (files.length === 0) output.push("No files found")
|
||||
if (files.length > 0) {
|
||||
output.push(...files.map((file) => file.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
||||
@@ -69,8 +88,8 @@ export const GlobTool = Tool.define(
|
||||
return {
|
||||
title: path.relative(ins.worktree, search),
|
||||
metadata: {
|
||||
count: files.files.length,
|
||||
truncated: files.truncated,
|
||||
count: files.length,
|
||||
truncated,
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Search } from "../file/search"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import { Tool } from "./tool"
|
||||
@@ -14,7 +14,7 @@ export const GrepTool = Tool.define(
|
||||
"grep",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -58,7 +58,7 @@ export const GrepTool = Tool.define(
|
||||
kind: info?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
const result = yield* searchSvc.search({
|
||||
const result = yield* rg.search({
|
||||
cwd,
|
||||
pattern: params.pattern,
|
||||
glob: params.include ? [params.include] : undefined,
|
||||
@@ -74,13 +74,37 @@ export const GrepTool = Tool.define(
|
||||
line: item.line_number,
|
||||
text: item.lines.text,
|
||||
}))
|
||||
const times = new Map(
|
||||
(yield* Effect.forEach(
|
||||
[...new Set(rows.map((row) => row.path))],
|
||||
Effect.fnUntraced(function* (file) {
|
||||
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info || info.type === "Directory") return undefined
|
||||
return [
|
||||
file,
|
||||
info.mtime.pipe(
|
||||
Option.map((time) => time.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
) ?? 0,
|
||||
] as const
|
||||
}),
|
||||
{ concurrency: 16 },
|
||||
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
|
||||
)
|
||||
const matches = rows.flatMap((row) => {
|
||||
const mtime = times.get(row.path)
|
||||
if (mtime === undefined) return []
|
||||
return [{ ...row, mtime }]
|
||||
})
|
||||
|
||||
matches.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
const limit = 100
|
||||
const truncated = rows.length > limit
|
||||
const final = truncated ? rows.slice(0, limit) : rows
|
||||
const truncated = matches.length > limit
|
||||
const final = truncated ? matches.slice(0, limit) : matches
|
||||
if (final.length === 0) return empty
|
||||
|
||||
const total = rows.length
|
||||
const total = matches.length
|
||||
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let current = ""
|
||||
@@ -106,10 +130,6 @@ export const GrepTool = Tool.define(
|
||||
output.push("")
|
||||
output.push("(Some paths were inaccessible and skipped)")
|
||||
}
|
||||
if (result.regexFallbackError) {
|
||||
output.push("")
|
||||
output.push(`(Regex fallback: ${result.regexFallbackError})`)
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.pattern,
|
||||
|
||||
@@ -3,7 +3,7 @@ import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Search } from "../file/search"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
import { Tool } from "./tool"
|
||||
@@ -40,7 +40,7 @@ const LIMIT = 100
|
||||
export const ListTool = Tool.define(
|
||||
"list",
|
||||
Effect.gen(function* () {
|
||||
const searchSvc = yield* Search.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -67,7 +67,7 @@ export const ListTool = Tool.define(
|
||||
})
|
||||
|
||||
const glob = IGNORE_PATTERNS.map((item) => `!${item}*`).concat(params.ignore?.map((item) => `!${item}`) || [])
|
||||
const files = yield* searchSvc.files({ cwd: search, glob, signal: ctx.abort }).pipe(
|
||||
const files = yield* rg.files({ cwd: search, glob, signal: ctx.abort }).pipe(
|
||||
Stream.take(LIMIT + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Tool } from "./tool"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Search } from "../file/search"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
@@ -32,7 +31,6 @@ export const ReadTool = Tool.define(
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const instruction = yield* Instruction.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const search = yield* Search.Service
|
||||
const time = yield* FileTime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
@@ -78,7 +76,6 @@ export const ReadTool = Tool.define(
|
||||
})
|
||||
|
||||
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) {
|
||||
yield* search.open({ file: filepath }).pipe(Effect.ignore)
|
||||
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
yield* time.read(sessionID, filepath)
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ import { Effect, Layer, Context } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { Search } from "../file/search"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Format } from "../format"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Env } from "../env"
|
||||
@@ -93,7 +93,7 @@ export namespace ToolRegistry {
|
||||
| Bus.Service
|
||||
| HttpClient.HttpClient
|
||||
| ChildProcessSpawner
|
||||
| Search.Service
|
||||
| Ripgrep.Service
|
||||
| Format.Service
|
||||
| Truncate.Service
|
||||
> = Layer.effect(
|
||||
@@ -344,7 +344,7 @@ export namespace ToolRegistry {
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Search.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { Search } from "../file/search"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Skill } from "../skill"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
@@ -16,7 +16,7 @@ export const SkillTool = Tool.define(
|
||||
"skill",
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -62,7 +62,7 @@ export const SkillTool = Tool.define(
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
const limit = 10
|
||||
const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
|
||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
|
||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||
Stream.map((file) => path.resolve(dir, file)),
|
||||
Stream.take(limit),
|
||||
|
||||
@@ -295,6 +295,46 @@ describe("acp.agent event subscription", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not emit user_message_chunk for live prompt parts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
const cwd = "/tmp/opencode-acp-test"
|
||||
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
||||
|
||||
controller.push({
|
||||
directory: cwd,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: "part_1",
|
||||
sessionID: sessionId,
|
||||
messageID: "msg_user",
|
||||
type: "text",
|
||||
text: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(
|
||||
sessionUpdates
|
||||
.filter((u) => u.sessionId === sessionId)
|
||||
.some((u) => u.update.sessionUpdate === "user_message_chunk"),
|
||||
).toBe(false)
|
||||
|
||||
stop()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer, Logger } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { EffectLogger } from "../../src/effect/logger"
|
||||
|
||||
function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
|
||||
return {
|
||||
defaultLogger: loggers.has(Logger.defaultLogger),
|
||||
tracerLogger: loggers.has(Logger.tracerLogger),
|
||||
effectLogger: loggers.has(EffectLogger.logger),
|
||||
size: loggers.size,
|
||||
}
|
||||
}
|
||||
|
||||
test("makeRuntime installs EffectLogger through Observability.layer", async () => {
|
||||
class Dummy extends Context.Service<Dummy, { readonly current: () => Effect.Effect<ReturnType<typeof check>> }>()(
|
||||
"@test/Dummy",
|
||||
) {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Dummy,
|
||||
Effect.gen(function* () {
|
||||
return Dummy.of({
|
||||
current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const rt = makeRuntime(Dummy, layer)
|
||||
const current = await rt.runPromise((svc) => svc.current())
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
expect(current.defaultLogger).toBe(false)
|
||||
})
|
||||
|
||||
test("AppRuntime also installs EffectLogger through Observability.layer", async () => {
|
||||
const current = await AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check))
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
expect(current.defaultLogger).toBe(false)
|
||||
})
|
||||
@@ -20,6 +20,7 @@ const transportCalls: Array<{
|
||||
// Controls whether the mock transport simulates a 401 that triggers the SDK
|
||||
// auth flow (which calls provider.state()) or a simple UnauthorizedError.
|
||||
let simulateAuthFlow = true
|
||||
let connectSucceedsImmediately = false
|
||||
|
||||
// Mock the transport constructors to simulate OAuth auto-auth on 401
|
||||
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
@@ -40,6 +41,8 @@ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
})
|
||||
}
|
||||
async start() {
|
||||
if (connectSucceedsImmediately) return
|
||||
|
||||
// Simulate what the real SDK transport does on 401:
|
||||
// It calls auth() which eventually calls provider.state(), then
|
||||
// provider.redirectToAuthorization(), then throws UnauthorizedError.
|
||||
@@ -85,6 +88,14 @@ mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
async connect(transport: { start: () => Promise<void> }) {
|
||||
await transport.start()
|
||||
}
|
||||
|
||||
setNotificationHandler() {}
|
||||
|
||||
async listTools() {
|
||||
return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] }
|
||||
}
|
||||
|
||||
async close() {}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -96,6 +107,7 @@ mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
beforeEach(() => {
|
||||
transportCalls.length = 0
|
||||
simulateAuthFlow = true
|
||||
connectSucceedsImmediately = false
|
||||
})
|
||||
|
||||
// Import modules after mocking
|
||||
@@ -222,3 +234,49 @@ test("state() returns existing state when one is saved", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("authenticate() stores a connected client when auth completes without redirect", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
`${dir}/opencode.json`,
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
"test-oauth-connect": {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Effect.runPromise(
|
||||
MCP.Service.use((mcp) =>
|
||||
Effect.gen(function* () {
|
||||
const added = yield* mcp.add("test-oauth-connect", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
})
|
||||
const before = added.status as Record<string, { status: string; error?: string }>
|
||||
expect(before["test-oauth-connect"]?.status).toBe("needs_auth")
|
||||
|
||||
simulateAuthFlow = false
|
||||
connectSucceedsImmediately = true
|
||||
|
||||
const result = yield* mcp.authenticate("test-oauth-connect")
|
||||
expect(result.status).toBe("connected")
|
||||
|
||||
const after = yield* mcp.status()
|
||||
expect(after["test-oauth-connect"]?.status).toBe("connected")
|
||||
}),
|
||||
).pipe(Effect.provide(MCP.defaultLayer)),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,19 @@ import { SessionID } from "../../src/session/schema"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { $ } from "bun"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Effect } from "effect"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* fn(svc)
|
||||
}).pipe(Effect.provide(Project.defaultLayer)),
|
||||
)
|
||||
}
|
||||
|
||||
function uid() {
|
||||
return SessionID.make(crypto.randomUUID())
|
||||
}
|
||||
@@ -58,7 +68,7 @@ describe("migrateFromGlobal", () => {
|
||||
await $`git config user.name "Test"`.cwd(tmp.path).quiet()
|
||||
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
||||
const { project: pre } = await Project.fromDirectory(tmp.path)
|
||||
const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(pre.id).toBe(ProjectID.global)
|
||||
|
||||
// 2. Seed a session under "global" with matching directory
|
||||
@@ -68,7 +78,7 @@ describe("migrateFromGlobal", () => {
|
||||
// 3. Make a commit so the project gets a real ID
|
||||
await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project: real } = await Project.fromDirectory(tmp.path)
|
||||
const { project: real } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(real.id).not.toBe(ProjectID.global)
|
||||
|
||||
// 4. The session should have been migrated to the real project ID
|
||||
@@ -80,7 +90,7 @@ describe("migrateFromGlobal", () => {
|
||||
test("migrates global sessions even when project row already exists", async () => {
|
||||
// 1. Create a repo with a commit — real project ID created immediately
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
|
||||
@@ -94,7 +104,7 @@ describe("migrateFromGlobal", () => {
|
||||
|
||||
// 4. Call fromDirectory again — project row already exists,
|
||||
// so the current code skips migration entirely. This is the bug.
|
||||
await Project.fromDirectory(tmp.path)
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
@@ -103,7 +113,7 @@ describe("migrateFromGlobal", () => {
|
||||
|
||||
test("does not claim sessions with empty directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
ensureGlobal()
|
||||
@@ -113,7 +123,7 @@ describe("migrateFromGlobal", () => {
|
||||
const id = uid()
|
||||
seed({ id, dir: "", project: ProjectID.global })
|
||||
|
||||
await Project.fromDirectory(tmp.path)
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
@@ -122,7 +132,7 @@ describe("migrateFromGlobal", () => {
|
||||
|
||||
test("does not steal sessions from unrelated directories", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
ensureGlobal()
|
||||
@@ -131,7 +141,7 @@ describe("migrateFromGlobal", () => {
|
||||
const id = uid()
|
||||
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
|
||||
|
||||
await Project.fromDirectory(tmp.path)
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { GlobalBus } from "../../src/bus/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -16,6 +16,15 @@ Log.init({ print: false })
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>, layer = Project.defaultLayer) {
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* fn(svc)
|
||||
}).pipe(Effect.provide(layer)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
|
||||
* matching `failArg` and returns exit code 128, while delegating everything
|
||||
@@ -64,7 +73,7 @@ describe("Project.fromDirectory", () => {
|
||||
await using tmp = await tmpdir()
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
@@ -78,7 +87,7 @@ describe("Project.fromDirectory", () => {
|
||||
test("should handle git repository with commits", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
@@ -91,14 +100,14 @@ describe("Project.fromDirectory", () => {
|
||||
|
||||
test("returns global for non-git directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
})
|
||||
|
||||
test("derives stable project ID from root commit", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project: a } = await Project.fromDirectory(tmp.path)
|
||||
const { project: b } = await Project.fromDirectory(tmp.path)
|
||||
const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
const { project: b } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(b.id).toBe(a.id)
|
||||
})
|
||||
})
|
||||
@@ -109,7 +118,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
|
||||
// rev-list fails because HEAD doesn't exist yet — this is the natural scenario
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
@@ -119,9 +128,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const layer = projectLayerWithFailure("--show-toplevel")
|
||||
|
||||
const { project, sandbox } = await Effect.runPromise(
|
||||
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
|
||||
)
|
||||
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
})
|
||||
@@ -130,9 +137,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const layer = projectLayerWithFailure("--git-common-dir")
|
||||
|
||||
const { project, sandbox } = await Effect.runPromise(
|
||||
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
|
||||
)
|
||||
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
})
|
||||
@@ -142,7 +147,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
test("should set worktree to root when called from root", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project, sandbox } = await Project.fromDirectory(tmp.path)
|
||||
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
@@ -156,7 +161,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
try {
|
||||
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project, sandbox } = await Project.fromDirectory(worktreePath)
|
||||
const { project, sandbox } = await run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(worktreePath)
|
||||
@@ -173,13 +178,13 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
test("worktree should share project ID with main repo", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project: main } = await Project.fromDirectory(tmp.path)
|
||||
const { project: main } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
|
||||
try {
|
||||
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project: wt } = await Project.fromDirectory(worktreePath)
|
||||
const { project: wt } = await run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(wt.id).toBe(main.id)
|
||||
|
||||
@@ -205,8 +210,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
|
||||
await $`git clone ${bare} ${clone}`.quiet()
|
||||
|
||||
const { project: a } = await Project.fromDirectory(tmp.path)
|
||||
const { project: b } = await Project.fromDirectory(clone)
|
||||
const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
const { project: b } = await run((svc) => svc.fromDirectory(clone))
|
||||
|
||||
expect(b.id).toBe(a.id)
|
||||
} finally {
|
||||
@@ -223,8 +228,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
|
||||
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
|
||||
|
||||
await Project.fromDirectory(worktree1)
|
||||
const { project } = await Project.fromDirectory(worktree2)
|
||||
await run((svc) => svc.fromDirectory(worktree1))
|
||||
const { project } = await run((svc) => svc.fromDirectory(worktree2))
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(project.sandboxes).toContain(worktree1)
|
||||
@@ -246,12 +251,12 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
describe("Project.discover", () => {
|
||||
test("should discover favicon.png in root", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
|
||||
|
||||
await Project.discover(project)
|
||||
await run((svc) => svc.discover(project))
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -263,11 +268,11 @@ describe("Project.discover", () => {
|
||||
|
||||
test("should not discover non-image files", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
|
||||
|
||||
await Project.discover(project)
|
||||
await run((svc) => svc.discover(project))
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -278,12 +283,14 @@ describe("Project.discover", () => {
|
||||
describe("Project.update", () => {
|
||||
test("should update name", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const updated = await Project.update({
|
||||
projectID: project.id,
|
||||
name: "New Project Name",
|
||||
})
|
||||
const updated = await run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
name: "New Project Name",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.name).toBe("New Project Name")
|
||||
|
||||
@@ -293,12 +300,14 @@ describe("Project.update", () => {
|
||||
|
||||
test("should update icon url", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const updated = await Project.update({
|
||||
projectID: project.id,
|
||||
icon: { url: "https://example.com/icon.png" },
|
||||
})
|
||||
const updated = await run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { url: "https://example.com/icon.png" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.icon?.url).toBe("https://example.com/icon.png")
|
||||
|
||||
@@ -308,12 +317,14 @@ describe("Project.update", () => {
|
||||
|
||||
test("should update icon color", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const updated = await Project.update({
|
||||
projectID: project.id,
|
||||
icon: { color: "#ff0000" },
|
||||
})
|
||||
const updated = await run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { color: "#ff0000" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.icon?.color).toBe("#ff0000")
|
||||
|
||||
@@ -323,12 +334,14 @@ describe("Project.update", () => {
|
||||
|
||||
test("should update commands", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const updated = await Project.update({
|
||||
projectID: project.id,
|
||||
commands: { start: "npm run dev" },
|
||||
})
|
||||
const updated = await run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
commands: { start: "npm run dev" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.commands?.start).toBe("npm run dev")
|
||||
|
||||
@@ -338,16 +351,18 @@ describe("Project.update", () => {
|
||||
|
||||
test("should throw error when project not found", async () => {
|
||||
await expect(
|
||||
Project.update({
|
||||
projectID: ProjectID.make("nonexistent-project-id"),
|
||||
name: "Should Fail",
|
||||
}),
|
||||
run((svc) =>
|
||||
svc.update({
|
||||
projectID: ProjectID.make("nonexistent-project-id"),
|
||||
name: "Should Fail",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Project not found: nonexistent-project-id")
|
||||
})
|
||||
|
||||
test("should emit GlobalBus event on update", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
let eventPayload: any = null
|
||||
const on = (data: any) => {
|
||||
@@ -356,10 +371,7 @@ describe("Project.update", () => {
|
||||
GlobalBus.on("event", on)
|
||||
|
||||
try {
|
||||
await Project.update({
|
||||
projectID: project.id,
|
||||
name: "Updated Name",
|
||||
})
|
||||
await run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
|
||||
|
||||
expect(eventPayload).not.toBeNull()
|
||||
expect(eventPayload.payload.type).toBe("project.updated")
|
||||
@@ -371,14 +383,16 @@ describe("Project.update", () => {
|
||||
|
||||
test("should update multiple fields at once", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const updated = await Project.update({
|
||||
projectID: project.id,
|
||||
name: "Multi Update",
|
||||
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
|
||||
commands: { start: "make start" },
|
||||
})
|
||||
const updated = await run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
name: "Multi Update",
|
||||
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
|
||||
commands: { start: "make start" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updated.name).toBe("Multi Update")
|
||||
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
|
||||
@@ -390,7 +404,7 @@ describe("Project.update", () => {
|
||||
describe("Project.list and Project.get", () => {
|
||||
test("list returns all projects", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const all = Project.list()
|
||||
expect(all.length).toBeGreaterThan(0)
|
||||
@@ -399,7 +413,7 @@ describe("Project.list and Project.get", () => {
|
||||
|
||||
test("get returns project by id", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const found = Project.get(project.id)
|
||||
expect(found).toBeDefined()
|
||||
@@ -415,7 +429,7 @@ describe("Project.list and Project.get", () => {
|
||||
describe("Project.setInitialized", () => {
|
||||
test("sets time_initialized on project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
expect(project.time.initialized).toBeUndefined()
|
||||
|
||||
@@ -429,15 +443,15 @@ describe("Project.setInitialized", () => {
|
||||
describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
test("addSandbox adds directory and removeSandbox removes it", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
const sandboxDir = path.join(tmp.path, "sandbox-test")
|
||||
|
||||
await Project.addSandbox(project.id, sandboxDir)
|
||||
await run((svc) => svc.addSandbox(project.id, sandboxDir))
|
||||
|
||||
let found = Project.get(project.id)
|
||||
expect(found?.sandboxes).toContain(sandboxDir)
|
||||
|
||||
await Project.removeSandbox(project.id, sandboxDir)
|
||||
await run((svc) => svc.removeSandbox(project.id, sandboxDir))
|
||||
|
||||
found = Project.get(project.id)
|
||||
expect(found?.sandboxes).not.toContain(sandboxDir)
|
||||
@@ -445,14 +459,14 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
|
||||
test("addSandbox emits GlobalBus event", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
const sandboxDir = path.join(tmp.path, "sandbox-event")
|
||||
|
||||
const events: any[] = []
|
||||
const on = (evt: any) => events.push(evt)
|
||||
GlobalBus.on("event", on)
|
||||
|
||||
await Project.addSandbox(project.id, sandboxDir)
|
||||
await run((svc) => svc.addSandbox(project.id, sandboxDir))
|
||||
|
||||
GlobalBus.off("event", on)
|
||||
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
|
||||
|
||||
@@ -1,27 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
describe("Session.listGlobal", () => {
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
setArchived(input: z.output<typeof SessionNs.SetArchivedInput>) {
|
||||
return run(SessionNs.Service.use((svc) => svc.setArchived(input)))
|
||||
},
|
||||
}
|
||||
|
||||
describe("session.listGlobal", () => {
|
||||
test("lists sessions across projects with project metadata", async () => {
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
|
||||
const firstSession = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => Session.create({ title: "first-session" }),
|
||||
fn: async () => svc.create({ title: "first-session" }),
|
||||
})
|
||||
const secondSession = await Instance.provide({
|
||||
directory: second.path,
|
||||
fn: async () => Session.create({ title: "second-session" }),
|
||||
fn: async () => svc.create({ title: "second-session" }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||
const sessions = [...svc.listGlobal({ limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(firstSession.id)
|
||||
@@ -44,20 +60,20 @@ describe("Session.listGlobal", () => {
|
||||
|
||||
const archived = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "archived-session" }),
|
||||
fn: async () => svc.create({ title: "archived-session" }),
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
|
||||
fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||
const sessions = [...svc.listGlobal({ limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).not.toContain(archived.id)
|
||||
|
||||
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
|
||||
const allSessions = [...svc.listGlobal({ limit: 200, archived: true })]
|
||||
const allIds = allSessions.map((session) => session.id)
|
||||
|
||||
expect(allIds).toContain(archived.id)
|
||||
@@ -68,19 +84,19 @@ describe("Session.listGlobal", () => {
|
||||
|
||||
const first = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "page-one" }),
|
||||
fn: async () => svc.create({ title: "page-one" }),
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "page-two" }),
|
||||
fn: async () => svc.create({ title: "page-two" }),
|
||||
})
|
||||
|
||||
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
|
||||
const page = [...svc.listGlobal({ directory: tmp.path, limit: 1 })]
|
||||
expect(page.length).toBe(1)
|
||||
expect(page[0].id).toBe(second.id)
|
||||
|
||||
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
|
||||
const next = [...svc.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
|
||||
const ids = next.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import type { SessionID } from "../../src/session/schema"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
remove(id: SessionID) {
|
||||
return run(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("session action routes", () => {
|
||||
test("abort route calls SessionPrompt.cancel", async () => {
|
||||
test("abort route returns success", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
|
||||
const session = await svc.create({})
|
||||
const app = Server.Default().app
|
||||
|
||||
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
expect(cancel).toHaveBeenCalledWith(session.id)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("Session.list", () => {
|
||||
describe("session.list", () => {
|
||||
test("filters by directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const first = await Session.create({})
|
||||
const first = await svc.create({})
|
||||
|
||||
await using other = await tmpdir({ git: true })
|
||||
const second = await Instance.provide({
|
||||
directory: other.path,
|
||||
fn: async () => Session.create({}),
|
||||
fn: async () => svc.create({}),
|
||||
})
|
||||
|
||||
const sessions = [...Session.list({ directory: tmp.path })]
|
||||
const sessions = [...svc.list({ directory: tmp.path })]
|
||||
const ids = sessions.map((s) => s.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
@@ -38,10 +50,10 @@ describe("Session.list", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const root = await Session.create({ title: "root-session" })
|
||||
const child = await Session.create({ title: "child-session", parentID: root.id })
|
||||
const root = await svc.create({ title: "root-session" })
|
||||
const child = await svc.create({ title: "child-session", parentID: root.id })
|
||||
|
||||
const sessions = [...Session.list({ roots: true })]
|
||||
const sessions = [...svc.list({ roots: true })]
|
||||
const ids = sessions.map((s) => s.id)
|
||||
|
||||
expect(ids).toContain(root.id)
|
||||
@@ -55,10 +67,10 @@ describe("Session.list", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "new-session" })
|
||||
const session = await svc.create({ title: "new-session" })
|
||||
const futureStart = Date.now() + 86400000
|
||||
|
||||
const sessions = [...Session.list({ start: futureStart })]
|
||||
const sessions = [...svc.list({ start: futureStart })]
|
||||
expect(sessions.length).toBe(0)
|
||||
},
|
||||
})
|
||||
@@ -69,10 +81,10 @@ describe("Session.list", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Session.create({ title: "unique-search-term-abc" })
|
||||
await Session.create({ title: "other-session-xyz" })
|
||||
await svc.create({ title: "unique-search-term-abc" })
|
||||
await svc.create({ title: "other-session-xyz" })
|
||||
|
||||
const sessions = [...Session.list({ search: "unique-search" })]
|
||||
const sessions = [...svc.list({ search: "unique-search" })]
|
||||
const titles = sessions.map((s) => s.title)
|
||||
|
||||
expect(titles).toContain("unique-search-term-abc")
|
||||
@@ -86,11 +98,11 @@ describe("Session.list", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Session.create({ title: "session-1" })
|
||||
await Session.create({ title: "session-2" })
|
||||
await Session.create({ title: "session-3" })
|
||||
await svc.create({ title: "session-1" })
|
||||
await svc.create({ title: "session-2" })
|
||||
await svc.create({ title: "session-3" })
|
||||
|
||||
const sessions = [...Session.list({ limit: 2 })]
|
||||
const sessions = [...svc.list({ limit: 2 })]
|
||||
expect(sessions.length).toBe(2)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session } from "../../src/session"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { Log } from "../../src/util/log"
|
||||
@@ -9,6 +10,26 @@ import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
remove(id: SessionID) {
|
||||
return run(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
},
|
||||
updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
},
|
||||
updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
@@ -30,7 +51,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = MessageID.ascending()
|
||||
ids.push(id)
|
||||
await Session.updateMessage({
|
||||
await svc.updateMessage({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
@@ -40,7 +61,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: id,
|
||||
@@ -58,7 +79,7 @@ describe("session messages endpoint", () => {
|
||||
Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 5)
|
||||
const app = Server.Default().app
|
||||
|
||||
@@ -75,7 +96,7 @@ describe("session messages endpoint", () => {
|
||||
const bBody = (await b.json()) as MessageV2.WithParts[]
|
||||
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -87,7 +108,7 @@ describe("session messages endpoint", () => {
|
||||
Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 3)
|
||||
const app = Server.Default().app
|
||||
|
||||
@@ -96,7 +117,7 @@ describe("session messages endpoint", () => {
|
||||
const body = (await res.json()) as MessageV2.WithParts[]
|
||||
expect(body.map((item) => item.info.id)).toEqual(ids)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -108,7 +129,7 @@ describe("session messages endpoint", () => {
|
||||
Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const app = Server.Default().app
|
||||
|
||||
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
|
||||
@@ -117,7 +138,7 @@ describe("session messages endpoint", () => {
|
||||
const miss = await app.request(`/session/ses_missing/message?limit=2`)
|
||||
expect(miss.status).toBe(404)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -129,7 +150,7 @@ describe("session messages endpoint", () => {
|
||||
Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
await fill(session.id, 520)
|
||||
const app = Server.Default().app
|
||||
|
||||
@@ -138,7 +159,7 @@ describe("session messages endpoint", () => {
|
||||
const body = (await res.json()) as MessageV2.WithParts[]
|
||||
expect(body).toHaveLength(510)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Session } from "../../src/session"
|
||||
import { Effect } from "effect"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import type { SessionID } from "../../src/session/schema"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
@@ -7,6 +9,20 @@ import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
remove(id: SessionID) {
|
||||
return run(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
@@ -18,7 +34,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// #given
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
|
||||
// #when
|
||||
const app = Server.Default().app
|
||||
@@ -33,7 +49,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const body = await response.json()
|
||||
expect(body).toBe(true)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { APICallError } from "ai"
|
||||
import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
@@ -14,7 +15,7 @@ import { Log } from "../../src/util/log"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { Session } from "../../src/session"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
@@ -29,6 +30,26 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
messages(input: z.output<typeof SessionNs.MessagesInput>) {
|
||||
return run(SessionNs.Service.use((svc) => svc.messages(input)))
|
||||
},
|
||||
updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
},
|
||||
updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
},
|
||||
}
|
||||
|
||||
const summary = Layer.succeed(
|
||||
SessionSummary.Service,
|
||||
SessionSummary.Service.of({
|
||||
@@ -80,7 +101,7 @@ function createModel(opts: {
|
||||
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
|
||||
|
||||
async function user(sessionID: SessionID, text: string) {
|
||||
const msg = await Session.updateMessage({
|
||||
const msg = await svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
@@ -88,7 +109,7 @@ async function user(sessionID: SessionID, text: string) {
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID,
|
||||
@@ -119,12 +140,12 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string
|
||||
time: { created: Date.now() },
|
||||
finish: "end_turn",
|
||||
}
|
||||
await Session.updateMessage(msg)
|
||||
await svc.updateMessage(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) {
|
||||
return Session.updatePart({
|
||||
return svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID,
|
||||
@@ -171,7 +192,7 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
|
||||
Layer.provide(provider.layer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionNs.defaultLayer),
|
||||
Layer.provide(layer(result)),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(plugin),
|
||||
@@ -191,9 +212,9 @@ const deps = Layer.mergeAll(
|
||||
)
|
||||
|
||||
const env = Layer.mergeAll(
|
||||
Session.defaultLayer,
|
||||
SessionNs.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
SessionCompaction.layer.pipe(Layer.provide(Session.defaultLayer), Layer.provideMerge(deps)),
|
||||
SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)),
|
||||
)
|
||||
|
||||
const it = testEffect(env)
|
||||
@@ -227,7 +248,7 @@ function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fa
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
||||
Layer.provide(provider.layer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionNs.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(layer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
@@ -467,9 +488,9 @@ describe("session.compaction.create", () => {
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const compact = yield* SessionCompaction.Service
|
||||
const session = yield* Session.Service
|
||||
const ssn = yield* SessionNs.Service
|
||||
|
||||
const info = yield* session.create({})
|
||||
const info = yield* ssn.create({})
|
||||
|
||||
yield* compact.create({
|
||||
sessionID: info.id,
|
||||
@@ -479,7 +500,7 @@ describe("session.compaction.create", () => {
|
||||
overflow: true,
|
||||
})
|
||||
|
||||
const msgs = yield* session.messages({ sessionID: info.id })
|
||||
const msgs = yield* ssn.messages({ sessionID: info.id })
|
||||
expect(msgs).toHaveLength(1)
|
||||
expect(msgs[0].info.role).toBe("user")
|
||||
expect(msgs[0].parts).toHaveLength(1)
|
||||
@@ -499,9 +520,9 @@ describe("session.compaction.prune", () => {
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const compact = yield* SessionCompaction.Service
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const a = yield* session.updateMessage({
|
||||
const ssn = yield* SessionNs.Service
|
||||
const info = yield* ssn.create({})
|
||||
const a = yield* ssn.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
@@ -509,7 +530,7 @@ describe("session.compaction.prune", () => {
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
yield* session.updatePart({
|
||||
yield* ssn.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: a.id,
|
||||
sessionID: info.id,
|
||||
@@ -536,8 +557,8 @@ describe("session.compaction.prune", () => {
|
||||
time: { created: Date.now() },
|
||||
finish: "end_turn",
|
||||
}
|
||||
yield* session.updateMessage(b)
|
||||
yield* session.updatePart({
|
||||
yield* ssn.updateMessage(b)
|
||||
yield* ssn.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: b.id,
|
||||
sessionID: info.id,
|
||||
@@ -554,7 +575,7 @@ describe("session.compaction.prune", () => {
|
||||
},
|
||||
})
|
||||
for (const text of ["second", "third"]) {
|
||||
const msg = yield* session.updateMessage({
|
||||
const msg = yield* ssn.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
@@ -562,7 +583,7 @@ describe("session.compaction.prune", () => {
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
yield* session.updatePart({
|
||||
yield* ssn.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID: info.id,
|
||||
@@ -573,7 +594,7 @@ describe("session.compaction.prune", () => {
|
||||
|
||||
yield* compact.prune({ sessionID: info.id })
|
||||
|
||||
const msgs = yield* session.messages({ sessionID: info.id })
|
||||
const msgs = yield* ssn.messages({ sessionID: info.id })
|
||||
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
||||
expect(part?.type).toBe("tool")
|
||||
expect(part?.state.status).toBe("completed")
|
||||
@@ -589,9 +610,9 @@ describe("session.compaction.prune", () => {
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const compact = yield* SessionCompaction.Service
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const a = yield* session.updateMessage({
|
||||
const ssn = yield* SessionNs.Service
|
||||
const info = yield* ssn.create({})
|
||||
const a = yield* ssn.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
@@ -599,7 +620,7 @@ describe("session.compaction.prune", () => {
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
yield* session.updatePart({
|
||||
yield* ssn.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: a.id,
|
||||
sessionID: info.id,
|
||||
@@ -626,8 +647,8 @@ describe("session.compaction.prune", () => {
|
||||
time: { created: Date.now() },
|
||||
finish: "end_turn",
|
||||
}
|
||||
yield* session.updateMessage(b)
|
||||
yield* session.updatePart({
|
||||
yield* ssn.updateMessage(b)
|
||||
yield* ssn.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: b.id,
|
||||
sessionID: info.id,
|
||||
@@ -644,7 +665,7 @@ describe("session.compaction.prune", () => {
|
||||
},
|
||||
})
|
||||
for (const text of ["second", "third"]) {
|
||||
const msg = yield* session.updateMessage({
|
||||
const msg = yield* ssn.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
@@ -652,7 +673,7 @@ describe("session.compaction.prune", () => {
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
yield* session.updatePart({
|
||||
yield* ssn.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID: info.id,
|
||||
@@ -663,7 +684,7 @@ describe("session.compaction.prune", () => {
|
||||
|
||||
yield* compact.prune({ sessionID: info.id })
|
||||
|
||||
const msgs = yield* session.messages({ sessionID: info.id })
|
||||
const msgs = yield* ssn.messages({ sessionID: info.id })
|
||||
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
||||
expect(part?.type).toBe("tool")
|
||||
if (part?.type === "tool" && part.state.status === "completed") {
|
||||
@@ -680,12 +701,12 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const reply = await assistant(session.id, msg.id, tmp.path)
|
||||
const rt = runtime("continue")
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
await expect(
|
||||
rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
@@ -710,9 +731,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const done = defer()
|
||||
let seen = false
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
@@ -760,11 +781,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = runtime("compact", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
@@ -776,7 +797,7 @@ describe("session.compaction.process", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const summary = (await Session.messages({ sessionID: session.id })).find(
|
||||
const summary = (await svc.messages({ sessionID: session.id })).find(
|
||||
(msg) => msg.info.role === "assistant" && msg.info.summary,
|
||||
)
|
||||
|
||||
@@ -798,11 +819,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
@@ -814,7 +835,7 @@ describe("session.compaction.process", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const all = await Session.messages({ sessionID: session.id })
|
||||
const all = await svc.messages({ sessionID: session.id })
|
||||
const last = all.at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
@@ -838,11 +859,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = runtime("continue", autocontinue(false), wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
@@ -854,7 +875,7 @@ describe("session.compaction.process", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const all = await Session.messages({ sessionID: session.id })
|
||||
const all = await svc.messages({ sessionID: session.id })
|
||||
const last = all.at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
@@ -881,10 +902,10 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
await user(session.id, "root")
|
||||
const replay = await user(session.id, "image")
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: replay.id,
|
||||
sessionID: session.id,
|
||||
@@ -896,7 +917,7 @@ describe("session.compaction.process", () => {
|
||||
const msg = await user(session.id, "current")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
@@ -909,7 +930,7 @@ describe("session.compaction.process", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const last = (await Session.messages({ sessionID: session.id })).at(-1)
|
||||
const last = (await svc.messages({ sessionID: session.id })).at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
@@ -929,13 +950,13 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
await user(session.id, "earlier")
|
||||
const msg = await user(session.id, "current")
|
||||
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
@@ -948,7 +969,7 @@ describe("session.compaction.process", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const last = (await Session.messages({ sessionID: session.id })).at(-1)
|
||||
const last = (await svc.messages({ sessionID: session.id })).at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
@@ -989,9 +1010,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const abort = new AbortController()
|
||||
const rt = liveRuntime(stub.layer, wide())
|
||||
let off: (() => void) | undefined
|
||||
@@ -1063,9 +1084,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
const abort = new AbortController()
|
||||
const rt = runtime("continue", plugin(ready), wide())
|
||||
let run: Promise<"continue" | "stop"> | undefined
|
||||
@@ -1100,7 +1121,7 @@ describe("session.compaction.process", () => {
|
||||
abort.abort()
|
||||
expect(await run).toBe("stop")
|
||||
|
||||
const all = await Session.messages({ sessionID: session.id })
|
||||
const all = await svc.messages({ sessionID: session.id })
|
||||
expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false)
|
||||
} finally {
|
||||
abort.abort()
|
||||
@@ -1165,11 +1186,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = liveRuntime(stub.layer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const msgs = await svc.messages({ sessionID: session.id })
|
||||
await rt.runPromise(
|
||||
SessionCompaction.Service.use((svc) =>
|
||||
svc.process({
|
||||
@@ -1181,7 +1202,7 @@ describe("session.compaction.process", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const summary = (await Session.messages({ sessionID: session.id })).find(
|
||||
const summary = (await svc.messages({ sessionID: session.id })).find(
|
||||
(item) => item.info.role === "assistant" && item.info.summary,
|
||||
)
|
||||
|
||||
@@ -1211,10 +1232,10 @@ describe("util.token.estimate", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.getUsage", () => {
|
||||
describe("SessionNs.getUsage", () => {
|
||||
test("normalizes standard usage to token format", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
@@ -1241,7 +1262,7 @@ describe("session.getUsage", () => {
|
||||
|
||||
test("extracts cached tokens to cache.read", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
@@ -1265,7 +1286,7 @@ describe("session.getUsage", () => {
|
||||
|
||||
test("handles anthropic cache write metadata", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
@@ -1294,7 +1315,7 @@ describe("session.getUsage", () => {
|
||||
test("subtracts cached tokens for anthropic provider", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
@@ -1321,7 +1342,7 @@ describe("session.getUsage", () => {
|
||||
|
||||
test("separates reasoning tokens from output tokens", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
@@ -1355,7 +1376,7 @@ describe("session.getUsage", () => {
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
})
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
@@ -1380,7 +1401,7 @@ describe("session.getUsage", () => {
|
||||
|
||||
test("handles undefined optional values gracefully", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
@@ -1416,7 +1437,7 @@ describe("session.getUsage", () => {
|
||||
cache: { read: 0.3, write: 3.75 },
|
||||
},
|
||||
})
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 1_000_000,
|
||||
@@ -1457,7 +1478,7 @@ describe("session.getUsage", () => {
|
||||
},
|
||||
}
|
||||
if (npm === "@ai-sdk/amazon-bedrock") {
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage,
|
||||
metadata: {
|
||||
@@ -1478,7 +1499,7 @@ describe("session.getUsage", () => {
|
||||
return
|
||||
}
|
||||
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage,
|
||||
metadata: {
|
||||
@@ -1499,7 +1520,7 @@ describe("session.getUsage", () => {
|
||||
|
||||
test("extracts cache write tokens from vertex metadata key", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
||||
const result = Session.getUsage({
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
@@ -10,12 +11,32 @@ import { Log } from "../../src/util/log"
|
||||
const root = path.join(__dirname, "../..")
|
||||
Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
remove(id: SessionID) {
|
||||
return run(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
},
|
||||
updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
},
|
||||
updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
},
|
||||
}
|
||||
|
||||
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
|
||||
const ids = [] as MessageID[]
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = MessageID.ascending()
|
||||
ids.push(id)
|
||||
await Session.updateMessage({
|
||||
await svc.updateMessage({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
@@ -25,7 +46,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: id,
|
||||
@@ -38,7 +59,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
|
||||
|
||||
async function addUser(sessionID: SessionID, text?: string) {
|
||||
const id = MessageID.ascending()
|
||||
await Session.updateMessage({
|
||||
await svc.updateMessage({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
@@ -49,7 +70,7 @@ async function addUser(sessionID: SessionID, text?: string) {
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
if (text) {
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: id,
|
||||
@@ -66,7 +87,7 @@ async function addAssistant(
|
||||
opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] },
|
||||
) {
|
||||
const id = MessageID.ascending()
|
||||
await Session.updateMessage({
|
||||
await svc.updateMessage({
|
||||
id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
@@ -87,7 +108,7 @@ async function addAssistant(
|
||||
}
|
||||
|
||||
async function addCompactionPart(sessionID: SessionID, messageID: MessageID) {
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID,
|
||||
@@ -101,14 +122,14 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
await fill(session.id, 2)
|
||||
|
||||
const result = MessageV2.page({ sessionID: session.id, limit: 10 })
|
||||
expect(result).toBeDefined()
|
||||
expect(result.items).toBeArray()
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -117,7 +138,7 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 6)
|
||||
|
||||
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
|
||||
@@ -136,7 +157,7 @@ describe("MessageV2.page", () => {
|
||||
expect(c.more).toBe(false)
|
||||
expect(c.cursor).toBeUndefined()
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -145,13 +166,13 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 4)
|
||||
|
||||
const result = MessageV2.page({ sessionID: session.id, limit: 4 })
|
||||
expect(result.items.map((item) => item.info.id)).toEqual(ids)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -160,14 +181,14 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
|
||||
const result = MessageV2.page({ sessionID: session.id, limit: 10 })
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.more).toBe(false)
|
||||
expect(result.cursor).toBeUndefined()
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -186,7 +207,7 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 3)
|
||||
|
||||
const result = MessageV2.page({ sessionID: session.id, limit: 3 })
|
||||
@@ -194,7 +215,7 @@ describe("MessageV2.page", () => {
|
||||
expect(result.more).toBe(false)
|
||||
expect(result.cursor).toBeUndefined()
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -203,7 +224,7 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 5)
|
||||
|
||||
const result = MessageV2.page({ sessionID: session.id, limit: 1 })
|
||||
@@ -211,7 +232,7 @@ describe("MessageV2.page", () => {
|
||||
expect(result.items[0].info.id).toBe(ids[ids.length - 1])
|
||||
expect(result.more).toBe(true)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -220,10 +241,10 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const [id] = await fill(session.id, 1)
|
||||
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: session.id,
|
||||
messageID: id,
|
||||
@@ -235,7 +256,7 @@ describe("MessageV2.page", () => {
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].parts).toHaveLength(2)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -244,7 +265,7 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 4, (i) => 1000.5 + i)
|
||||
|
||||
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
|
||||
@@ -253,7 +274,7 @@ describe("MessageV2.page", () => {
|
||||
expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2))
|
||||
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -262,7 +283,7 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 4, () => 1000)
|
||||
|
||||
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
|
||||
@@ -273,7 +294,7 @@ describe("MessageV2.page", () => {
|
||||
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
|
||||
expect(b.more).toBe(false)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -282,8 +303,8 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const a = await Session.create({})
|
||||
const b = await Session.create({})
|
||||
const a = await svc.create({})
|
||||
const b = await svc.create({})
|
||||
await fill(a.id, 3)
|
||||
await fill(b.id, 2)
|
||||
|
||||
@@ -294,8 +315,8 @@ describe("MessageV2.page", () => {
|
||||
expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true)
|
||||
expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true)
|
||||
|
||||
await Session.remove(a.id)
|
||||
await Session.remove(b.id)
|
||||
await svc.remove(a.id)
|
||||
await svc.remove(b.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -304,7 +325,7 @@ describe("MessageV2.page", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 10)
|
||||
|
||||
const result = MessageV2.page({ sessionID: session.id, limit: 100 })
|
||||
@@ -313,7 +334,7 @@ describe("MessageV2.page", () => {
|
||||
expect(result.more).toBe(false)
|
||||
expect(result.cursor).toBeUndefined()
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -324,13 +345,13 @@ describe("MessageV2.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 5)
|
||||
|
||||
const items = Array.from(MessageV2.stream(session.id))
|
||||
expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse())
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -339,12 +360,12 @@ describe("MessageV2.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
|
||||
const items = Array.from(MessageV2.stream(session.id))
|
||||
expect(items).toHaveLength(0)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -353,14 +374,14 @@ describe("MessageV2.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 1)
|
||||
|
||||
const items = Array.from(MessageV2.stream(session.id))
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].info.id).toBe(ids[0])
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -369,7 +390,7 @@ describe("MessageV2.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
await fill(session.id, 3)
|
||||
|
||||
const items = Array.from(MessageV2.stream(session.id))
|
||||
@@ -378,7 +399,7 @@ describe("MessageV2.stream", () => {
|
||||
expect(item.parts[0].type).toBe("text")
|
||||
}
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -387,7 +408,7 @@ describe("MessageV2.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 60)
|
||||
|
||||
const items = Array.from(MessageV2.stream(session.id))
|
||||
@@ -395,7 +416,7 @@ describe("MessageV2.stream", () => {
|
||||
expect(items[0].info.id).toBe(ids[ids.length - 1])
|
||||
expect(items[59].info.id).toBe(ids[0])
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -404,7 +425,7 @@ describe("MessageV2.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
await fill(session.id, 1)
|
||||
|
||||
const gen = MessageV2.stream(session.id)
|
||||
@@ -414,7 +435,7 @@ describe("MessageV2.stream", () => {
|
||||
expect(first).toHaveProperty("done")
|
||||
expect(first.done).toBe(false)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -425,7 +446,7 @@ describe("MessageV2.parts", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const [id] = await fill(session.id, 1)
|
||||
|
||||
const result = MessageV2.parts(id)
|
||||
@@ -433,7 +454,7 @@ describe("MessageV2.parts", () => {
|
||||
expect(result[0].type).toBe("text")
|
||||
expect((result[0] as MessageV2.TextPart).text).toBe("m0")
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -442,13 +463,13 @@ describe("MessageV2.parts", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const id = await addUser(session.id)
|
||||
|
||||
const result = MessageV2.parts(id)
|
||||
expect(result).toEqual([])
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -457,17 +478,17 @@ describe("MessageV2.parts", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const [id] = await fill(session.id, 1)
|
||||
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: session.id,
|
||||
messageID: id,
|
||||
type: "text",
|
||||
text: "second",
|
||||
})
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: session.id,
|
||||
messageID: id,
|
||||
@@ -481,7 +502,7 @@ describe("MessageV2.parts", () => {
|
||||
expect((result[1] as MessageV2.TextPart).text).toBe("second")
|
||||
expect((result[2] as MessageV2.TextPart).text).toBe("third")
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -490,7 +511,7 @@ describe("MessageV2.parts", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
await Session.create({})
|
||||
await svc.create({})
|
||||
const result = MessageV2.parts(MessageID.ascending())
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
@@ -501,14 +522,14 @@ describe("MessageV2.parts", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const [id] = await fill(session.id, 1)
|
||||
|
||||
const result = MessageV2.parts(id)
|
||||
expect(result[0].sessionID).toBe(session.id)
|
||||
expect(result[0].messageID).toBe(id)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -519,7 +540,7 @@ describe("MessageV2.get", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const [id] = await fill(session.id, 1)
|
||||
|
||||
const result = MessageV2.get({ sessionID: session.id, messageID: id })
|
||||
@@ -529,7 +550,7 @@ describe("MessageV2.get", () => {
|
||||
expect(result.parts).toHaveLength(1)
|
||||
expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0")
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -538,13 +559,13 @@ describe("MessageV2.get", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
|
||||
expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow(
|
||||
"NotFoundError",
|
||||
)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -553,16 +574,16 @@ describe("MessageV2.get", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const a = await Session.create({})
|
||||
const b = await Session.create({})
|
||||
const a = await svc.create({})
|
||||
const b = await svc.create({})
|
||||
const [id] = await fill(a.id, 1)
|
||||
|
||||
expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError")
|
||||
const result = MessageV2.get({ sessionID: a.id, messageID: id })
|
||||
expect(result.info.id).toBe(id)
|
||||
|
||||
await Session.remove(a.id)
|
||||
await Session.remove(b.id)
|
||||
await svc.remove(a.id)
|
||||
await svc.remove(b.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -571,10 +592,10 @@ describe("MessageV2.get", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const [id] = await fill(session.id, 1)
|
||||
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: session.id,
|
||||
messageID: id,
|
||||
@@ -585,7 +606,7 @@ describe("MessageV2.get", () => {
|
||||
const result = MessageV2.get({ sessionID: session.id, messageID: id })
|
||||
expect(result.parts).toHaveLength(2)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -594,11 +615,11 @@ describe("MessageV2.get", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const uid = await addUser(session.id, "hello")
|
||||
const aid = await addAssistant(session.id, uid)
|
||||
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: session.id,
|
||||
messageID: aid,
|
||||
@@ -611,7 +632,7 @@ describe("MessageV2.get", () => {
|
||||
expect(result.parts).toHaveLength(1)
|
||||
expect((result.parts[0] as MessageV2.TextPart).text).toBe("response")
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -620,14 +641,14 @@ describe("MessageV2.get", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const id = await addUser(session.id)
|
||||
|
||||
const result = MessageV2.get({ sessionID: session.id, messageID: id })
|
||||
expect(result.info.id).toBe(id)
|
||||
expect(result.parts).toEqual([])
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -638,7 +659,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 5)
|
||||
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
|
||||
@@ -646,7 +667,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
// reversed from newest-first to chronological
|
||||
expect(result.map((item) => item.info.id)).toEqual(ids)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -655,13 +676,13 @@ describe("MessageV2.filterCompacted", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
|
||||
// Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2
|
||||
// Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break
|
||||
const u1 = await addUser(session.id, "first question")
|
||||
const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" })
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: session.id,
|
||||
messageID: a1,
|
||||
@@ -672,7 +693,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
|
||||
const u2 = await addUser(session.id, "new question")
|
||||
const a2 = await addAssistant(session.id, u2)
|
||||
await Session.updatePart({
|
||||
await svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: session.id,
|
||||
messageID: a2,
|
||||
@@ -685,7 +706,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
expect(result[0].info.id).toBe(u1)
|
||||
expect(result.length).toBe(4)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -699,7 +720,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
|
||||
const u1 = await addUser(session.id, "hello")
|
||||
await addCompactionPart(session.id, u1)
|
||||
@@ -708,7 +729,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -717,7 +738,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
|
||||
const u1 = await addUser(session.id, "hello")
|
||||
await addCompactionPart(session.id, u1)
|
||||
@@ -733,7 +754,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
// Error assistant doesn't add to completed, so compaction boundary never triggers
|
||||
expect(result).toHaveLength(3)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -742,7 +763,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
|
||||
const u1 = await addUser(session.id, "hello")
|
||||
await addCompactionPart(session.id, u1)
|
||||
@@ -754,7 +775,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
|
||||
expect(result).toHaveLength(3)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -808,7 +829,7 @@ describe("MessageV2 consistency", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
await fill(session.id, 3)
|
||||
|
||||
const paged = MessageV2.page({ sessionID: session.id, limit: 10 })
|
||||
@@ -818,7 +839,7 @@ describe("MessageV2 consistency", () => {
|
||||
expect(got.parts).toEqual(item.parts)
|
||||
}
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -827,14 +848,14 @@ describe("MessageV2 consistency", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const [id] = await fill(session.id, 1)
|
||||
|
||||
const got = MessageV2.get({ sessionID: session.id, messageID: id })
|
||||
const standalone = MessageV2.parts(id)
|
||||
expect(got.parts).toEqual(standalone)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -843,7 +864,7 @@ describe("MessageV2 consistency", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
await fill(session.id, 7)
|
||||
|
||||
const streamed = Array.from(MessageV2.stream(session.id))
|
||||
@@ -861,7 +882,7 @@ describe("MessageV2 consistency", () => {
|
||||
|
||||
expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id))
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -870,7 +891,7 @@ describe("MessageV2 consistency", () => {
|
||||
await Instance.provide({
|
||||
directory: root,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const session = await svc.create({})
|
||||
const ids = await fill(session.id, 4)
|
||||
|
||||
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
|
||||
@@ -878,7 +899,7 @@ describe("MessageV2 consistency", () => {
|
||||
|
||||
expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id))
|
||||
|
||||
await Session.remove(session.id)
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { Log } from "../../src/util/log"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Search } from "../../src/file/search"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -187,7 +187,7 @@ function makeHttp() {
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Search.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
@@ -210,7 +210,7 @@ function makeHttp() {
|
||||
Layer.provide(SystemPrompt.defaultLayer),
|
||||
Layer.provideMerge(deps),
|
||||
),
|
||||
)
|
||||
).pipe(Layer.provide(summary))
|
||||
}
|
||||
|
||||
const it = testEffect(makeHttp())
|
||||
@@ -384,25 +384,23 @@ it.live("loop calls LLM and returns assistant message", () =>
|
||||
it.live("static loop returns assistant text through local provider", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const session = yield* Effect.promise(() =>
|
||||
Session.create({
|
||||
title: "Prompt provider",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
}),
|
||||
)
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({
|
||||
title: "Prompt provider",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
)
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
|
||||
yield* llm.text("world")
|
||||
|
||||
const result = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
|
||||
const result = yield* prompt.loop({ sessionID: session.id })
|
||||
expect(result.info.role).toBe("assistant")
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
|
||||
expect(yield* llm.hits).toHaveLength(1)
|
||||
@@ -415,40 +413,36 @@ it.live("static loop returns assistant text through local provider", () =>
|
||||
it.live("static loop consumes queued replies across turns", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const session = yield* Effect.promise(() =>
|
||||
Session.create({
|
||||
title: "Prompt provider turns",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
}),
|
||||
)
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({
|
||||
title: "Prompt provider turns",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello one" }],
|
||||
}),
|
||||
)
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello one" }],
|
||||
})
|
||||
|
||||
yield* llm.text("world one")
|
||||
|
||||
const first = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
|
||||
const first = yield* prompt.loop({ sessionID: session.id })
|
||||
expect(first.info.role).toBe("assistant")
|
||||
expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello two" }],
|
||||
}),
|
||||
)
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello two" }],
|
||||
})
|
||||
|
||||
yield* llm.text("world two")
|
||||
|
||||
const second = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
|
||||
const second = yield* prompt.loop({ sessionID: session.id })
|
||||
expect(second.info.role).toBe("assistant")
|
||||
expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Session } from "../../src/session"
|
||||
@@ -12,6 +13,12 @@ import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
|
||||
return Effect.runPromise(
|
||||
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
|
||||
)
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
@@ -104,34 +111,39 @@ describe("session.prompt missing file", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
|
||||
const missing = path.join(tmp.path, "does-not-exist.ts")
|
||||
const msg = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [
|
||||
{ type: "text", text: "please review @does-not-exist.ts" },
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${missing}`,
|
||||
filename: "does-not-exist.ts",
|
||||
},
|
||||
],
|
||||
})
|
||||
const missing = path.join(tmp.path, "does-not-exist.ts")
|
||||
const msg = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [
|
||||
{ type: "text", text: "please review @does-not-exist.ts" },
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${missing}`,
|
||||
filename: "does-not-exist.ts",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (msg.info.role !== "user") throw new Error("expected user message")
|
||||
if (msg.info.role !== "user") throw new Error("expected user message")
|
||||
|
||||
const hasFailure = msg.parts.some(
|
||||
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
|
||||
)
|
||||
expect(hasFailure).toBe(true)
|
||||
const hasFailure = msg.parts.some(
|
||||
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
|
||||
)
|
||||
expect(hasFailure).toBe(true)
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
yield* sessions.remove(session.id)
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,39 +161,44 @@ describe("session.prompt missing file", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
|
||||
const missing = path.join(tmp.path, "still-missing.ts")
|
||||
const msg = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${missing}`,
|
||||
filename: "still-missing.ts",
|
||||
},
|
||||
{ type: "text", text: "after-file" },
|
||||
],
|
||||
})
|
||||
const missing = path.join(tmp.path, "still-missing.ts")
|
||||
const msg = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${missing}`,
|
||||
filename: "still-missing.ts",
|
||||
},
|
||||
{ type: "text", text: "after-file" },
|
||||
],
|
||||
})
|
||||
|
||||
if (msg.info.role !== "user") throw new Error("expected user message")
|
||||
if (msg.info.role !== "user") throw new Error("expected user message")
|
||||
|
||||
const stored = await MessageV2.get({
|
||||
sessionID: session.id,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
|
||||
const stored = MessageV2.get({
|
||||
sessionID: session.id,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
|
||||
|
||||
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
|
||||
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
|
||||
expect(text[2]).toBe("after-file")
|
||||
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
|
||||
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
|
||||
expect(text[2]).toBe("after-file")
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
yield* sessions.remove(session.id)
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -197,31 +214,36 @@ describe("session.prompt special characters", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const template = "Read @file#name.txt"
|
||||
const parts = await SessionPrompt.resolvePromptParts(template)
|
||||
const fileParts = parts.filter((part) => part.type === "file")
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
const template = "Read @file#name.txt"
|
||||
const parts = yield* prompt.resolvePromptParts(template)
|
||||
const fileParts = parts.filter((part) => part.type === "file")
|
||||
|
||||
expect(fileParts.length).toBe(1)
|
||||
expect(fileParts[0].filename).toBe("file#name.txt")
|
||||
expect(fileParts[0].url).toContain("%23")
|
||||
expect(fileParts.length).toBe(1)
|
||||
expect(fileParts[0].filename).toBe("file#name.txt")
|
||||
expect(fileParts[0].url).toContain("%23")
|
||||
|
||||
const decodedPath = fileURLToPath(fileParts[0].url)
|
||||
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
|
||||
const decodedPath = fileURLToPath(fileParts[0].url)
|
||||
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
|
||||
|
||||
const message = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts,
|
||||
noReply: true,
|
||||
})
|
||||
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
||||
const textParts = stored.parts.filter((part) => part.type === "text")
|
||||
const hasContent = textParts.some((part) => part.text.includes("special content"))
|
||||
expect(hasContent).toBe(true)
|
||||
const message = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts,
|
||||
noReply: true,
|
||||
})
|
||||
const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
||||
const textParts = stored.parts.filter((part) => part.type === "text")
|
||||
const hasContent = textParts.some((part) => part.text.includes("special content"))
|
||||
expect(hasContent).toBe(true)
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
yield* sessions.remove(session.id)
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -273,21 +295,26 @@ describe("session.prompt regression", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Prompt regression" })
|
||||
const result = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
|
||||
})
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Prompt regression" })
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
|
||||
expect(calls).toBe(1)
|
||||
},
|
||||
const msgs = yield* sessions.messages({ sessionID: session.id })
|
||||
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
|
||||
expect(calls).toBe(1)
|
||||
}),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
@@ -342,36 +369,45 @@ describe("session.prompt regression", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Prompt cancel regression" })
|
||||
const run = SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
parts: [{ type: "text", text: "Cancel me" }],
|
||||
})
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Prompt cancel regression" })
|
||||
const task = Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
parts: [{ type: "text", text: "Cancel me" }],
|
||||
}),
|
||||
)
|
||||
|
||||
await ready.promise
|
||||
await SessionPrompt.cancel(session.id)
|
||||
yield* Effect.promise(() => ready.promise)
|
||||
yield* prompt.cancel(session.id)
|
||||
|
||||
const result = await Promise.race([
|
||||
run,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
|
||||
),
|
||||
])
|
||||
const result = yield* Effect.promise(() =>
|
||||
Promise.race([
|
||||
task,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.error?.name).toBe("MessageAbortedError")
|
||||
}
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.error?.name).toBe("MessageAbortedError")
|
||||
}
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const last = msgs.findLast((msg) => msg.info.role === "assistant")
|
||||
expect(last?.info.role).toBe("assistant")
|
||||
if (last?.info.role === "assistant") {
|
||||
expect(last.info.error?.name).toBe("MessageAbortedError")
|
||||
}
|
||||
},
|
||||
const msgs = yield* sessions.messages({ sessionID: session.id })
|
||||
const last = msgs.findLast((msg) => msg.info.role === "assistant")
|
||||
expect(last?.info.role).toBe("assistant")
|
||||
if (last?.info.role === "assistant") {
|
||||
expect(last.info.error?.name).toBe("MessageAbortedError")
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
@@ -399,45 +435,50 @@ describe("session.prompt agent variant", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
|
||||
const other = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
if (other.info.role !== "user") throw new Error("expected user message")
|
||||
expect(other.info.model.variant).toBeUndefined()
|
||||
const other = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
if (other.info.role !== "user") throw new Error("expected user message")
|
||||
expect(other.info.model.variant).toBeUndefined()
|
||||
|
||||
const match = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello again" }],
|
||||
})
|
||||
if (match.info.role !== "user") throw new Error("expected user message")
|
||||
expect(match.info.model).toEqual({
|
||||
providerID: ProviderID.make("openai"),
|
||||
modelID: ModelID.make("gpt-5.2"),
|
||||
variant: "xhigh",
|
||||
})
|
||||
expect(match.info.model.variant).toBe("xhigh")
|
||||
const match = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello again" }],
|
||||
})
|
||||
if (match.info.role !== "user") throw new Error("expected user message")
|
||||
expect(match.info.model).toEqual({
|
||||
providerID: ProviderID.make("openai"),
|
||||
modelID: ModelID.make("gpt-5.2"),
|
||||
variant: "xhigh",
|
||||
})
|
||||
expect(match.info.model.variant).toBe("xhigh")
|
||||
|
||||
const override = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
variant: "high",
|
||||
parts: [{ type: "text", text: "hello third" }],
|
||||
})
|
||||
if (override.info.role !== "user") throw new Error("expected user message")
|
||||
expect(override.info.model.variant).toBe("high")
|
||||
const override = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
variant: "high",
|
||||
parts: [{ type: "text", text: "hello third" }],
|
||||
})
|
||||
if (override.info.role !== "user") throw new Error("expected user message")
|
||||
expect(override.info.model.variant).toBe("high")
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
yield* sessions.remove(session.id)
|
||||
}),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OPENAI_API_KEY
|
||||
@@ -451,24 +492,33 @@ describe("session.agent-resolution", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const err = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "nonexistent-agent-xyz",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
)
|
||||
expect(err).toBeDefined()
|
||||
expect(err).not.toBeInstanceOf(TypeError)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
|
||||
}
|
||||
},
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
const err = yield* Effect.promise(() =>
|
||||
Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "nonexistent-agent-xyz",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
),
|
||||
)
|
||||
expect(err).toBeDefined()
|
||||
expect(err).not.toBeInstanceOf(TypeError)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
@@ -476,22 +526,31 @@ describe("session.agent-resolution", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const err = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "nonexistent-agent-xyz",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain("build")
|
||||
}
|
||||
},
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
const err = yield* Effect.promise(() =>
|
||||
Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "nonexistent-agent-xyz",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
),
|
||||
)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain("build")
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
@@ -499,24 +558,33 @@ describe("session.agent-resolution", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const err = await SessionPrompt.command({
|
||||
sessionID: session.id,
|
||||
command: "nonexistent-command-xyz",
|
||||
arguments: "",
|
||||
}).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
)
|
||||
expect(err).toBeDefined()
|
||||
expect(err).not.toBeInstanceOf(TypeError)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
|
||||
expect(err.data.message).toContain("init")
|
||||
}
|
||||
},
|
||||
fn: () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
const err = yield* Effect.promise(() =>
|
||||
Effect.runPromise(
|
||||
prompt.command({
|
||||
sessionID: session.id,
|
||||
command: "nonexistent-command-xyz",
|
||||
arguments: "",
|
||||
}),
|
||||
).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
),
|
||||
)
|
||||
expect(err).toBeDefined()
|
||||
expect(err).not.toBeInstanceOf(TypeError)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
|
||||
expect(err.data.message).toContain("init")
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
@@ -1,43 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Session } from "../../src/session"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
Log.init({ print: false })
|
||||
|
||||
function create(input?: SessionNs.CreateInput) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
}
|
||||
|
||||
function get(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
|
||||
function remove(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
}
|
||||
|
||||
function updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
}
|
||||
|
||||
function updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
}
|
||||
|
||||
describe("session.created event", () => {
|
||||
test("should emit session.created event when session is created", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
let eventReceived = false
|
||||
let receivedInfo: Session.Info | undefined
|
||||
let receivedInfo: SessionNs.Info | undefined
|
||||
|
||||
const unsub = Bus.subscribe(Session.Event.Created, (event) => {
|
||||
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
|
||||
eventReceived = true
|
||||
receivedInfo = event.properties.info as Session.Info
|
||||
receivedInfo = event.properties.info as SessionNs.Info
|
||||
})
|
||||
|
||||
const session = await Session.create({})
|
||||
|
||||
const info = await create({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
unsub()
|
||||
|
||||
expect(eventReceived).toBe(true)
|
||||
expect(receivedInfo).toBeDefined()
|
||||
expect(receivedInfo?.id).toBe(session.id)
|
||||
expect(receivedInfo?.projectID).toBe(session.projectID)
|
||||
expect(receivedInfo?.directory).toBe(session.directory)
|
||||
expect(receivedInfo?.title).toBe(session.title)
|
||||
expect(receivedInfo?.id).toBe(info.id)
|
||||
expect(receivedInfo?.projectID).toBe(info.projectID)
|
||||
expect(receivedInfo?.directory).toBe(info.directory)
|
||||
expect(receivedInfo?.title).toBe(info.title)
|
||||
|
||||
await Session.remove(session.id)
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -48,18 +67,16 @@ describe("session.created event", () => {
|
||||
fn: async () => {
|
||||
const events: string[] = []
|
||||
|
||||
const unsubCreated = Bus.subscribe(Session.Event.Created, () => {
|
||||
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
|
||||
events.push("created")
|
||||
})
|
||||
|
||||
const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => {
|
||||
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
|
||||
events.push("updated")
|
||||
})
|
||||
|
||||
const session = await Session.create({})
|
||||
|
||||
const info = await create({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
unsubCreated()
|
||||
unsubUpdated()
|
||||
|
||||
@@ -67,7 +84,7 @@ describe("session.created event", () => {
|
||||
expect(events).toContain("updated")
|
||||
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
|
||||
|
||||
await Session.remove(session.id)
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -80,12 +97,12 @@ describe("step-finish token propagation via Bus event", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const info = await create({})
|
||||
|
||||
const messageID = MessageID.ascending()
|
||||
await Session.updateMessage({
|
||||
await updateMessage({
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
sessionID: info.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "user",
|
||||
@@ -110,15 +127,14 @@ describe("step-finish token propagation via Bus event", () => {
|
||||
const partInput = {
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID: session.id,
|
||||
sessionID: info.id,
|
||||
type: "step-finish" as const,
|
||||
reason: "stop",
|
||||
cost: 0.005,
|
||||
tokens,
|
||||
}
|
||||
|
||||
await Session.updatePart(partInput)
|
||||
|
||||
await updatePart(partInput)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toBeDefined()
|
||||
@@ -134,7 +150,7 @@ describe("step-finish token propagation via Bus event", () => {
|
||||
expect(received).not.toBe(partInput)
|
||||
|
||||
unsub()
|
||||
await Session.remove(session.id)
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -146,17 +162,17 @@ describe("Session", () => {
|
||||
test("remove works without an instance", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const session = await Instance.provide({
|
||||
const info = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "remove-without-instance" }),
|
||||
fn: () => create({ title: "remove-without-instance" }),
|
||||
})
|
||||
|
||||
await expect(async () => {
|
||||
await Session.remove(session.id)
|
||||
await remove(info.id)
|
||||
}).not.toThrow()
|
||||
|
||||
let missing = false
|
||||
await Session.get(session.id).catch(() => {
|
||||
await get(info.id).catch(() => {
|
||||
missing = true
|
||||
})
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Search } from "../../src/file/search"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
|
||||
Log.init({ print: false })
|
||||
@@ -141,7 +141,7 @@ function makeHttp() {
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Search.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { Log } from "../../src/util/log"
|
||||
@@ -20,51 +21,63 @@ async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
|
||||
})
|
||||
}
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
|
||||
return Effect.runPromise(
|
||||
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
|
||||
)
|
||||
}
|
||||
|
||||
describe("StructuredOutput Integration", () => {
|
||||
test.skipIf(!hasApiKey)(
|
||||
"produces structured output with simple schema",
|
||||
async () => {
|
||||
await withInstance(async () => {
|
||||
const session = await Session.create({ title: "Structured Output Test" })
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Structured Output Test" })
|
||||
|
||||
const result = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 2 + 2? Provide a simple answer.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
answer: { type: "number", description: "The numerical answer" },
|
||||
explanation: { type: "string", description: "Brief explanation" },
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 2 + 2? Provide a simple answer.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
answer: { type: "number", description: "The numerical answer" },
|
||||
explanation: { type: "string", description: "Brief explanation" },
|
||||
},
|
||||
required: ["answer"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
required: ["answer"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
expect(typeof result.info.structured).toBe("object")
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
expect(typeof result.info.structured).toBe("object")
|
||||
|
||||
const output = result.info.structured as any
|
||||
expect(output.answer).toBe(4)
|
||||
const output = result.info.structured as any
|
||||
expect(output.answer).toBe(4)
|
||||
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
})
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
@@ -72,62 +85,68 @@ describe("StructuredOutput Integration", () => {
|
||||
test.skipIf(!hasApiKey)(
|
||||
"produces structured output with nested objects",
|
||||
async () => {
|
||||
await withInstance(async () => {
|
||||
const session = await Session.create({ title: "Nested Schema Test" })
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Nested Schema Test" })
|
||||
|
||||
const result = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Tell me about Anthropic company in a structured format.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
company: {
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Tell me about Anthropic company in a structured format.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
founded: { type: "number" },
|
||||
company: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
founded: { type: "number" },
|
||||
},
|
||||
required: ["name", "founded"],
|
||||
},
|
||||
products: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["name", "founded"],
|
||||
},
|
||||
products: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
required: ["company"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
required: ["company"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
const output = result.info.structured as any
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
const output = result.info.structured as any
|
||||
|
||||
expect(output.company).toBeDefined()
|
||||
expect(output.company.name).toBe("Anthropic")
|
||||
expect(typeof output.company.founded).toBe("number")
|
||||
expect(output.company).toBeDefined()
|
||||
expect(output.company.name).toBe("Anthropic")
|
||||
expect(typeof output.company.founded).toBe("number")
|
||||
|
||||
if (output.products) {
|
||||
expect(Array.isArray(output.products)).toBe(true)
|
||||
}
|
||||
if (output.products) {
|
||||
expect(Array.isArray(output.products)).toBe(true)
|
||||
}
|
||||
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
})
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
@@ -135,35 +154,41 @@ describe("StructuredOutput Integration", () => {
|
||||
test.skipIf(!hasApiKey)(
|
||||
"works with text outputFormat (default)",
|
||||
async () => {
|
||||
await withInstance(async () => {
|
||||
const session = await Session.create({ title: "Text Output Test" })
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Text Output Test" })
|
||||
|
||||
const result = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Say hello.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "text",
|
||||
},
|
||||
})
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Say hello.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "text",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify no structured output (text mode) and no error
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeUndefined()
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no structured output (text mode) and no error
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeUndefined()
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Verify we got a response with parts
|
||||
expect(result.parts.length).toBeGreaterThan(0)
|
||||
// Verify we got a response with parts
|
||||
expect(result.parts.length).toBeGreaterThan(0)
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
})
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
@@ -171,47 +196,53 @@ describe("StructuredOutput Integration", () => {
|
||||
test.skipIf(!hasApiKey)(
|
||||
"stores outputFormat on user message",
|
||||
async () => {
|
||||
await withInstance(async () => {
|
||||
const session = await Session.create({ title: "OutputFormat Storage Test" })
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
|
||||
|
||||
await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 1 + 1?",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { type: "number" },
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 1 + 1?",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { type: "number" },
|
||||
},
|
||||
required: ["result"],
|
||||
},
|
||||
retryCount: 3,
|
||||
},
|
||||
required: ["result"],
|
||||
},
|
||||
retryCount: 3,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Get all messages from session
|
||||
const messages = await Session.messages({ sessionID: session.id })
|
||||
const userMessage = messages.find((m) => m.info.role === "user")
|
||||
// Get all messages from session
|
||||
const messages = yield* sessions.messages({ sessionID: session.id })
|
||||
const userMessage = messages.find((m) => m.info.role === "user")
|
||||
|
||||
// Verify outputFormat was stored on user message
|
||||
expect(userMessage).toBeDefined()
|
||||
if (userMessage?.info.role === "user") {
|
||||
expect(userMessage.info.format).toBeDefined()
|
||||
expect(userMessage.info.format?.type).toBe("json_schema")
|
||||
if (userMessage.info.format?.type === "json_schema") {
|
||||
expect(userMessage.info.format.retryCount).toBe(3)
|
||||
}
|
||||
}
|
||||
// Verify outputFormat was stored on user message
|
||||
expect(userMessage).toBeDefined()
|
||||
if (userMessage?.info.role === "user") {
|
||||
expect(userMessage.info.format).toBeDefined()
|
||||
expect(userMessage.info.format?.type).toBe("json_schema")
|
||||
if (userMessage.info.format?.type === "json_schema") {
|
||||
expect(userMessage.info.format.retryCount).toBe(3)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
})
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
|
||||
@@ -1024,6 +1024,7 @@ describe("tool.bash abort", () => {
|
||||
)
|
||||
expect(result.output).toContain("started")
|
||||
expect(result.output).toContain("bash tool terminated command after exceeding timeout")
|
||||
expect(result.output).toContain("retry with a larger timeout value in milliseconds")
|
||||
},
|
||||
})
|
||||
}, 15_000)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Search } from "../../src/file/search"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
@@ -15,7 +15,7 @@ const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Search.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Search } from "../../src/file/search"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -15,7 +15,7 @@ const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Search.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
),
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Search } from "../../src/file/search"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
@@ -43,7 +42,6 @@ const it = testEffect(
|
||||
FileTime.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Search.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void;
|
||||
}
|
||||
}
|
||||
|
||||
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
|
||||
function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts {
|
||||
const id = MessageID.ascending()
|
||||
return {
|
||||
info: {
|
||||
|
||||
Reference in New Issue
Block a user