Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@
"poe-oauth": "0.0.6"
},
"patchedDependencies": {
"@opentui/solid@https://github.com/SMARK2022/opentui/releases/download/v0.4.3-smark.13/opentui-solid-0.4.3-smark.13.tgz": "patches/@opentui%2Fsolid@0.4.3-smark.13.patch",
"@opentui/core@https://github.com/SMARK2022/opentui/releases/download/v0.4.3-smark.13/opentui-core-0.4.3-smark.13.tgz": "patches/@opentui%2Fcore@0.4.3-smark.13.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
Expand Down
20 changes: 12 additions & 8 deletions packages/codemode/src/interpreter/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { parse } from "acorn"
import { Cause, Effect, Exit, Fiber, Scope } from "effect" // 根 Scope 统一拥有 Promise 生产者;许可移交真实工具入口。
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
import {
copyIn,
copyOut,
Expand Down Expand Up @@ -117,7 +116,9 @@ import {

// 仅缓存纯语法树,不携带环境或能力;八项同时容纳用户源码与 fetch 的常量工厂。
const programs = new Map<string, ProgramNode>()
const parseProgram = (code: string): ProgramNode => {
// 两处转译共用实际加载的 TypeScript;仅传递依赖,不改语法缓存或诊断处理。
const parseProgram = (code: string, typescript: typeof import("typescript")): ProgramNode => {
const { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } = typescript
const cached = programs.get(code)
if (cached) return cached
// 保存真实包装长度,解析诊断才能区分用户源码与解释器添加的内容。
Expand Down Expand Up @@ -715,6 +716,7 @@ class Interpreter<R> {
) {}

static make<R>(
typescript: typeof import("typescript"),
execution: Execution<R>,
hasFetch: boolean,
environment = new Environment(),
Expand Down Expand Up @@ -767,7 +769,7 @@ class Interpreter<R> {
return { url: data.url, status: data.status, statusText: data.statusText, ok: data.ok, redirected: data.redirected,
headers: { get: name => data.headers[name.toLowerCase()] ?? null, has: name => Object.keys(data.headers).includes(name.toLowerCase()), entries: () => Object.entries(data.headers) },
text: async () => data.text, json: async () => JSON.parse(data.text), bytes: async () => data.bytes };
}`).body[0], "fetch response")
}`, typescript).body[0], "fetch response")
// 响应闭包与普通函数共用全局能力,异步读方法不创建第二套宿主状态。
execution.fetchResponse = new CodeModeFunction(
getArray(factory, "params").map((param) => asNode(param, "params")),
Expand Down Expand Up @@ -3482,19 +3484,21 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
})
}

const operation = Effect.gen(function* () {
// 目录读取不执行程序;编译器在实际运行时加载,不改变工具、回调和空代码的构造时点。
const operation = (typescript: typeof import("typescript")) => Effect.gen(function* () {
// 先让外层 race 登记根 Fiber,再进入脚本;脚本内部仍同步启动每个 Promise。
yield* Effect.yieldNow
const environment =
options.snapshotEnvironment && options.environment
? snapshotEnvironment(options.environment)
: options.environment
const program = parseProgram(options.code)
const program = parseProgram(options.code, typescript)
const promises: Array<SandboxPromise> = []
let unobservedFailure = false
// 正常 return 也关闭仍在运行的生产者;已完成的宿主副作用保持原样。
const value = yield* Effect.scopedWith((scope) =>
Interpreter.make(
typescript,
{
invokeTool: tools.invoke,
toolKeys: tools.keys,
Expand Down Expand Up @@ -3557,9 +3561,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
} satisfies Result),
),
)
})

return operation.pipe(
}).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
Expand All @@ -3572,6 +3574,8 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
),
Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
)
// 原预算仍只约束解析和执行;导入不计入预算,也不转成脚本可消费的失败诊断。
return Effect.promise(() => import("typescript")).pipe(Effect.flatMap(operation))
}

const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
Expand Down
94 changes: 92 additions & 2 deletions packages/codemode/test/codemode.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
// 参数形式回归沿用真实转换型 Schema,避免只测展示用 JSON。
import { Cause, Deferred, Effect, Exit, Fiber, Schema, SchemaGetter } from "effect"
import { Cause, Clock, Deferred, Duration, Effect, Exit, Fiber, Scheduler, Schema, SchemaGetter } from "effect"
import { TestClock } from "effect/testing"
import { CodeMode, Tool, toolError } from "../src/index.js"

Expand Down Expand Up @@ -859,6 +859,72 @@ describe("CodeMode public contract", () => {
const input: CodeMode.Input = { code: source }
expect(Schema.decodeUnknownSync(CodeMode.Input)(input)).toStrictEqual(input)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable)

// 同一执行契约内用新进程检查冷加载边界,不新增测试注册。
const child = Bun.spawn(
[
process.execPath,
"--no-install",
"--eval",
`
const { CodeMode } = await import(${JSON.stringify(new URL("../src/index.ts", import.meta.url).href)})
const { Effect } = await import("effect")
// resolve 只定位已安装编译器;cache 观察不能为检查而提前执行模块。
const compiler = require.resolve("typescript")
const loaded = () => Boolean(require.cache[compiler])
// 库导入、目录渲染和两种 Effect 构造都不执行程序,分别检查资源边界。
const states = [loaded()]
const runtime = CodeMode.make()
const metadata = { catalog: runtime.catalog(), hasInstructions: runtime.instructions().length > 0 }
states.push(loaded())
const prepared = runtime.execute("const answer: number = 42; return answer")
// fetch 只取本地 Effect 数据;响应 json 方法仍经过解释器的第二处真实转译。
const direct = CodeMode.execute({
code: 'const response: object = await fetch("https://example.invalid"); return { url: response.url, status: response.status, body: await response.json() }',
fetch: (url) => Effect.succeed({ url, status: 200, statusText: "OK", ok: true,
redirected: false, headers: {}, text: "2", bytes: [50] }),
})
states.push(loaded())
// 真实类型注解要求两入口仍使用同一编译器,不能通过跳过执行获得轻量结果。
const values = await Promise.all([Effect.runPromise(prepared), Effect.runPromise(direct)])
console.log(JSON.stringify({ states, loaded: loaded(), metadata, values }))
`,
],
{
cwd: import.meta.dir,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
// 子进程四秒预算短于既有用例默认五秒,保留回收时间;不放宽原用例期限。
timeout: 4_000,
},
)
try {
// 同时消费两条管道并等待退出,防止资源探针自身因输出反压而挂起。
const [exit, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect({ exit, stderr }).toEqual({ exit: 0, stderr: "" })
expect(JSON.parse(stdout)).toStrictEqual({
states: [false, false, false],
loaded: true,
metadata: { catalog: [], hasInstructions: true },
values: [
{ ok: true, value: 42, toolCalls: [] },
{
ok: true,
value: { url: "https://example.invalid", status: 200, body: 2 },
toolCalls: [{ name: "$codemode.fetch" }],
},
],
})
} finally {
// 即使读取或断言失败,本测试也负责终止并收完自己创建的进程。
if (child.exitCode === null) child.kill()
await child.exited
}
})

test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
Expand Down Expand Up @@ -2802,11 +2868,35 @@ describe("CodeMode retained environments", () => {
expect(calls).toEqual([1])
expect(yield* CodeMode.execute({ environment, code: "return count;" })).toMatchObject({ value: 0 })

const base = yield* Clock.Clock
const ready = yield* Deferred.make<void>()
const observed = {
// 显式转发原 Clock 方法,只观察本例的真实计时器,不替换默认 sleep 能力。
currentTimeMillis: base.currentTimeMillis,
currentTimeNanos: base.currentTimeNanos,
currentTimeMillisUnsafe: () => base.currentTimeMillisUnsafe(),
currentTimeNanosUnsafe: () => base.currentTimeNanosUnsafe(),
sleep: (duration: Parameters<typeof base.sleep>[0]) => {
if (Duration.toMillis(duration) !== 100) return base.sleep(duration)
return Effect.gen(function* () {
// 仅计时器启动段禁自动让步;原 TestClock 入队后才允许父测试推进时间。
const timer = yield* base.sleep(duration).pipe(
Effect.forkChild({ startImmediately: true }),
Effect.provideService(Scheduler.PreventSchedulerYield, true),
)
yield* Deferred.succeed(ready, undefined)
// 结构化子任务仍随真实执行取消,解释器自身的调度策略不变。
yield* Fiber.join(timer)
})
},
}
const running = yield* CodeMode.execute({
environment,
tools,
code: "return await work();",
}).pipe(Effect.forkChild)
}).pipe(Effect.provideService(Clock.Clock, observed), Effect.forkChild)
// 动态导入可能先挂起;等待计时器实际登记,而不是假定 fork 后已经开始睡眠。
yield* Deferred.await(ready)
// 不靠墙钟猜测:推进到到期前一毫秒仍不能出现第二次真实调用。
yield* TestClock.adjust(99)
expect(calls).toEqual([1])
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/cli/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,12 @@ if (isDaemon) {
await reconcileStale({ kind: "exit-full" }, { budgetMs: remaining }).catch((error) => log.error("stale-turn exit-full failed", { error: String(error) }))
} else log.info("stale-turn exit-full skipped: services unavailable or no cleanup budget")

// 主动停机让末帧走既有 HTTP 优雅窗口;其他退出仍强关,五秒硬截止不变。
// 监听资源归 daemon 所有,共享它的 TUI 退出只释放自己的连接。
const stopped = await Promise.allSettled([
Promise.resolve().then(() => controlServer?.stop(true)),
Promise.resolve().then(() => externalServer?.stop(true)),
Promise.resolve().then(() => internalServer?.stop(true)),
Promise.resolve().then(() => externalServer?.stop(reason !== DisposedReason.DaemonStop)),
Promise.resolve().then(() => internalServer?.stop(reason !== DisposedReason.DaemonStop)),
])
// scoped文件logger关闭即排空缓冲;等待仍受原五秒硬截止保护。
const runtime = await Promise.allSettled([AppRuntime.dispose(), daemonLogging.dispose()])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RootHttpApi } from "../api"
import { GlobalUpgradeInput } from "../groups/global"
import { EventClients } from "@opencode-ai/server/event-clients"
import { PartView } from "@opencode-ai/core/session/part-view"
import { DisposedReason, Event } from "@/server/event"
import type { MessageV2 } from "@/session/message-v2"

function eventData(data: unknown): Sse.Event {
Expand Down Expand Up @@ -67,6 +68,10 @@ function eventResponse(clients: EventClients.Interface, viewer: boolean) {
// 计数属于响应 body 的 scope;未消费响应不虚增,取消流自动释放。
Stream.unwrap(Effect.as(clients.acquire, Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
// 仅主动停机成为末帧;普通 dispose/凭证刷新仍保留同一订阅。
Stream.takeUntil((event) =>
event.payload?.type === Event.Disposed.type && event.payload.properties?.reason === DisposedReason.DaemonStop,
),
Stream.map((event) => eventData(viewer ? projectViewerEvent(event) : event)),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ export function createSharedServices() {
AppNodeBuilderV1.build(MoveSession.node, replacements),
).pipe(Layer.provideMerge(locationServiceMapV2))
})).pipe(Layer.provideMerge(legacyOwners))
return { layer, memoMap }
// 复用组合根及其 scope;仅共享应用服务,listener 的认证与路由仍各自构建。
return { layer: Layer.suspend(() => layer), memoMap }
}

export function createRoutes(
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>, list
return Effect.gen(function* () {
const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore))
const closeScopeOnce = yield* Effect.cached(
Scope.close(state.scope, Exit.void).pipe(
state.websockets.closeAll.pipe(
Effect.ensuring(Scope.close(state.scope, Exit.void)),
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
Expand Down
10 changes: 8 additions & 2 deletions packages/opencode/test/cli/run/lazy-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ import path from "path"
test("importing the run command does not execute the interactive runtime module", async () => {
// clearAll会连同preload的Solid转换一起清除;仅在子进程注册探针,父进程不改插件表。
// 子进程只导入命令元数据,runtime被替换为计数器,绝不执行交互handler。
// 在命令依赖有机会触发转换前取样,验证全局插件注册不提前加载 Babel。
const child = Bun.spawn(
[
process.execPath,
"-e",
`const key = Symbol.for("opencode.test.run-runtime-loaded");
`const { createRequire } = await import("node:module");
const babelCoreLoaded = Object.keys(createRequire(import.meta.url).cache).some(
(file) => /[/\\\\]@babel[/\\\\]core[/\\\\]/.test(file),
);
const key = Symbol.for("opencode.test.run-runtime-loaded");
globalThis[key] = 0;
Bun.plugin({
name: "run-runtime-eager-probe",
Expand All @@ -21,7 +26,7 @@ Bun.plugin({
});
const command = await import("./src/cli/cmd/run");
await Bun.sleep(0);
console.log(JSON.stringify({ command: command.RunCommand.command, loaded: globalThis[key] }));`,
console.log(JSON.stringify({ command: command.RunCommand.command, loaded: globalThis[key], babelCoreLoaded }));`,
],
{
cwd: path.join(import.meta.dir, "../../.."),
Expand All @@ -40,4 +45,5 @@ console.log(JSON.stringify({ command: command.RunCommand.command, loaded: global
const result = JSON.parse(stdout.trim().split("\n").at(-1)!)
expect(result.command).toContain("run")
expect(result.loaded).toBe(0)
expect(result.babelCoreLoaded).toBe(false)
})
Loading
Loading