Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53242d581b | ||
|
|
7d85813a0f | ||
|
|
ef96fdfd83 | ||
|
|
241b7b5792 | ||
|
|
53d45b2894 | ||
|
|
9d11b62ff0 | ||
|
|
1c453bd4e1 | ||
|
|
4f455f1869 | ||
|
|
d33517c6b5 | ||
|
|
99c758e343 | ||
|
|
27de1ee982 | ||
|
|
8e0676ebea | ||
|
|
990a705063 | ||
|
|
33a78ead3d | ||
|
|
3739eee8cf | ||
|
|
816f5e526b | ||
|
|
63abe037d8 | ||
|
|
f2733330f7 | ||
|
|
c41dc21292 | ||
|
|
f80bf7a423 | ||
|
|
13ea36f575 | ||
|
|
02b7367b4e | ||
|
|
aa4cf681a9 | ||
|
|
f1c0b2651a | ||
|
|
eab9a4f564 | ||
|
|
012fc184bc | ||
|
|
0ed88cfc21 | ||
|
|
98689fb125 | ||
|
|
62bd7c82d3 |
@@ -2,7 +2,12 @@
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {},
|
||||
"permission": {},
|
||||
"mcp": {},
|
||||
"mcp": {
|
||||
"opencode": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:43110/mcp"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
"github-pr-search": false,
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# Effect Service Dependency Graph — Simulated Routes
|
||||
|
||||
Generated for `createSimulatedRoutes` in `packages/opencode/src/server/routes/instance/httpapi/server.ts`.
|
||||
|
||||
## Notation
|
||||
|
||||
- `→ X` means "yields `X.Service` from its `Effect.gen` body at layer init (a true `RIn` of `.layer`)"
|
||||
- `(lazy)` means "uses `InstanceState.context` or similar at call time, not at layer construction"
|
||||
- `(opt)` means "uses `Effect.serviceOption(X)` — not strictly required"
|
||||
- `(internal)` means "satisfied internally by the `.layer` itself via `Layer.provide(...)`, NOT a residual requirement"
|
||||
|
||||
## Service → Dependencies (current, post-rebase)
|
||||
|
||||
```
|
||||
─── External / Platform ─────────────────────────────────────────
|
||||
NodePath (no app deps) provides Path.Path
|
||||
FetchHttpClient (no app deps) provides HttpClient.HttpClient
|
||||
HttpServer.layerServices (no app deps)
|
||||
ChildProcessSpawner (from SimulationSpawner) (no app deps)
|
||||
|
||||
─── Leaf services (no app deps) ─────────────────────────────────
|
||||
Global (no app deps)
|
||||
Env (no app deps — uses InstanceState.make, no Service yields)
|
||||
Bus (no app deps — uses InstanceState.make, no Service yields)
|
||||
SyncEvent → RuntimeFlags, Bus (NEW — previously listed as leaf/lazy)
|
||||
AccountRepo (no app deps — pure DB closures)
|
||||
PtyTicket (no app deps — Cache only)
|
||||
Truncate → AppFileSystem
|
||||
|
||||
─── Middleware/route layers (no app service deps) ───────────────
|
||||
errorLayer
|
||||
compressionLayer → HttpServerRequest (builtin)
|
||||
corsVaryFix
|
||||
fenceLayer → HttpServerRequest (builtin)
|
||||
simulationShareNextLayer provides ShareNext (Layer.succeed override)
|
||||
|
||||
─── Simulation overrides ────────────────────────────────────────
|
||||
SimulationFileSystem provides AppFileSystem (does NOT provide FileSystem.FileSystem;
|
||||
tier0 also merges FileSystem.layerNoop({}) for that tag)
|
||||
SimulationSpawner provides ChildProcessSpawner (Layer.succeed; no deps)
|
||||
SimulationNetwork provides SimulationNetwork.Service + HttpClient.HttpClient
|
||||
(httpClientLayer composed inside `layer(options)`)
|
||||
SimulationGit → AppFileSystem (overrides Git tag)
|
||||
SimulationProvider → Simulation (overrides Provider tag)
|
||||
Simulation → AppFileSystem, SimulationNetwork
|
||||
|
||||
─── Core services ───────────────────────────────────────────────
|
||||
EffectFlock → Global, AppFileSystem
|
||||
Auth → AppFileSystem
|
||||
McpAuth → AppFileSystem
|
||||
Account → AccountRepo, HttpClient
|
||||
Npm → AppFileSystem, Global, FileSystem.FileSystem, EffectFlock
|
||||
Config → AppFileSystem, Auth, Account, Env, Npm
|
||||
Permission → Bus
|
||||
Plugin → Bus, Config, RuntimeFlags (NEW: RuntimeFlags)
|
||||
Discovery → AppFileSystem, Path, HttpClient
|
||||
Skill → Discovery, Config, Bus, AppFileSystem, Global,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
SystemPrompt → Skill
|
||||
|
||||
─── File / git ──────────────────────────────────────────────────
|
||||
Ripgrep → AppFileSystem, HttpClient, ChildProcessSpawner
|
||||
File → AppFileSystem, Ripgrep, Git, Scope
|
||||
FileWatcher → Config, Git
|
||||
Format → Config, AppProcess, RuntimeFlags (CHANGED: was ChildProcessSpawner; now AppProcess + RuntimeFlags)
|
||||
Snapshot → AppFileSystem, AppProcess, Config (CHANGED: was ChildProcessSpawner)
|
||||
Storage → AppFileSystem, Git
|
||||
Vcs → Git, Bus, Scope
|
||||
Worktree → Scope, AppFileSystem, Path, AppProcess,
|
||||
Git, Project, InstanceStore (CHANGED: AppProcess instead of ChildProcessSpawner)
|
||||
Project → AppFileSystem, Path, ChildProcessSpawner,
|
||||
Bus, RuntimeFlags (NEW: RuntimeFlags)
|
||||
|
||||
─── Provider / LSP / MCP ────────────────────────────────────────
|
||||
ModelsDev → AppFileSystem, HttpClient
|
||||
ProviderAuth → Auth, Plugin
|
||||
LSP → Config, RuntimeFlags (NEW: RuntimeFlags)
|
||||
McpAuth → AppFileSystem
|
||||
MCP → ChildProcessSpawner, McpAuth, Bus, Config
|
||||
|
||||
─── Session graph ───────────────────────────────────────────────
|
||||
Todo → Bus
|
||||
Question → Bus
|
||||
SessionStatus → Bus
|
||||
SessionRunState → BackgroundJob, SessionStatus (NEW: BackgroundJob)
|
||||
Instruction → Config, AppFileSystem, Global, HttpClient,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
|
||||
Session → BackgroundJob, Bus, Storage, SyncEvent,
|
||||
RuntimeFlags (NEW: BackgroundJob, RuntimeFlags)
|
||||
SessionSummary → Session, Snapshot, Storage, Bus
|
||||
|
||||
SessionRevert → Session, Snapshot, Storage, Bus,
|
||||
SessionSummary, SessionRunState, SyncEvent
|
||||
LLM → Auth, Config, Provider, Plugin, RuntimeFlags (CHANGED: Permission satisfied internally;
|
||||
(Permission satisfied internally via .layer) RuntimeFlags is new)
|
||||
|
||||
Agent → Config, Auth, Plugin, Skill, Provider,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
Command → Config, MCP, Skill
|
||||
|
||||
SessionProcessor → Session, Config, Bus, Snapshot, Agent, LLM,
|
||||
Permission, Plugin, SessionSummary,
|
||||
SessionStatus, Image, EventV2Bridge,
|
||||
RuntimeFlags, Scope (NEW: Image, EventV2Bridge, RuntimeFlags)
|
||||
SessionCompaction → Bus, Config, Session, Agent, Plugin,
|
||||
SessionProcessor, Provider, EventV2Bridge,
|
||||
RuntimeFlags (NEW: EventV2Bridge, RuntimeFlags)
|
||||
SessionPrompt → Bus, SessionStatus, Session, Agent, Provider,
|
||||
SessionProcessor, SessionCompaction, Plugin,
|
||||
Command, Config, Permission, AppFileSystem,
|
||||
MCP, LSP, ToolRegistry, Truncate,
|
||||
ChildProcessSpawner, Scope, Instruction,
|
||||
SessionRunState, SessionRevert,
|
||||
SessionSummary, SystemPrompt, LLM,
|
||||
Image, Reference, EventV2Bridge,
|
||||
RuntimeFlags (NEW: Image, Reference, EventV2Bridge, RuntimeFlags)
|
||||
|
||||
ToolRegistry → Config, Plugin, Question, Todo, Agent, Skill,
|
||||
Session, SessionStatus, BackgroundJob,
|
||||
Provider, Git, Reference, LSP, Instruction,
|
||||
AppFileSystem, Bus, HttpClient,
|
||||
ChildProcessSpawner, Ripgrep, Format,
|
||||
Truncate, RuntimeFlags (NEW: BackgroundJob, Reference, RuntimeFlags)
|
||||
|
||||
─── Share / Workspace ───────────────────────────────────────────
|
||||
ShareNext (provided by simulationShareNextLayer in sim)
|
||||
SessionShare → Config, Session, ShareNext, Scope, SyncEvent,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
Workspace → Auth, Session, SessionPrompt, HttpClient,
|
||||
SyncEvent, Vcs, AppFileSystem, RuntimeFlags (NEW: AppFileSystem (explicit), RuntimeFlags)
|
||||
|
||||
─── Misc ────────────────────────────────────────────────────────
|
||||
Installation → HttpClient, AppProcess (CHANGED: AppProcess instead of ChildProcessSpawner)
|
||||
Pty → Config, Bus, Plugin
|
||||
|
||||
─── Instance lifecycle ──────────────────────────────────────────
|
||||
InstanceBootstrap → Config, File, FileWatcher, Format, LSP, Plugin,
|
||||
Project, Reference, ShareNext, Snapshot, Vcs (NEW: Reference)
|
||||
InstanceStore → Project, InstanceBootstrap, Scope
|
||||
|
||||
Observability (no app deps; provides Logger + tracer)
|
||||
```
|
||||
|
||||
## NEW dependencies introduced since previous graph
|
||||
|
||||
The rebase pulled in several new cross-cutting services that need to be
|
||||
satisfied somewhere in tier0/tier1 of the simulated chain. They are NOT yet
|
||||
listed in the `Tier*Services` unions or merged into any tier in `server.ts`:
|
||||
|
||||
```
|
||||
RuntimeFlags.Service — yielded by ~17 services (Plugin, Skill, Project,
|
||||
Session, SyncEvent, Format, LSP, Instruction,
|
||||
Agent, LLM, SessionShare, SessionProcessor,
|
||||
SessionCompaction, ToolRegistry, SessionPrompt,
|
||||
Workspace, SessionRunState).
|
||||
Source: `@/effect/runtime-flags`.
|
||||
|
||||
AppProcess.Service — yielded by Installation, Format, Snapshot,
|
||||
Worktree (and prod Git, but sim uses SimulationGit).
|
||||
Source: presumed `@opencode-ai/core/app-process`
|
||||
or similar — needs `AppProcess.defaultLayer`.
|
||||
|
||||
BackgroundJob.Service — yielded by Session, SessionRunState, ToolRegistry.
|
||||
Needs `BackgroundJob.defaultLayer`.
|
||||
|
||||
Image.Service — yielded by SessionProcessor, SessionPrompt.
|
||||
|
||||
EventV2Bridge.Service — yielded by SessionProcessor, SessionCompaction,
|
||||
SessionPrompt. Source: `@/event-v2-bridge` (already
|
||||
imported in server.ts but never added to a tier).
|
||||
|
||||
Reference.Service — yielded by ToolRegistry, SessionPrompt,
|
||||
InstanceBootstrap.
|
||||
```
|
||||
|
||||
## Dependency Tiers (topological order)
|
||||
|
||||
Roughly, build order from leaves to roots:
|
||||
|
||||
```
|
||||
Tier 0 (no deps):
|
||||
Global, Env, NodePath, AccountRepo, PtyTicket, Bus, SyncEvent,
|
||||
AppFileSystem (sim), ChildProcessSpawner (sim), HttpClient (sim),
|
||||
SimulationNetwork, FileSystem.layerNoop, simulationShareNextLayer
|
||||
+ (NEW REQUIRED) RuntimeFlags, AppProcess, BackgroundJob, Image,
|
||||
EventV2Bridge, Reference
|
||||
(SyncEvent now depends on RuntimeFlags + Bus, so it's actually tier 1.)
|
||||
|
||||
Tier 1:
|
||||
Auth, Truncate, EffectFlock, Permission, Todo, Question,
|
||||
SessionStatus, McpAuth, Discovery, SimulationGit, Ripgrep, Account,
|
||||
SyncEvent (needs RuntimeFlags + Bus)
|
||||
|
||||
Tier 2:
|
||||
Npm, ModelsDev, Project, Installation, Storage, Vcs, SessionRunState
|
||||
|
||||
Tier 3:
|
||||
Config, File, Simulation, Session
|
||||
|
||||
Tier 4:
|
||||
Plugin, FileWatcher, Format, Snapshot, LSP, MCP, Skill, Instruction,
|
||||
SimulationProvider (= Provider)
|
||||
|
||||
Tier 5:
|
||||
Pty, ProviderAuth, SessionSummary, Agent, Command, LLM, SystemPrompt
|
||||
|
||||
Tier 6:
|
||||
SessionRevert, SessionProcessor, SessionShare
|
||||
|
||||
Tier 7:
|
||||
SessionCompaction, ToolRegistry
|
||||
|
||||
Tier 8:
|
||||
SessionPrompt
|
||||
|
||||
Tier 9:
|
||||
Workspace, InstanceBootstrap
|
||||
|
||||
Tier 10:
|
||||
InstanceStore
|
||||
|
||||
Tier 11:
|
||||
Worktree
|
||||
```
|
||||
|
||||
## Potential cycles / hazards
|
||||
|
||||
```
|
||||
Worktree → InstanceStore → InstanceBootstrap → Project → (back to Worktree?)
|
||||
- InstanceBootstrap requires Project (yes)
|
||||
- Project does NOT require Worktree directly
|
||||
- Worktree requires InstanceStore at layer init
|
||||
→ Worktree must be built AFTER InstanceStore.
|
||||
|
||||
SimulationProvider provides Provider tag, depends on Simulation.
|
||||
Many downstream services depend on Provider — those resolve to
|
||||
SimulationProvider in this layer chain.
|
||||
|
||||
SimulationGit provides Git tag, used by File, FileWatcher,
|
||||
Storage, Vcs, Worktree, ToolRegistry tools.
|
||||
|
||||
LLM.layer pipes Layer.provide(Permission.defaultLayer) internally.
|
||||
So LLM's residual requirements no longer include Permission, BUT the
|
||||
sim chain still provides Permission.layer separately (correct — used by
|
||||
SessionProcessor, SessionPrompt directly).
|
||||
```
|
||||
|
||||
## Why the simulated chain fails today
|
||||
|
||||
The current `server.ts` `Tier0Services` union lists:
|
||||
```
|
||||
AppFileSystem, FileSystem.FileSystem, ChildProcessSpawner, HttpClient,
|
||||
SimulationNetwork, Path, Global, Env, Bus, AccountRepo, ShareNext,
|
||||
SyncEvent, PtyTicket
|
||||
```
|
||||
|
||||
But the actual `Layer.mergeAll(...)` body at tier0 has residual requirements
|
||||
beyond that union. The TS error says
|
||||
`Layer<..., never, Service | Service>` — those two unresolved `Service`s
|
||||
are members of the NEW dependencies table above.
|
||||
|
||||
The most likely culprits, in order of leakage:
|
||||
|
||||
1. **`SyncEvent.layer`** now yields `RuntimeFlags` and `Bus`. `Bus` is in tier0
|
||||
already, but `RuntimeFlags` is not provided anywhere in `createSimulatedRoutes`.
|
||||
So `SyncEvent` leaks `RuntimeFlags` into tier0's `RIn`.
|
||||
|
||||
2. Downstream tiers also leak `RuntimeFlags`, `AppProcess`, `BackgroundJob`,
|
||||
`Image`, `EventV2Bridge`, `Reference` — these cascade through every higher
|
||||
tier as `Service | Service | ...` in the error type.
|
||||
|
||||
## Fix strategy
|
||||
|
||||
The minimal change to make tier0 type-check:
|
||||
|
||||
1. Add `RuntimeFlags.defaultLayer` to tier0 and `RuntimeFlags.Service` to
|
||||
`Tier0Services`. This satisfies `SyncEvent`'s new dep and unblocks every
|
||||
service that yields `RuntimeFlags`.
|
||||
2. Add `AppProcess.defaultLayer` (or a simulated equivalent) to tier0 and
|
||||
`AppProcess.Service` to `Tier0Services`. Needed by `Installation`, `Format`,
|
||||
`Snapshot`, `Worktree`.
|
||||
3. Add `BackgroundJob.defaultLayer` to tier0 and `BackgroundJob.Service` to
|
||||
`Tier0Services`. Needed by `Session`, `SessionRunState`, `ToolRegistry`.
|
||||
4. Add `Image.defaultLayer` to tier0 (or wherever it fits) and `Image.Service`
|
||||
to `Tier0Services`. Needed by `SessionProcessor`, `SessionPrompt`.
|
||||
5. Add `EventV2Bridge.defaultLayer` to tier0 and `EventV2Bridge.Service` to
|
||||
`Tier0Services`. (Note: `EventV2Bridge` is already imported in `server.ts`
|
||||
for the production routes but is not in any simulated tier.)
|
||||
6. Add `Reference.defaultLayer` to a tier that satisfies its deps (it's a leaf
|
||||
wrt the listed graph above) and `Reference.Service` to the corresponding
|
||||
tier union. Needed by `ToolRegistry`, `SessionPrompt`, `InstanceBootstrap`.
|
||||
|
||||
Production routes (`createProductionRoutes`) already include
|
||||
`RuntimeFlags.defaultLayer` and `EventV2Bridge.defaultLayer` in the flat
|
||||
`Layer.provide([...])` list — they were simply never carried over to the
|
||||
simulated chain after the rebase.
|
||||
|
||||
## See also
|
||||
|
||||
- `dependency-graph.html` — interactive visualization of the same data.
|
||||
@@ -6,6 +6,7 @@ function truthy(key: string) {
|
||||
}
|
||||
|
||||
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
const OPENCODE_SIMULATION = truthy("OPENCODE_SIMULATION")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
export const Flag = {
|
||||
@@ -28,6 +29,8 @@ export const Flag = {
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||
OPENCODE_SIMULATION,
|
||||
OPENCODE_SIMULATION_BACKEND: OPENCODE_SIMULATION || truthy("OPENCODE_SIMULATION_BACKEND"),
|
||||
|
||||
// Experimental
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
|
||||
@@ -104,6 +104,7 @@ export const Provider = Schema.Struct({
|
||||
})
|
||||
|
||||
export type Provider = Schema.Schema.Type<typeof Provider>
|
||||
export const Catalog = Schema.Record(Schema.String, Provider)
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Record<string, Provider>>
|
||||
|
||||
@@ -54,6 +54,29 @@ export interface Options {
|
||||
level?: Level
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly time: string
|
||||
readonly level: Level
|
||||
readonly tags: Record<string, unknown>
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 5_000
|
||||
const memory: Entry[] = []
|
||||
|
||||
function record(entry: Entry) {
|
||||
memory.push(entry)
|
||||
if (memory.length > MAX_ENTRIES) memory.splice(0, memory.length - MAX_ENTRIES)
|
||||
}
|
||||
|
||||
export function entries(): Entry[] {
|
||||
return memory.slice()
|
||||
}
|
||||
|
||||
export function clearEntries() {
|
||||
memory.length = 0
|
||||
}
|
||||
|
||||
let logpath = ""
|
||||
export function file() {
|
||||
return logpath
|
||||
@@ -139,24 +162,39 @@ export function create(tags?: Record<string, any>) {
|
||||
last = next.getTime()
|
||||
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
|
||||
}
|
||||
|
||||
function capture(level: Level, message: any, extra?: Record<string, any>) {
|
||||
const text = message instanceof Error ? formatError(message) : message === undefined ? "" : String(message)
|
||||
record({
|
||||
time: new Date().toISOString(),
|
||||
level,
|
||||
tags: { ...tags, ...extra },
|
||||
message: text,
|
||||
})
|
||||
}
|
||||
|
||||
const result: Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("DEBUG")) {
|
||||
capture("DEBUG", message, extra)
|
||||
write("DEBUG " + build(message, extra))
|
||||
}
|
||||
},
|
||||
info(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("INFO")) {
|
||||
capture("INFO", message, extra)
|
||||
write("INFO " + build(message, extra))
|
||||
}
|
||||
},
|
||||
error(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("ERROR")) {
|
||||
capture("ERROR", message, extra)
|
||||
write("ERROR " + build(message, extra))
|
||||
}
|
||||
},
|
||||
warn(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("WARN")) {
|
||||
capture("WARN", message, extra)
|
||||
write("WARN " + build(message, extra))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Simulation script runner.
|
||||
|
||||
Usage:
|
||||
bun run.ts <script.json> [options]
|
||||
|
||||
Options:
|
||||
--mcp <url> MCP endpoint (default http://127.0.0.1:43110/mcp)
|
||||
--chunk <n> Actions per step batch (default 3)
|
||||
--max-steps <n> Hard cap on step calls (default unlimited)
|
||||
--level <lvl> Stop level: DEBUG|INFO|WARN|ERROR (default ERROR)
|
||||
--message-includes <s> Only stop when message includes substring
|
||||
--service-includes <s> Only stop when tag.service includes substring
|
||||
--reset Reset sim state + restart TUI before load
|
||||
--no-reset Skip reset (default)
|
||||
--keep-going Don't stop on errors; continue to end
|
||||
--quiet Suppress per-batch progress
|
||||
--json Emit JSON summary at the end
|
||||
--check-every <n> Check logs every N batches (default 1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,8 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
@@ -61,6 +61,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"just-bash": "3.0.1",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Property-Based TUI Testing Working Notes
|
||||
|
||||
## Mock LLM Provider
|
||||
|
||||
- `SessionPrompt` gets model metadata through `Provider.Service.getModel(...)`.
|
||||
- Actual generation is routed through `LLM.Service` / `streamText(...)`, so the first mock should return an AI SDK `LanguageModelV3` from the provider service.
|
||||
- The normal `Provider.layer` builds providers from config/models.dev/plugin state. For first pass, the simulated graph can replace `Provider.Service` with a smaller simulation provider service instead of trying to flow through provider config.
|
||||
- Control state should own an ordered LLM script queue. The provider/model should consume from that queue when the AI SDK calls the language model.
|
||||
- First version can support text-only output. Tool calls and stream chunk fidelity can come next.
|
||||
- Missing script should fail loudly with a typed simulation error, not silently return an empty assistant message.
|
||||
- Implemented `SimulationProvider.layer`, replacing `Provider.Service` in `createSimulatedRoutes`.
|
||||
- The provider exposes provider `simulation` and model `mock`.
|
||||
- `doGenerate` and `doStream` both consume one queued script through `Simulation.Service.nextLLM()`.
|
||||
- Current script support: `text`, `thinking` (treated as text for now), and `error`.
|
||||
- Snapshot currently records `llmQueued` and `llmConsumed`, not per-step details yet.
|
||||
|
||||
## OpenTUI Fake Renderer
|
||||
|
||||
- OpenTUI Solid exposes `testRender(...)` from `@opentui/solid`.
|
||||
- The lower-level core API is `createTestRenderer(...)` from `@opentui/core/testing`.
|
||||
- `createTestRenderer(...)` returns `renderer`, `mockInput`, `mockMouse`, `renderOnce`, `captureCharFrame`, `captureSpans`, and `resize`.
|
||||
- `captureCharFrame()` is the simple screen-buffer string API used heavily in OpenTUI snapshots.
|
||||
- `captureSpans()` returns structured lines/spans plus cursor position, which is a better starting point for visible element discovery than parsing raw characters.
|
||||
- `mockInput` supports interactions like `typeText`, `pressEnter`, and `pressArrow`.
|
||||
- Implemented `TuiSimulation.createSimulationRenderer(...)` beside `thread.ts`. It creates a test renderer and exposes `renderOnce`, `screen`, `spans`, and `destroy`.
|
||||
- `thread.ts` checks `OPENCODE_SIMULATION`, creates the fake renderer there, starts the normal worker/backend, and passes the renderer into `tui(...)`.
|
||||
- Backend route assembly checks `OPENCODE_SIMULATION_BACKEND`; `OPENCODE_SIMULATION` implies this flag.
|
||||
- `OPENCODE_SIMULATION_BACKEND=1` can run a real frontend against a simulated backend.
|
||||
- `tui(...)` now accepts an injected `CliRenderer`, test mode, and an `onReady` callback. Production still creates the real renderer.
|
||||
|
||||
## OpenTUI Action APIs
|
||||
|
||||
- Interactable discovery can walk `renderer.root.getChildren()` recursively.
|
||||
- `Renderable.focusable` and `Renderable.focused` are public and enough to discover focus targets.
|
||||
- `renderer.currentFocusedEditor` identifies active text input/edit-buffer targets for typing/submission.
|
||||
- `renderer.hitTest(x, y)` maps terminal coordinates through the hit grid to a renderable id.
|
||||
- Renderables have public geometry: `screenX`, `screenY`, `width`, `height`, and `num`.
|
||||
- Mouse listener metadata is stored internally on renderables; first pass checks `_mouseListener` / `_mouseListeners` at runtime to identify clickable targets. This is pragmatic but not a stable public API.
|
||||
- Test execution uses `mockInput.typeText`, `mockInput.pressEnter`, `mockInput.pressArrow`, and `mockMouse.click` from OpenTUI testing.
|
||||
- Implemented `SimulationActions` with `elements(...)`, `actions(...)`, and `execute(...)`.
|
||||
@@ -0,0 +1,388 @@
|
||||
# Property-Based TUI Testing
|
||||
|
||||
Status: first-pass implementation plan.
|
||||
|
||||
The goal is to drive the TUI against the real opencode app/backend while replacing external effects with deterministic simulation boundaries. The first pass should produce the smallest end-to-end system that can run the real app in a deterministic simulation environment and assert only that the app does not crash.
|
||||
|
||||
## Scope
|
||||
|
||||
Build these pieces first:
|
||||
|
||||
- Mock `AppFileSystem.Service` layer.
|
||||
- Mock `FetchHttpClient` layer with schema-generated responses through `toArbitrary()`.
|
||||
- Backend simulation control endpoint.
|
||||
- Mock LLM provider controlled by the endpoint.
|
||||
- OpenTUI fake renderer/screen-buffer/interactable-element access.
|
||||
- Basic action generator that drives the TUI forward.
|
||||
- Simulation-only embedded MCP server that lets agents observe and drive the TUI.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No semantic graph yet.
|
||||
- No advanced properties beyond no-crash.
|
||||
- No fake clock/timer control yet.
|
||||
- No shrinking yet.
|
||||
- No broad replacement of app services.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Load the normal app by default.
|
||||
- Keep overrides narrow and explicit.
|
||||
- The first core overrides are `AppFileSystem.Service` and `FetchHttpClient.layer`.
|
||||
- Do not replace `Provider.Service`, `SessionPrompt.Service`, `ToolRegistry.Service`, or the route tree wholesale unless we prove a narrow seam is impossible.
|
||||
- Use a backend control endpoint for LLM scripts and simulation state.
|
||||
- Force `OPENCODE_DB=:memory:` before any code imports `storage/db.ts`.
|
||||
- Run local simulation under `sandbox-exec` using the old branch setup as the starting point.
|
||||
- Use `sandbox-exec` as the safety boundary, not as the normal simulated I/O mechanism.
|
||||
- First built-in property: the app does not crash.
|
||||
- Only two simulation flags exist: `OPENCODE_SIMULATION` and `OPENCODE_SIMULATION_BACKEND`.
|
||||
- `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over stdio and implies backend simulation.
|
||||
- `OPENCODE_SIMULATION_BACKEND` without `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over loopback HTTP; in the TUI it shows the URL in the home screen.
|
||||
|
||||
## Target End-To-End Flow
|
||||
|
||||
1. Start opencode through the simulation runner.
|
||||
2. Runner sets `OPENCODE_DB=:memory:` before backend modules load.
|
||||
3. Runner installs the mock filesystem and mock HTTP client as narrow core overrides.
|
||||
4. Runner starts under `sandbox-exec` with host writes denied and external network denied.
|
||||
5. Runner mounts the TUI with a fake OpenTUI renderer instead of a real terminal.
|
||||
6. Test calls the simulation endpoint to seed filesystem/network/LLM state.
|
||||
7. Action generator performs one TUI action.
|
||||
8. Backend handles real app requests and uses endpoint-provided LLM scripts.
|
||||
9. Runner waits for quiescence.
|
||||
10. Built-in no-crash property checks TUI and backend errors.
|
||||
|
||||
## Mock AppFileSystem
|
||||
|
||||
Goal: backend-visible project/config/state files live in memory and never hit the host filesystem.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add `packages/opencode/src/testing/simulation/filesystem.ts`.
|
||||
- Implement an in-memory filesystem that can back `AppFileSystem.Service`.
|
||||
- Seed it from JSON fixtures supplied through the simulation endpoint or runner config.
|
||||
- Serialize it into replay traces.
|
||||
- Fail unsupported operations with typed simulation errors instead of silently falling back to host FS.
|
||||
- Enable the full simulation runner with `OPENCODE_SIMULATION`.
|
||||
- Enable only backend simulation with `OPENCODE_SIMULATION_BACKEND`.
|
||||
- `OPENCODE_SIMULATION` implies `OPENCODE_SIMULATION_BACKEND`.
|
||||
- Use a fixed virtual root, not `process.cwd()`, so host paths are denied by default.
|
||||
- Use the old branch's Bun preload/plugin redirection only for code paths that bypass `AppFileSystem.Service`.
|
||||
- Let `sandbox-exec` catch any remaining direct `fs`, `Bun.file`, or process-level filesystem access.
|
||||
|
||||
Required capabilities:
|
||||
|
||||
- Files and directories.
|
||||
- Text and binary content.
|
||||
- Deterministic `stat` metadata.
|
||||
- Deterministic path resolution for workspace root, cwd, home, config, state, and temp.
|
||||
- Reads and writes used by tools and config loading.
|
||||
- Directory listing and recursive traversal for glob/grep equivalents.
|
||||
- Snapshot/diff support or enough primitives for existing snapshot code to work.
|
||||
|
||||
Direct bypass candidates identified so far:
|
||||
|
||||
- `tool/read.ts` uses `createReadStream` directly for text line reads.
|
||||
- `patch/index.ts` uses `fs/promises` and `readFileSync` directly.
|
||||
- `storage/db.ts` uses sync `fs` APIs and must be protected by forcing `OPENCODE_DB=:memory:` before import.
|
||||
- `lsp/server.ts`, `util/filesystem.ts`, `file/watcher.ts`, and several CLI/TUI utilities use direct host filesystem APIs.
|
||||
- These should be redirected only when needed; otherwise `sandbox-exec` should catch leaks.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Inspect `AppFileSystem.Service` interface and all methods used by backend code.
|
||||
- [x] List direct `@/util/filesystem`, `fs`, and `Bun.file` bypasses that matter in simulation mode.
|
||||
- [x] Define mock filesystem data model and fixture JSON format.
|
||||
- [x] Implement the `AppFileSystem.Service` layer.
|
||||
- [x] Add typed errors for unsupported operations and host-FS escapes.
|
||||
- [x] Add activation path from startup through `OPENCODE_SIMULATION` / `OPENCODE_SIMULATION_BACKEND`.
|
||||
- [x] Add a tiny fixture that includes `opencode.json`, a workspace root, and a few files.
|
||||
- [ ] Verify read/glob/grep/write/edit use the mock filesystem.
|
||||
- [ ] Verify sandbox denies host writes when a bypass is introduced.
|
||||
|
||||
## Mock FetchHttpClient
|
||||
|
||||
Goal: no backend code makes external network calls. Calls either return generated deterministic mock data or fail with a typed simulation error.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add `packages/opencode/src/testing/simulation/network.ts`.
|
||||
- Provide a narrow replacement for `FetchHttpClient.layer` / `HttpClient.HttpClient` in simulation startup.
|
||||
- Allow loopback only when needed for local app/TUI communication.
|
||||
- Deny all non-loopback network by default.
|
||||
- Add a response registry controlled by the simulation endpoint.
|
||||
- For registered schemas, generate deterministic data with `toArbitrary()` and the run seed.
|
||||
|
||||
Schema inference problem:
|
||||
|
||||
- Raw HTTP requests do not always carry the desired response schema.
|
||||
- First implementation should find where schema information exists for each network call path.
|
||||
- If the schema is not available from the raw `HttpClient` call, add a small registry keyed by request matcher and schema.
|
||||
- The endpoint can register `{ matcher, schema, seedOffset }`, and the mock client can call `toArbitrary(schema)` to generate the response.
|
||||
- Unknown requests should fail loudly instead of returning generic data.
|
||||
|
||||
Network call families found in the first inventory:
|
||||
|
||||
- Effect `HttpClient` with schemas close to the call site:
|
||||
- `account/account.ts`: opencode account/device/auth/org/user/config APIs. Response schemas are local (`TokenRefresh`, `Org`, `User`, `RemoteConfig`, `DeviceAuth`, `DeviceToken`).
|
||||
- `provider/models.ts`: `${OPENCODE_MODELS_URL || "https://models.dev"}/api.json`. Response schema is `Record<string, Provider>` but currently parsed after `res.text`; register this URL to the provider catalog schema.
|
||||
- `share/share-next.ts`: share create/sync/remove. Create response schema is `ShareSchema`; sync/remove can be empty/status-only.
|
||||
- `skill/discovery.ts`: skill index response schema is `Index`; skill file downloads are raw bytes/text.
|
||||
- `session/instruction.ts`: configured remote instruction URLs return text.
|
||||
- `tool/mcp-websearch.ts`, `tool/websearch.ts`, and `tool/codesearch.ts`: MCP-style tool calls to Exa/Parallel. Request schemas are local; response shape is MCP JSON-RPC/SSE with `McpResult`.
|
||||
- `tool/webfetch.ts`: arbitrary user URL returns raw text/html/image bytes, so it needs explicit registration by URL/content type rather than generic schema generation.
|
||||
- Effect `HttpClient` that should usually be disabled in first-pass simulation:
|
||||
- `installation/index.ts`: update/install metadata.
|
||||
- `file/ripgrep.ts`: ripgrep binary download.
|
||||
- UI and workspace proxy paths: allow only explicitly registered workspace URLs or loopback/local app traffic.
|
||||
- Raw `fetch` paths:
|
||||
- `config/config.ts`: well-known and remote config fetches. The schema is loose config JSON; register by configured URL if tests need this path.
|
||||
- `lsp/server.ts`: language-server release/download fetches. Disable by config in simulation or deny unless explicitly registered.
|
||||
- plugin auth/provider helpers (`plugin/codex.ts`, `plugin/github-copilot/*`, CLI commands): not part of first-pass TUI smoke unless explicitly exercised.
|
||||
- Provider SDK calls:
|
||||
- Most model traffic happens inside AI SDK provider packages, not directly through Effect `HttpClient`.
|
||||
- First pass should avoid mocking arbitrary provider SDK HTTP. Instead, register a local mock provider/model through the normal provider path and deny provider SDK fetches unless explicitly registered.
|
||||
- Remote MCP servers:
|
||||
- Config `mcp.<name>.url` is the registration point. When the app is given a remote MCP URL, the simulation network should register that URL as an MCP protocol endpoint for that named server.
|
||||
- The schema is not a single app schema; it is the MCP JSON-RPC/SSE protocol plus configured tool/resource/prompt definitions. The mock network should handle MCP protocol methods for registered MCP URLs and generate tool/list/call responses from simulation state.
|
||||
- The MCP SDK transport may bypass Effect `HttpClient`, so this likely needs either transport-level injection if the SDK supports custom fetch, or the old preload/global `fetch` redirection for registered MCP URLs only.
|
||||
|
||||
Registration model:
|
||||
|
||||
- `SimulationNetwork.Service` owns a registry keyed by method + URL matcher.
|
||||
- Registry entries should include a `source`/`kind` so failures explain why a URL was allowed or denied.
|
||||
- Rough implementation exists at `packages/opencode/src/testing/simulation/network.ts`.
|
||||
- Current rough registry supports exact URL, regex URL, or predicate matchers, optional method filters, parsed request bodies, static responses, dynamic response functions, and full handlers.
|
||||
- `SimulationNetworkRoutes` imports known schemas from the services that own HTTP call sites and registers schema-backed routes for hardcoded/configurable URL families.
|
||||
- Configurable/client-provided URLs should be registered through route-family helpers, e.g. `account(baseUrl)`, `models(baseUrl)`, `share(baseUrl)`, `skills(baseUrl)`, and `installation(registryUrl)`.
|
||||
- Some production schemas are too broad for `Schema.toArbitrary()` today, such as provider catalog fields containing arbitrary mutable JSON. For those cases, the first-pass route can use a narrower generated schema whose values still decode under the production schema.
|
||||
- Supported entry kinds for the first pass:
|
||||
- `jsonSchema`: generate JSON from an Effect `Schema` via `toArbitrary()`.
|
||||
- `text`: return deterministic text/html/markdown content for exact URLs.
|
||||
- `bytes`: return deterministic binary content for exact URLs.
|
||||
- `status`: return empty/status-only responses.
|
||||
- `handler`: inspect method, URL, headers, and parsed body to build a custom response.
|
||||
- `mcp`: handle JSON-RPC/SSE MCP protocol for a configured MCP server URL.
|
||||
- `loopback`: allow local app/TUI traffic only.
|
||||
- Prefer explicit registration at configuration/control boundaries over guessing from arbitrary URLs:
|
||||
- Account/server URL registrations come from account/auth setup.
|
||||
- MCP URL registrations come from `config.mcp`.
|
||||
- Web fetch/search URLs come from the simulation control endpoint or generated tool action.
|
||||
- Provider model responses come from the mock provider script registry, not generic provider SDK HTTP.
|
||||
- Unknown non-loopback URLs fail with a typed simulation network error.
|
||||
|
||||
Layering caveat:
|
||||
|
||||
- Several `defaultLayer`s still provide `FetchHttpClient.layer` internally (`Account`, `ModelsDev`, `ToolRegistry`, `ShareNext`, `SkillDiscovery`, `Instruction`, `Installation`, `Ripgrep`, `Workspace`). A top-level `HttpClient.HttpClient` mock does not necessarily affect those self-contained default layers.
|
||||
- First-pass startup wiring must either use non-default service layers and provide `SimulationNetwork.layer` once, or make these default layers explicitly mock-aware.
|
||||
- The same caveat already exists for `AppFileSystem.defaultLayer` in some default layers, so the final simulation startup needs an explicit “normal app with narrow mock boundaries” layer assembly rather than blindly using all default layers.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Locate all backend uses of `HttpClient.HttpClient`, raw `fetch`, provider SDK fetches, webfetch/websearch/share/update paths.
|
||||
- [x] Classify first-pass network call families into schema-generated, text/bytes, MCP protocol, loopback, and denied.
|
||||
- [x] Decide where `toArbitrary()` lives or which package exports it.
|
||||
- [x] Define rough request matcher shape: exact URL, regex URL, or predicate.
|
||||
- [x] Add method-aware matching and parsed request body support.
|
||||
- [x] Define rough schema registration shape for generated responses.
|
||||
- [x] Add schema-backed route helpers for hardcoded and configurable URL families.
|
||||
- [ ] Define final schema registration shape for generated responses.
|
||||
- [ ] Define MCP URL registration from `config.mcp.<name>.url` to an MCP protocol handler.
|
||||
- [x] Implement rough seeded response generation with `Schema.toArbitrary()`.
|
||||
- [x] Add loopback allowlist handling.
|
||||
- [x] Add typed simulation error for unregistered non-loopback request.
|
||||
- [ ] Verify sandbox also blocks external network if mock client is bypassed.
|
||||
|
||||
## Control Endpoint And Mock LLM Provider
|
||||
|
||||
Goal: tests control backend behavior through an endpoint, and the model follows endpoint-provided scripts through the real prompt/session pipeline.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add simulation control state under `packages/opencode/src/testing/simulation/service.ts`.
|
||||
- Add HTTP routes under a simulation-gated path like `/experimental/simulation/*`.
|
||||
- Keep the route inaccessible unless simulation mode is explicitly enabled.
|
||||
- First pass uses a raw route wrapper at `packages/opencode/src/server/routes/instance/httpapi/simulation.ts` to avoid SDK regeneration while the API shape is still moving.
|
||||
- Current control service can reset state, seed filesystem files, register static network responses, and return a snapshot.
|
||||
- Register/configure a local mock provider/model through the normal provider path.
|
||||
- The simulated route graph replaces `Provider.Service` with `SimulationProvider.layer`.
|
||||
- The mock model reads queued scripts from simulation control state.
|
||||
- Current mock provider supports text/thinking/error actions for the first step only. Tool calls and multi-round step selection are still pending.
|
||||
- No JSON-in-prompt fallback.
|
||||
- Missing script means typed simulation error.
|
||||
|
||||
Initial endpoints:
|
||||
|
||||
- `POST /experimental/simulation/reset`
|
||||
- `POST /experimental/simulation/filesystem/seed`
|
||||
- `POST /experimental/simulation/network/register`
|
||||
- `POST /experimental/simulation/llm/enqueue`
|
||||
- `GET /experimental/simulation/snapshot`
|
||||
|
||||
Initial LLM script:
|
||||
|
||||
```ts
|
||||
type LLMScriptAction =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; name: string; input: Record<string, unknown> }
|
||||
| { type: "list_tools" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
type LLMScript = {
|
||||
steps: LLMScriptAction[][]
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
|
||||
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Keep the old useful rule: step `0` runs before tool results, step `N` runs after `N` tool-result rounds.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Define simulation mode activation flag/env.
|
||||
- [x] Add simulation control state and reset semantics.
|
||||
- [x] Add gated simulation endpoints for reset, filesystem seed, network register, and snapshot.
|
||||
- [x] Decide raw route vs typed HttpApi route. Raw route for first pass; no SDK regeneration yet.
|
||||
- [x] Implement mock provider/model on the normal provider path.
|
||||
- [x] Make missing scripts fail with a typed simulation error.
|
||||
- [x] Record consumed script count in simulation snapshot.
|
||||
- [ ] Support tool-call script actions.
|
||||
- [ ] Support multi-step script selection after tool result rounds.
|
||||
- [ ] Verify `session.prompt_async` exercises real `SessionPrompt` and `SessionProcessor`.
|
||||
|
||||
## OpenTUI Fake Renderer And Interactable Elements
|
||||
|
||||
Goal: run the TUI without a real terminal, inspect the screen buffer, and discover/act on interactable elements.
|
||||
|
||||
Known starting points:
|
||||
|
||||
- Current TUI creates a real renderer in `packages/opencode/src/cli/cmd/tui/app.tsx` through `createCliRenderer(...)`.
|
||||
- Existing tests use `@opentui/solid` `testRender(...)`.
|
||||
- Existing tests use `@opentui/core/testing` `createTestRenderer(...)` for renderer snapshots.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add a renderer factory/testing hook to `tui(...)` so tests can pass a fake renderer.
|
||||
- Current first pass checks `OPENCODE_SIMULATION` in `cli/cmd/tui/thread.ts`, starts the normal worker/backend, and injects an OpenTUI test renderer into `tui(...)`.
|
||||
- `OPENCODE_SIMULATION_BACKEND` leaves the frontend real but makes backend route assembly use simulated services.
|
||||
- Fake renderer setup lives in `cli/cmd/tui/simulation.ts` and returns `renderOnce`, `screen`, and `spans` helpers for the thread-side simulation runner.
|
||||
- In simulation MCP modes, the TUI side starts an MCP server documented in `simulation-mcp-server.md`.
|
||||
- Initial action discovery lives in `packages/opencode/src/testing/simulation/actions.ts`.
|
||||
- OpenTUI exposes `renderer.root` for walking renderables, `Renderable.focusable`, `renderer.currentFocusedEditor`, `renderer.hitTest(...)`, and test `mockInput` / `mockMouse` APIs for execution.
|
||||
- Do not render to a real terminal in simulation mode.
|
||||
- Investigate OpenTUI APIs for walking the render tree and extracting focusable/clickable/editable elements.
|
||||
- Investigate OpenTUI APIs for reading the screen buffer from the fake renderer.
|
||||
- If OpenTUI does not expose enough semantic information, add a small TUI semantic registry later. Do not block first pass on a full registry.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Inspect `@opentui/core/testing` `createTestRenderer` capabilities.
|
||||
- [x] Inspect `@opentui/solid` `testRender` capabilities.
|
||||
- [x] Determine how to get a screen buffer string/snapshot from the fake renderer.
|
||||
- [x] Determine first structured capture API for interactable discovery: `captureSpans()`.
|
||||
- [x] Add first pass renderable-based interactable discovery for focused editors, focusable elements, and mouse handlers.
|
||||
- [x] Add a minimal renderer factory override to `tui(...)` or app startup.
|
||||
- [ ] Expose prompt ref, route, sync state, keymap, and renderer to the simulation harness.
|
||||
- [ ] Verify TUI starts in fake renderer with no real terminal output.
|
||||
- [ ] Verify screen buffer can be captured after a render.
|
||||
|
||||
## Simulation MCP Server
|
||||
|
||||
Goal: let agents discover, inspect, and drive the simulated TUI through MCP without adding production remote-control behavior.
|
||||
|
||||
Design document:
|
||||
|
||||
- `packages/opencode/specs/simulation-mcp-server.md`
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Start in one of two modes: local stdio (`OPENCODE_SIMULATION=1`) or remote loopback HTTP (`OPENCODE_SIMULATION_BACKEND=1` without `OPENCODE_SIMULATION`).
|
||||
- Live in the TUI/frontend process so it can access the OpenTUI renderer.
|
||||
- Use stdio for the local agent-launched MCP mode.
|
||||
- Bind remote streamable HTTP MCP servers to `127.0.0.1` on an ephemeral port.
|
||||
- Print the URL to stdout for remote headless mode.
|
||||
- Show the URL at the bottom of the home screen for remote visible TUI mode.
|
||||
- Expose screen/spans/UI-state tools and resources.
|
||||
- Execute UI driving through `SimulationActions.execute(...)`, not a second action path.
|
||||
- Proxy filesystem/network/LLM/reset/snapshot operations to the backend simulation control endpoint.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Add design doc and first-pass todo list.
|
||||
- [x] Implement TUI-side MCP server startup and shutdown.
|
||||
- [x] Add local stdio mode.
|
||||
- [x] Add remote loopback mode.
|
||||
- [x] Print remote headless URL to stdout.
|
||||
- [x] Show remote visible TUI URL on the home screen.
|
||||
- [x] Expose observation tools/resources.
|
||||
- [x] Expose generated action execution tools.
|
||||
- [x] Expose backend control proxy tools.
|
||||
- [x] Add a smoke test that connects with the MCP client and calls one observation tool.
|
||||
|
||||
## Basic Action Generator
|
||||
|
||||
Goal: drive the TUI forward with generated actions and assert only that the app does not crash.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add a seeded action generator under `packages/opencode/test/property` or `packages/opencode/src/testing/simulation` depending on whether it needs production imports.
|
||||
- Start with a tiny action set: submit prompt, key command, paste/type text, click/select visible interactable.
|
||||
- Prefer OpenTUI/fake-renderer interactions over direct component refs where possible.
|
||||
- Allow direct prompt ref use for the very first smoke path if OpenTUI interaction APIs are not ready.
|
||||
- After each action, wait for basic quiescence.
|
||||
- Built-in property is only `app.does-not-crash`.
|
||||
|
||||
Initial no-crash check:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Todos:
|
||||
|
||||
- [ ] Define `UIAction` union for the first pass.
|
||||
- [ ] Implement seeded RNG for action selection.
|
||||
- [ ] Generate ordinary prompt text and enqueue matching LLM scripts through the control endpoint.
|
||||
- [ ] Execute actions through fake renderer/OpenTUI APIs where available.
|
||||
- [ ] Add temporary prompt-ref execution path if needed for first smoke.
|
||||
- [ ] Wait for quiescence after each action.
|
||||
- [ ] Capture screen buffer and backend snapshot after each action.
|
||||
- [ ] Check only `app.does-not-crash`.
|
||||
- [ ] Persist a simple replay trace with seed, filesystem fixture, network registrations, LLM scripts, actions, and observations.
|
||||
|
||||
## First Milestone
|
||||
|
||||
The first milestone is one deterministic run that:
|
||||
|
||||
- Starts under `sandbox-exec`.
|
||||
- Uses `OPENCODE_DB=:memory:`.
|
||||
- Seeds the mock filesystem.
|
||||
- Mounts the TUI using a fake renderer.
|
||||
- Enqueues an LLM script through the control endpoint.
|
||||
- Submits an ordinary prompt through the TUI.
|
||||
- Receives a mocked model response through the real session pipeline.
|
||||
- Captures a screen buffer.
|
||||
- Starts the simulation MCP server in the selected transport mode.
|
||||
- Passes the no-crash property.
|
||||
|
||||
## First-Pass Todos
|
||||
|
||||
- [x] Mock filesystem layer works.
|
||||
- [ ] Mock FetchHttpClient works for registered schemas and fails unknown network. Rough static registry is implemented; schema generation remains.
|
||||
- [ ] Control endpoint can seed filesystem, register network schemas, enqueue LLM scripts, and snapshot state.
|
||||
- [ ] Mock provider/model consumes endpoint scripts through the real LLM path.
|
||||
- [ ] TUI runs with fake renderer.
|
||||
- [ ] Runner can inspect screen buffer.
|
||||
- [x] Simulation MCP server exposes screen/UI/actions/control to agents.
|
||||
- [ ] Runner can identify at least one interactable path to submit a prompt.
|
||||
- [ ] Basic action generator executes multiple deterministic steps.
|
||||
- [ ] No-crash property runs after each step.
|
||||
- [ ] Replay trace is written outside the sandbox.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Simulation MCP Server
|
||||
|
||||
Status: first-pass implementation plan.
|
||||
|
||||
The simulation MCP server gives agents a simulation-only control surface for the TUI. It lives in the TUI process because only the frontend has direct access to the OpenTUI renderer, captured screen buffer, focused editor, interactable elements, and input/mouse drivers.
|
||||
|
||||
## Goals
|
||||
|
||||
- Support local stdio mode for agents that launch opencode as an MCP server.
|
||||
- Support remote loopback HTTP mode for users that want to run the server themselves.
|
||||
- Expose current TUI state to agents: screen text, structured spans, interactable elements, focused editor, and generated actions.
|
||||
- Let agents drive the UI through the same `SimulationActions` execution path used by property tests.
|
||||
- Proxy backend simulation control operations through the existing `/experimental/simulation/*` endpoint.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not start this server outside simulation modes.
|
||||
- Do not add a general remote-control API to production TUI mode.
|
||||
- Do not replace the property-test action generator; MCP should call the same action generator.
|
||||
- Do not expose arbitrary host filesystem or network access.
|
||||
|
||||
## Location
|
||||
|
||||
- Server module: `packages/opencode/src/cli/cmd/tui/simulation-mcp.ts`.
|
||||
- Startup: `packages/opencode/src/cli/cmd/tui/thread.ts`, next to fake renderer creation.
|
||||
- Action/state source: `packages/opencode/src/testing/simulation/actions.ts`.
|
||||
- Backend mutation source: existing simulation control endpoints.
|
||||
|
||||
## Modes
|
||||
|
||||
Local stdio mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION=1`.
|
||||
- Uses an OpenTUI test renderer and stdio MCP transport.
|
||||
- Prints nothing to stdout except MCP protocol messages.
|
||||
- Intended for agent MCP configs where the agent launches `opencode` as a local command.
|
||||
|
||||
Remote headless mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set.
|
||||
- Uses an OpenTUI test renderer and streamable HTTP MCP transport.
|
||||
- Prints the running MCP URL to stdout once.
|
||||
- Intended for users or harnesses that want to start opencode and connect to the URL manually.
|
||||
|
||||
Remote visible TUI mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set and the user is running the TUI.
|
||||
- Uses the normal visible TUI renderer and streamable HTTP MCP transport.
|
||||
- Does not print the URL to stdout because stdout belongs to the TUI.
|
||||
- Shows `Simulation mode MCP: <url>` at the bottom of the home screen.
|
||||
|
||||
## Transport
|
||||
|
||||
- Local stdio mode uses `StdioServerTransport`.
|
||||
- Remote modes use streamable HTTP over loopback.
|
||||
- Remote modes bind host `127.0.0.1`.
|
||||
- Remote port is ephemeral by default and configurable through `OPENCODE_SIMULATION_MCP_PORT`.
|
||||
|
||||
## Initial Tools
|
||||
|
||||
Observation:
|
||||
|
||||
- `simulation_screen_get`: return the current captured character frame.
|
||||
- `simulation_spans_get`: return OpenTUI captured spans.
|
||||
- `simulation_ui_state_get`: return elements, available generated actions, and focus state.
|
||||
|
||||
Driving:
|
||||
|
||||
- `simulation_action_execute`: execute one generated action and render once.
|
||||
- `simulation_action_sequence_execute`: execute a bounded sequence and return the final state.
|
||||
- `simulation_render_once`: force one render and return the screen/state.
|
||||
|
||||
Backend control proxy:
|
||||
|
||||
- `simulation_control_reset`
|
||||
- `simulation_control_filesystem_seed`
|
||||
- `simulation_control_network_register`
|
||||
- `simulation_control_llm_enqueue`
|
||||
- `simulation_control_snapshot`
|
||||
|
||||
## Initial Resources
|
||||
|
||||
- `simulation://screen`
|
||||
- `simulation://spans`
|
||||
- `simulation://ui-state`
|
||||
- `simulation://backend-snapshot`
|
||||
|
||||
## Initial Prompt
|
||||
|
||||
- `simulation-driver`: short instructions for agents to inspect state, choose available generated actions, drive the UI, then inspect again.
|
||||
|
||||
## Safety
|
||||
|
||||
- Guard startup with `OPENCODE_SIMULATION` or `OPENCODE_SIMULATION_BACKEND`.
|
||||
- Bind to loopback only.
|
||||
- Close the MCP server before destroying the renderer.
|
||||
- Keep backend state changes routed through the existing simulation control endpoint.
|
||||
|
||||
## Todos
|
||||
|
||||
- [x] Add this design document.
|
||||
- [x] Implement a first-pass TUI-side MCP server.
|
||||
- [x] Support local stdio mode.
|
||||
- [x] Support remote loopback mode.
|
||||
- [x] Print remote headless URL to stdout.
|
||||
- [x] Show remote background TUI URL on the home screen.
|
||||
- [x] Expose screen/spans/UI-state observation tools.
|
||||
- [x] Expose action execution tools using `SimulationActions.execute`.
|
||||
- [x] Expose backend control proxy tools.
|
||||
- [x] Add an automated smoke test that starts the MCP server and calls `tools/list` plus one observation tool.
|
||||
- [ ] Add richer action generation with generated text and bounded sequence traces.
|
||||
- [ ] Add trace capture for every MCP-driven action.
|
||||
- [ ] Add protocol-level docs for external agent authors.
|
||||
@@ -0,0 +1,103 @@
|
||||
# 02 Semantic Discovery
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
This phase starts after the first-pass action generator can drive the TUI and assert that the app does not crash.
|
||||
|
||||
## Goal
|
||||
|
||||
Build a semantic map of TUI states, available actions, backend requests, and backend state changes. This lets later runs focus on workflows instead of random screen poking.
|
||||
|
||||
## UI Semantics
|
||||
|
||||
We need a way to know what the runner can interact with on the current screen.
|
||||
|
||||
Preferred order:
|
||||
|
||||
- Use OpenTUI render tree/fake renderer APIs if they expose interactable elements.
|
||||
- Add a small TUI semantic registry only for missing metadata.
|
||||
- Avoid large per-component instrumentation at first.
|
||||
|
||||
Potential semantic element shape:
|
||||
|
||||
```ts
|
||||
type SemanticElement = {
|
||||
id: string
|
||||
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
|
||||
label: string
|
||||
enabled: boolean
|
||||
visible: boolean
|
||||
state?: Record<string, unknown>
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
actions: SemanticAction[]
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Mapping
|
||||
|
||||
Every generated UI action should have an action ID. TUI requests should include simulation headers so backend observations can be correlated.
|
||||
|
||||
Headers:
|
||||
|
||||
- `x-opencode-simulation-run`
|
||||
- `x-opencode-simulation-action`
|
||||
- `x-opencode-simulation-step`
|
||||
|
||||
Record requests and events with enough metadata to answer:
|
||||
|
||||
- Which UI actions produced which backend requests?
|
||||
- Which backend domains changed?
|
||||
- Which TUI states became reachable?
|
||||
- Which generated path caused the crash if a crash happens?
|
||||
|
||||
Backend domains to consider later:
|
||||
|
||||
- `session`
|
||||
- `message`
|
||||
- `part`
|
||||
- `permission`
|
||||
- `question`
|
||||
- `todo`
|
||||
- `tool`
|
||||
- `mcp`
|
||||
- `filesystem`
|
||||
- `network`
|
||||
- `status`
|
||||
|
||||
## Graph Shape
|
||||
|
||||
The graph should abstract states rather than storing every concrete buffer.
|
||||
|
||||
```ts
|
||||
type SemanticState = {
|
||||
id: string
|
||||
route: string
|
||||
dialog?: string
|
||||
elementSignature: string
|
||||
backendSignature?: string
|
||||
}
|
||||
|
||||
type SemanticTransition = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
action: UIAction
|
||||
uiChanged: string[]
|
||||
backendRequests: BackendRequestRecord[]
|
||||
backendEvents: BackendEventRecord[]
|
||||
failures: SimulationFailure[]
|
||||
}
|
||||
```
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Reassess OpenTUI APIs after first-pass fake renderer work.
|
||||
- [ ] Decide whether a TUI semantic registry is needed.
|
||||
- [ ] Add action IDs to generated actions.
|
||||
- [ ] Add action headers to TUI fetch wrapper.
|
||||
- [ ] Record backend request spans.
|
||||
- [ ] Record backend events and changed domains.
|
||||
- [ ] Define normalized UI state signatures.
|
||||
- [ ] Build first UI transition graph artifact.
|
||||
- [ ] Build first backend endpoint/domain graph artifact.
|
||||
- [ ] Use graph to bias action generation toward a selected workflow.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 03 Properties And Replay
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
The first pass only checks that the app does not crash. Add more properties only after the basic runner and traces are stable.
|
||||
|
||||
## Property API
|
||||
|
||||
Properties should be ordinary TypeScript functions registered with the runner.
|
||||
|
||||
```ts
|
||||
type Property = {
|
||||
name: string
|
||||
domains: string[]
|
||||
check: (ctx: PropertyContext) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
The `domains` field lets the runner skip checks when unrelated state changed.
|
||||
|
||||
First pass property:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Later candidate properties:
|
||||
|
||||
- No non-loopback network call.
|
||||
- Session eventually becomes idle after prompt-like actions.
|
||||
- No pending tool call remains after idle.
|
||||
- Every TUI-visible session message has valid message/part schemas.
|
||||
- Permission/question overlays correspond to backend pending requests.
|
||||
- Replay trace can be parsed and rerun.
|
||||
- Text should not flicker across stable frames.
|
||||
- Dialog focus should remain valid.
|
||||
- Route state and visible route agree.
|
||||
- Backend DB invariants hold after endpoint groups.
|
||||
- Tool call lifecycle events are balanced.
|
||||
- No generated action sequence can strand a session in busy state.
|
||||
|
||||
## Failure Reports
|
||||
|
||||
Failure reports should be human-readable and point to the smallest useful context.
|
||||
|
||||
Report fields:
|
||||
|
||||
- Failed property name.
|
||||
- Seed and action index.
|
||||
- Minimal replay command.
|
||||
- Last N UI actions.
|
||||
- Backend requests/events caused by the failing action.
|
||||
- Visible TUI buffer before and after.
|
||||
- Relevant session/message/tool IDs.
|
||||
|
||||
## Replay Trace
|
||||
|
||||
Trace fields:
|
||||
|
||||
- Seed and run configuration.
|
||||
- Mock filesystem fixture and workspace/config path mapping.
|
||||
- Mock network schema registrations.
|
||||
- Simulation control calls.
|
||||
- LLM scripts consumed.
|
||||
- UI action sequence.
|
||||
- HTTP request records.
|
||||
- Backend events.
|
||||
- UI observations before/after each action.
|
||||
- Property checks and failure details.
|
||||
- Normalization version.
|
||||
|
||||
## Shrinking
|
||||
|
||||
Shrinking should come after exact replay is reliable.
|
||||
|
||||
Candidate shrink steps:
|
||||
|
||||
- Delete contiguous chunks of actions.
|
||||
- Reduce generated prompt text.
|
||||
- Reduce LLM scripts to fewer actions/steps.
|
||||
- Prefer semantic action shrinking over raw key shrinking.
|
||||
- Preserve control calls needed to reproduce backend state.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Keep first pass to `app.does-not-crash` only.
|
||||
- [ ] Define trace JSON schema after first runner exists.
|
||||
- [ ] Write replay command that reruns an exact trace.
|
||||
- [ ] Add readable failure report formatter.
|
||||
- [ ] Add network property after mock network is stable.
|
||||
- [ ] Add session/tool lifecycle properties after backend mapping is stable.
|
||||
- [ ] Add shrinker only after replay is deterministic.
|
||||
@@ -0,0 +1,52 @@
|
||||
# 04 DST Hardening
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
This phase moves from seeded generation plus replay toward deterministic simulation testing. Do not start here; first get the app running under the first-pass simulation environment.
|
||||
|
||||
## Stage 1: Record And Normalize
|
||||
|
||||
- Seed RNG for the runner.
|
||||
- Normalize timestamps and generated IDs in traces.
|
||||
- Record timer registrations and delayed events where easy.
|
||||
- Use quiescence waits instead of fake time.
|
||||
|
||||
## Stage 2: Deterministic Data Sources
|
||||
|
||||
- Add deterministic ID generation behind a narrow simulation mode if normalization becomes too noisy.
|
||||
- Replace `Math.random()` usage in simulation-facing paths with seeded RNG.
|
||||
- Keep provider, filesystem, and network deterministic through the first-pass simulation boundaries.
|
||||
|
||||
## Stage 3: Controlled Clock
|
||||
|
||||
- Move high-impact backend `Date.now()` call sites to Effect clock/time services where practical.
|
||||
- Add a simulation clock service.
|
||||
- Let the runner advance logical time.
|
||||
|
||||
## Stage 4: Controlled Timers And Event Loop
|
||||
|
||||
- Wrap TUI timer use through a scheduler service where practical.
|
||||
- Expose SDK event batching timers to the harness.
|
||||
- Let the runner advance timers as part of quiescence.
|
||||
|
||||
## Stage 5: Async Interleaving Exploration
|
||||
|
||||
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
|
||||
- Replay exact interleavings from traces.
|
||||
|
||||
## Differential Runs
|
||||
|
||||
Later, reuse the useful idea from the old branch's differential runner:
|
||||
|
||||
- Run the same trace against two app versions or two configurations.
|
||||
- Normalize volatile fields.
|
||||
- Report semantic diffs instead of timestamp/ID noise.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Define which nondeterminism remains after first-pass replay.
|
||||
- [ ] Decide whether deterministic IDs are needed or trace normalization is enough.
|
||||
- [ ] Identify highest-impact `Date.now()` call sites.
|
||||
- [ ] Design a minimal simulation clock only if needed.
|
||||
- [ ] Design timer control only after fake renderer/action runner behavior is stable.
|
||||
- [ ] Add differential runner after trace replay is reliable.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 05 Reference Notes
|
||||
|
||||
Status: reference material. Keep this short and update as implementation discovers new seams.
|
||||
|
||||
## Current TUI Map
|
||||
|
||||
- `packages/opencode/src/cli/cmd/tui/thread.ts`: starts the TUI worker and in-process transport.
|
||||
- `packages/opencode/src/cli/cmd/tui/app.tsx`: creates the OpenTUI renderer/keymap and renders the Solid app.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/sdk.tsx`: SDK client, custom fetch, event source, event batching.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/sync.tsx`: projects backend events into TUI state.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/route.tsx`: route state.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/prompt.tsx`: current prompt ref.
|
||||
- `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`: prompt input and submit path.
|
||||
- `packages/opencode/src/cli/cmd/tui/keymap.tsx`: base keymap registration and `useBindings` exports.
|
||||
- `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`: useful model for harness context exposure.
|
||||
|
||||
## Current Backend Map
|
||||
|
||||
- `packages/opencode/src/server/server.ts`: exposes `Server.Default().app.request(...)`.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/server.ts`: route tree and production layers.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`: prompt/prompt_async/session endpoints.
|
||||
- `packages/opencode/src/session/prompt.ts`: real prompt loop, tool resolution, LLM orchestration.
|
||||
- `packages/opencode/src/session/llm.ts`: provider language model seam and `streamText(...)` call.
|
||||
- `packages/opencode/src/provider/provider.ts`: normal provider discovery/loading path.
|
||||
- `packages/opencode/src/mcp/index.ts`: MCP network/process seam.
|
||||
- `packages/opencode/src/tool/registry.ts`: built-in and plugin tool registry.
|
||||
- `packages/opencode/src/storage/db.ts`: `OPENCODE_DB` and `:memory:` support.
|
||||
- `packages/opencode/src/id/id.ts`: timestamp/random ID generation.
|
||||
|
||||
## Prior Branch Notes
|
||||
|
||||
Branch: `jlongster/fuzz-backend`.
|
||||
|
||||
Useful ideas to reuse:
|
||||
|
||||
- Mock AI SDK provider emitted real language-model stream chunks.
|
||||
- Compact LLM script action format worked well.
|
||||
- Step selection by counting tool-result rounds worked well.
|
||||
- HTTP/SSE backend runner waited for `session.status` idle.
|
||||
- Tool discovery and schema-shaped fake input generation were useful.
|
||||
- TUI runner used internal prompt ref to submit scripted prompts.
|
||||
- Differential runner normalized volatile fields and compared runs.
|
||||
- SQLite was forced to `:memory:`.
|
||||
- `sandbox-exec` denied external network and host filesystem access.
|
||||
- Bun preload/plugin direction can catch imports that bypass service boundaries.
|
||||
|
||||
Things to avoid:
|
||||
|
||||
- No JSON-in-prompt protocol or fallback.
|
||||
- No unseeded `Math.random()` in generated actions.
|
||||
- No partial mock filesystem that silently falls back to host FS.
|
||||
- No broad replacement of app service graph when a narrow override works.
|
||||
|
||||
## Old Sandbox Setup
|
||||
|
||||
Starting files on prior branch:
|
||||
|
||||
- `packages/opencode/src/provider/sdk/mock/sandbox.sb`
|
||||
- `packages/opencode/src/provider/sdk/mock/run`
|
||||
|
||||
Important behavior:
|
||||
|
||||
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
|
||||
- `(allow default)` so the process can boot.
|
||||
- `(deny network*)` with localhost re-allowed.
|
||||
- `(deny file-write*)`.
|
||||
- Deny reads from `$HOME/.local` and `$HOME/.config`.
|
||||
|
||||
Adapt this setup into the new simulation runner layout rather than inventing a new sandbox policy first.
|
||||
@@ -0,0 +1,881 @@
|
||||
# Property-Based TUI And Backend Testing Plan
|
||||
|
||||
Status: rough architectural draft.
|
||||
|
||||
This document sketches an incremental path for property-based, deterministic-simulation-style testing of the opencode TUI against a real opencode backend, without hitting external network services.
|
||||
|
||||
## Goals
|
||||
|
||||
- Drive the TUI as the primary user surface.
|
||||
- Exercise the real backend request, session, message, tool, permission, and event pipelines.
|
||||
- Replace external effects with deterministic local services.
|
||||
- Record enough information to replay failures.
|
||||
- Build a semantic model of UI actions, backend requests, and state transitions over time.
|
||||
- Start with a small useful runner, then grow toward deterministic simulation testing.
|
||||
|
||||
## Non-Goals For The First Pass
|
||||
|
||||
- Full fake-clock replacement for every `setTimeout`, `Date.now`, and animation path.
|
||||
- Exhaustive exploration of every visual TUI state.
|
||||
- Web app testing.
|
||||
- Real external LLM/provider, MCP, webfetch, websearch, update, or share network calls.
|
||||
|
||||
## Current Code Map
|
||||
|
||||
### TUI
|
||||
|
||||
- TUI startup is centered in `packages/opencode/src/cli/cmd/tui/thread.ts` and `packages/opencode/src/cli/cmd/tui/app.tsx`.
|
||||
- `TuiThreadCommand` starts a worker, builds an in-process fetch/event transport when possible, and calls `tui(...)`.
|
||||
- `tui(...)` creates the OpenTUI `CliRenderer`, creates the keymap, and renders the Solid app.
|
||||
- `SDKProvider` in `context/sdk.tsx` owns SDK creation, custom fetch injection, event subscription, event batching, retry, and timers.
|
||||
- `SyncProvider` in `context/sync.tsx` projects backend events into TUI state: sessions, messages, parts, permissions, questions, todos, diffs, MCP, formatter, LSP, and VCS.
|
||||
- `RouteProvider` in `context/route.tsx` owns route state.
|
||||
- `PromptRefProvider` in `context/prompt.tsx` exposes the current prompt ref.
|
||||
- The main prompt is `component/prompt/index.tsx`. It exposes `set`, `reset`, and `submit` through `PromptRef`, and its submit path eventually calls SDK session APIs.
|
||||
- `keymap.tsx` centralizes base keymap registration and re-exports `useBindings`; app and prompt commands are registered through this layer.
|
||||
- `plugin/api.tsx` already centralizes access to renderer, route, keymap, state, SDK client, dialog, KV, and event APIs. This is a useful model for a test harness API.
|
||||
|
||||
### Backend
|
||||
|
||||
- `packages/opencode/src/server/server.ts` exposes `Server.Default().app.request(...)`, which is useful for in-process HTTP tests.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/server.ts` assembles all routes and provides production service layers.
|
||||
- `createRoutes(...)` currently provides concrete production layers inside the route builder, including `Provider.defaultLayer`, `MCP.defaultLayer`, `ToolRegistry.defaultLayer`, `AppFileSystem.defaultLayer`, and `FetchHttpClient.layer`.
|
||||
- `groups/session.ts` and `handlers/session.ts` define the important session HTTP surface: create, prompt, prompt_async, command, shell, abort, permission response, message reads, revert, and update paths.
|
||||
- `SessionPrompt.Service` in `session/prompt.ts` creates user messages, resolves prompt parts, resolves tools, loops over LLM/tool calls, and writes messages/parts.
|
||||
- `LLM.Service` in `session/llm.ts` is the main provider seam. It calls `Provider.Service.getLanguage(...)` and then `streamText(...)`.
|
||||
- `Provider.Service` in `provider/provider.ts` can dynamically load provider SDKs and may install packages or use network. Simulation should use the normal provider path with a local mock provider/model and sandbox/network guards, not wholesale service replacement.
|
||||
- `MCP.Service` in `mcp/index.ts` can open remote HTTP/SSE connections or local child processes. Simulation should keep normal app startup and disable/configure MCP by default; only add a narrow MCP control seam when a test needs MCP states.
|
||||
- `ToolRegistry.Service` in `tool/registry.ts` exposes built-in and plugin tools. Filesystem tools should run against the mock filesystem in simulation mode; process/network tools must be disabled or replaced.
|
||||
- `Database.Path` in `storage/db.ts` is controlled by `OPENCODE_DB` and supports `:memory:`. Tests already reset/close DB state.
|
||||
- `Identifier` in `id/id.ts`, many `Date.now()` calls, and some `Math.random()` use are determinism hazards.
|
||||
|
||||
### Previous `jlongster/fuzz-backend` Branch
|
||||
|
||||
Useful ideas:
|
||||
|
||||
- A mock AI SDK provider emitted real language-model stream chunks.
|
||||
- The old branch showed that a compact scripted action format works, but scripts must be supplied through simulation control APIs instead of user prompt text.
|
||||
- Actions included `text`, `thinking`, `tool_call`, `list_tools`, and `error`.
|
||||
- Step selection by counting tool-result rounds after the last user message was a good fit for model/tool loops.
|
||||
- The runner drove the backend through HTTP plus SSE, waited for `session.status` to become idle, and then inspected messages.
|
||||
- `/experimental/tool` discovery plus schema-based fake input generation was a useful generation seed.
|
||||
- The TUI runner used an internal component to select the mock model, set prompt text through `PromptRef`, submit, and wait for idle.
|
||||
- The differential runner normalized volatile fields and compared runs.
|
||||
- The runner forced SQLite to `:memory:` so each run started with a clean in-process database.
|
||||
- The runner used macOS `sandbox-exec` to deny external network and host filesystem access around the whole app process.
|
||||
- The branch included a mock filesystem direction; the concept is correct and should be made complete enough for backend tools and app services instead of relying on real workspace files.
|
||||
|
||||
Ideas to avoid or rework:
|
||||
|
||||
- Do not hardcode the mock provider into normal provider discovery.
|
||||
- Do not use unseeded `Math.random()`.
|
||||
- Do not make the user-visible prompt text carry hidden control instructions at all.
|
||||
- Do not implement a partial mock filesystem and assume all filesystem effects are covered; the backend mock filesystem must be a first-class simulation service with explicit unsupported-operation failures.
|
||||
|
||||
## Core Design Decision: Endpoint Control, Not Prompt Control
|
||||
|
||||
The primary harness should control backend behavior through a test-only simulation control endpoint or in-process control service. The TUI should then submit ordinary prompt text through the normal UI.
|
||||
|
||||
This is better than embedding control data in the prompt because:
|
||||
|
||||
- It keeps prompt contents realistic, so prompt UI behavior can be tested independently from backend scripting.
|
||||
- It keeps transcripts and message history understandable.
|
||||
- It works for non-prompt workflows like command palette actions, session summarization, permission flows, shell mode, model switching, and future MCP controls.
|
||||
- It lets the runner prepare backend state before the next UI action.
|
||||
- It gives us a natural place to force future backend state, such as MCP state, tool results, filesystem state, provider errors, and pending permission/question state.
|
||||
- It makes replay traces explicit: `control.enqueueLLM(...)`, then `ui.submitPrompt(...)`.
|
||||
|
||||
There should be no JSON-in-prompt fallback. If no endpoint-enqueued script matches a model request, the mock LLM should fail with a clear simulation error. This keeps user-visible prompt text realistic and makes replay traces explicit.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
The system has five layers:
|
||||
|
||||
1. Simulation backend services.
|
||||
2. TUI driver and observation harness.
|
||||
3. Semantic UI and backend graph builder.
|
||||
4. Property runner, generator, replay, and shrinker.
|
||||
5. Later DST controls for clock, timers, schedulers, and async ordering.
|
||||
|
||||
The initial runner loop should look like this:
|
||||
|
||||
```text
|
||||
seed -> start isolated backend -> mount TUI -> observe state
|
||||
repeat N times:
|
||||
choose next UI action from current semantic state
|
||||
optionally enqueue backend script/control data
|
||||
execute the UI action
|
||||
wait for quiescence
|
||||
record UI/backend/network/event observations
|
||||
run relevant properties
|
||||
update semantic graph
|
||||
on failure:
|
||||
persist replay trace and human-readable report
|
||||
```
|
||||
|
||||
## Simulation Backend Services
|
||||
|
||||
### Production App With Narrow Overrides
|
||||
|
||||
The runner should load the normal app by default. Avoid building a separate test route tree or installing a broad graph of mock services. The goal is to run production wiring and only override the few core effect boundaries that must be deterministic.
|
||||
|
||||
The first required override is `AppFileSystem.Service`, so backend-visible files come from the in-memory mock filesystem. Other overrides should be added only when the app cannot be controlled through configuration, the simulation control endpoint, or the sandbox policy.
|
||||
|
||||
Possible narrow shape:
|
||||
|
||||
```ts
|
||||
// Conceptual API, not final names.
|
||||
export function createRoutes(input?: {
|
||||
cors?: CorsOptions
|
||||
overrides?: {
|
||||
appFileSystem?: Layer.Layer<AppFileSystem.Service>
|
||||
}
|
||||
}) {
|
||||
return productionRoutesWithProductionServices(input)
|
||||
}
|
||||
```
|
||||
|
||||
The important part is not the exact type. The important part is that simulation mode should not need to re-provide provider, MCP, tool registry, network, or most backend services. It should load the whole app and make the smallest viable changes, starting with the filesystem boundary.
|
||||
|
||||
### Simulation Control State
|
||||
|
||||
Add simulation-only control state that owns deterministic run state. This is not a replacement for app services; it is the small state store used by control endpoints and the mock provider.
|
||||
|
||||
Proposed source location:
|
||||
|
||||
- `packages/opencode/src/testing/simulation/service.ts`
|
||||
- `packages/opencode/src/testing/simulation/provider.ts`
|
||||
- `packages/opencode/src/testing/simulation/filesystem.ts`
|
||||
- `packages/opencode/src/testing/simulation/httpapi.ts`
|
||||
- `packages/opencode/src/testing/simulation/network.ts`
|
||||
- `packages/opencode/src/testing/simulation/runner.ts`
|
||||
|
||||
The service should be instance-scoped where possible and keyed by a `runID`.
|
||||
|
||||
Core responsibilities:
|
||||
|
||||
- Hold seeded RNG state.
|
||||
- Hold queued LLM scripts.
|
||||
- Hold mock filesystem state.
|
||||
- Record UI action IDs, backend request IDs, events, tool calls, and state changes.
|
||||
- Enforce network policy.
|
||||
- Provide snapshots for replay/failure reports.
|
||||
- Reset state between runs.
|
||||
|
||||
Conceptual control API:
|
||||
|
||||
```ts
|
||||
type SimulationControl = {
|
||||
reset(input: { runID: string; seed: string }): Effect.Effect<void>
|
||||
enqueueLLM(input: { runID: string; match?: LLMScriptMatch; script: LLMScript }): Effect.Effect<void>
|
||||
snapshot(input: { runID: string }): Effect.Effect<SimulationSnapshot>
|
||||
recordAction(input: UIActionRecord): Effect.Effect<void>
|
||||
recordRequest(input: BackendRequestRecord): Effect.Effect<void>
|
||||
recordEvent(input: BackendEventRecord): Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
### Control Endpoint
|
||||
|
||||
Add a gated endpoint under the instance HTTP API, probably `/experimental/simulation/*`.
|
||||
|
||||
Suggested endpoints:
|
||||
|
||||
- `POST /experimental/simulation/reset`
|
||||
- `POST /experimental/simulation/llm/enqueue`
|
||||
- `GET /experimental/simulation/snapshot`
|
||||
- `POST /experimental/simulation/action/start`
|
||||
- `POST /experimental/simulation/action/end`
|
||||
|
||||
Access should be impossible in normal production use unless explicit simulation mode is enabled. If the endpoint is added to the typed HttpApi surface, regenerate the JS SDK with `./packages/sdk/js/script/build.ts`. The runner can also call the endpoint with raw fetch to avoid making this a public user-facing API.
|
||||
|
||||
### Mock LLM Provider
|
||||
|
||||
The main LLM mock should be a real provider/model path, not a replacement for `SessionPrompt` or a wholesale replacement of `Provider.Service`.
|
||||
|
||||
Preferred seam:
|
||||
|
||||
- Register or configure a local simulation provider/model through the normal provider system.
|
||||
- Implement its language model with an AI SDK-compatible mock language model adapted to the current AI SDK version.
|
||||
- Let the existing `LLM.Service` call `streamText(...)`, process tools, and emit normal stream events.
|
||||
|
||||
This preserves more of the real backend path than replacing `LLM.Service` or `Provider.Service` directly.
|
||||
|
||||
The mock model should read the next script from `Simulation.Service` using request context:
|
||||
|
||||
- `runID`
|
||||
- `sessionID`
|
||||
- `messageID` or last user message ID
|
||||
- model/provider ID
|
||||
- tool round number
|
||||
|
||||
If no endpoint-enqueued script matches, the mock model should fail with a typed simulation error that includes the run ID, session ID, model, and tool round. Silent default responses and prompt parsing would hide missing runner setup.
|
||||
|
||||
Script action schema:
|
||||
|
||||
```ts
|
||||
type LLMScriptAction =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; name: string; input: Record<string, unknown> }
|
||||
| { type: "list_tools" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
type LLMScript = {
|
||||
steps: LLMScriptAction[][]
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
|
||||
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Keep the old rule that step `0` runs before tool results and step `N` runs after `N` tool-result rounds. The endpoint-backed service should also record which script step was consumed so replay reports are explicit.
|
||||
|
||||
### Database, Filesystem, Tools, MCP, And Network
|
||||
|
||||
Initial policy:
|
||||
|
||||
- Force `OPENCODE_DB=:memory:` for the simulation backend process. This should be a hard simulation-mode invariant, not a per-test preference.
|
||||
- Run the app/backend under macOS `sandbox-exec` by default for local simulation runs, following the old branch's technique: deny external network, deny host filesystem writes, and only allow the minimum paths needed to boot the process and communicate over loopback/in-process transports.
|
||||
- Add a first-class backend mock filesystem and provide it by overriding `AppFileSystem.Service` instead of relying on a real temp workspace for app-visible files.
|
||||
- Use the normal provider system with a local simulation provider/model; do not replace `Provider.Service` unless a later implementation proves a small seam is unavoidable.
|
||||
- Keep MCP on the normal app path and disable/configure it by default so it starts no network or child processes. Add narrow MCP controls later only for tests that explicitly target MCP states.
|
||||
- Use sandbox/network guards to reject non-loopback network. Only override `HttpClient.HttpClient` if a minimal core override is needed to make failures typed and observable.
|
||||
- Disable or fake webfetch, websearch, share, update, repo clone, and other external tools through normal config/tool policy where possible.
|
||||
- Run read/glob/grep/write/edit against the mock filesystem, not the host filesystem.
|
||||
- Treat bash/shell execution as opt-in and fake it by default, because real process execution bypasses the mock filesystem and sandbox policy is the last line of defense.
|
||||
|
||||
Later policy:
|
||||
|
||||
- Add deterministic fake child process and shell tools.
|
||||
- Add deterministic fake LSP/file-watcher events.
|
||||
|
||||
The mock filesystem should be authoritative for backend-visible project files. The host filesystem should only be used for runner artifacts, bundled source/config needed to start the app, and sandbox-allowed runtime plumbing. Any app path that escapes the mock filesystem should fail with a typed simulation error so missing coverage is obvious.
|
||||
|
||||
### In-Memory Database
|
||||
|
||||
Simulation mode should set the database to memory globally for the backend process:
|
||||
|
||||
```text
|
||||
OPENCODE_DB=:memory:
|
||||
```
|
||||
|
||||
This must happen before any import path evaluates `storage/db.ts`, because `Database.Path` is computed at module load. The simulation bootstrap should own process startup so this cannot be missed. Each run should start from an empty DB and seed any required sessions/state through public services or simulation controls.
|
||||
|
||||
### Sandbox Isolation
|
||||
|
||||
The local runner should reuse the old branch's `sandbox-exec` setup on macOS as the starting implementation. Specifically, adapt `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/sandbox.sb` and `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/run` into the new simulation runner layout.
|
||||
|
||||
The old setup did the right first-order thing:
|
||||
|
||||
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
|
||||
- Start from `(allow default)` so the process can boot.
|
||||
- Deny all network with `(deny network*)`.
|
||||
- Re-allow localhost network for local server/TUI communication.
|
||||
- Deny all filesystem writes with `(deny file-write*)`.
|
||||
- Deny reads from sensitive user config/state directories like `$HOME/.local` and `$HOME/.config`.
|
||||
|
||||
The sandbox is not the primary abstraction for deterministic behavior; it is the safety boundary that proves missed hooks cannot touch the host filesystem or external network.
|
||||
|
||||
Initial sandbox policy should stay close to the old branch:
|
||||
|
||||
- Deny outbound network except loopback when a real listener is used.
|
||||
- Deny host filesystem writes inside the sandbox. The parent runner can write trace artifacts outside the sandbox after collecting them over stdout, HTTP, or another explicit control channel.
|
||||
- Deny reads from user config/state locations unless explicitly mounted as test fixtures.
|
||||
- Fail fast when a denied operation happens so the trace records a simulation escape.
|
||||
|
||||
The mock filesystem and network guards should still exist inside the app. `sandbox-exec` catches leaks; it should not be the mechanism that normal simulated I/O depends on.
|
||||
|
||||
### Backend Mock Filesystem
|
||||
|
||||
The mock filesystem should be implemented as a real simulation service, not as test fixture files on disk.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Store files, directories, symlinks if needed, executable bits if needed, mtimes, and binary/text content in memory.
|
||||
- Provide deterministic path resolution for workspace root, current directory, home, config, state, and temp paths.
|
||||
- Expose operations needed by `AppFileSystem.Service`, read/write/edit tools, glob/grep/ripgrep equivalents, config reads, snapshot/diff, and prompt file attachment resolution.
|
||||
- Emit deterministic file change/snapshot events when writes happen.
|
||||
- Make unsupported operations fail explicitly with typed simulation errors.
|
||||
- Support seeding initial file trees from JSON fixtures and serializing filesystem state into replay traces.
|
||||
|
||||
Implementation should prefer a narrow `AppFileSystem.Service` override first. If file/ripgrep services or filesystem tools bypass that boundary, make the smallest targeted change to route them through the mock filesystem rather than replacing the whole tool registry. If some backend paths still import `@/util/filesystem` or direct host filesystem APIs, use the same Bun preload/plugin technique from the old branch to redirect those imports in simulation mode. The sandbox should then catch any remaining direct `fs`, `Bun.file`, or child-process access that was not routed through the mock filesystem.
|
||||
|
||||
### Backend Quiescence
|
||||
|
||||
The first useful quiescence definition should be pragmatic:
|
||||
|
||||
- No session has `session.status.type === "busy"`.
|
||||
- The TUI sync queue has flushed.
|
||||
- The runner has seen all events produced by the current action.
|
||||
- The renderer has completed at least one frame after the last event.
|
||||
- No pending simulation-controlled LLM stream or tool call remains.
|
||||
|
||||
This is not full DST yet. It is enough to avoid racing the next action against obvious async work.
|
||||
|
||||
## TUI Driver And Observation Harness
|
||||
|
||||
### Harness Injection
|
||||
|
||||
Do not add another hardcoded environment-runner component like the old `Mock` component. Instead, extend `tui(...)`/`App` with an optional test harness hook.
|
||||
|
||||
Conceptual shape:
|
||||
|
||||
```ts
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
fetch?: typeof fetch
|
||||
events?: EventSource
|
||||
testing?: TuiHarness.Input
|
||||
})
|
||||
```
|
||||
|
||||
The `App` can mount a tiny `TuiHarnessProbe` only when `testing` is provided. The probe exposes the same kinds of context that `plugin/api.tsx` already gathers:
|
||||
|
||||
- route
|
||||
- keymap
|
||||
- prompt ref
|
||||
- sync state
|
||||
- SDK client
|
||||
- renderer
|
||||
- dialog state
|
||||
- KV state
|
||||
- event bus
|
||||
- local model/agent state
|
||||
|
||||
This gives tests a stable internal API without coupling to a specific visible component.
|
||||
|
||||
### Driver Modes
|
||||
|
||||
Start with two modes:
|
||||
|
||||
- Semantic in-process mode: uses context APIs, keymap commands, prompt refs, SDK fetch wrappers, and renderer snapshots. This is the main property runner.
|
||||
- Terminal/PTY mode: later, spawn the real binary in a PTY, inject bytes, and read terminal snapshots. This catches lower-level terminal regressions but is slower.
|
||||
|
||||
The semantic mode should still exercise OpenTUI rendering, Solid state, keymap registration, SDK calls, backend routes, SSE/events, and prompt submit flows.
|
||||
|
||||
### Action Types
|
||||
|
||||
Initial action types:
|
||||
|
||||
```ts
|
||||
type UIAction =
|
||||
| { type: "command"; command: string }
|
||||
| { type: "prompt.set"; text: string }
|
||||
| { type: "prompt.submit"; text?: string; llm?: LLMScript }
|
||||
| { type: "key"; key: string; modifiers?: string[] }
|
||||
| { type: "paste"; text: string }
|
||||
| { type: "click"; elementID: string }
|
||||
| { type: "wait"; condition: "idle" | "frame"; timeoutMs: number }
|
||||
```
|
||||
|
||||
The runner should prefer semantic commands first. Raw key and mouse actions are useful, but command-level actions are easier to shrink and replay.
|
||||
|
||||
### Observation Types
|
||||
|
||||
Every action should record before/after observations:
|
||||
|
||||
```ts
|
||||
type UIObservation = {
|
||||
route: unknown
|
||||
dialogDepth: number
|
||||
focusedElement?: string
|
||||
semanticElements: SemanticElement[]
|
||||
bufferHash?: string
|
||||
visibleText?: string
|
||||
syncSummary: SyncSummary
|
||||
errors: SimulationError[]
|
||||
}
|
||||
```
|
||||
|
||||
Initial `visibleText` can come from renderer/test-render snapshots where available. Later PTY mode should capture the terminal buffer directly.
|
||||
|
||||
### TUI State Changers To Track
|
||||
|
||||
Frontend state can change because of:
|
||||
|
||||
- Keyboard events.
|
||||
- Mouse events.
|
||||
- Paste and IME submit deferrals.
|
||||
- Terminal resize and theme detection.
|
||||
- SDK HTTP responses.
|
||||
- SDK event stream messages.
|
||||
- Timers used for batching, focus, prompt submit, animations, retry, and placeholders.
|
||||
- Local KV, prompt history, prompt stash, model recents/favorites.
|
||||
- Plugin registration, routes, slots, commands, events, and toasts.
|
||||
- Clipboard/selection flows.
|
||||
- Process signals and terminal suspend/resume.
|
||||
|
||||
The first runner does not need full control over all of these. It should record them when they occur and gradually move high-impact sources under simulation control.
|
||||
|
||||
## Semantic UI Graph
|
||||
|
||||
### Semantic Registry
|
||||
|
||||
Add a TUI semantic registry that components can use to announce interactive elements and available actions.
|
||||
|
||||
Conceptual element shape:
|
||||
|
||||
```ts
|
||||
type SemanticElement = {
|
||||
id: string
|
||||
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
|
||||
label: string
|
||||
enabled: boolean
|
||||
visible: boolean
|
||||
state?: Record<string, unknown>
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
actions: SemanticAction[]
|
||||
}
|
||||
```
|
||||
|
||||
First components to instrument:
|
||||
|
||||
- `Prompt` for text input, submit, shell mode, slash commands, file/agent attachments.
|
||||
- App commands registered in `app.tsx`.
|
||||
- Prompt commands registered in `component/prompt/index.tsx`.
|
||||
- `DialogSelect` for option movement/filter/select.
|
||||
- Permission and question overlays.
|
||||
- Route state in `RouteProvider`.
|
||||
- Session message parts, especially tool and error parts.
|
||||
|
||||
This should be additive metadata. It should not change rendering behavior.
|
||||
|
||||
### Graph Shape
|
||||
|
||||
The graph should abstract states instead of storing every concrete UI snapshot.
|
||||
|
||||
```ts
|
||||
type SemanticState = {
|
||||
id: string
|
||||
route: string
|
||||
dialog?: string
|
||||
elementSignature: string
|
||||
backendSignature?: string
|
||||
}
|
||||
|
||||
type SemanticTransition = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
action: UIAction
|
||||
uiChanged: string[]
|
||||
backendRequests: BackendRequestRecord[]
|
||||
backendEvents: BackendEventRecord[]
|
||||
coverage: string[]
|
||||
failures: SimulationFailure[]
|
||||
}
|
||||
```
|
||||
|
||||
State hashing should initially normalize volatile IDs/timestamps. As deterministic IDs/clocks land, less normalization will be needed.
|
||||
|
||||
### Discovery Pass
|
||||
|
||||
The discovery runner randomly chooses from currently available semantic actions, executes them, observes transitions, and writes a graph artifact.
|
||||
|
||||
Suggested output:
|
||||
|
||||
- `.opencode/simulation/ui-graph.json`
|
||||
- `.opencode/simulation/backend-graph.json`
|
||||
- `.opencode/simulation/runs/<runID>.jsonl`
|
||||
|
||||
The graph is not expected to be perfect. It should answer practical questions:
|
||||
|
||||
- What actions are available from each abstract UI state?
|
||||
- Which actions produce backend requests?
|
||||
- Which actions open dialogs, create sessions, request permissions, create tool parts, or show errors?
|
||||
- Which action sequences reach the prompt, session, permission, question, model selection, MCP, and session list states?
|
||||
|
||||
### Directed Runner
|
||||
|
||||
After discovery, the directed runner should use the graph to bias generation toward requested targets.
|
||||
|
||||
Examples:
|
||||
|
||||
- "Focus prompt submit with tool calls."
|
||||
- "Exercise permission approve/reject flows."
|
||||
- "Exercise session list and route changes."
|
||||
- "Exercise backend prompt_async and SessionPrompt loop states."
|
||||
|
||||
The directed runner can plan a route through the graph to a target state, then run generated variants from there.
|
||||
|
||||
## Mapping UI Actions To Backend Requests
|
||||
|
||||
Every generated action should have an `actionID`.
|
||||
|
||||
The TUI fetch wrapper should add headers:
|
||||
|
||||
- `x-opencode-simulation-run`
|
||||
- `x-opencode-simulation-action`
|
||||
- `x-opencode-simulation-step`
|
||||
|
||||
The backend should record request spans:
|
||||
|
||||
```ts
|
||||
type BackendRequestRecord = {
|
||||
runID: string
|
||||
actionID?: string
|
||||
requestID: string
|
||||
method: string
|
||||
path: string
|
||||
endpoint?: string
|
||||
status: number
|
||||
startedAt: number
|
||||
endedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
Async work needs explicit correlation. For example, `prompt_async` returns before the session run finishes. The handler should attach the current `actionID` to the created user message/session run in `Simulation.Service`, so later LLM/tool/session events can be attributed to the same UI action.
|
||||
|
||||
Backend events should also be recorded:
|
||||
|
||||
```ts
|
||||
type BackendEventRecord = {
|
||||
runID: string
|
||||
actionID?: string
|
||||
eventID: string
|
||||
type: string
|
||||
sessionID?: string
|
||||
messageID?: string
|
||||
domains: string[]
|
||||
}
|
||||
```
|
||||
|
||||
This gives the graph the important edge information: UI action -> HTTP request -> backend state/event changes -> TUI sync changes.
|
||||
|
||||
## Backend Semantic Analysis
|
||||
|
||||
Backend semantic analysis should start from cheap instrumentation:
|
||||
|
||||
- HTTP endpoint entry/exit.
|
||||
- Bus/SyncEvent publications.
|
||||
- Session status changes.
|
||||
- Message and part writes.
|
||||
- Permission and question asks/replies.
|
||||
- Tool start/finish/error.
|
||||
- LLM stream start/finish/error.
|
||||
|
||||
The first backend state domains:
|
||||
|
||||
- `session`
|
||||
- `message`
|
||||
- `part`
|
||||
- `permission`
|
||||
- `question`
|
||||
- `todo`
|
||||
- `tool`
|
||||
- `mcp`
|
||||
- `filesystem`
|
||||
- `network`
|
||||
- `status`
|
||||
|
||||
Later, add DB snapshots or table-level hashes for deeper invariants. Do not read and diff the whole database after every action until we know it is needed.
|
||||
|
||||
## Property API
|
||||
|
||||
Properties should be ordinary TypeScript functions registered with the runner.
|
||||
|
||||
Conceptual API:
|
||||
|
||||
```ts
|
||||
type Property = {
|
||||
name: string
|
||||
domains: string[]
|
||||
check: (ctx: PropertyContext) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
`domains` lets the runner skip checks when unrelated state changed.
|
||||
|
||||
Example properties:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Built-in properties for pass one:
|
||||
|
||||
- The app does not crash. This includes uncaught TUI render errors and unhandled backend failures caused by the generated action.
|
||||
|
||||
Later properties:
|
||||
|
||||
- No non-loopback network call.
|
||||
- Session eventually becomes idle after prompt-like actions.
|
||||
- No pending tool call remains after idle.
|
||||
- Every TUI-visible session message has valid message/part schemas.
|
||||
- Permission/question overlays correspond to backend pending requests.
|
||||
- Replay trace can be parsed and rerun.
|
||||
- Text should not flicker across stable frames.
|
||||
- Dialog focus should remain valid.
|
||||
- Route state and visible route agree.
|
||||
- Backend DB invariants hold after every endpoint group.
|
||||
- Tool call lifecycle events are balanced.
|
||||
- No generated action sequence can strand a session in busy state.
|
||||
|
||||
## Failure Reports And Replay
|
||||
|
||||
On failure, persist a trace and a concise report.
|
||||
|
||||
Trace should include:
|
||||
|
||||
- Seed and run configuration.
|
||||
- Initial mock filesystem fixture and workspace/config path mapping.
|
||||
- Simulation control calls.
|
||||
- UI action sequence.
|
||||
- LLM scripts consumed.
|
||||
- HTTP request records.
|
||||
- Backend events.
|
||||
- UI observations before/after each action.
|
||||
- Property checks and failure details.
|
||||
- Normalization version.
|
||||
|
||||
Human report should include:
|
||||
|
||||
- Failed property name.
|
||||
- Seed and action index.
|
||||
- Minimal replay command.
|
||||
- The last N UI actions.
|
||||
- The backend requests/events caused by the failing action.
|
||||
- Visible TUI text/buffer before and after.
|
||||
- Any session/message/tool IDs relevant to the failure.
|
||||
|
||||
Initial replay can simply rerun the exact trace. Shrinking can come later.
|
||||
|
||||
Shrinking plan:
|
||||
|
||||
- Delete contiguous chunks of actions.
|
||||
- Reduce generated prompt text.
|
||||
- Reduce LLM scripts to fewer actions/steps.
|
||||
- Prefer semantic action shrinking over raw key shrinking.
|
||||
- Preserve explicit control calls needed to reproduce backend state.
|
||||
|
||||
## DST Roadmap
|
||||
|
||||
Full deterministic simulation testing requires more than seeded random actions. It requires control over time and async scheduling. Build this gradually.
|
||||
|
||||
### Stage 1: Record And Normalize
|
||||
|
||||
- Seed RNG for the runner.
|
||||
- Normalize timestamps and generated IDs in traces.
|
||||
- Record timer registrations and delayed events where easy.
|
||||
- Use quiescence waits instead of fake time.
|
||||
|
||||
### Stage 2: Deterministic Data Sources
|
||||
|
||||
- Add deterministic ID generation behind an injectable service or simulation mode.
|
||||
- Replace `Math.random()` usage in TUI placeholders/tests with seeded RNG in simulation mode.
|
||||
- Replace provider, MCP, network, and unsafe tools with deterministic services.
|
||||
|
||||
### Stage 3: Controlled Clock
|
||||
|
||||
- Move high-impact backend `Date.now()` call sites to Effect clock/time services.
|
||||
- Add a simulation clock service.
|
||||
- Let the runner advance logical time.
|
||||
|
||||
### Stage 4: Controlled Timers And Event Loop
|
||||
|
||||
- Wrap TUI timer use through a scheduler service where practical.
|
||||
- Expose SDK event batching timers to the harness.
|
||||
- Let the runner advance timers as part of quiescence.
|
||||
|
||||
### Stage 5: Async Interleaving Exploration
|
||||
|
||||
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
|
||||
- Replay exact interleavings from traces.
|
||||
|
||||
## Implementation Passes
|
||||
|
||||
### Pass 1: Backend-Only Deterministic Prompt Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Local mock LLM provider/model registered through the normal provider path.
|
||||
- Simulation control state and raw or typed control endpoint.
|
||||
- Sandboxed backend runner that loads the normal app and applies only narrow core overrides, starting with `AppFileSystem.Service`.
|
||||
- Process bootstrap that forces `OPENCODE_DB=:memory:` before backend modules load.
|
||||
- Initial backend mock filesystem service with seeded fixture support.
|
||||
- macOS `sandbox-exec` runner wrapper that denies external network and host filesystem access.
|
||||
- Seeded generation of LLM scripts based on available tools.
|
||||
- Replay trace for backend-only prompt runs.
|
||||
|
||||
Scope:
|
||||
|
||||
- Create session.
|
||||
- Seed mock filesystem contents.
|
||||
- Enqueue LLM script.
|
||||
- Call `prompt_async`.
|
||||
- Wait for idle over events.
|
||||
- Assert basic backend properties.
|
||||
|
||||
Validation:
|
||||
|
||||
- Run 10 to 100 generated backend prompt cases without external network.
|
||||
- Prove filesystem reads/writes hit the mock filesystem and not the host filesystem.
|
||||
- Prove tool-call, text, reasoning, and error scripts hit the real `SessionPrompt` and `SessionProcessor` path.
|
||||
|
||||
### Pass 2: TUI Prompt Smoke Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Optional `testing` hook in `tui(...)`/`App`.
|
||||
- In-process TUI harness exposing prompt ref, route, sync, keymap, SDK, and renderer.
|
||||
- Fetch/event wrappers that add simulation action headers and record requests/events.
|
||||
- A runner action that enqueues LLM script, sets prompt text through TUI, submits, waits for idle, and checks no crash.
|
||||
|
||||
Scope:
|
||||
|
||||
- Prompt input and submit only.
|
||||
- Normal text and one tool-call script.
|
||||
- Real backend, fake external services.
|
||||
|
||||
Validation:
|
||||
|
||||
- Run TUI -> prompt -> backend -> LLM script -> tool/result -> TUI message display.
|
||||
- Persist and replay a trace.
|
||||
|
||||
### Pass 3: Semantic UI Registry
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `TuiSemanticProvider` and registry API.
|
||||
- Instrument prompt, app commands, prompt commands, route state, dialog select, permission, and question components.
|
||||
- Snapshot current semantic elements from the harness.
|
||||
- Random semantic action generator.
|
||||
|
||||
Scope:
|
||||
|
||||
- Commands, prompt text/submit, dialog option select, permission approve/reject, question answer/reject.
|
||||
|
||||
Validation:
|
||||
|
||||
- Generate random semantic actions for a fixed number of steps.
|
||||
- Build a small UI transition graph.
|
||||
- Replay any generated sequence.
|
||||
|
||||
### Pass 4: Directed Property Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Property registration API.
|
||||
- Domain-based property filtering.
|
||||
- Built-in no-crash/no-network/session-idle/tool-lifecycle properties.
|
||||
- Directed generation targets based on semantic graph.
|
||||
|
||||
Scope:
|
||||
|
||||
- User asks for a focus area and iteration/depth count.
|
||||
- Runner biases actions toward graph paths related to that area.
|
||||
|
||||
Validation:
|
||||
|
||||
- `prompt submit with tool calls` target produces many prompt/tool/session variants.
|
||||
- `permission flows` target reaches permission UI and exercises approve/reject.
|
||||
|
||||
### Pass 5: Backend Graph And Endpoint Mapping
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Request and event correlation by action ID.
|
||||
- Backend domain change records.
|
||||
- Endpoint/action graph export.
|
||||
- Basic backend state signatures.
|
||||
|
||||
Scope:
|
||||
|
||||
- Session, message, part, permission, question, todo, tool, and status domains.
|
||||
|
||||
Validation:
|
||||
|
||||
- Given a UI action, report which backend endpoints and state domains changed.
|
||||
- Given a backend endpoint/domain, report UI actions that reached it.
|
||||
|
||||
### Pass 6: Determinism Hardening
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Seeded RNG everywhere in the runner.
|
||||
- Deterministic ID/time mode for high-impact backend paths.
|
||||
- Timer registration recording.
|
||||
- More complete network/process guards.
|
||||
- Complete backend mock filesystem coverage for configured filesystem tools and app services.
|
||||
|
||||
Scope:
|
||||
|
||||
- Reduce trace normalization.
|
||||
- Make failures replay reliably across machines.
|
||||
|
||||
Validation:
|
||||
|
||||
- Same seed and trace produce same observations modulo approved volatile fields.
|
||||
|
||||
### Pass 7: Shrinking And Differential Runs
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Action sequence shrinker.
|
||||
- Prompt/script shrinker.
|
||||
- Dual-run differential runner similar in spirit to the old branch.
|
||||
- Stable normalization rules for diff output.
|
||||
|
||||
Scope:
|
||||
|
||||
- Compare current branch against baseline or two configurations.
|
||||
|
||||
Validation:
|
||||
|
||||
- Induced failure shrinks to a short reproducible action trace.
|
||||
- Differential runner reports meaningful semantic diffs, not timestamp/ID noise.
|
||||
|
||||
## Suggested Initial File Layout
|
||||
|
||||
```text
|
||||
packages/opencode/src/testing/simulation/service.ts
|
||||
packages/opencode/src/testing/simulation/provider.ts
|
||||
packages/opencode/src/testing/simulation/filesystem.ts
|
||||
packages/opencode/src/testing/simulation/httpapi.ts
|
||||
packages/opencode/src/testing/simulation/network.ts
|
||||
packages/opencode/src/testing/simulation/mcp.ts
|
||||
packages/opencode/src/testing/simulation/tool-registry.ts
|
||||
packages/opencode/src/testing/simulation/sandbox.sb
|
||||
packages/opencode/src/testing/simulation/run.ts
|
||||
packages/opencode/src/cli/cmd/tui/testing/harness.tsx
|
||||
packages/opencode/src/cli/cmd/tui/testing/semantic.tsx
|
||||
packages/opencode/test/property/backend-runner.test.ts
|
||||
packages/opencode/test/property/tui-runner.test.ts
|
||||
packages/opencode/test/property/properties.ts
|
||||
packages/opencode/test/property/generator.ts
|
||||
```
|
||||
|
||||
If we want the harness code completely out of production bundles, keep more of it under `test/property`. The server route, TUI optional hook, and any simulation-gated services that the app imports need to live under `src`.
|
||||
|
||||
## Open Questions To Resolve During Implementation
|
||||
|
||||
- Should the simulation endpoint be a typed HttpApi route that regenerates SDK, or an internal raw route used only by the runner?
|
||||
- Should the first mock model target the current AI SDK provider interface directly, or temporarily fake `LLM.Service` while the provider mock is adapted?
|
||||
- How much renderer tree metadata does OpenTUI expose for stable bounds and visible text snapshots?
|
||||
- Which tools should be enabled by default in generation: read/glob/grep/todo only, or write/edit against the mock filesystem too?
|
||||
- Where should trace artifacts live by default so they are easy to inspect but not accidentally committed?
|
||||
|
||||
## Recommended Starting Point
|
||||
|
||||
Start with Pass 1 and Pass 2.
|
||||
|
||||
The smallest useful end-to-end test is:
|
||||
|
||||
1. Start an isolated backend under `sandbox-exec` with `OPENCODE_DB=:memory:`, simulation provider, fake MCP, guarded network, and seeded mock filesystem.
|
||||
2. Mount the TUI with a harness hook and in-process fetch/event transport.
|
||||
3. Enqueue an LLM script through simulation control.
|
||||
4. Set the prompt to ordinary text like `hello` and submit through `PromptRef`.
|
||||
5. Wait for session idle and TUI sync.
|
||||
6. Assert no TUI/backend errors and that the expected assistant text/tool part appears.
|
||||
7. Persist a replay trace containing the seed, control call, UI action, requests, events, and observations.
|
||||
|
||||
This gives immediate value while leaving room for semantic graph discovery, directed properties, failure shrinking, and true DST controls later.
|
||||
@@ -65,24 +65,24 @@ export type ActiveOrg = {
|
||||
org: Org
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
export class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
export const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
export class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
export class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
@@ -90,14 +90,14 @@ class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
export class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
export class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
@@ -110,22 +110,22 @@ class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError"
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
export const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
export class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
export class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
export class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
export class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
import * as TuiAudio from "@tui/util/audio"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
Switch,
|
||||
@@ -68,6 +68,7 @@ import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
|
||||
import { DiffViewer } from "./routes/diff"
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
@@ -102,6 +103,7 @@ const appBindingCommands = [
|
||||
"theme.switch",
|
||||
"theme.switch_mode",
|
||||
"theme.mode.lock",
|
||||
"diff.open",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"app.debug",
|
||||
@@ -165,6 +167,10 @@ export function tui(input: {
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
renderer?: CliRenderer
|
||||
mode?: "dark" | "light"
|
||||
onReady?: (ctx: { renderer: CliRenderer }) => void | Promise<void | { simulationMcpUrl?: string }>
|
||||
onStop?: (stop: () => Promise<void>) => void
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
|
||||
@@ -182,10 +188,11 @@ export function tui(input: {
|
||||
TuiAudio.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
const renderer = input.renderer ?? (await createCliRenderer(rendererConfig(input.config)))
|
||||
const [simulationMcpUrl, setSimulationMcpUrl] = createSignal<string | undefined>()
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
const mode = input.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||
@@ -198,7 +205,7 @@ export function tui(input: {
|
||||
)}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ArgsProvider {...input.args} simulationMcpUrl={simulationMcpUrl}>
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
@@ -232,7 +239,7 @@ export function tui(input: {
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
<AppLifecycle onSnapshot={input.onSnapshot} onStop={input.onStop} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
@@ -256,9 +263,17 @@ export function tui(input: {
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
const ready = await input.onReady?.({ renderer })
|
||||
if (ready?.simulationMcpUrl) setSimulationMcpUrl(ready.simulationMcpUrl)
|
||||
})
|
||||
}
|
||||
|
||||
function AppLifecycle(props: { onSnapshot?: () => Promise<string[]>; onStop?: (stop: () => Promise<void>) => void }) {
|
||||
const exit = useExit()
|
||||
props.onStop?.(() => exit())
|
||||
return <App onSnapshot={props.onSnapshot} />
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const tuiConfig = useTuiConfig()
|
||||
const route = useRoute()
|
||||
@@ -339,6 +354,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
const [diffOpen, setDiffOpen] = createSignal(false)
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
|
||||
)
|
||||
@@ -650,6 +666,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "diff.open",
|
||||
title: "Open diff viewer",
|
||||
slashName: "diff",
|
||||
run: () => {
|
||||
setDiffOpen(true)
|
||||
dialog.clear()
|
||||
},
|
||||
category: "VCS",
|
||||
},
|
||||
{
|
||||
name: "help.show",
|
||||
title: "Help",
|
||||
@@ -949,6 +975,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
<TuiPluginRuntime.Slot name="app_bottom" />
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="app" />
|
||||
<Show when={diffOpen()}>
|
||||
<DiffViewer onClose={() => setDiffOpen(false)} />
|
||||
</Show>
|
||||
</Show>
|
||||
<StartupLoading ready={ready} />
|
||||
</box>
|
||||
|
||||
@@ -67,6 +67,7 @@ export const Definitions = {
|
||||
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
|
||||
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
|
||||
status_view: keybind("<leader>s", "View status"),
|
||||
diff_open: keybind("<leader>d", "Open diff viewer"),
|
||||
|
||||
session_export: keybind("<leader>x", "Export session to editor"),
|
||||
session_copy: keybind("none", "Copy session transcript"),
|
||||
@@ -251,6 +252,7 @@ export const CommandMap = {
|
||||
sidebar_toggle: "session.sidebar.toggle",
|
||||
scrollbar_toggle: "session.toggle.scrollbar",
|
||||
status_view: "opencode.status",
|
||||
diff_open: "diff.open",
|
||||
session_export: "session.export",
|
||||
session_copy: "session.copy",
|
||||
session_new: "session.new",
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Args {
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
simulationMcpUrl?: () => string | undefined
|
||||
continue?: boolean
|
||||
sessionID?: string
|
||||
fork?: boolean
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { parsePatch } from "diff"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
type DiffFile = {
|
||||
readonly file: string
|
||||
readonly patch: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
const stripPrefix = (file: string | undefined) => {
|
||||
if (!file || file === "/dev/null") return undefined
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const splitRawDiff = (text: string) => {
|
||||
const starts = [...text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
|
||||
match[0].startsWith("\n") ? match.index + 1 : match.index,
|
||||
)
|
||||
if (starts.length === 0) return text.trim() ? [text] : []
|
||||
return starts.map((start, index) => text.slice(start, starts[index + 1] ?? text.length))
|
||||
}
|
||||
|
||||
const parseRawDiff = (text: string): DiffFile[] => {
|
||||
const chunks = splitRawDiff(text)
|
||||
return chunks.flatMap((chunk) => {
|
||||
const parsed = parsePatch(chunk)[0]
|
||||
const file = stripPrefix(parsed?.newFileName) ?? stripPrefix(parsed?.oldFileName)
|
||||
if (!parsed || !file) return []
|
||||
|
||||
const counts = parsed.hunks.flatMap((hunk) => hunk.lines).reduce(
|
||||
(acc, line) => ({
|
||||
additions: acc.additions + (line.startsWith("+") ? 1 : 0),
|
||||
deletions: acc.deletions + (line.startsWith("-") ? 1 : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
file,
|
||||
patch: chunk,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: parsed.oldFileName === "/dev/null" ? "added" : parsed.newFileName === "/dev/null" ? "deleted" : "modified",
|
||||
} satisfies DiffFile,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const lineKind = (line: string) => {
|
||||
if (line.startsWith("+")) return "added"
|
||||
if (line.startsWith("-")) return "deleted"
|
||||
if (line.startsWith("@@")) return "hunk"
|
||||
if (line.startsWith("diff --git") || line.startsWith("index ")) return "meta"
|
||||
return "context"
|
||||
}
|
||||
|
||||
export function DiffViewer(props: { onClose: () => void }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [raw] = createResource(async () => {
|
||||
const result = await sdk.client.vcs.diff2.raw(undefined, { throwOnError: true })
|
||||
return result.data ?? ""
|
||||
})
|
||||
const files = createMemo(() => parseRawDiff(raw() ?? ""))
|
||||
const current = createMemo(() => files()[selected()])
|
||||
const lines = createMemo(() => current()?.patch.trimEnd().split(/\r?\n/) ?? [])
|
||||
|
||||
const move = (delta: number) => {
|
||||
const total = files().length
|
||||
if (total === 0) return
|
||||
setSelected((selected() + delta + total) % total)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (selected() >= files().length) setSelected(Math.max(0, files().length - 1))
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
priority: 2000,
|
||||
bindings: [
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous file",
|
||||
group: "Diff",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "k",
|
||||
desc: "Previous file",
|
||||
group: "Diff",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "down",
|
||||
desc: "Next file",
|
||||
group: "Diff",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{
|
||||
key: "j",
|
||||
desc: "Next file",
|
||||
group: "Diff",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Close diff viewer",
|
||||
group: "Diff",
|
||||
cmd: props.onClose,
|
||||
},
|
||||
{
|
||||
key: "q",
|
||||
desc: "Close diff viewer",
|
||||
group: "Diff",
|
||||
cmd: props.onClose,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={2500}
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>Diff</text>
|
||||
<text fg={theme.textMuted}>working tree</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>j/k select · q/esc close</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0} gap={2}>
|
||||
<box
|
||||
width={32}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left", "right"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<text fg={theme.textMuted}>Files</text>
|
||||
<Switch>
|
||||
<Match when={raw.loading}>
|
||||
<text fg={theme.textMuted}>Loading diff...</text>
|
||||
</Match>
|
||||
<Match when={raw.error}>
|
||||
<text fg={theme.error}>Failed to load diff</text>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<text fg={theme.text}>No changes</text>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<For each={files()}>
|
||||
{(file, index) => (
|
||||
<box flexDirection="row" gap={1} backgroundColor={index() === selected() ? theme.backgroundElement : undefined}>
|
||||
<text fg={index() === selected() ? theme.accent : theme.text}>{index() === selected() ? "›" : " "}</text>
|
||||
<text fg={theme.text} wrapMode="none">
|
||||
{file.file}
|
||||
</text>
|
||||
<text fg={theme.diffAdded}>+{file.additions}</text>
|
||||
<text fg={theme.diffRemoved}>-{file.deletions}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left", "right"]}
|
||||
borderColor={theme.borderActive}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<Show
|
||||
when={current()}
|
||||
fallback={<text fg={theme.textMuted}>{raw.loading ? "Loading diff..." : raw.error ? "Failed to load diff" : "No diff to show"}</text>}
|
||||
>
|
||||
{(file) => (
|
||||
<>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={theme.text}>{file().file}</text>
|
||||
<text fg={theme.textMuted}>{file().status}</text>
|
||||
<text fg={theme.diffAdded}>+{file().additions}</text>
|
||||
<text fg={theme.diffRemoved}>-{file().deletions}</text>
|
||||
</box>
|
||||
<scrollbox flexGrow={1} minHeight={0}>
|
||||
<For each={lines()}>
|
||||
{(line) => {
|
||||
const kind = lineKind(line)
|
||||
return (
|
||||
<text
|
||||
fg={
|
||||
kind === "added"
|
||||
? theme.diffAdded
|
||||
: kind === "deleted"
|
||||
? theme.diffRemoved
|
||||
: kind === "hunk"
|
||||
? theme.accent
|
||||
: kind === "meta"
|
||||
? theme.textMuted
|
||||
: theme.text
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
{line || " "}
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createSignal, onMount } from "solid-js"
|
||||
import { createEffect, createSignal, onMount, Show } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useProject } from "../context/project"
|
||||
import { useSync } from "../context/sync"
|
||||
@@ -88,7 +88,10 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<Toast />
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<box width="100%" flexShrink={0} paddingLeft={2} paddingRight={2}>
|
||||
<Show when={args.simulationMcpUrl?.()}>
|
||||
{(url) => <text fg="yellow">Simulation mode MCP: {url()}</text>}
|
||||
</Show>
|
||||
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||
</box>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import {
|
||||
OPENCODE_PROCESS_ROLE,
|
||||
OPENCODE_RUN_ID,
|
||||
ensureRunID,
|
||||
sanitizedProcessEnv,
|
||||
} from "@opencode-ai/core/util/opencode-process"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import type { SimulationMcpRuntimeState } from "./simulation-mcp"
|
||||
import type { rpc } from "./worker"
|
||||
import { SimulationDebugLog } from "../../../testing/simulation/debug-log"
|
||||
import { SimulationNetworkLog } from "./simulation-network-log"
|
||||
import { fileURLToPath } from "url"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
|
||||
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
|
||||
const simulatedDirectory = "/opencode"
|
||||
const simulatedCwdEnv = "OPENCODE_SIMULATION_CWD"
|
||||
|
||||
function fakeCwd(directory: string) {
|
||||
process.env.PWD = directory
|
||||
Object.defineProperty(process, "cwd", {
|
||||
value: () => directory,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
interface Transport {
|
||||
readonly url: string
|
||||
readonly fetch: typeof fetch
|
||||
readonly events: EventSource
|
||||
}
|
||||
|
||||
interface RunningInstance {
|
||||
readonly client: RpcClient
|
||||
readonly done: Promise<void>
|
||||
readonly stopTui: () => Promise<void>
|
||||
readonly stopWorker: () => Promise<void>
|
||||
}
|
||||
|
||||
function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(input, init)
|
||||
const body = request.body ? await request.text() : undefined
|
||||
const headers = Object.fromEntries(request.headers.entries())
|
||||
SimulationDebugLog.write("simulate.fetch.start", { method: request.method, url: request.url })
|
||||
const startedAt = Date.now()
|
||||
const startedAtIso = new Date(startedAt).toISOString()
|
||||
try {
|
||||
const result = await client.call("fetch", {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
SimulationDebugLog.write("simulate.fetch.end", {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: result.status,
|
||||
})
|
||||
SimulationNetworkLog.record({
|
||||
time: startedAtIso,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: result.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
requestHeaders: headers,
|
||||
requestBody: body,
|
||||
responseHeaders: result.headers,
|
||||
responseBody: result.body,
|
||||
})
|
||||
return new Response(result.body, {
|
||||
status: result.status,
|
||||
headers: result.headers,
|
||||
})
|
||||
} catch (err) {
|
||||
SimulationNetworkLog.record({
|
||||
time: startedAtIso,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
requestHeaders: headers,
|
||||
requestBody: body,
|
||||
responseHeaders: {},
|
||||
responseBody: "",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return fn as typeof fetch
|
||||
}
|
||||
|
||||
function createEventSource(client: RpcClient, onSubscribe?: () => void): EventSource {
|
||||
return {
|
||||
subscribe: async (handler) => {
|
||||
// SimulationDebugLog.write("simulate.events.subscribe")
|
||||
onSubscribe?.()
|
||||
return client.on<GlobalEvent>("global.event", (e) => {
|
||||
// SimulationDebugLog.write("simulate.events.received", {
|
||||
// directory: e.directory,
|
||||
// workspace: e.workspace,
|
||||
// type: e.payload?.type,
|
||||
// sync: e.payload?.type === "sync",
|
||||
// })
|
||||
handler(e)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function target() {
|
||||
const workerPath = Reflect.get(globalThis, "OPENCODE_WORKER_PATH")
|
||||
if (typeof workerPath === "string") return workerPath
|
||||
const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
|
||||
if (await Filesystem.exists(fileURLToPath(dist))) return dist
|
||||
return new URL("./worker.ts", import.meta.url)
|
||||
}
|
||||
|
||||
export const SimulateCommand = cmd({
|
||||
command: "simulate",
|
||||
describe: "start restartable simulated opencode tui",
|
||||
handler: async () => {
|
||||
SimulationDebugLog.reset()
|
||||
fakeCwd(simulatedDirectory)
|
||||
const file = await target()
|
||||
const cwd = simulatedDirectory
|
||||
const config = await TuiConfig.get()
|
||||
const simulationMcpMode = Flag.OPENCODE_SIMULATION ? "stdio" : "remote"
|
||||
|
||||
let currentInstance: RunningInstance | undefined
|
||||
let currentRuntime: SimulationMcpRuntimeState | undefined
|
||||
let restartRequested = false
|
||||
let restartWaiter:
|
||||
| {
|
||||
readonly promise: Promise<{ restarted: true }>
|
||||
readonly resolve: (value: { restarted: true }) => void
|
||||
readonly reject: (error: unknown) => void
|
||||
}
|
||||
| undefined
|
||||
|
||||
const error = (e: unknown) => {
|
||||
Log.Default.error("process error", { error: errorMessage(e) })
|
||||
}
|
||||
const reload = () => {
|
||||
currentInstance?.client.call("reload", undefined).catch((err) => {
|
||||
Log.Default.warn("worker reload failed", {
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
process.on("uncaughtException", error)
|
||||
process.on("unhandledRejection", error)
|
||||
process.on("SIGUSR2", reload)
|
||||
|
||||
const simulationMcpModule = await import("./simulation-mcp")
|
||||
const simulationMcp = await simulationMcpModule.TuiSimulationMcp.createSimulationMcpServer({
|
||||
mode: simulationMcpMode,
|
||||
runtime: {
|
||||
current: () => currentRuntime,
|
||||
restart: async () => {
|
||||
if (restartWaiter) return restartWaiter.promise
|
||||
if (!currentInstance) throw new Error("Simulation TUI is not ready")
|
||||
restartRequested = true
|
||||
let resolve!: (value: { restarted: true }) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<{ restarted: true }>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
restartWaiter = { promise, resolve, reject }
|
||||
currentInstance.stopTui().catch((err) => restartWaiter?.reject(err))
|
||||
return promise
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const stopWorker = async (client: RpcClient, worker: Worker) => {
|
||||
await withTimeout(client.call("shutdown", undefined), 5000).catch((err) => {
|
||||
Log.Default.warn("worker shutdown failed", {
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
worker.terminate()
|
||||
}
|
||||
|
||||
const start = async (): Promise<RunningInstance> => {
|
||||
const worker = new Worker(file, {
|
||||
env: sanitizedProcessEnv({
|
||||
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||
PWD: simulatedDirectory,
|
||||
[simulatedCwdEnv]: simulatedDirectory,
|
||||
// Simulated filesystem lives in InMemoryFs — SQLite needs to be in-memory
|
||||
// too, since real fs writes to `/opencode/.local/share/opencode/...` will
|
||||
// fail (the directory only exists in the sim FS).
|
||||
OPENCODE_DB: ":memory:",
|
||||
}),
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error("thread error", {
|
||||
message: e.message,
|
||||
filename: e.filename,
|
||||
lineno: e.lineno,
|
||||
colno: e.colno,
|
||||
error: e.error,
|
||||
})
|
||||
}
|
||||
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
let eventSubscribedResolve!: () => void
|
||||
const eventSubscribed = new Promise<void>((resolve) => {
|
||||
eventSubscribedResolve = resolve
|
||||
})
|
||||
const transport: Transport = {
|
||||
url: "http://opencode.internal",
|
||||
fetch: createWorkerFetch(client),
|
||||
events: createEventSource(client, eventSubscribedResolve),
|
||||
}
|
||||
|
||||
const { tui } = await import("./app")
|
||||
const simulationRenderer = Flag.OPENCODE_SIMULATION
|
||||
? await (await import("./simulation")).TuiSimulation.createSimulationRenderer()
|
||||
: undefined
|
||||
|
||||
let stopTui = async () => {}
|
||||
let stopReadyResolve!: () => void
|
||||
const stopReady = new Promise<void>((resolve) => {
|
||||
stopReadyResolve = resolve
|
||||
})
|
||||
let readyResolve!: () => void
|
||||
let readyReject!: (error: unknown) => void
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
readyResolve = resolve
|
||||
readyReject = reject
|
||||
})
|
||||
const done = tui({
|
||||
url: transport.url,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
events: transport.events,
|
||||
renderer: simulationRenderer?.renderer,
|
||||
mode: simulationRenderer ? "dark" : undefined,
|
||||
onStop: (stop) => {
|
||||
stopTui = stop
|
||||
stopReadyResolve()
|
||||
},
|
||||
onReady: async (ctx) => {
|
||||
try {
|
||||
currentRuntime = {
|
||||
harness: simulationRenderer
|
||||
? simulationMcpModule.TuiSimulationMcp.harnessFromSimulationRenderer(simulationRenderer)
|
||||
: simulationMcpModule.TuiSimulationMcp.harnessFromRenderer(ctx.renderer),
|
||||
controlUrl: transport.url,
|
||||
controlFetch: transport.fetch,
|
||||
}
|
||||
if (simulationRenderer) await simulationRenderer.renderOnce()
|
||||
readyResolve()
|
||||
return { simulationMcpUrl: simulationMcp.url }
|
||||
} catch (err) {
|
||||
readyReject(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
args: {},
|
||||
})
|
||||
|
||||
await Promise.all([ready, stopReady, eventSubscribed])
|
||||
return {
|
||||
client,
|
||||
done,
|
||||
stopTui: () => stopTui(),
|
||||
stopWorker: async () => {
|
||||
simulationRenderer?.destroy()
|
||||
await stopWorker(client, worker)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
try {
|
||||
currentInstance = await start()
|
||||
restartWaiter?.resolve({ restarted: true })
|
||||
restartWaiter = undefined
|
||||
restartRequested = false
|
||||
} catch (err) {
|
||||
restartWaiter?.reject(err)
|
||||
throw err
|
||||
}
|
||||
|
||||
await currentInstance.done
|
||||
currentRuntime = undefined
|
||||
await currentInstance.stopWorker()
|
||||
currentInstance = undefined
|
||||
if (!restartRequested) break
|
||||
}
|
||||
} finally {
|
||||
currentRuntime = undefined
|
||||
await currentInstance?.stopTui().catch(() => {})
|
||||
await currentInstance?.stopWorker()
|
||||
await simulationMcp.stop()
|
||||
process.off("uncaughtException", error)
|
||||
process.off("unhandledRejection", error)
|
||||
process.off("SIGUSR2", reload)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export * as TuiSimulate from "./simulate"
|
||||
@@ -0,0 +1,891 @@
|
||||
import { SimulationActions } from "@/testing/simulation/actions"
|
||||
import { SimulationNetworkLog } from "./simulation-network-log"
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import type { CapturedFrame, CliRenderer } from "@opentui/core"
|
||||
import { createMockKeys, createMockMouse } from "@opentui/core/testing"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import z from "zod/v4"
|
||||
import type { SimulationRenderer } from "./simulation"
|
||||
|
||||
export type SimulationMcpMode = "stdio" | "remote"
|
||||
|
||||
export interface SimulationMcpHarness {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mockInput: SimulationActions.MockInput
|
||||
readonly mockMouse: SimulationActions.MockMouse
|
||||
readonly renderOnce: () => Promise<void>
|
||||
readonly screen: () => string
|
||||
}
|
||||
|
||||
export interface SimulationMcpOptions {
|
||||
readonly mode: SimulationMcpMode
|
||||
readonly harness: SimulationMcpHarness
|
||||
readonly controlUrl: string
|
||||
readonly controlFetch?: typeof fetch
|
||||
}
|
||||
|
||||
export interface SimulationMcpRuntimeState {
|
||||
readonly harness: SimulationMcpHarness
|
||||
readonly controlUrl: string
|
||||
readonly controlFetch?: typeof fetch
|
||||
}
|
||||
|
||||
export interface RestartableSimulationMcpOptions {
|
||||
readonly mode: SimulationMcpMode
|
||||
readonly runtime: {
|
||||
readonly current: () => SimulationMcpRuntimeState | undefined
|
||||
readonly restart: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type Options = SimulationMcpOptions | RestartableSimulationMcpOptions
|
||||
|
||||
export interface SimulationMcpServer {
|
||||
readonly mode: SimulationMcpMode
|
||||
readonly url?: string
|
||||
readonly stop: () => Promise<void>
|
||||
}
|
||||
|
||||
const DefaultRemotePort = 43110
|
||||
const MaxPortAttempts = 100
|
||||
const MasterInstanceID = "master"
|
||||
|
||||
interface RemoteInstance {
|
||||
readonly id: string
|
||||
readonly port: number
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
readonly result?: unknown
|
||||
readonly error?: {
|
||||
readonly code?: number
|
||||
readonly message?: string
|
||||
}
|
||||
}
|
||||
|
||||
type RenderBuffer = {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
getRealCharBytes(includeAnsi?: boolean): Uint8Array
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const ActionSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("typeText"), text: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("pressKey"),
|
||||
key: z.string(),
|
||||
modifiers: z
|
||||
.object({
|
||||
ctrl: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
meta: z.boolean().optional(),
|
||||
super: z.boolean().optional(),
|
||||
hyper: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({ type: z.literal("pressEnter") }),
|
||||
z.object({ type: z.literal("pressArrow"), direction: z.enum(["up", "down", "left", "right"]) }),
|
||||
z.object({ type: z.literal("focus"), target: z.number() }),
|
||||
z.object({ type: z.literal("click"), target: z.number(), x: z.number(), y: z.number() }),
|
||||
]) satisfies z.ZodType<SimulationActions.Action>
|
||||
|
||||
const FileContentSchema = z.union([
|
||||
z.string(),
|
||||
z.object({ encoding: z.literal("base64"), data: z.string() }),
|
||||
])
|
||||
|
||||
const NetworkRegistrationSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("json"),
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.unknown(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("text"),
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("status"),
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
])
|
||||
|
||||
const LlmScriptActionSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("text"), content: z.string() }),
|
||||
z.object({ type: z.literal("thinking"), content: z.string() }),
|
||||
z.object({ type: z.literal("error"), message: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("tool-call"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.any(),
|
||||
}),
|
||||
])
|
||||
|
||||
const LlmScriptSchema = z.object({
|
||||
steps: z.array(z.array(LlmScriptActionSchema)),
|
||||
usage: z
|
||||
.object({
|
||||
inputTokens: z.number(),
|
||||
outputTokens: z.number(),
|
||||
totalTokens: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
finish: z.enum(["stop", "tool-calls", "error", "length", "unknown"]).optional(),
|
||||
})
|
||||
|
||||
const ScriptActionSchema = z.union([
|
||||
ActionSchema,
|
||||
z.object({ type: z.literal("writeFile"), path: z.string(), content: FileContentSchema }),
|
||||
z.object({ type: z.literal("enqueueLLM"), scripts: z.array(LlmScriptSchema) }),
|
||||
z.object({ type: z.literal("wait"), ms: z.number().min(0).max(30_000).optional() }),
|
||||
])
|
||||
|
||||
const ScriptSchema = z.union([z.array(ScriptActionSchema), z.object({ actions: z.array(ScriptActionSchema) })])
|
||||
const TargetSchema = z.union([z.string(), z.array(z.string()).min(1), z.literal("all")])
|
||||
const BackendFetchSchema = z.object({
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.unknown().optional(),
|
||||
})
|
||||
const remoteInstances = new Map<string, RemoteInstance>()
|
||||
|
||||
function currentBuffer(renderer: CliRenderer): RenderBuffer {
|
||||
return Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer
|
||||
}
|
||||
|
||||
function remotePort() {
|
||||
const port = Number(process.env.OPENCODE_SIMULATION_MCP_PORT)
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65535) return port
|
||||
return DefaultRemotePort
|
||||
}
|
||||
|
||||
function masterEnabled() {
|
||||
return process.env.OPENCODE_SIMULATION_MCP_MASTER === "1" || process.env.OPENCODE_SIMULATION_MCP_MASTER === "true"
|
||||
}
|
||||
|
||||
function childURL(port: number) {
|
||||
return `http://127.0.0.1:${port}/mcp`
|
||||
}
|
||||
|
||||
function jsonRpcError(response: JsonRpcResponse) {
|
||||
return response.error?.message ?? `MCP request failed${response.error?.code === undefined ? "" : `: ${response.error.code}`}`
|
||||
}
|
||||
|
||||
function isPortUnavailable(error: unknown) {
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
||||
return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use")
|
||||
}
|
||||
|
||||
function serveRemote(
|
||||
fetch: (request: Request) => Response | Promise<Response>,
|
||||
port = remotePort(),
|
||||
attempts = MaxPortAttempts,
|
||||
): ReturnType<typeof Bun.serve> {
|
||||
try {
|
||||
return Bun.serve({ hostname: "127.0.0.1", port, idleTimeout: 0, fetch })
|
||||
} catch (error) {
|
||||
if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error
|
||||
return serveRemote(fetch, port + 1, attempts - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function harnessFromSimulationRenderer(renderer: SimulationRenderer): SimulationMcpHarness {
|
||||
return renderer
|
||||
}
|
||||
|
||||
export function harnessFromRenderer(renderer: CliRenderer): SimulationMcpHarness {
|
||||
return {
|
||||
renderer,
|
||||
mockInput: createMockKeys(renderer),
|
||||
mockMouse: createMockMouse(renderer),
|
||||
renderOnce: async () => {
|
||||
renderer.requestRender()
|
||||
await renderer.idle()
|
||||
},
|
||||
screen: () => decoder.decode(currentBuffer(renderer).getRealCharBytes(true)),
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(value: unknown) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function current(options: Options) {
|
||||
if ("runtime" in options) {
|
||||
const value = options.runtime.current()
|
||||
if (value) return value
|
||||
throw new Error("Simulation TUI is not ready")
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function state(options: Options) {
|
||||
const running = current(options)
|
||||
return {
|
||||
focused: {
|
||||
renderable: running.harness.renderer.currentFocusedRenderable?.num,
|
||||
editor: Boolean(running.harness.renderer.currentFocusedEditor),
|
||||
},
|
||||
elements: SimulationActions.elements(running.harness.renderer),
|
||||
actions: SimulationActions.actions(running.harness.renderer),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(options: Options) {
|
||||
const running = current(options)
|
||||
return {
|
||||
screen: running.harness.screen(),
|
||||
ui: state(options),
|
||||
}
|
||||
}
|
||||
|
||||
async function control(options: Options, method: string, pathname: string, body?: unknown) {
|
||||
const running = current(options)
|
||||
const response = await (running.controlFetch ?? fetch)(new URL(pathname, running.controlUrl), {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = text ? JSON.parse(text) : undefined
|
||||
if (response.ok) return data
|
||||
throw new Error(typeof data?.error === "string" ? data.error : `Simulation control request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
async function backendFetch(options: Options, input: z.infer<typeof BackendFetchSchema>) {
|
||||
const running = current(options)
|
||||
const headers = new Headers(input.headers)
|
||||
const body =
|
||||
input.body === undefined ? undefined : typeof input.body === "string" ? input.body : JSON.stringify(input.body)
|
||||
if (body !== undefined && !headers.has("content-type") && typeof input.body !== "string") headers.set("content-type", "application/json")
|
||||
|
||||
const response = await (running.controlFetch ?? fetch)(new URL(input.url, running.controlUrl), {
|
||||
method: input.method ?? (body === undefined ? "GET" : "POST"),
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
return {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: await response.text(),
|
||||
}
|
||||
}
|
||||
|
||||
async function mcpRequest(url: string, method: string, params: unknown, timeout = 500) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: crypto.randomUUID(), method, params }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
})
|
||||
if (!response.ok) throw new Error(`MCP request failed: HTTP ${response.status}`)
|
||||
const data = (await response.json()) as JsonRpcResponse
|
||||
if (data.error) throw new Error(jsonRpcError(data))
|
||||
return data.result
|
||||
}
|
||||
|
||||
async function initializeChild(url: string, timeout?: number) {
|
||||
await mcpRequest(
|
||||
url,
|
||||
"initialize",
|
||||
{
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode-simulation-master", version: InstallationVersion },
|
||||
},
|
||||
timeout,
|
||||
)
|
||||
await mcpRequest(url, "notifications/initialized", {}, timeout).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function childToolCall(instance: RemoteInstance, name: string, args: unknown) {
|
||||
await initializeChild(instance.url, 2_000)
|
||||
return mcpRequest(instance.url, "tools/call", { name, arguments: args }, 30_000)
|
||||
}
|
||||
|
||||
function instances() {
|
||||
return [{ id: MasterInstanceID, port: remotePort(), url: childURL(remotePort()) }, ...remoteInstances.values()]
|
||||
}
|
||||
|
||||
function selectedTargets(target: z.infer<typeof TargetSchema>) {
|
||||
const ids = target === "all" ? instances().map((item) => item.id) : Array.isArray(target) ? target : [target]
|
||||
return ids.map((id) => {
|
||||
if (id === MasterInstanceID) return { id, local: true as const }
|
||||
const remote = remoteInstances.get(id)
|
||||
if (!remote) throw new Error(`Unknown simulation instance: ${id}`)
|
||||
return { id, local: false as const, remote }
|
||||
})
|
||||
}
|
||||
|
||||
async function discoverInstances(input: { startPort?: number; maxPorts?: number; consecutiveFailures?: number } = {}) {
|
||||
const startPort = input.startPort ?? remotePort() + 1
|
||||
const maxPorts = input.maxPorts ?? 30
|
||||
const consecutiveFailures = input.consecutiveFailures ?? 3
|
||||
let failures = 0
|
||||
const found: RemoteInstance[] = []
|
||||
|
||||
for (let offset = 0; offset < maxPorts && failures < consecutiveFailures; offset++) {
|
||||
const port = startPort + offset
|
||||
if (port === remotePort()) continue
|
||||
const instance = { id: `simulation-${port}`, port, url: childURL(port) }
|
||||
try {
|
||||
await initializeChild(instance.url)
|
||||
await childToolCall(instance, "simulation_control_snapshot", {})
|
||||
remoteInstances.set(instance.id, instance)
|
||||
found.push(instance)
|
||||
failures = 0
|
||||
} catch {
|
||||
failures++
|
||||
remoteInstances.delete(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
return { instances: instances(), discovered: found, scanned: { startPort, maxPorts, consecutiveFailures } }
|
||||
}
|
||||
|
||||
type ScriptAction = z.infer<typeof ScriptActionSchema>
|
||||
type ScriptCounts = { uiActions: number; fileWrites: number; llmScriptsQueued: number; waits: number }
|
||||
|
||||
async function executeAction(options: Options, action: ScriptAction, counts: ScriptCounts) {
|
||||
if (action.type === "writeFile") {
|
||||
await control(options, "POST", "/experimental/simulation/filesystem/write", {
|
||||
path: action.path,
|
||||
content: action.content,
|
||||
})
|
||||
counts.fileWrites++
|
||||
return
|
||||
}
|
||||
if (action.type === "enqueueLLM") {
|
||||
await control(options, "POST", "/experimental/simulation/llm/enqueue", { scripts: action.scripts })
|
||||
counts.llmScriptsQueued += action.scripts.length
|
||||
return
|
||||
}
|
||||
if (action.type === "wait") {
|
||||
await new Promise((resolve) => setTimeout(resolve, action.ms ?? 1_000))
|
||||
await current(options).harness.renderOnce()
|
||||
counts.waits++
|
||||
return
|
||||
}
|
||||
await SimulationActions.execute(current(options).harness, action)
|
||||
counts.uiActions++
|
||||
}
|
||||
|
||||
async function runScript(options: Options, file: string) {
|
||||
const parsed = ScriptSchema.parse(await Bun.file(file).json())
|
||||
const actions = Array.isArray(parsed) ? parsed : parsed.actions
|
||||
const counts: ScriptCounts = { uiActions: 0, fileWrites: 0, llmScriptsQueued: 0, waits: 0 }
|
||||
for (const action of actions) await executeAction(options, action, counts)
|
||||
return { file, actions: actions.length, ...counts, snapshot: snapshot(options) }
|
||||
}
|
||||
|
||||
// ─── Step-controlled script execution ───────────────────────────────────────
|
||||
//
|
||||
// `simulation_script_load` parses a script and stores its actions in process
|
||||
// memory keyed by a generated id. The script is NOT executed yet. Subsequent
|
||||
// calls to `simulation_script_step` advance the cursor by one or more actions
|
||||
// at a time, returning the snapshot and updated counts after each batch. This
|
||||
// lets MCP clients drive scripts at their own pace and inspect state between
|
||||
// steps. Only one script is active at a time per process; loading a new one
|
||||
// while a previous one is still pending requires either consuming it to
|
||||
// completion, calling `simulation_script_cancel`, or specifying replace=true.
|
||||
|
||||
interface LoadedScript {
|
||||
readonly id: string
|
||||
readonly file: string | null
|
||||
readonly actions: ScriptAction[]
|
||||
cursor: number
|
||||
readonly counts: ScriptCounts
|
||||
readonly loadedAt: string
|
||||
}
|
||||
|
||||
let loaded: LoadedScript | undefined
|
||||
let loadSeq = 0
|
||||
|
||||
function loadedSummary(state: LoadedScript) {
|
||||
return {
|
||||
id: state.id,
|
||||
file: state.file,
|
||||
total: state.actions.length,
|
||||
cursor: state.cursor,
|
||||
remaining: state.actions.length - state.cursor,
|
||||
done: state.cursor >= state.actions.length,
|
||||
counts: { ...state.counts },
|
||||
loadedAt: state.loadedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScript(input: {
|
||||
file?: string
|
||||
script?: unknown
|
||||
replace?: boolean
|
||||
}) {
|
||||
if (loaded && loaded.cursor < loaded.actions.length && !input.replace) {
|
||||
throw new Error(
|
||||
`A script is already loaded (id=${loaded.id}, ${loaded.actions.length - loaded.cursor} actions remaining). Pass replace=true or call simulation_script_cancel first.`,
|
||||
)
|
||||
}
|
||||
const raw = input.file
|
||||
? await Bun.file(input.file).json()
|
||||
: (input.script ?? (() => {
|
||||
throw new Error("simulation_script_load requires either `file` or `script`.")
|
||||
})())
|
||||
const parsed = ScriptSchema.parse(raw)
|
||||
const actions = Array.isArray(parsed) ? parsed : parsed.actions
|
||||
loaded = {
|
||||
id: `script-${(++loadSeq).toString(36)}`,
|
||||
file: input.file ?? null,
|
||||
actions: [...actions] as ScriptAction[],
|
||||
cursor: 0,
|
||||
counts: { uiActions: 0, fileWrites: 0, llmScriptsQueued: 0, waits: 0 },
|
||||
loadedAt: new Date().toISOString(),
|
||||
}
|
||||
return loadedSummary(loaded)
|
||||
}
|
||||
|
||||
async function stepScript(options: Options, input: { steps?: number; renderEach?: boolean }) {
|
||||
if (!loaded) throw new Error("No script loaded. Call simulation_script_load first.")
|
||||
const max = input.steps ?? 1
|
||||
const executed: ScriptAction[] = []
|
||||
for (let i = 0; i < max && loaded.cursor < loaded.actions.length; i++) {
|
||||
const action = loaded.actions[loaded.cursor]!
|
||||
executed.push(action)
|
||||
await executeAction(options, action, loaded.counts)
|
||||
loaded.cursor++
|
||||
if (input.renderEach && i < max - 1) await current(options).harness.renderOnce()
|
||||
}
|
||||
return {
|
||||
executed,
|
||||
state: loadedSummary(loaded),
|
||||
snapshot: snapshot(options),
|
||||
}
|
||||
}
|
||||
|
||||
function cancelScript() {
|
||||
const was = loaded ? loadedSummary(loaded) : null
|
||||
loaded = undefined
|
||||
return { cancelled: was !== null, was }
|
||||
}
|
||||
|
||||
function statusScript() {
|
||||
return loaded ? loadedSummary(loaded) : null
|
||||
}
|
||||
|
||||
async function runOnTargets<A>(
|
||||
options: Options,
|
||||
target: z.infer<typeof TargetSchema>,
|
||||
local: () => Promise<A>,
|
||||
remote: (instance: RemoteInstance) => Promise<unknown>,
|
||||
) {
|
||||
const output = []
|
||||
for (const item of selectedTargets(target)) {
|
||||
output.push({ id: item.id, result: item.local ? await local() : await remote(item.remote) })
|
||||
}
|
||||
return { results: output, snapshot: snapshot(options) }
|
||||
}
|
||||
|
||||
function createServer(options: Options) {
|
||||
const server = new McpServer(
|
||||
{ name: "opencode-simulation", version: InstallationVersion },
|
||||
{
|
||||
instructions:
|
||||
"Use simulation_ui_state_get before acting. Prefer generated actions and execute them with simulation_action_execute. Inspect state after each action. Use control tools to seed filesystem, network, and LLM state.",
|
||||
},
|
||||
)
|
||||
|
||||
server.registerResource("screen", "simulation://screen", { mimeType: "text/plain" }, () => ({
|
||||
contents: [{ uri: "simulation://screen", mimeType: "text/plain", text: current(options).harness.screen() }],
|
||||
}))
|
||||
server.registerResource("ui-state", "simulation://ui-state", { mimeType: "application/json" }, () => ({
|
||||
contents: [{ uri: "simulation://ui-state", mimeType: "application/json", text: JSON.stringify(state(options)) }],
|
||||
}))
|
||||
server.registerResource("backend-snapshot", "simulation://backend-snapshot", { mimeType: "application/json" }, async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: "simulation://backend-snapshot",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(await control(options, "GET", "/experimental/simulation/snapshot")),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
server.registerPrompt("simulation-driver", { description: "Instructions for driving the simulated TUI." }, () => ({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Inspect simulation_ui_state_get, choose one generated action, call simulation_action_execute, then inspect again. Use control tools to seed deterministic backend state.",
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
server.registerTool("simulation_screen_get", { description: "Get the current TUI screen buffer." }, () =>
|
||||
toolResult({ screen: current(options).harness.screen() }),
|
||||
)
|
||||
server.registerTool("simulation_ui_state_get", { description: "Get elements, focus state, and generated actions." }, () =>
|
||||
toolResult(state(options)),
|
||||
)
|
||||
server.registerTool("simulation_render_once", { description: "Force one render and return current state." }, async () => {
|
||||
await current(options).harness.renderOnce()
|
||||
return toolResult(snapshot(options))
|
||||
})
|
||||
server.registerTool(
|
||||
"simulation_backend_fetch",
|
||||
{
|
||||
description: "Proxy one fetch request through the simulated backend and return the raw response.",
|
||||
inputSchema: BackendFetchSchema,
|
||||
},
|
||||
async (input) => toolResult(await backendFetch(options, input)),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_action_execute",
|
||||
{
|
||||
description: "Execute one generated simulation action and render once.",
|
||||
inputSchema: z.object({ action: ActionSchema }),
|
||||
},
|
||||
async (input) => {
|
||||
await SimulationActions.execute(current(options).harness, input.action)
|
||||
return toolResult(snapshot(options))
|
||||
},
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_action_sequence_execute",
|
||||
{
|
||||
description: "Execute a bounded sequence of simulation actions and return final state.",
|
||||
inputSchema: z.object({ actions: z.array(ActionSchema).max(50) }),
|
||||
},
|
||||
async (input) => {
|
||||
for (const action of input.actions) await SimulationActions.execute(current(options).harness, action)
|
||||
return toolResult(snapshot(options))
|
||||
},
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_script_run",
|
||||
{
|
||||
description: "Run a JSON simulation script from a host filesystem path.",
|
||||
inputSchema: z.object({ path: z.string() }),
|
||||
},
|
||||
async (input) => toolResult(await runScript(options, input.path)),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_script_load",
|
||||
{
|
||||
description:
|
||||
"Load a simulation script into memory WITHOUT executing it. Pass `path` to load from a JSON file on disk, or `script` to load inline JSON. Returns the parsed action count and a script id. Use `simulation_script_step` to execute actions one (or N) at a time. Only one script may be loaded at once unless `replace` is true.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().optional(),
|
||||
script: z.any().optional(),
|
||||
replace: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => toolResult(await loadScript({ file: input.path, script: input.script, replace: input.replace })),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_script_step",
|
||||
{
|
||||
description:
|
||||
"Execute the next action(s) of the loaded script and return the snapshot afterwards. Defaults to one step. Pass `steps` to advance multiple actions in a single call (1-100). When `renderEach` is true, the simulated TUI is forced to render between steps.",
|
||||
inputSchema: z.object({
|
||||
steps: z.number().int().min(1).max(100).optional(),
|
||||
renderEach: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => toolResult(await stepScript(options, { steps: input.steps, renderEach: input.renderEach })),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_script_status",
|
||||
{
|
||||
description:
|
||||
"Return the currently-loaded script's progress: id, total actions, cursor, remaining, and execution counts. Returns null when nothing is loaded.",
|
||||
},
|
||||
async () => toolResult({ status: statusScript() }),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_script_cancel",
|
||||
{ description: "Discard the currently-loaded script (if any). Subsequent step calls fail until a new script is loaded." },
|
||||
async () => toolResult(cancelScript()),
|
||||
)
|
||||
|
||||
if ("runtime" in options) {
|
||||
server.registerTool("simulation_restart", { description: "Restart the simulated TUI and backend while keeping MCP alive." }, async () =>
|
||||
toolResult(await options.runtime.restart()),
|
||||
)
|
||||
}
|
||||
|
||||
server.registerTool("simulation_control_reset", { description: "Reset backend simulation state." }, async () =>
|
||||
toolResult(await control(options, "POST", "/experimental/simulation/reset")),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_control_filesystem_seed",
|
||||
{
|
||||
description: "Seed backend simulated filesystem files.",
|
||||
inputSchema: z.object({ files: z.record(z.string(), FileContentSchema) }),
|
||||
},
|
||||
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/filesystem/seed", input)),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_control_filesystem_write",
|
||||
{
|
||||
description: "Write one file into the backend simulated filesystem.",
|
||||
inputSchema: z.object({ path: z.string(), content: FileContentSchema }),
|
||||
},
|
||||
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/filesystem/write", input)),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_control_network_register",
|
||||
{
|
||||
description: "Register one backend simulated network response.",
|
||||
inputSchema: NetworkRegistrationSchema,
|
||||
},
|
||||
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/network/register", input)),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_control_llm_enqueue",
|
||||
{
|
||||
description: "Queue backend mock LLM scripts.",
|
||||
inputSchema: z.object({ scripts: z.array(LlmScriptSchema) }),
|
||||
},
|
||||
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/llm/enqueue", input)),
|
||||
)
|
||||
server.registerTool("simulation_control_snapshot", { description: "Get backend simulation state snapshot." }, async () =>
|
||||
toolResult(await control(options, "GET", "/experimental/simulation/snapshot")),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_network_log_get",
|
||||
{
|
||||
description:
|
||||
"Return the persistent network log: every HTTP request the simulated TUI sent to the backend, with method, URL, status code, headers, and response body (body truncated at 32KB per entry). Capped to the last 500 entries.",
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
urlIncludes: z.string().optional(),
|
||||
statusMin: z.number().int().optional(),
|
||||
statusMax: z.number().int().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => {
|
||||
let entries = SimulationNetworkLog.snapshot()
|
||||
if (input.urlIncludes) entries = entries.filter((e) => e.url.includes(input.urlIncludes!))
|
||||
if (typeof input.statusMin === "number") entries = entries.filter((e) => e.status >= input.statusMin!)
|
||||
if (typeof input.statusMax === "number") entries = entries.filter((e) => e.status <= input.statusMax!)
|
||||
if (typeof input.limit === "number") entries = entries.slice(-input.limit)
|
||||
return toolResult({ entries, total: entries.length })
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_network_log_clear",
|
||||
{
|
||||
description: "Clear the simulated TUI's persistent network log. Use between scripted runs to isolate observations.",
|
||||
},
|
||||
async () => {
|
||||
SimulationNetworkLog.clear()
|
||||
return toolResult({ cleared: true })
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_log_get",
|
||||
{
|
||||
description:
|
||||
"Return the in-memory log buffer captured by `@opencode-ai/core/util/log` inside the simulated backend (capped at 5000 most recent entries). Filter by `level` to return only entries at or above that level. Also supports optional `limit` and substring filters.",
|
||||
inputSchema: z.object({
|
||||
level: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).optional(),
|
||||
limit: z.number().int().min(1).max(5000).optional(),
|
||||
messageIncludes: z.string().optional(),
|
||||
serviceIncludes: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => {
|
||||
const data = await control(options, "GET", "/experimental/simulation/log/entries")
|
||||
let entries = (data?.entries ?? []) as Array<{
|
||||
time: string
|
||||
level: "DEBUG" | "INFO" | "WARN" | "ERROR"
|
||||
tags: Record<string, unknown>
|
||||
message: string
|
||||
}>
|
||||
if (input.level) {
|
||||
const priority = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 } as const
|
||||
const threshold = priority[input.level]
|
||||
entries = entries.filter((e) => priority[e.level] >= threshold)
|
||||
}
|
||||
if (input.messageIncludes) entries = entries.filter((e) => e.message.includes(input.messageIncludes!))
|
||||
if (input.serviceIncludes)
|
||||
entries = entries.filter((e) => String(e.tags?.service ?? "").includes(input.serviceIncludes!))
|
||||
if (typeof input.limit === "number") entries = entries.slice(-input.limit)
|
||||
return toolResult({ entries, total: entries.length })
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_log_clear",
|
||||
{ description: "Clear the simulated backend's in-memory log buffer." },
|
||||
async () => toolResult(await control(options, "POST", "/experimental/simulation/log/clear")),
|
||||
)
|
||||
|
||||
if (masterEnabled()) {
|
||||
server.registerTool(
|
||||
"simulation_instances_discover",
|
||||
{
|
||||
description: "Discover child simulation MCP servers on sequential localhost ports.",
|
||||
inputSchema: z.object({
|
||||
startPort: z.number().optional(),
|
||||
maxPorts: z.number().optional(),
|
||||
consecutiveFailures: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => toolResult(await discoverInstances(input)),
|
||||
)
|
||||
server.registerTool("simulation_instances_list", { description: "List known simulation instances." }, () =>
|
||||
toolResult({ instances: instances() }),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_instances_action_execute",
|
||||
{
|
||||
description: "Execute one UI action on one or more simulation instances.",
|
||||
inputSchema: z.object({ target: TargetSchema, action: ActionSchema }),
|
||||
},
|
||||
async (input) =>
|
||||
toolResult(
|
||||
await runOnTargets(
|
||||
options,
|
||||
input.target,
|
||||
async () => {
|
||||
await SimulationActions.execute(current(options).harness, input.action)
|
||||
return snapshot(options)
|
||||
},
|
||||
(instance) => childToolCall(instance, "simulation_action_execute", { action: input.action }),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_instances_filesystem_write",
|
||||
{
|
||||
description: "Write one file on one or more simulation instances.",
|
||||
inputSchema: z.object({ target: TargetSchema, path: z.string(), content: FileContentSchema }),
|
||||
},
|
||||
async (input) =>
|
||||
toolResult(
|
||||
await runOnTargets(
|
||||
options,
|
||||
input.target,
|
||||
() => control(options, "POST", "/experimental/simulation/filesystem/write", { path: input.path, content: input.content }),
|
||||
(instance) => childToolCall(instance, "simulation_control_filesystem_write", { path: input.path, content: input.content }),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_instances_llm_enqueue",
|
||||
{
|
||||
description: "Queue LLM scripts on one or more simulation instances.",
|
||||
inputSchema: z.object({ target: TargetSchema, scripts: z.array(LlmScriptSchema) }),
|
||||
},
|
||||
async (input) =>
|
||||
toolResult(
|
||||
await runOnTargets(
|
||||
options,
|
||||
input.target,
|
||||
() => control(options, "POST", "/experimental/simulation/llm/enqueue", { scripts: input.scripts }),
|
||||
(instance) => childToolCall(instance, "simulation_control_llm_enqueue", { scripts: input.scripts }),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_instances_script_run",
|
||||
{
|
||||
description: "Run a JSON simulation script on one or more simulation instances.",
|
||||
inputSchema: z.object({ target: TargetSchema, path: z.string() }),
|
||||
},
|
||||
async (input) =>
|
||||
toolResult(
|
||||
await runOnTargets(
|
||||
options,
|
||||
input.target,
|
||||
() => runScript(options, input.path),
|
||||
(instance) => childToolCall(instance, "simulation_script_run", { path: input.path }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
export async function createSimulationMcpServer(options: Options): Promise<SimulationMcpServer> {
|
||||
if (options.mode === "stdio") {
|
||||
const server = createServer(options)
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
return {
|
||||
mode: options.mode,
|
||||
stop: () => server.close(),
|
||||
}
|
||||
}
|
||||
|
||||
const servers = new Set<McpServer>()
|
||||
|
||||
const http = serveRemote(
|
||||
async (request) => {
|
||||
if (new URL(request.url).pathname !== "/mcp") return new Response("Not found", { status: 404 })
|
||||
const server = createServer(options)
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
})
|
||||
servers.add(server)
|
||||
request.signal.addEventListener("abort", () => {
|
||||
servers.delete(server)
|
||||
void server.close()
|
||||
})
|
||||
await server.connect(transport)
|
||||
return transport.handleRequest(request)
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
mode: options.mode,
|
||||
url: `http://${http.hostname}:${http.port}/mcp`,
|
||||
stop: async () => {
|
||||
http.stop(true)
|
||||
await Promise.all([...servers].map((server) => server.close()))
|
||||
servers.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as TuiSimulationMcp from "./simulation-mcp"
|
||||
@@ -0,0 +1,46 @@
|
||||
// Per-process network log for the simulated TUI. Captures every HTTP request
|
||||
// the TUI sends to the worker-backed backend (via `createWorkerFetch`).
|
||||
// Exposed through the simulation MCP server so tests / agents can inspect what
|
||||
// the running TUI is actually doing.
|
||||
|
||||
export interface NetworkLogEntry {
|
||||
readonly id: number
|
||||
readonly time: string
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly status: number
|
||||
readonly durationMs: number
|
||||
readonly requestHeaders: Record<string, string>
|
||||
readonly requestBody?: string
|
||||
readonly responseHeaders: Record<string, string>
|
||||
readonly responseBody: string
|
||||
readonly responseTruncated: boolean
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 500
|
||||
const MAX_BODY_BYTES = 32_768
|
||||
|
||||
const entries: NetworkLogEntry[] = []
|
||||
let nextId = 1
|
||||
|
||||
function truncate(text: string): { body: string; truncated: boolean } {
|
||||
if (text.length <= MAX_BODY_BYTES) return { body: text, truncated: false }
|
||||
return { body: text.slice(0, MAX_BODY_BYTES), truncated: true }
|
||||
}
|
||||
|
||||
export function record(entry: Omit<NetworkLogEntry, "id" | "responseTruncated"> & { responseBody: string }) {
|
||||
const { body, truncated } = truncate(entry.responseBody)
|
||||
entries.push({ ...entry, id: nextId++, responseBody: body, responseTruncated: truncated })
|
||||
if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES)
|
||||
}
|
||||
|
||||
export function snapshot(): NetworkLogEntry[] {
|
||||
return entries.slice()
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
entries.length = 0
|
||||
}
|
||||
|
||||
export * as SimulationNetworkLog from "./simulation-network-log"
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import type { CapturedFrame } from "@opentui/core"
|
||||
import type { SimulationActions } from "@/testing/simulation/actions"
|
||||
|
||||
export interface SimulationRenderer {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mockInput: SimulationActions.MockInput
|
||||
readonly mockMouse: SimulationActions.MockMouse
|
||||
readonly renderOnce: () => Promise<void>
|
||||
readonly screen: () => string
|
||||
readonly spans: () => CapturedFrame
|
||||
readonly destroy: () => void
|
||||
}
|
||||
|
||||
export async function createSimulationRenderer(): Promise<SimulationRenderer> {
|
||||
const { createTestRenderer } = await import("@opentui/core/testing")
|
||||
const setup = await createTestRenderer({
|
||||
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
|
||||
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
|
||||
screenMode: "main-screen",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
|
||||
return {
|
||||
renderer: setup.renderer,
|
||||
mockInput: setup.mockInput,
|
||||
mockMouse: setup.mockMouse,
|
||||
renderOnce: setup.renderOnce,
|
||||
screen: setup.captureCharFrame,
|
||||
spans: setup.captureSpans,
|
||||
destroy: () => setup.renderer.destroy(),
|
||||
}
|
||||
}
|
||||
|
||||
export * as TuiSimulation from "./simulation"
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
@@ -13,8 +14,26 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
|
||||
import { Effect } from "effect"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
|
||||
import { SimulationDebugLog } from "../../../testing/simulation/debug-log"
|
||||
import fs from "fs/promises"
|
||||
|
||||
ensureProcessMetadata("worker")
|
||||
if (process.env.OPENCODE_SIMULATION_CWD) {
|
||||
process.env.PWD = process.env.OPENCODE_SIMULATION_CWD
|
||||
process.env.OPENCODE_TEST_HOME = process.env.OPENCODE_SIMULATION_CWD
|
||||
Global.Path.data = `${process.env.OPENCODE_SIMULATION_CWD}/.local/share/opencode`
|
||||
Global.Path.cache = `${process.env.OPENCODE_SIMULATION_CWD}/.cache/opencode`
|
||||
Global.Path.config = `${process.env.OPENCODE_SIMULATION_CWD}/.config/opencode`
|
||||
Global.Path.state = `${process.env.OPENCODE_SIMULATION_CWD}/.local/state/opencode`
|
||||
Global.Path.tmp = `${process.env.OPENCODE_SIMULATION_CWD}/tmp/opencode`
|
||||
Global.Path.bin = `${Global.Path.cache}/bin`
|
||||
Global.Path.repos = `${Global.Path.data}/repos`
|
||||
Object.defineProperty(process, "cwd", {
|
||||
value: () => process.env.OPENCODE_SIMULATION_CWD!,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
void fs.writeFile("/tmp/opencode-http-errors.log", "")
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -41,6 +60,12 @@ process.on("uncaughtException", (e) => {
|
||||
|
||||
// Subscribe to global events and forward them via RPC
|
||||
GlobalBus.on("event", (event) => {
|
||||
SimulationDebugLog.write("worker.global.event", {
|
||||
directory: event.directory,
|
||||
workspace: event.workspace,
|
||||
type: event.payload?.type,
|
||||
syncType: event.payload?.syncEvent?.type,
|
||||
})
|
||||
Rpc.emit("global.event", event)
|
||||
})
|
||||
|
||||
@@ -48,6 +73,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
SimulationDebugLog.write("worker.fetch.start", { method: input.method, url: input.url })
|
||||
const headers = { ...input.headers }
|
||||
const auth = ServerAuth.header()
|
||||
if (auth && !headers["authorization"] && !headers["Authorization"]) {
|
||||
@@ -60,6 +86,7 @@ export const rpc = {
|
||||
})
|
||||
const response = await Server.Default().app.fetch(request)
|
||||
const body = await response.text()
|
||||
SimulationDebugLog.write("worker.fetch.end", { method: input.method, url: input.url, status: response.status })
|
||||
return {
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ExportCommand } from "./cli/cmd/export"
|
||||
import { ImportCommand } from "./cli/cmd/import"
|
||||
import { AttachCommand } from "./cli/cmd/tui/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||
import { SimulateCommand } from "./cli/cmd/tui/simulate"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { EOL } from "os"
|
||||
import { WebCommand } from "./cli/cmd/web"
|
||||
@@ -40,6 +41,7 @@ import { Heap } from "./cli/heap"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
const processMetadata = ensureProcessMetadata("main")
|
||||
|
||||
@@ -67,7 +69,7 @@ function show(out: string) {
|
||||
process.stderr.write(out)
|
||||
}
|
||||
|
||||
const cli = yargs(args)
|
||||
const baseCli = yargs(args)
|
||||
.parserConfiguration({ "populate--": true })
|
||||
.scriptName("opencode")
|
||||
.wrap(100)
|
||||
@@ -178,6 +180,8 @@ const cli = yargs(args)
|
||||
.command(SessionCommand)
|
||||
.command(PluginCommand)
|
||||
.command(DbCommand)
|
||||
|
||||
const cli = (Flag.OPENCODE_SIMULATION || Flag.OPENCODE_SIMULATION_BACKEND ? baseCli.command(SimulateCommand) : baseCli)
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg?.startsWith("Unknown argument") ||
|
||||
|
||||
@@ -69,16 +69,16 @@ export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedErr
|
||||
}) {}
|
||||
|
||||
// Response schemas for external version APIs
|
||||
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
||||
const NpmPackage = Schema.Struct({ version: Schema.String })
|
||||
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
||||
const BrewInfoV2 = Schema.Struct({
|
||||
export const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
||||
export const NpmPackage = Schema.Struct({ version: Schema.String })
|
||||
export const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
||||
export const BrewInfoV2 = Schema.Struct({
|
||||
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
|
||||
})
|
||||
const ChocoPackage = Schema.Struct({
|
||||
export const ChocoPackage = Schema.Struct({
|
||||
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
|
||||
})
|
||||
const ScoopManifest = NpmPackage
|
||||
export const ScoopManifest = NpmPackage
|
||||
|
||||
export interface Interface {
|
||||
readonly info: () => Effect.Effect<Info>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Effect, Schema } from "effect"
|
||||
import type * as LSPServer from "./server"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
@@ -30,7 +31,7 @@ const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
const busRuntime = makeRuntime(Bus.Service, Bus.layer)
|
||||
|
||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
||||
export type Info = NonNullable<Effect.Success<ReturnType<typeof create>>>
|
||||
|
||||
export type Diagnostic = VSCodeDiagnostic
|
||||
|
||||
@@ -138,13 +139,18 @@ function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
|
||||
return serverID === "typescript"
|
||||
}
|
||||
|
||||
export async function create(input: {
|
||||
export const create = Effect.fn("LSPClient.create")(function* (input: {
|
||||
serverID: string
|
||||
server: LSPServer.Handle
|
||||
root: string
|
||||
directory: string
|
||||
instance: InstanceContext
|
||||
}) {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
// Bridge: read file content through AppFileSystem (so the simulated backend
|
||||
// can satisfy file reads from the in-memory FS instead of real disk).
|
||||
const readText = (p: string) => Effect.runPromise(appFs.readFileString(p) as Effect.Effect<string, Error>)
|
||||
return yield* Effect.promise(async () => {
|
||||
const logger = log.clone().tag("serverID", input.serverID)
|
||||
logger.info("starting client")
|
||||
const instance = input.instance
|
||||
@@ -596,7 +602,7 @@ export async function create(input: {
|
||||
request.path = Filesystem.normalizePath(
|
||||
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
|
||||
)
|
||||
const text = await Filesystem.readText(request.path)
|
||||
const text = await readText(request.path)
|
||||
const extension = path.extname(request.path)
|
||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||
|
||||
@@ -702,6 +708,7 @@ export async function create(input: {
|
||||
logger.info("initialized")
|
||||
|
||||
return result
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export * as LSPClient from "./client"
|
||||
|
||||
+343
-327
@@ -13,6 +13,7 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { containsPath } from "@/project/instance-context"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
@@ -111,6 +112,11 @@ const filterExperimentalServers = (servers: Record<string, LSPServer.Info>, flag
|
||||
}
|
||||
}
|
||||
|
||||
const builtinServers: LSPServer.Info[] = Object.values(LSPServer).filter(
|
||||
(value): value is LSPServer.Info =>
|
||||
typeof value === "object" && value !== null && typeof (value as LSPServer.Info).id === "string",
|
||||
)
|
||||
|
||||
type LocInput = { file: string; line: number; character: number }
|
||||
|
||||
interface State {
|
||||
@@ -139,369 +145,379 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
export const makeLayer = (supported: LSPServer.Info[]) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
|
||||
if (!cfg.lsp) {
|
||||
log.info("all LSPs are disabled")
|
||||
} else {
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
servers[server.id] = server
|
||||
}
|
||||
if (!cfg.lsp) {
|
||||
log.info("all LSPs are disabled")
|
||||
} else {
|
||||
for (const server of supported) {
|
||||
servers[server.id] = server
|
||||
}
|
||||
|
||||
filterExperimentalServers(servers, flags)
|
||||
filterExperimentalServers(servers, flags)
|
||||
|
||||
if (cfg.lsp !== true) {
|
||||
for (const [name, item] of Object.entries(cfg.lsp)) {
|
||||
const existing = servers[name]
|
||||
if (item.disabled) {
|
||||
log.info(`LSP server ${name} is disabled`)
|
||||
delete servers[name]
|
||||
continue
|
||||
}
|
||||
servers[name] = {
|
||||
...existing,
|
||||
id: name,
|
||||
root: existing?.root ?? (async (_file, ctx) => ctx.directory),
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => ({
|
||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...item.env },
|
||||
if (cfg.lsp !== true) {
|
||||
for (const [name, item] of Object.entries(cfg.lsp)) {
|
||||
const existing = servers[name]
|
||||
if (item.disabled) {
|
||||
log.info(`LSP server ${name} is disabled`)
|
||||
delete servers[name]
|
||||
continue
|
||||
}
|
||||
servers[name] = {
|
||||
...existing,
|
||||
id: name,
|
||||
root: existing?.root ?? (async (_file, ctx) => ctx.directory),
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => ({
|
||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...item.env },
|
||||
}),
|
||||
initialization: item.initialization,
|
||||
}),
|
||||
initialization: item.initialization,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("enabled LSP servers", {
|
||||
serverIds: Object.values(servers)
|
||||
.map((server) => server.id)
|
||||
.join(", "),
|
||||
})
|
||||
}
|
||||
|
||||
log.info("enabled LSP servers", {
|
||||
serverIds: Object.values(servers)
|
||||
.map((server) => server.id)
|
||||
.join(", "),
|
||||
})
|
||||
}
|
||||
const s: State = {
|
||||
clients: [],
|
||||
servers,
|
||||
broken: new Set(),
|
||||
spawning: new Map(),
|
||||
}
|
||||
|
||||
const s: State = {
|
||||
clients: [],
|
||||
servers,
|
||||
broken: new Set(),
|
||||
spawning: new Map(),
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(s.clients.map((client) => client.shutdown()))
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(s.clients.map((client) => client.shutdown()))
|
||||
}),
|
||||
)
|
||||
return s
|
||||
}),
|
||||
)
|
||||
|
||||
return s
|
||||
}),
|
||||
)
|
||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
||||
const handle = await server
|
||||
.spawn(root, ctx, flags)
|
||||
.then((value) => {
|
||||
if (!value) s.broken.add(key)
|
||||
return value
|
||||
})
|
||||
.catch((err) => {
|
||||
s.broken.add(key)
|
||||
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
||||
const handle = await server
|
||||
.spawn(root, ctx, flags)
|
||||
.then((value) => {
|
||||
if (!value) s.broken.add(key)
|
||||
return value
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!handle) return undefined
|
||||
log.info("spawned lsp server", { serverID: server.id, root })
|
||||
|
||||
const client = await Effect.runPromise(
|
||||
LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
directory: ctx.directory,
|
||||
instance: ctx,
|
||||
}).pipe(Effect.provideService(AppFileSystem.Service, appFs)),
|
||||
).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
|
||||
await Process.stop(handle.process)
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!handle) return undefined
|
||||
log.info("spawned lsp server", { serverID: server.id, root })
|
||||
if (!client) return undefined
|
||||
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
directory: ctx.directory,
|
||||
instance: ctx,
|
||||
}).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
await Process.stop(handle.process)
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!client) return undefined
|
||||
|
||||
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (existing) {
|
||||
await Process.stop(handle.process)
|
||||
return existing
|
||||
}
|
||||
|
||||
s.clients.push(client)
|
||||
return client
|
||||
}
|
||||
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
|
||||
const root = await server.root(file, ctx)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (match) {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
|
||||
const inflight = s.spawning.get(root + server.id)
|
||||
if (inflight) {
|
||||
const client = await inflight
|
||||
if (!client) continue
|
||||
result.push(client)
|
||||
continue
|
||||
}
|
||||
|
||||
const task = schedule(server, root, root + server.id)
|
||||
s.spawning.set(root + server.id, task)
|
||||
|
||||
task.finally(() => {
|
||||
if (s.spawning.get(root + server.id) === task) {
|
||||
s.spawning.delete(root + server.id)
|
||||
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (existing) {
|
||||
await Process.stop(handle.process)
|
||||
return existing
|
||||
}
|
||||
|
||||
s.clients.push(client)
|
||||
return client
|
||||
}
|
||||
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
|
||||
const root = await server.root(file, ctx)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (match) {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
|
||||
const inflight = s.spawning.get(root + server.id)
|
||||
if (inflight) {
|
||||
const client = await inflight
|
||||
if (!client) continue
|
||||
result.push(client)
|
||||
continue
|
||||
}
|
||||
|
||||
const task = schedule(server, root, root + server.id)
|
||||
s.spawning.set(root + server.id, task)
|
||||
|
||||
task.finally(() => {
|
||||
if (s.spawning.get(root + server.id) === task) {
|
||||
s.spawning.delete(root + server.id)
|
||||
}
|
||||
})
|
||||
|
||||
const client = await task
|
||||
if (!client) continue
|
||||
|
||||
result.push(client)
|
||||
await Bus.publish(ctx, Event.Updated, {})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const clients = yield* getClients(file)
|
||||
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const init = Effect.fn("LSP.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
const status = Effect.fn("LSP.status")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result: Status[] = []
|
||||
for (const client of s.clients) {
|
||||
result.push({
|
||||
id: client.serverID,
|
||||
name: s.servers[client.serverID].id,
|
||||
root: path.relative(ctx.directory, client.root),
|
||||
status: "connected",
|
||||
})
|
||||
|
||||
const client = await task
|
||||
if (!client) continue
|
||||
|
||||
result.push(client)
|
||||
await Bus.publish(ctx, Event.Updated, {})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const clients = yield* getClients(file)
|
||||
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const init = Effect.fn("LSP.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
const status = Effect.fn("LSP.status")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result: Status[] = []
|
||||
for (const client of s.clients) {
|
||||
result.push({
|
||||
id: client.serverID,
|
||||
name: s.servers[client.serverID].id,
|
||||
root: path.relative(ctx.directory, client.root),
|
||||
status: "connected",
|
||||
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file, ctx)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file, ctx)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {
|
||||
log.info("touching file", { file: input })
|
||||
const clients = yield* getClients(input)
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
clients.map(async (client) => {
|
||||
const after = Date.now()
|
||||
const version = await client.notify.open({ path: input })
|
||||
if (!diagnostics) return
|
||||
return client.waitForDiagnostics({
|
||||
path: input,
|
||||
version,
|
||||
mode: diagnostics,
|
||||
after,
|
||||
})
|
||||
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {
|
||||
log.info("touching file", { file: input })
|
||||
const clients = yield* getClients(input)
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
clients.map(async (client) => {
|
||||
const after = Date.now()
|
||||
const version = await client.notify.open({ path: input })
|
||||
if (!diagnostics) return
|
||||
return client.waitForDiagnostics({
|
||||
path: input,
|
||||
version,
|
||||
mode: diagnostics,
|
||||
after,
|
||||
})
|
||||
}),
|
||||
).catch((err) => {
|
||||
log.error("failed to touch file", { err, file: input })
|
||||
}),
|
||||
).catch((err) => {
|
||||
log.error("failed to touch file", { err, file: input })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
|
||||
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
||||
const all = yield* runAll(async (client) => client.diagnostics)
|
||||
for (const result of all) {
|
||||
for (const [p, diags] of result.entries()) {
|
||||
const arr = results[p] || []
|
||||
arr.push(...diags)
|
||||
results[p] = arr
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
|
||||
return yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/hover", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
})
|
||||
|
||||
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
context: { includeDeclaration: true },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/implementation", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
|
||||
const file = fileURLToPath(uri)
|
||||
const results = yield* run(file, (client) =>
|
||||
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
|
||||
)
|
||||
return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)
|
||||
})
|
||||
|
||||
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
||||
const results = yield* runAll((client) =>
|
||||
client.connection
|
||||
.sendRequest<Symbol[]>("workspace/symbol", { query })
|
||||
.then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
|
||||
.catch(() => [] as Symbol[]),
|
||||
)
|
||||
return results.flat()
|
||||
})
|
||||
|
||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const callHierarchyRequest = Effect.fnUntraced(function* (
|
||||
input: LocInput,
|
||||
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
|
||||
) {
|
||||
const results = yield* run(input.file, async (client) => {
|
||||
const items = await client.connection
|
||||
.sendRequest<unknown[] | null>("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => [] as unknown[])
|
||||
if (!items?.length) return []
|
||||
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
|
||||
)
|
||||
})
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
|
||||
})
|
||||
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
|
||||
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
||||
const all = yield* runAll(async (client) => client.diagnostics)
|
||||
for (const result of all) {
|
||||
for (const [p, diags] of result.entries()) {
|
||||
const arr = results[p] || []
|
||||
arr.push(...diags)
|
||||
results[p] = arr
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
|
||||
})
|
||||
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
|
||||
return yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/hover", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init,
|
||||
status,
|
||||
hasClients,
|
||||
touchFile,
|
||||
diagnostics,
|
||||
hover,
|
||||
definition,
|
||||
references,
|
||||
implementation,
|
||||
documentSymbol,
|
||||
workspaceSymbol,
|
||||
prepareCallHierarchy,
|
||||
incomingCalls,
|
||||
outgoingCalls,
|
||||
})
|
||||
}),
|
||||
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
context: { includeDeclaration: true },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/implementation", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
|
||||
const file = fileURLToPath(uri)
|
||||
const results = yield* run(file, (client) =>
|
||||
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
|
||||
)
|
||||
return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)
|
||||
})
|
||||
|
||||
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
||||
const results = yield* runAll((client) =>
|
||||
client.connection
|
||||
.sendRequest<Symbol[]>("workspace/symbol", { query })
|
||||
.then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
|
||||
.catch(() => [] as Symbol[]),
|
||||
)
|
||||
return results.flat()
|
||||
})
|
||||
|
||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const callHierarchyRequest = Effect.fnUntraced(function* (
|
||||
input: LocInput,
|
||||
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
|
||||
) {
|
||||
const results = yield* run(input.file, async (client) => {
|
||||
const items = await client.connection
|
||||
.sendRequest<unknown[] | null>("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => [] as unknown[])
|
||||
if (!items?.length) return []
|
||||
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
|
||||
})
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
|
||||
})
|
||||
|
||||
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init,
|
||||
status,
|
||||
hasClients,
|
||||
touchFile,
|
||||
diagnostics,
|
||||
hover,
|
||||
definition,
|
||||
references,
|
||||
implementation,
|
||||
documentSymbol,
|
||||
workspaceSymbol,
|
||||
prepareCallHierarchy,
|
||||
incomingCalls,
|
||||
outgoingCalls,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = makeLayer(builtinServers)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer))
|
||||
|
||||
export * as Diagnostic from "./diagnostic"
|
||||
|
||||
export * as LSP from "./lsp"
|
||||
|
||||
@@ -199,8 +199,10 @@ export const layer: Layer.Layer<
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
log.info("fromDirectory dotgit discovery", { directory, dotgit, matches: dotgitMatches })
|
||||
|
||||
if (!dotgit) {
|
||||
log.info("fromDirectory no dotgit", { directory, fakeVcs })
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
@@ -212,8 +214,10 @@ export const layer: Layer.Layer<
|
||||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
log.info("fromDirectory dotgit found", { directory, dotgit, sandbox, gitBinary, cachedProjectId: id })
|
||||
|
||||
if (!gitBinary) {
|
||||
log.info("fromDirectory no git binary", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
@@ -223,7 +227,9 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
log.info("fromDirectory git common-dir", { directory, sandbox, code: commonDir.code, text: commonDir.text, stderr: commonDir.stderr })
|
||||
if (commonDir.code !== 0) {
|
||||
log.info("fromDirectory git common-dir failed", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
@@ -235,13 +241,24 @@ export const layer: Layer.Layer<
|
||||
const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
|
||||
const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
|
||||
const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
|
||||
log.info("fromDirectory git repository metadata", {
|
||||
directory,
|
||||
sandbox,
|
||||
common,
|
||||
bareCheckCode: bareCheck.code,
|
||||
bareCheckText: bareCheck.text,
|
||||
isBareRepo,
|
||||
worktree,
|
||||
})
|
||||
|
||||
if (id == null) {
|
||||
id = yield* readCachedProjectId(common)
|
||||
log.info("fromDirectory common cached project id", { directory, common, cachedProjectId: id })
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||
log.info("fromDirectory git rev-list roots", { directory, sandbox, code: revList.code, text: revList.text, stderr: revList.stderr })
|
||||
const roots = revList.text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
@@ -255,11 +272,14 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
log.info("fromDirectory no project id", { directory, sandbox, worktree })
|
||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||
}
|
||||
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||
log.info("fromDirectory git top-level", { directory, sandbox, code: topLevel.code, text: topLevel.text, stderr: topLevel.stderr })
|
||||
if (topLevel.code !== 0) {
|
||||
log.info("fromDirectory git top-level failed", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
@@ -269,8 +289,10 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||
|
||||
log.info("fromDirectory discovered git project", { directory, id, sandbox, worktree })
|
||||
return { id, sandbox, worktree, vcs: "git" as const }
|
||||
})
|
||||
log.info("fromDirectory discovery result", data)
|
||||
|
||||
// Phase 2: upsert
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
@@ -292,6 +314,7 @@ export const layer: Layer.Layer<
|
||||
vcs: data.vcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
log.info("fromDirectory existing project row", { directory, hasExisting: Boolean(row), existing })
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
|
||||
@@ -3,8 +3,22 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigError } from "@/config/error"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
|
||||
import fs from "fs/promises"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
const errorLogPath = "/tmp/opencode-http-errors.log"
|
||||
|
||||
function writeHttpError(cause: Cause.Cause<unknown>, error: unknown) {
|
||||
void fs.appendFile(
|
||||
errorLogPath,
|
||||
JSON.stringify({
|
||||
time: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
cause: Cause.pretty(cause),
|
||||
}) + "\n",
|
||||
)
|
||||
}
|
||||
|
||||
// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
|
||||
export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Config as EffectConfig, Context, Effect, Layer } from "effect"
|
||||
import { Config as EffectConfig, Context, Effect, Layer, FileSystem, Path } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { Image } from "@/image/image"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { Git } from "@/git"
|
||||
import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
@@ -10,10 +18,14 @@ import {
|
||||
} from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountRepo } from "@/account/repo"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Auth } from "@/auth"
|
||||
import { Bus } from "@/bus"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Config } from "@/config/config"
|
||||
import { Command } from "@/command"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
@@ -24,9 +36,15 @@ import { Format } from "@/format"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpAuth } from "@/mcp/auth"
|
||||
import { Permission } from "@/permission"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Env } from "@/env"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
@@ -38,6 +56,8 @@ import { Question } from "@/question"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { SessionProcessor } from "@/session/processor"
|
||||
import { Instruction } from "@/session/instruction"
|
||||
import { SessionRevert } from "@/session/revert"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
@@ -46,18 +66,32 @@ import { Todo } from "@/session/todo"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { SystemPrompt } from "@/session/system"
|
||||
import { Skill } from "@/skill"
|
||||
import { Discovery } from "@/skill/discovery"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { SimulationFileSystem } from "@/testing/simulation/filesystem"
|
||||
import { SimulationNetwork } from "@/testing/simulation/network"
|
||||
import { SimulationNetworkRoutes } from "@/testing/simulation/network-routes"
|
||||
import { SimulationProvider } from "@/testing/simulation/provider"
|
||||
import { SimulationSpawner } from "@/testing/simulation/spawner"
|
||||
import { SimulationGit } from "@/testing/simulation/git"
|
||||
import { SimulationLsp } from "@/testing/simulation/lsp"
|
||||
import { Simulation } from "@/testing/simulation/service"
|
||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { InMemoryFs } from "just-bash"
|
||||
import { PublicApi } from "./public"
|
||||
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||
import { EventApi } from "./groups/event"
|
||||
@@ -82,6 +116,7 @@ import { workspaceHandlers } from "./handlers/workspace"
|
||||
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
|
||||
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { simulationRoute } from "./simulation"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { compressionLayer } from "./middleware/compression"
|
||||
import { corsVaryFix } from "./middleware/cors-vary"
|
||||
@@ -172,6 +207,27 @@ const uiRoute = HttpRouter.use((router) =>
|
||||
}),
|
||||
).pipe(Layer.provide(authOnlyRouterLayer))
|
||||
|
||||
const simulationShareNextLayer = Layer.succeed(
|
||||
ShareNext.Service,
|
||||
ShareNext.Service.of({
|
||||
init: () => Effect.void,
|
||||
url: () => Effect.succeed("https://opncd.ai"),
|
||||
request: () =>
|
||||
Effect.succeed({
|
||||
headers: {},
|
||||
baseUrl: "https://opncd.ai",
|
||||
api: {
|
||||
create: "/api/shares",
|
||||
sync: (shareID) => `/api/shares/${shareID}/sync`,
|
||||
remove: (shareID) => `/api/shares/${shareID}`,
|
||||
data: (shareID) => `/api/shares/${shareID}/data`,
|
||||
},
|
||||
}),
|
||||
create: () => Effect.succeed({ id: "", url: "", secret: "" }),
|
||||
remove: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
type RouteRequirements =
|
||||
| HttpRouter.HttpRouter
|
||||
| HttpRouter.Request<"Error", unknown>
|
||||
@@ -179,7 +235,7 @@ type RouteRequirements =
|
||||
| HttpRouter.Request<"Requires", unknown>
|
||||
| HttpRouter.Request<"GlobalRequires", never>
|
||||
|
||||
export function createRoutes(
|
||||
function createProductionRoutes(
|
||||
corsOptions?: CorsOptions,
|
||||
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
@@ -240,6 +296,378 @@ export function createRoutes(
|
||||
)
|
||||
}
|
||||
|
||||
export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typeof createProductionRoutes> {
|
||||
const fs = new InMemoryFs()
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Pattern: `consumer.pipe(Layer.provideMerge(dep))` — `consumer` (LHS) CAN
|
||||
// see `dep` (RHS). We build STRICTLY bottom-up via topological sort:
|
||||
// consumers at the START of the pipe, deepest leaves at the END.
|
||||
//
|
||||
// Each tier is built and TYPE-ANNOTATED with its expected `Layer.Layer<
|
||||
// ROut, E, RIn>` so we can verify the composition is correct step by step.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Tier 0: true leaves with NO app deps ───────────────────────────────
|
||||
// Provides: everything in this Layer.mergeAll. Requires: nothing.
|
||||
// (SimulationGit needs AppFileSystem; goes in tier1.)
|
||||
type Tier0Services =
|
||||
| AppFileSystem.Service
|
||||
| FileSystem.FileSystem
|
||||
| ChildProcessSpawner.ChildProcessSpawner
|
||||
| HttpClient.HttpClient
|
||||
| SimulationNetwork.Service
|
||||
| Path.Path
|
||||
| Global.Service
|
||||
| Env.Service
|
||||
| Bus.Service
|
||||
| AccountRepo.Service
|
||||
| ShareNext.Service
|
||||
| PtyTicket.Service
|
||||
| RuntimeFlags.Service
|
||||
| BackgroundJob.Service
|
||||
| EventV2.Service
|
||||
|
||||
const tier0: Layer.Layer<Tier0Services, never, never> = Layer.mergeAll(
|
||||
SimulationFileSystem.layer({
|
||||
root: "/opencode",
|
||||
fs,
|
||||
files: {
|
||||
".git/HEAD": "ref: refs/heads/main\n",
|
||||
".git/config": '[core]\n\trepositoryformatversion = 0\n\tbare = false\n[branch "main"]\n',
|
||||
// Enable built-in LSP servers (e.g. the simulated typescript stub) so
|
||||
// tools like `edit` / `write` get diagnostics in the simulated chain.
|
||||
"opencode.json": JSON.stringify({ $schema: "https://opencode.ai/config.json", lsp: true }, null, 2) + "\n",
|
||||
},
|
||||
}),
|
||||
// SimulationFileSystem.layer no longer provides FileSystem.FileSystem; satisfy
|
||||
// it with a no-op so nothing in the chain falls back to NodeFileSystem.
|
||||
FileSystem.layerNoop({}),
|
||||
SimulationSpawner.layer({ root: "/opencode", fs }),
|
||||
SimulationNetwork.layer({ entries: SimulationNetworkRoutes.defaults(), allowLoopback: true }),
|
||||
NodePath.layer,
|
||||
Global.layer,
|
||||
Env.layer,
|
||||
Bus.layer,
|
||||
AccountRepo.layer,
|
||||
simulationShareNextLayer,
|
||||
PtyTicket.layer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
BackgroundJob.layer,
|
||||
EventV2.layer,
|
||||
)
|
||||
|
||||
// ─── Tier 1: services depending only on tier 0 ──────────────────────────
|
||||
// SimulationGit → AppFileSystem
|
||||
// Truncate → AppFileSystem
|
||||
// Auth → AppFileSystem
|
||||
// McpAuth → AppFileSystem
|
||||
// EffectFlock → Global, AppFileSystem
|
||||
// Permission → Bus
|
||||
// Todo → Bus
|
||||
// Question → Bus
|
||||
// SessionStatus → Bus
|
||||
// Discovery → AppFileSystem, Path, HttpClient
|
||||
// Ripgrep → AppFileSystem, HttpClient, ChildProcessSpawner
|
||||
// Account → AccountRepo, HttpClient
|
||||
// SyncEvent → RuntimeFlags, Bus
|
||||
// AppProcess → ChildProcessSpawner
|
||||
const tier1: Layer.Layer<
|
||||
| Tier0Services
|
||||
| Git.Service
|
||||
| Truncate.Service
|
||||
| Auth.Service
|
||||
| McpAuth.Service
|
||||
| EffectFlock.Service
|
||||
| Permission.Service
|
||||
| Todo.Service
|
||||
| Question.Service
|
||||
| SessionStatus.Service
|
||||
| Discovery.Service
|
||||
| Ripgrep.Service
|
||||
| Account.Service
|
||||
| SyncEvent.Service
|
||||
| AppProcess.Service,
|
||||
never,
|
||||
never
|
||||
> = Layer.mergeAll(
|
||||
SimulationGit.layer,
|
||||
Truncate.layer,
|
||||
Auth.layer,
|
||||
McpAuth.layer,
|
||||
EffectFlock.layer,
|
||||
Permission.layer,
|
||||
Todo.layer,
|
||||
Question.layer,
|
||||
SessionStatus.layer,
|
||||
Discovery.layer,
|
||||
Ripgrep.layer,
|
||||
Account.layer,
|
||||
SyncEvent.layer,
|
||||
AppProcess.layer,
|
||||
).pipe(Layer.provideMerge(tier0))
|
||||
|
||||
type Tier1Services =
|
||||
| Tier0Services
|
||||
| Git.Service
|
||||
| Truncate.Service
|
||||
| Auth.Service
|
||||
| McpAuth.Service
|
||||
| EffectFlock.Service
|
||||
| Permission.Service
|
||||
| Todo.Service
|
||||
| Question.Service
|
||||
| SessionStatus.Service
|
||||
| Discovery.Service
|
||||
| Ripgrep.Service
|
||||
| Account.Service
|
||||
| SyncEvent.Service
|
||||
| AppProcess.Service
|
||||
|
||||
// ─── Tier 2: services depending only on tier 0 + tier 1 ─────────────────
|
||||
// Npm → AppFileSystem, Global, FileSystem, EffectFlock
|
||||
// ModelsDev → AppFileSystem, HttpClient
|
||||
// Project → AppFileSystem, Path, ChildProcessSpawner, Bus, RuntimeFlags
|
||||
// Installation → HttpClient, AppProcess
|
||||
// Storage → AppFileSystem, Git
|
||||
// Vcs → Git, Bus
|
||||
// SessionRunState → BackgroundJob, SessionStatus
|
||||
// EventV2Bridge → EventV2, Bus, SyncEvent
|
||||
const tier2: Layer.Layer<
|
||||
| Tier1Services
|
||||
| Npm.Service
|
||||
| ModelsDev.Service
|
||||
| Project.Service
|
||||
| Installation.Service
|
||||
| Storage.Service
|
||||
| Vcs.Service
|
||||
| SessionRunState.Service
|
||||
| EventV2Bridge.Service,
|
||||
never,
|
||||
never
|
||||
> = Layer.mergeAll(
|
||||
Npm.layer,
|
||||
ModelsDev.layer,
|
||||
Project.layer,
|
||||
Installation.layer,
|
||||
Storage.layer,
|
||||
Vcs.layer,
|
||||
SessionRunState.layer,
|
||||
EventV2Bridge.layer,
|
||||
).pipe(Layer.provideMerge(tier1))
|
||||
|
||||
type Tier2Services =
|
||||
| Tier1Services
|
||||
| Npm.Service
|
||||
| ModelsDev.Service
|
||||
| Project.Service
|
||||
| Installation.Service
|
||||
| Storage.Service
|
||||
| Vcs.Service
|
||||
| SessionRunState.Service
|
||||
| EventV2Bridge.Service
|
||||
|
||||
// ─── Tier 3: services depending only on tier 0-2 ────────────────────────
|
||||
// Config → AppFileSystem, Auth, Account, Env, Npm
|
||||
// File → AppFileSystem, Ripgrep, Git, Scope
|
||||
// Simulation → AppFileSystem, SimulationNetwork
|
||||
// Session → Bus, Storage, SyncEvent
|
||||
const tier3: Layer.Layer<
|
||||
Tier2Services | Config.Service | File.Service | Simulation.Service | Session.Service,
|
||||
never,
|
||||
never
|
||||
> = Layer.mergeAll(Config.layer, File.layer, Simulation.layer, Session.layer).pipe(Layer.provideMerge(tier2))
|
||||
|
||||
type Tier3Services = Tier2Services | Config.Service | File.Service | Simulation.Service | Session.Service
|
||||
|
||||
// ─── Tier 4: services depending on tier 0-3 (mostly Config) ─────────────
|
||||
// Plugin → Bus, Config, RuntimeFlags
|
||||
// FileWatcher → Config, Git
|
||||
// Format → Config, AppProcess, RuntimeFlags
|
||||
// Snapshot → AppFileSystem, AppProcess, Config
|
||||
// LSP → Config, RuntimeFlags
|
||||
// MCP → ChildProcessSpawner, McpAuth, Bus, Config
|
||||
// Skill → Discovery, Config, Bus, AppFileSystem, Global, RuntimeFlags
|
||||
// Instruction → Config, AppFileSystem, Global, HttpClient, RuntimeFlags
|
||||
// SimulationProvider → Simulation (provides Provider tag)
|
||||
// Image → Config
|
||||
// Reference → Config, AppFileSystem, Git, RuntimeFlags
|
||||
const tier4: Layer.Layer<
|
||||
| Tier3Services
|
||||
| Plugin.Service
|
||||
| FileWatcher.Service
|
||||
| Format.Service
|
||||
| Snapshot.Service
|
||||
| LSP.Service
|
||||
| MCP.Service
|
||||
| Skill.Service
|
||||
| Instruction.Service
|
||||
| Provider.Service
|
||||
| Image.Service
|
||||
| Reference.Service,
|
||||
never,
|
||||
never
|
||||
> = Layer.mergeAll(
|
||||
Plugin.layer,
|
||||
FileWatcher.layer,
|
||||
Format.layer,
|
||||
Snapshot.layer,
|
||||
LSP.makeLayer(SimulationLsp.supportedServers),
|
||||
MCP.layer,
|
||||
Skill.layer,
|
||||
Instruction.layer,
|
||||
SimulationProvider.layer,
|
||||
Image.layer,
|
||||
Reference.layer,
|
||||
).pipe(Layer.provideMerge(tier3))
|
||||
|
||||
type Tier4Services =
|
||||
| Tier3Services
|
||||
| Plugin.Service
|
||||
| FileWatcher.Service
|
||||
| Format.Service
|
||||
| Snapshot.Service
|
||||
| LSP.Service
|
||||
| MCP.Service
|
||||
| Skill.Service
|
||||
| Instruction.Service
|
||||
| Provider.Service
|
||||
| Image.Service
|
||||
| Reference.Service
|
||||
|
||||
// ─── Tier 5: services depending on tier 0-4 ─────────────────────────────
|
||||
// Pty → Config, Bus, Plugin
|
||||
// ProviderAuth → Auth, Plugin
|
||||
// SessionSummary → Session, Snapshot, Storage, Bus
|
||||
// Agent → Config, Auth, Plugin, Skill, Provider
|
||||
// Command → Config, MCP, Skill
|
||||
// LLM → Auth, Config, Provider, Plugin, Permission
|
||||
// SystemPrompt → Skill
|
||||
const tier5: Layer.Layer<
|
||||
| Tier4Services
|
||||
| Pty.Service
|
||||
| ProviderAuth.Service
|
||||
| SessionSummary.Service
|
||||
| Agent.Service
|
||||
| Command.Service
|
||||
| LLM.Service
|
||||
| SystemPrompt.Service,
|
||||
never,
|
||||
never
|
||||
> = Layer.mergeAll(
|
||||
Pty.layer,
|
||||
ProviderAuth.layer,
|
||||
SessionSummary.layer,
|
||||
Agent.layer,
|
||||
Command.layer,
|
||||
LLM.layer,
|
||||
SystemPrompt.layer,
|
||||
).pipe(Layer.provideMerge(tier4))
|
||||
|
||||
type Tier5Services =
|
||||
| Tier4Services
|
||||
| Pty.Service
|
||||
| ProviderAuth.Service
|
||||
| SessionSummary.Service
|
||||
| Agent.Service
|
||||
| Command.Service
|
||||
| LLM.Service
|
||||
| SystemPrompt.Service
|
||||
|
||||
// ─── Tier 6: services depending on tier 0-5 ─────────────────────────────
|
||||
// SessionRevert → Session, Snapshot, Storage, Bus, SessionSummary, SessionRunState, SyncEvent
|
||||
// SessionProcessor → Session, Config, Bus, Snapshot, Agent, LLM, Permission, Plugin,
|
||||
// SessionSummary, Scope, SessionStatus
|
||||
// SessionShare → Config, Session, ShareNext, SyncEvent
|
||||
const tier6: Layer.Layer<
|
||||
Tier5Services | SessionRevert.Service | SessionProcessor.Service | SessionShare.Service,
|
||||
never,
|
||||
never
|
||||
> = Layer.mergeAll(SessionRevert.layer, SessionProcessor.layer, SessionShare.layer).pipe(Layer.provideMerge(tier5))
|
||||
|
||||
type Tier6Services = Tier5Services | SessionRevert.Service | SessionProcessor.Service | SessionShare.Service
|
||||
|
||||
// ─── Tier 7: services depending on tier 0-6 ─────────────────────────────
|
||||
// SessionCompaction → Bus, Config, Session, Agent, Plugin, SessionProcessor, Provider
|
||||
// SessionPrompt → Bus, SessionStatus, Session, Agent, Provider, SessionProcessor,
|
||||
// SessionCompaction(!), Plugin, Command, Config, Permission, AppFileSystem,
|
||||
// MCP, LSP, ToolRegistry(!), Truncate, ChildProcessSpawner, Instruction,
|
||||
// SessionRunState, SessionRevert, SessionSummary, SystemPrompt, LLM
|
||||
// ToolRegistry → Config, Plugin, Agent, Skill, Truncate, Question, Todo, Session,
|
||||
// Provider, Git, LSP, Instruction, AppFileSystem, Bus, HttpClient,
|
||||
// ChildProcessSpawner, Ripgrep, Format
|
||||
//
|
||||
// SessionPrompt needs SessionCompaction AND ToolRegistry — so it's tier 8.
|
||||
const tier7: Layer.Layer<Tier6Services | SessionCompaction.Service | ToolRegistry.Service, never, never> =
|
||||
Layer.mergeAll(SessionCompaction.layer, ToolRegistry.layer).pipe(Layer.provideMerge(tier6))
|
||||
|
||||
type Tier7Services = Tier6Services | SessionCompaction.Service | ToolRegistry.Service
|
||||
|
||||
// ─── Tier 8: services depending on tier 0-7 ─────────────────────────────
|
||||
// SessionPrompt → tier 7 (SessionCompaction, ToolRegistry, etc.)
|
||||
// (Workspace needs SessionPrompt — goes to tier 9)
|
||||
const tier8: Layer.Layer<Tier7Services | SessionPrompt.Service, never, never> = Layer.mergeAll(
|
||||
SessionPrompt.layer,
|
||||
).pipe(Layer.provideMerge(tier7))
|
||||
|
||||
type Tier8Services = Tier7Services | SessionPrompt.Service
|
||||
|
||||
// ─── Tier 9: Workspace + InstanceBootstrap (depend on tier 0-8) ─────────
|
||||
// Workspace → Auth, Session, SessionPrompt, HttpClient, SyncEvent, Vcs
|
||||
// InstanceBootstrap → Config, File, FileWatcher, Format, LSP, Plugin, Project,
|
||||
// ShareNext, Snapshot, Vcs
|
||||
const tier9: Layer.Layer<Tier8Services | Workspace.Service | InstanceBootstrap.Service, never, never> =
|
||||
Layer.mergeAll(Workspace.layer, InstanceBootstrap.layer).pipe(Layer.provideMerge(tier8))
|
||||
|
||||
type Tier9Services = Tier8Services | Workspace.Service | InstanceBootstrap.Service
|
||||
|
||||
// ─── Tier 10: InstanceStore + Worktree (depend on InstanceBootstrap) ────
|
||||
// InstanceStore → Project, InstanceBootstrap
|
||||
// Worktree → AppFileSystem, Path, ChildProcessSpawner, Git, Project, InstanceStore
|
||||
// (Worktree → InstanceStore → tier 10 itself, so Worktree is tier 11)
|
||||
const tier10: Layer.Layer<Tier9Services | InstanceStore.Service, never, never> = Layer.mergeAll(
|
||||
InstanceStore.layer,
|
||||
).pipe(Layer.provideMerge(tier9))
|
||||
|
||||
type Tier10Services = Tier9Services | InstanceStore.Service
|
||||
|
||||
// ─── Tier 11: Worktree (depends on InstanceStore) ───────────────────────
|
||||
const tier11: Layer.Layer<Tier10Services | Worktree.Service, never, never> = Layer.mergeAll(Worktree.layer).pipe(
|
||||
Layer.provideMerge(tier10),
|
||||
)
|
||||
|
||||
// ─── Tier 12: HTTP-level middleware + platform ──────────────────────────
|
||||
// Handles Request<"Error", *>, HttpRouter, HttpPlatform, Generator, etc.
|
||||
const tier12 = Layer.mergeAll(
|
||||
errorLayer,
|
||||
compressionLayer,
|
||||
corsVaryFix,
|
||||
fenceLayer,
|
||||
cors(corsOptions),
|
||||
HttpServer.layerServices,
|
||||
).pipe(Layer.provideMerge(tier11))
|
||||
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute, simulationRoute).pipe(
|
||||
Layer.provide(tier12),
|
||||
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
|
||||
// Build a simulated InstanceLayer equivalent that exposes InstanceStore.Service in
|
||||
// the result's `ROut` (matching the production layer's shape) WITHOUT pulling in
|
||||
// `InstanceLayer.layer` from `@/project/instance-layer` — that one uses
|
||||
// `InstanceStore.defaultLayer` + `InstanceBootstrap.defaultLayer` which transitively
|
||||
// pull `AppFileSystem.defaultLayer` and `NodeFileSystem.layer` into our chain.
|
||||
Layer.provideMerge(InstanceStore.layer.pipe(Layer.provide(InstanceBootstrap.layer), Layer.provide(tier9))),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
}
|
||||
|
||||
export function createRoutes(corsOptions?: CorsOptions) {
|
||||
if (Flag.OPENCODE_SIMULATION_BACKEND) {
|
||||
return createSimulatedRoutes(corsOptions)
|
||||
}
|
||||
|
||||
return createProductionRoutes(corsOptions)
|
||||
}
|
||||
|
||||
export const routes = createRoutes()
|
||||
|
||||
export const webHandler = lazy(() =>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Simulation } from "@/testing/simulation/service"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const ok = { ok: true }
|
||||
|
||||
function json<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return effect.pipe(
|
||||
Effect.map((result) => HttpServerResponse.jsonUnsafe(result)),
|
||||
Effect.catch((error) => Effect.succeed(HttpServerResponse.jsonUnsafe({ error: String(error) }, { status: 400 }))),
|
||||
)
|
||||
}
|
||||
|
||||
export const simulationRoute = HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
|
||||
yield* router.add("POST", "/experimental/simulation/reset", () =>
|
||||
json(simulation.reset().pipe(Effect.as(ok))),
|
||||
)
|
||||
|
||||
yield* router.add("POST", "/experimental/simulation/filesystem/seed", () =>
|
||||
json(
|
||||
Effect.gen(function* () {
|
||||
const input = yield* HttpServerRequest.schemaBodyJson(Simulation.FilesystemSeedInput)
|
||||
return yield* simulation.seedFilesystem(input)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* router.add("POST", "/experimental/simulation/filesystem/write", () =>
|
||||
json(
|
||||
Effect.gen(function* () {
|
||||
const input = yield* HttpServerRequest.schemaBodyJson(Simulation.FilesystemWriteInput)
|
||||
return yield* simulation.writeFile(input)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* router.add("POST", "/experimental/simulation/network/register", () =>
|
||||
json(
|
||||
Effect.gen(function* () {
|
||||
const input = yield* HttpServerRequest.schemaBodyJson(Simulation.NetworkRegisterInput)
|
||||
return yield* simulation.registerNetwork(input)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* router.add("POST", "/experimental/simulation/llm/enqueue", () =>
|
||||
json(
|
||||
Effect.gen(function* () {
|
||||
const input = yield* HttpServerRequest.schemaBodyJson(Simulation.LLMEnqueueInput)
|
||||
return yield* simulation.enqueueLLM(input)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* router.add("GET", "/experimental/simulation/snapshot", () => json(simulation.snapshot()))
|
||||
|
||||
yield* router.add("GET", "/experimental/simulation/log/entries", () =>
|
||||
Effect.succeed(HttpServerResponse.jsonUnsafe({ entries: Log.entries() })),
|
||||
)
|
||||
|
||||
yield* router.add("POST", "/experimental/simulation/log/clear", () =>
|
||||
Effect.succeed(
|
||||
HttpServerResponse.jsonUnsafe(((): { cleared: true } => {
|
||||
Log.clearEntries()
|
||||
return { cleared: true }
|
||||
})()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export * as SimulationHttpRoute from "./simulation"
|
||||
@@ -23,6 +23,7 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { SimulationDebugLog } from "../testing/simulation/debug-log"
|
||||
|
||||
const log = Log.create({ service: "llm" })
|
||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||
@@ -408,14 +409,25 @@ const live: Layer.Layer<
|
||||
Stream.scoped(
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
SimulationDebugLog.write("llm.stream.start", {
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.id,
|
||||
small: input.small,
|
||||
messages: input.messages.length,
|
||||
tools: Object.keys(input.tools).length,
|
||||
stack: new Error().stack,
|
||||
})
|
||||
const ctrl = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => new AbortController()),
|
||||
(ctrl) => Effect.sync(() => ctrl.abort()),
|
||||
)
|
||||
|
||||
const result = yield* run({ ...input, abort: ctrl.signal })
|
||||
SimulationDebugLog.write("llm.stream.result")
|
||||
|
||||
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
|
||||
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e)))).pipe(
|
||||
Stream.tap((event) => Effect.sync(() => SimulationDebugLog.write("llm.stream.event", { type: event }))),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { SimulationDebugLog } from "../testing/simulation/debug-log"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
@@ -212,6 +213,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
||||
SimulationDebugLog.write("processor.handleEvent", { type: value.type })
|
||||
switch (value.type) {
|
||||
case "start":
|
||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||
@@ -719,15 +721,23 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
|
||||
SimulationDebugLog.write("processor.process.start", {
|
||||
providerID: streamInput.model.providerID,
|
||||
modelID: streamInput.model.id,
|
||||
})
|
||||
slog.info("process")
|
||||
ctx.needsCompaction = false
|
||||
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
|
||||
|
||||
SimulationDebugLog.write("JWL processing")
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
SimulationDebugLog.write("JWL inside")
|
||||
ctx.currentText = undefined
|
||||
ctx.reasoningMap = {}
|
||||
const stream = llm.stream(streamInput)
|
||||
SimulationDebugLog.write("processor.stream.created")
|
||||
|
||||
yield* stream.pipe(
|
||||
Stream.tap((event) => handleEvent(event)),
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Tool } from "@/tool/tool"
|
||||
import { Permission } from "@/permission"
|
||||
import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -1812,7 +1813,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||
sys.skills(agent),
|
||||
sys.environment(model),
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
instruction.system().pipe(
|
||||
Effect.tapError((error) =>
|
||||
bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: errorMessage(error) }, { cause: error }).toObject(),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
),
|
||||
MessageV2.toModelMessagesEffect(msgs, model),
|
||||
])
|
||||
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
|
||||
|
||||
@@ -31,7 +31,7 @@ export type Req = {
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
const ShareSchema = Schema.Struct({
|
||||
export const ShareSchema = Schema.Struct({
|
||||
id: Schema.String,
|
||||
url: Schema.String,
|
||||
secret: Schema.String,
|
||||
|
||||
@@ -9,12 +9,12 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
const skillConcurrency = 4
|
||||
const fileConcurrency = 8
|
||||
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||
export class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||
name: Schema.String,
|
||||
files: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
class Index extends Schema.Class<Index>("Index")({
|
||||
export class Index extends Schema.Class<Index>("Index")({
|
||||
skills: Schema.Array(IndexSkill),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { CliRenderer, Renderable } from "@opentui/core"
|
||||
|
||||
export interface MockInput {
|
||||
readonly typeText: (text: string) => Promise<void>
|
||||
readonly pressKey: (key: string, modifiers?: KeyModifiers) => void
|
||||
readonly pressEnter: () => void
|
||||
readonly pressArrow: (direction: "up" | "down" | "left" | "right") => void
|
||||
}
|
||||
|
||||
export interface KeyModifiers {
|
||||
readonly ctrl?: boolean
|
||||
readonly shift?: boolean
|
||||
readonly meta?: boolean
|
||||
readonly super?: boolean
|
||||
readonly hyper?: boolean
|
||||
}
|
||||
|
||||
export interface MockMouse {
|
||||
readonly click: (x: number, y: number) => Promise<void>
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mockInput: MockInput
|
||||
readonly mockMouse: MockMouse
|
||||
readonly renderOnce: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Element {
|
||||
readonly id: string
|
||||
readonly num: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly focusable: boolean
|
||||
readonly focused: boolean
|
||||
readonly clickable: boolean
|
||||
readonly editor: boolean
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| { readonly type: "typeText"; readonly text: string }
|
||||
| { readonly type: "pressKey"; readonly key: string; readonly modifiers?: KeyModifiers }
|
||||
| { readonly type: "pressEnter" }
|
||||
| { readonly type: "pressArrow"; readonly direction: "up" | "down" | "left" | "right" }
|
||||
| { readonly type: "focus"; readonly target: number }
|
||||
| { readonly type: "click"; readonly target: number; readonly x: number; readonly y: number }
|
||||
|
||||
function children(renderable: Renderable) {
|
||||
return renderable.getChildren().filter((child): child is Renderable => "num" in child)
|
||||
}
|
||||
|
||||
function all(renderable: Renderable): Renderable[] {
|
||||
return [renderable, ...children(renderable).flatMap(all)]
|
||||
}
|
||||
|
||||
function mouseListeners(renderable: Renderable) {
|
||||
const general = Reflect.get(renderable, "_mouseListener")
|
||||
const specific = Reflect.get(renderable, "_mouseListeners")
|
||||
return Boolean(general) || (specific && typeof specific === "object" && Object.keys(specific).length > 0)
|
||||
}
|
||||
|
||||
function hit(renderer: CliRenderer, renderable: Renderable) {
|
||||
if (renderable.width <= 0 || renderable.height <= 0) return false
|
||||
const x = Math.floor(renderable.screenX + renderable.width / 2)
|
||||
const y = Math.floor(renderable.screenY + renderable.height / 2)
|
||||
return renderer.hitTest(x, y) === renderable.num
|
||||
}
|
||||
|
||||
export function elements(renderer: CliRenderer): Element[] {
|
||||
return all(renderer.root)
|
||||
.filter((renderable) => renderable.visible && !renderable.isDestroyed)
|
||||
.map((renderable) => {
|
||||
const clickable = mouseListeners(renderable) && hit(renderer, renderable)
|
||||
return {
|
||||
id: renderable.id,
|
||||
num: renderable.num,
|
||||
x: renderable.screenX,
|
||||
y: renderable.screenY,
|
||||
width: renderable.width,
|
||||
height: renderable.height,
|
||||
focusable: renderable.focusable,
|
||||
focused: renderable.focused,
|
||||
clickable,
|
||||
editor: renderer.currentFocusedEditor === renderable,
|
||||
} satisfies Element
|
||||
})
|
||||
.filter((element) => element.focusable || element.clickable || element.editor)
|
||||
}
|
||||
|
||||
export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] {
|
||||
const result: Action[] = []
|
||||
const items = elements(renderer)
|
||||
if (renderer.currentFocusedEditor) {
|
||||
result.push({ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" })
|
||||
}
|
||||
result.push(...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })))
|
||||
result.push(
|
||||
...items
|
||||
.filter((item) => item.clickable)
|
||||
.map((item) => ({
|
||||
type: "click" as const,
|
||||
target: item.num,
|
||||
x: Math.floor(item.x + item.width / 2),
|
||||
y: Math.floor(item.y + item.height / 2),
|
||||
})),
|
||||
)
|
||||
result.push(
|
||||
{ type: "pressArrow", direction: "down" },
|
||||
{ type: "pressArrow", direction: "up" },
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function execute(harness: Harness, action: Action) {
|
||||
switch (action.type) {
|
||||
case "typeText":
|
||||
await harness.mockInput.typeText(action.text)
|
||||
break
|
||||
case "pressKey":
|
||||
harness.mockInput.pressKey(action.key, action.modifiers)
|
||||
break
|
||||
case "pressEnter":
|
||||
harness.mockInput.pressEnter()
|
||||
break
|
||||
case "pressArrow":
|
||||
harness.mockInput.pressArrow(action.direction)
|
||||
break
|
||||
case "focus": {
|
||||
const renderable = all(harness.renderer.root).find((item) => item.num === action.target)
|
||||
renderable?.focus()
|
||||
break
|
||||
}
|
||||
case "click":
|
||||
await harness.mockMouse.click(action.x, action.y)
|
||||
break
|
||||
}
|
||||
await harness.renderOnce()
|
||||
}
|
||||
|
||||
export * as SimulationActions from "./actions"
|
||||
@@ -0,0 +1,14 @@
|
||||
import fs from "fs/promises"
|
||||
|
||||
const file = "/tmp/opencode-simulation-stream.log"
|
||||
|
||||
export function reset() {
|
||||
void fs.writeFile(file, "")
|
||||
}
|
||||
|
||||
export function write(event: string, data?: unknown) {
|
||||
const line = JSON.stringify({ time: new Date().toISOString(), event, data }) + "\n"
|
||||
void fs.appendFile(file, line)
|
||||
}
|
||||
|
||||
export * as SimulationDebugLog from "./debug-log"
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { SimulationFileGenerator } from "./file-generator"
|
||||
|
||||
function arg(name: string) {
|
||||
const prefix = `--${name}=`
|
||||
return Bun.argv.find((item) => item.startsWith(prefix))?.slice(prefix.length)
|
||||
}
|
||||
|
||||
function numberArg(name: string, fallback: number) {
|
||||
const value = Number(arg(name))
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function weight(name: string) {
|
||||
const value = Number(arg(name))
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
const generated = SimulationFileGenerator.generateFiles({
|
||||
seed: numberArg("seed", Math.random() * 10000 | 0),
|
||||
count: numberArg("count", 10),
|
||||
maxDepth: numberArg("max-depth", 3),
|
||||
maxWidth: numberArg("max-width", 5),
|
||||
root: arg("root") ?? "src",
|
||||
weights: {
|
||||
txt: weight("txt"),
|
||||
ts: weight("ts"),
|
||||
shallow: weight("shallow"),
|
||||
deep: weight("deep"),
|
||||
},
|
||||
})
|
||||
|
||||
const patches = SimulationFileGenerator.generatePatches(generated, {
|
||||
seed: numberArg("patch-seed", numberArg("seed", 1) + 1),
|
||||
count: numberArg("patch-count", 3),
|
||||
patchDir: arg("patch-dir") ?? ".simulation/patches",
|
||||
weights: {
|
||||
editTxt: weight("edit-txt"),
|
||||
editTs: weight("edit-ts"),
|
||||
},
|
||||
})
|
||||
|
||||
if (Bun.argv.includes("--json")) {
|
||||
console.log(JSON.stringify({ generated, patches }, null, 2))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`seed: ${generated.seed}`)
|
||||
console.log(`files: ${generated.entries.length}`)
|
||||
for (const entry of generated.entries) {
|
||||
console.log(`${entry.path}: ${entry.content}\n`)
|
||||
}
|
||||
|
||||
console.log(`\npatch seed: ${patches.seed}`)
|
||||
console.log(`patches: ${patches.patches.length}`)
|
||||
for (const patch of patches.patches) {
|
||||
console.log('--------------------------------------------------------------------')
|
||||
console.log(patch.patch)
|
||||
}
|
||||
|
||||
console.log("\nTry: bun src/testing/simulation/file-generator.play.ts --seed=42 --count=5 --ts=4 --txt=1 --deep=3")
|
||||
console.log("JSON: bun src/testing/simulation/file-generator.play.ts --json")
|
||||
@@ -0,0 +1,397 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
export interface Weights {
|
||||
readonly txt?: number
|
||||
readonly ts?: number
|
||||
readonly shallow?: number
|
||||
readonly deep?: number
|
||||
readonly editTxt?: number
|
||||
readonly editTs?: number
|
||||
}
|
||||
|
||||
export interface GenerateFilesOptions {
|
||||
readonly seed?: number
|
||||
readonly count?: number
|
||||
readonly maxDepth?: number
|
||||
readonly maxWidth?: number
|
||||
readonly root?: string
|
||||
readonly weights?: Weights
|
||||
}
|
||||
|
||||
export interface GeneratedFile {
|
||||
readonly path: string
|
||||
readonly kind: "txt" | "ts"
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
export interface GeneratedFiles {
|
||||
readonly seed: number
|
||||
readonly files: Record<string, string>
|
||||
readonly entries: readonly GeneratedFile[]
|
||||
}
|
||||
|
||||
export interface GeneratePatchesOptions {
|
||||
readonly seed?: number
|
||||
readonly count?: number
|
||||
readonly patchDir?: string
|
||||
readonly weights?: Weights
|
||||
}
|
||||
|
||||
export interface GeneratedPatch {
|
||||
readonly path: string
|
||||
readonly target: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly patch: string
|
||||
}
|
||||
|
||||
export interface GeneratedPatches {
|
||||
readonly seed: number
|
||||
readonly files: Record<string, string>
|
||||
readonly patchedFiles: Record<string, string>
|
||||
readonly patches: readonly GeneratedPatch[]
|
||||
}
|
||||
|
||||
type Random = () => number
|
||||
|
||||
const defaults = {
|
||||
txt: 1,
|
||||
ts: 1,
|
||||
shallow: 3,
|
||||
deep: 1,
|
||||
editTxt: 1,
|
||||
editTs: 1,
|
||||
}
|
||||
|
||||
function random(seed: number): Random {
|
||||
let current = seed
|
||||
return () => {
|
||||
current = (current * 1664525 + 1013904223) >>> 0
|
||||
return current / 0x100000000
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSeed(value: number | undefined) {
|
||||
return value ?? Math.floor(Math.random() * 0xffffffff) + 1
|
||||
}
|
||||
|
||||
function integer(rng: Random, min: number, max: number) {
|
||||
return min + Math.floor(rng() * (max - min + 1))
|
||||
}
|
||||
|
||||
function pickWeighted<T extends string>(rng: Random, entries: readonly (readonly [T, number])[]) {
|
||||
const total = entries.reduce((sum, item) => sum + Math.max(0, item[1]), 0)
|
||||
const threshold = rng() * (total || entries.length)
|
||||
const picked = entries.reduce<{ value: T; remaining: number }>(
|
||||
(state, item) =>
|
||||
state.remaining <= 0 ? state : { value: item[0], remaining: state.remaining - Math.max(0, item[1]) },
|
||||
{ value: entries[0][0], remaining: threshold },
|
||||
)
|
||||
return picked.remaining <= 0 ? picked.value : entries.at(-1)![0]
|
||||
}
|
||||
|
||||
function slug(rng: Random) {
|
||||
const words = ["alpha", "bravo", "cedar", "delta", "ember", "field", "glade", "harbor", "iris", "juniper"]
|
||||
return `${words[integer(rng, 0, words.length - 1)]}-${integer(rng, 1, 99)}`
|
||||
}
|
||||
|
||||
function generatedPath(
|
||||
rng: Random,
|
||||
input: { root: string; kind: "txt" | "ts"; maxDepth: number; maxWidth: number; weights: Weights },
|
||||
) {
|
||||
const depthBias = pickWeighted(rng, [
|
||||
["shallow", input.weights.shallow ?? defaults.shallow],
|
||||
["deep", input.weights.deep ?? defaults.deep],
|
||||
])
|
||||
const depth = depthBias === "shallow" ? integer(rng, 0, Math.min(1, input.maxDepth)) : integer(rng, 1, input.maxDepth)
|
||||
return path.posix.join(
|
||||
input.root,
|
||||
...Array.from({ length: depth }, () => `dir-${integer(rng, 1, input.maxWidth)}`),
|
||||
`${slug(rng)}.${input.kind}`,
|
||||
)
|
||||
}
|
||||
|
||||
function txtContent(rng: Random, name: string) {
|
||||
return Array.from(
|
||||
{ length: integer(rng, 2, 6) },
|
||||
(_, index) => `${name} note ${index + 1}: ${slug(rng)} ${slug(rng)}.`,
|
||||
).join("\n")
|
||||
}
|
||||
|
||||
function tsContent(rng: Random, name: string) {
|
||||
const fn = name.replace(/[^a-zA-Z0-9]/g, "_")
|
||||
const templates = [
|
||||
() => [
|
||||
`export const ${fn}Value = ${integer(rng, 1, 100)}`,
|
||||
"",
|
||||
`export function ${fn}Message(input = \"${slug(rng)}\") {`,
|
||||
` return \`${name}: \${input}\``,
|
||||
"}",
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`export interface ${fn}Record {`,
|
||||
" readonly id: string",
|
||||
" readonly enabled: boolean",
|
||||
" readonly tags: readonly string[]",
|
||||
"}",
|
||||
"",
|
||||
`export const ${fn}Records: readonly ${fn}Record[] = [`,
|
||||
` { id: \"${slug(rng)}\", enabled: ${rng() > 0.5}, tags: [\"${slug(rng)}\", \"${slug(rng)}\"] },`,
|
||||
` { id: \"${slug(rng)}\", enabled: ${rng() > 0.5}, tags: [] },`,
|
||||
"]",
|
||||
"",
|
||||
`export function ${fn}Enabled() {`,
|
||||
` return ${fn}Records.filter((item) => item.enabled)`,
|
||||
"}",
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`export type ${fn}Event =`,
|
||||
` | { readonly type: \"created\"; readonly id: string; readonly count: number }`,
|
||||
` | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }`,
|
||||
` | { readonly type: \"deleted\"; readonly id: string }`,
|
||||
"",
|
||||
`export function ${fn}Label(event: ${fn}Event) {`,
|
||||
` if (event.type === \"created\") return \`created:\${event.id}:\${event.count}\``,
|
||||
` if (event.type === \"updated\") return \`updated:\${event.id}:\${event.fields.length}\``,
|
||||
` return \`deleted:\${event.id}\``,
|
||||
"}",
|
||||
"",
|
||||
`export const ${fn}Sample: ${fn}Event = { type: \"created\", id: \"${slug(rng)}\", count: ${integer(rng, 1, 20)} }`,
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`const ${fn}Defaults = {`,
|
||||
` retries: ${integer(rng, 1, 5)},`,
|
||||
` timeout: ${integer(rng, 100, 900)},`,
|
||||
` label: \"${slug(rng)}\",`,
|
||||
"} as const",
|
||||
"",
|
||||
`export async function load${fn}(input: Partial<typeof ${fn}Defaults> = {}) {`,
|
||||
` const config = { ...${fn}Defaults, ...input }`,
|
||||
" await Promise.resolve()",
|
||||
" return {",
|
||||
" ...config,",
|
||||
" ready: config.retries > 0 && config.timeout > 0,",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`export class ${fn}Store {`,
|
||||
" #items = new Map<string, number>()",
|
||||
"",
|
||||
" add(key: string, value: number) {",
|
||||
" this.#items.set(key, (this.#items.get(key) ?? 0) + value)",
|
||||
" return this",
|
||||
" }",
|
||||
"",
|
||||
" snapshot() {",
|
||||
" return Object.fromEntries(this.#items.entries())",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
`export const ${fn}StoreInstance = new ${fn}Store().add(\"${slug(rng)}\", ${integer(rng, 1, 10)})`,
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`export const ${fn}Matrix = [`,
|
||||
` [${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}],`,
|
||||
` [${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}],`,
|
||||
"] as const",
|
||||
"",
|
||||
`export const ${fn}Total = ${fn}Matrix.flatMap((row) => row).reduce((sum, value) => sum + value, 0)`,
|
||||
"",
|
||||
`export const ${fn}Lookup = new Map<string, number>([`,
|
||||
` [\"${slug(rng)}\", ${integer(rng, 10, 99)}],`,
|
||||
` [\"${slug(rng)}\", ${integer(rng, 10, 99)}],`,
|
||||
"])",
|
||||
"",
|
||||
],
|
||||
]
|
||||
return templates[integer(rng, 0, templates.length - 1)]().join("\n")
|
||||
}
|
||||
|
||||
function mutate(rng: Random, file: GeneratedFile) {
|
||||
const addHeavy = rng() > 0.5
|
||||
if (file.kind === "txt") {
|
||||
const lines = file.content.trimEnd().split("\n")
|
||||
const removeCount = Math.min(addHeavy ? integer(rng, 0, 1) : integer(rng, 2, 5), Math.max(0, lines.length - 1))
|
||||
const removed = new Set(Array.from({ length: removeCount }, () => integer(rng, 0, Math.max(0, lines.length - 1))))
|
||||
const editAt = integer(rng, 0, Math.max(0, lines.length - 1))
|
||||
const edited = lines
|
||||
.filter((_, index) => lines.length <= 1 || !removed.has(index))
|
||||
.map((line, index) => (index === editAt ? `${line} Extra detail: ${slug(rng)} ${slug(rng)}.` : line))
|
||||
return [
|
||||
...edited,
|
||||
"",
|
||||
...Array.from({ length: addHeavy ? integer(rng, 3, 6) : integer(rng, 0, 2) }, (_, index) =>
|
||||
[
|
||||
`## mutation ${integer(rng, 1, 999)}.${index + 1}`,
|
||||
`${slug(rng)} ${slug(rng)} ${slug(rng)}.`,
|
||||
`status=${rng() > 0.5 ? "active" : "pending"}`,
|
||||
].join("\n"),
|
||||
),
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const lines = file.content.trimEnd().split("\n")
|
||||
const removable = lines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter((item) => item.line.trim() && !item.line.trim().endsWith("{") && !item.line.trim().startsWith("}"))
|
||||
const removeCount = Math.min(addHeavy ? integer(rng, 0, 2) : integer(rng, 3, 8), Math.max(0, removable.length - 1))
|
||||
const removed = new Set(
|
||||
Array.from({ length: removeCount }, () => removable[integer(rng, 0, removable.length - 1)]?.index).filter(
|
||||
(item): item is number => item !== undefined,
|
||||
),
|
||||
)
|
||||
const renamed = lines
|
||||
.filter((_, index) => !removed.has(index))
|
||||
.map((line) =>
|
||||
line.includes("Value =") && rng() > 0.35 ? line.replace(/= \d+/, `= ${integer(rng, 101, 999)}`) : line,
|
||||
)
|
||||
const blocks = Array.from({ length: addHeavy ? integer(rng, 3, 6) : integer(rng, 0, 2) }, () => {
|
||||
const id = `generated${integer(rng, 100, 999)}`
|
||||
const templates = [
|
||||
() => [
|
||||
`export const ${id}Config = {`,
|
||||
` id: "${slug(rng)}",`,
|
||||
` retries: ${integer(rng, 1, 5)},`,
|
||||
` flags: ["${slug(rng)}", "${slug(rng)}"],`,
|
||||
"} as const",
|
||||
],
|
||||
() => [
|
||||
`export function ${id}Normalize(input: readonly string[]) {`,
|
||||
` return input.map((item) => item.trim()).filter(Boolean).join("${rng() > 0.5 ? "," : "|"}")`,
|
||||
"}",
|
||||
],
|
||||
() => [
|
||||
`export type ${id}State =`,
|
||||
` | { readonly ok: true; readonly value: "${slug(rng)}" }`,
|
||||
` | { readonly ok: false; readonly reason: "${slug(rng)}" }`,
|
||||
],
|
||||
() => [
|
||||
`export const ${id}Items = [`,
|
||||
` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`,
|
||||
` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`,
|
||||
` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`,
|
||||
"] as const",
|
||||
],
|
||||
]
|
||||
return templates[integer(rng, 0, templates.length - 1)]().join("\n")
|
||||
})
|
||||
const insertAt = rng() > 0.5 ? 0 : renamed.length
|
||||
return [
|
||||
...renamed.slice(0, insertAt),
|
||||
"",
|
||||
...blocks.flatMap((block) => [block, ""]),
|
||||
...renamed.slice(insertAt),
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function gitPatch(file: string, before: string, after: string) {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-simulation-diff-"))
|
||||
mkdirSync(path.join(dir, "a", path.dirname(file)), { recursive: true })
|
||||
mkdirSync(path.join(dir, "b", path.dirname(file)), { recursive: true })
|
||||
writeFileSync(path.join(dir, "a", file), before)
|
||||
writeFileSync(path.join(dir, "b", file), after)
|
||||
const result = Bun.spawnSync(["git", "-C", dir, "diff", "--no-index", "--no-prefix", "--", `a/${file}`, `b/${file}`])
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
if (result.exitCode > 1) throw new Error(new TextDecoder().decode(result.stderr))
|
||||
return new TextDecoder().decode(result.stdout)
|
||||
}
|
||||
|
||||
function isGeneratedFiles(input: GeneratedFiles | Record<string, string>): input is GeneratedFiles {
|
||||
return Array.isArray(Reflect.get(input, "entries"))
|
||||
}
|
||||
|
||||
export function generateFiles(options: GenerateFilesOptions = {}): GeneratedFiles {
|
||||
const resolvedSeed = resolveSeed(options.seed)
|
||||
const rng = random(resolvedSeed)
|
||||
const weights = { ...defaults, ...options.weights }
|
||||
const count = options.count ?? 12
|
||||
const maxDepth = Math.max(0, options.maxDepth ?? 3)
|
||||
const maxWidth = Math.max(1, options.maxWidth ?? 5)
|
||||
const root = options.root ?? "src"
|
||||
const entries = Array.from({ length: count }).reduce<GeneratedFile[]>((result) => {
|
||||
const kind = pickWeighted(rng, [
|
||||
["txt", weights.txt],
|
||||
["ts", weights.ts],
|
||||
])
|
||||
const file = generatedPath(rng, { root, kind, maxDepth, maxWidth, weights })
|
||||
if (result.some((item) => item.path === file)) return result
|
||||
const name = path.posix.basename(file, path.posix.extname(file)).replace(/-/g, "_")
|
||||
return [...result, { path: file, kind, content: kind === "txt" ? txtContent(rng, name) : tsContent(rng, name) }]
|
||||
}, [])
|
||||
|
||||
return {
|
||||
seed: resolvedSeed,
|
||||
entries,
|
||||
files: Object.fromEntries(entries.map((entry) => [entry.path, entry.content])),
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePatches(
|
||||
input: GeneratedFiles | Record<string, string>,
|
||||
options: GeneratePatchesOptions = {},
|
||||
): GeneratedPatches {
|
||||
const resolvedSeed = resolveSeed(options.seed)
|
||||
const rng = random(resolvedSeed)
|
||||
const weights = { ...defaults, ...options.weights }
|
||||
const sourceEntries: GeneratedFile[] = isGeneratedFiles(input)
|
||||
? [...input.entries]
|
||||
: Object.entries(input).map(([file, content]) => ({
|
||||
path: file,
|
||||
kind: file.endsWith(".ts") ? ("ts" as const) : ("txt" as const),
|
||||
content,
|
||||
}))
|
||||
const entries = sourceEntries
|
||||
.filter((entry) => entry.path.endsWith(".txt") || entry.path.endsWith(".ts"))
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
const selected = Array.from({ length: Math.min(options.count ?? 4, entries.length) }).reduce<GeneratedFile[]>(
|
||||
(result) => {
|
||||
const candidates = entries.filter((entry) => !result.some((item) => item.path === entry.path))
|
||||
if (candidates.length === 0) return result
|
||||
const targetKind = pickWeighted(rng, [
|
||||
["txt", weights.editTxt],
|
||||
["ts", weights.editTs],
|
||||
])
|
||||
const preferred = candidates.filter((entry) => entry.kind === targetKind)
|
||||
return [
|
||||
...result,
|
||||
(preferred.length ? preferred : candidates)[
|
||||
integer(rng, 0, (preferred.length ? preferred : candidates).length - 1)
|
||||
],
|
||||
]
|
||||
},
|
||||
[],
|
||||
)
|
||||
const patches = selected.map((entry, index) => {
|
||||
const after = mutate(rng, entry)
|
||||
const patch = gitPatch(entry.path, entry.content, after)
|
||||
return {
|
||||
path: path.posix.join(
|
||||
options.patchDir ?? ".simulation/patches",
|
||||
`${String(index + 1).padStart(3, "0")}-${path.posix.basename(entry.path)}.patch`,
|
||||
),
|
||||
target: entry.path,
|
||||
before: entry.content,
|
||||
after,
|
||||
patch,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
seed: resolvedSeed,
|
||||
files: Object.fromEntries(patches.map((patch) => [patch.path, patch.patch])),
|
||||
patchedFiles: Object.fromEntries(patches.map((patch) => [patch.target, patch.after])),
|
||||
patches,
|
||||
}
|
||||
}
|
||||
|
||||
export * as SimulationFileGenerator from "./file-generator"
|
||||
@@ -0,0 +1,322 @@
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Effect, FileSystem, Layer, Option, Stream } from "effect"
|
||||
import { badArgument, systemError, type PlatformError } from "effect/PlatformError"
|
||||
import { InMemoryFs } from "just-bash"
|
||||
import path from "path"
|
||||
|
||||
export interface Options {
|
||||
readonly root: string
|
||||
readonly files?: Record<string, string | Uint8Array>
|
||||
readonly fs?: InMemoryFs
|
||||
}
|
||||
|
||||
const unsupported = (method: string) =>
|
||||
badArgument({
|
||||
module: "SimulationFileSystem",
|
||||
method,
|
||||
description: "Operation is not supported by the simulated filesystem",
|
||||
})
|
||||
|
||||
const permissionDenied = (method: string, file: string) =>
|
||||
systemError({
|
||||
_tag: "PermissionDenied",
|
||||
module: "SimulationFileSystem",
|
||||
method,
|
||||
description: "Path is outside the simulated filesystem root",
|
||||
pathOrDescriptor: file,
|
||||
})
|
||||
|
||||
const failure = (method: string, file: string, cause: unknown) =>
|
||||
systemError({
|
||||
_tag: String(cause).toLowerCase().includes("exist") ? "AlreadyExists" : "NotFound",
|
||||
module: "SimulationFileSystem",
|
||||
method,
|
||||
description: cause instanceof Error ? cause.message : String(cause),
|
||||
pathOrDescriptor: file,
|
||||
})
|
||||
|
||||
export function make(options: Options) {
|
||||
const fs = options.fs ?? new InMemoryFs()
|
||||
const root = path.resolve(options.root)
|
||||
const temp = { value: 0 }
|
||||
|
||||
const normalize = (method: string, file: string): string | PlatformError => {
|
||||
const resolved = path.resolve(root, file)
|
||||
if (resolved === root || AppFileSystem.contains(root, resolved)) return resolved
|
||||
return permissionDenied(method, file)
|
||||
}
|
||||
|
||||
const normalizeEffect = (method: string, file: string) => {
|
||||
const normalized = normalize(method, file)
|
||||
if (typeof normalized === "string") return Effect.succeed(normalized)
|
||||
return Effect.fail(normalized)
|
||||
}
|
||||
|
||||
const normalizeSearchStart = (file: string) => {
|
||||
const resolved = path.resolve(root, file)
|
||||
if (resolved === root || AppFileSystem.contains(root, resolved)) return resolved
|
||||
}
|
||||
|
||||
const normalizePair = (method: string, fromPath: string, toPath: string) =>
|
||||
Effect.all([normalizeEffect(method, fromPath), normalizeEffect(method, toPath)] as const)
|
||||
|
||||
const run = <A>(method: string, file: string, fn: (file: string) => Promise<A>) =>
|
||||
Effect.gen(function* () {
|
||||
const normalized = yield* normalizeEffect(method, file)
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => fn(normalized),
|
||||
catch: (cause) => failure(method, file, cause),
|
||||
})
|
||||
})
|
||||
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
for (const [file, content] of Object.entries(options.files ?? {})) {
|
||||
const normalized = normalize("seed", file)
|
||||
if (typeof normalized === "string") {
|
||||
fs.mkdirSync(path.dirname(normalized), { recursive: true })
|
||||
fs.writeFileSync(normalized, content, { encoding: "utf8" })
|
||||
}
|
||||
}
|
||||
|
||||
const base = FileSystem.make({
|
||||
access: (file) => run("access", file, async (item) => void (await fs.stat(item))),
|
||||
chmod: (file, mode) => run("chmod", file, (item) => fs.chmod(item, mode)),
|
||||
chown: () => Effect.fail(unsupported("chown")),
|
||||
copy: (fromPath, toPath) =>
|
||||
Effect.gen(function* () {
|
||||
const [from, to] = yield* normalizePair("copy", fromPath, toPath)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => fs.cp(from, to, { recursive: true }),
|
||||
catch: (cause) => failure("copy", fromPath, cause),
|
||||
})
|
||||
}),
|
||||
copyFile: (fromPath, toPath) =>
|
||||
Effect.gen(function* () {
|
||||
const [from, to] = yield* normalizePair("copyFile", fromPath, toPath)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => fs.cp(from, to),
|
||||
catch: (cause) => failure("copyFile", fromPath, cause),
|
||||
})
|
||||
}),
|
||||
link: (existingPath, newPath) =>
|
||||
Effect.gen(function* () {
|
||||
const [existing, next] = yield* normalizePair("link", existingPath, newPath)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => fs.link(existing, next),
|
||||
catch: (cause) => failure("link", existingPath, cause),
|
||||
})
|
||||
}),
|
||||
makeDirectory: (file, methodOptions) => run("makeDirectory", file, (item) => fs.mkdir(item, methodOptions)),
|
||||
makeTempDirectory: (methodOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* normalizeEffect("makeTempDirectory", methodOptions?.directory ?? root)
|
||||
const file = path.join(directory, `${methodOptions?.prefix ?? "tmp-"}${++temp.value}`)
|
||||
yield* base.makeDirectory(file, { recursive: true })
|
||||
return file
|
||||
}),
|
||||
makeTempDirectoryScoped: (methodOptions) =>
|
||||
Effect.acquireRelease(
|
||||
base.makeTempDirectory(methodOptions),
|
||||
(file) => base.remove(file, { recursive: true, force: true }).pipe(Effect.ignore),
|
||||
),
|
||||
makeTempFile: (methodOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* normalizeEffect("makeTempFile", methodOptions?.directory ?? root)
|
||||
const file = path.join(directory, `${methodOptions?.prefix ?? "tmp-"}${++temp.value}${methodOptions?.suffix ?? ""}`)
|
||||
yield* base.writeFile(file, new Uint8Array())
|
||||
return file
|
||||
}),
|
||||
makeTempFileScoped: (methodOptions) =>
|
||||
Effect.acquireRelease(base.makeTempFile(methodOptions), (file) => base.remove(file, { force: true }).pipe(Effect.ignore)),
|
||||
open: (file) =>
|
||||
Effect.gen(function* () {
|
||||
const normalized = yield* normalizeEffect("open", file)
|
||||
yield* base.access(normalized)
|
||||
let position = 0
|
||||
const readCurrent = () => fs.readFileBuffer(normalized)
|
||||
return {
|
||||
[FileSystem.FileTypeId]: FileSystem.FileTypeId,
|
||||
fd: FileSystem.FileDescriptor(0),
|
||||
stat: base.stat(normalized),
|
||||
seek: (offset, from) =>
|
||||
Effect.sync(() => {
|
||||
position = from === "start" ? Number(offset) : position + Number(offset)
|
||||
}),
|
||||
sync: Effect.void,
|
||||
read: (buffer) =>
|
||||
Effect.gen(function* () {
|
||||
const content = yield* Effect.promise(readCurrent)
|
||||
const chunk = content.slice(position, position + buffer.length)
|
||||
buffer.set(chunk)
|
||||
position += chunk.length
|
||||
return FileSystem.Size(chunk.length)
|
||||
}),
|
||||
readAlloc: (size) =>
|
||||
Effect.gen(function* () {
|
||||
const content = yield* Effect.promise(readCurrent)
|
||||
const chunk = content.slice(position, position + Number(size))
|
||||
position += chunk.length
|
||||
return chunk.length === 0 ? Option.none() : Option.some(chunk)
|
||||
}),
|
||||
truncate: (size) => base.truncate(normalized, size),
|
||||
write: () => Effect.fail(unsupported("file.write")),
|
||||
writeAll: () => Effect.fail(unsupported("file.writeAll")),
|
||||
}
|
||||
}),
|
||||
readDirectory: (file, methodOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const normalized = yield* normalizeEffect("readDirectory", file)
|
||||
if (!methodOptions?.recursive) return yield* run("readDirectory", normalized, (item) => fs.readdir(item))
|
||||
return fs
|
||||
.getAllPaths()
|
||||
.filter((item) => item !== normalized && AppFileSystem.contains(normalized, item))
|
||||
.map((item) => path.relative(normalized, item))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
}),
|
||||
readFile: (file) => run("readFile", file, (item) => fs.readFileBuffer(item)),
|
||||
readLink: (file) => run("readLink", file, (item) => fs.readlink(item)),
|
||||
realPath: (file) => run("realPath", file, (item) => fs.realpath(item)),
|
||||
remove: (file, methodOptions) => run("remove", file, (item) => fs.rm(item, methodOptions)),
|
||||
rename: (oldPath, newPath) =>
|
||||
Effect.gen(function* () {
|
||||
const [oldNormalized, newNormalized] = yield* normalizePair("rename", oldPath, newPath)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => fs.mv(oldNormalized, newNormalized),
|
||||
catch: (cause) => failure("rename", oldPath, cause),
|
||||
})
|
||||
}),
|
||||
stat: (file) =>
|
||||
run("stat", file, async (item) => {
|
||||
const info = await fs.stat(item)
|
||||
return {
|
||||
type: info.isDirectory ? "Directory" : info.isSymbolicLink ? "SymbolicLink" : "File",
|
||||
mtime: Option.some(info.mtime),
|
||||
atime: Option.some(info.mtime),
|
||||
birthtime: Option.some(info.mtime),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: info.mode,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(info.size),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
} satisfies FileSystem.File.Info
|
||||
}),
|
||||
symlink: (target, linkPath) =>
|
||||
Effect.gen(function* () {
|
||||
const normalized = yield* normalizeEffect("symlink", linkPath)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => fs.symlink(target, normalized),
|
||||
catch: (cause) => failure("symlink", linkPath, cause),
|
||||
})
|
||||
}),
|
||||
truncate: (file, size = 0) =>
|
||||
run("truncate", file, async (item) => {
|
||||
const next = new Uint8Array(Number(size))
|
||||
next.set((await fs.readFileBuffer(item)).slice(0, next.length))
|
||||
await fs.writeFile(item, next)
|
||||
}),
|
||||
utimes: (file, atime, mtime) =>
|
||||
run("utimes", file, (item) =>
|
||||
fs.utimes(item, typeof atime === "number" ? new Date(atime) : atime, typeof mtime === "number" ? new Date(mtime) : mtime),
|
||||
),
|
||||
watch: () => Stream.fail(unsupported("watch")),
|
||||
writeFile: (file, content, methodOptions) =>
|
||||
run("writeFile", file, async (item) => {
|
||||
await fs.writeFile(item, content)
|
||||
if (methodOptions?.mode) await fs.chmod(item, methodOptions.mode)
|
||||
}),
|
||||
})
|
||||
|
||||
const glob = (pattern: string, globOptions?: Glob.Options) =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = yield* normalizeEffect("glob", globOptions?.cwd ?? root)
|
||||
const matches = yield* Effect.forEach(
|
||||
fs
|
||||
.getAllPaths()
|
||||
.filter((item) => item !== cwd && AppFileSystem.contains(cwd, item))
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
(file) =>
|
||||
base.stat(file).pipe(
|
||||
Effect.map((info) => ({ file, info, relative: path.relative(cwd, file) })),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
)
|
||||
return matches
|
||||
.filter((item) => item && (globOptions?.include === "all" || item.info.type === "File") && Glob.match(pattern, item.relative))
|
||||
.map((item) => (globOptions?.absolute ? item!.file : item!.relative))
|
||||
})
|
||||
|
||||
const service = AppFileSystem.Service.of({
|
||||
...base,
|
||||
isDir: (file) => base.stat(file).pipe(Effect.map((info) => info.type === "Directory"), Effect.catch(() => Effect.succeed(false))),
|
||||
isFile: (file) => base.stat(file).pipe(Effect.map((info) => info.type === "File"), Effect.catch(() => Effect.succeed(false))),
|
||||
existsSafe: (file) => base.exists(file).pipe(Effect.orElseSucceed(() => false)),
|
||||
readFileStringSafe: (file) => base.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined))),
|
||||
readJson: (file) => base.readFileString(file).pipe(Effect.map((content) => JSON.parse(content))),
|
||||
writeJson: (file, data, mode) =>
|
||||
base.writeFileString(file, JSON.stringify(data, null, 2)).pipe(Effect.andThen(mode ? base.chmod(file, mode) : Effect.void)),
|
||||
ensureDir: (file) => base.makeDirectory(file, { recursive: true }),
|
||||
writeWithDirs: (file, content, mode) =>
|
||||
Effect.gen(function* () {
|
||||
yield* base.makeDirectory(path.dirname(file), { recursive: true })
|
||||
if (typeof content === "string") yield* base.writeFileString(file, content, mode ? { mode } : undefined)
|
||||
else yield* base.writeFile(file, content, mode ? { mode } : undefined)
|
||||
}),
|
||||
readDirectoryEntries: (file) =>
|
||||
run("readDirectoryEntries", file, async (item) =>
|
||||
(await fs.readdirWithFileTypes(item))
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory ? "directory" : entry.isSymbolicLink ? "symlink" : entry.isFile ? "file" : "other",
|
||||
}) satisfies AppFileSystem.DirEntry)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
),
|
||||
findUp: (target, start, stop) => service.up({ targets: [target], start, stop }),
|
||||
up: (methodOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const result: string[] = []
|
||||
let current = normalizeSearchStart(methodOptions.start)
|
||||
if (!current) return result
|
||||
const normalizedStop = methodOptions.stop ? normalizeSearchStart(methodOptions.stop) : undefined
|
||||
while (true) {
|
||||
for (const target of methodOptions.targets) {
|
||||
const file = path.join(current, target)
|
||||
if (yield* base.exists(file)) result.push(file)
|
||||
}
|
||||
if (normalizedStop === current) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current || !AppFileSystem.contains(root, parent)) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
}),
|
||||
globUp: (pattern, start, stop) =>
|
||||
Effect.gen(function* () {
|
||||
const result: string[] = []
|
||||
let current = normalizeSearchStart(start)
|
||||
if (!current) return result
|
||||
const normalizedStop = stop ? normalizeSearchStart(stop) : undefined
|
||||
while (true) {
|
||||
result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
|
||||
if (normalizedStop === current) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current || !AppFileSystem.contains(root, parent)) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
}),
|
||||
glob,
|
||||
globMatch: Glob.match,
|
||||
})
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
export const layer = (options: Options) => Layer.succeed(AppFileSystem.Service)(make(options))
|
||||
|
||||
export * as SimulationFileSystem from "./filesystem"
|
||||
@@ -0,0 +1,142 @@
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Git } from "@/git"
|
||||
|
||||
const emptyResult = {
|
||||
exitCode: 0,
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
truncated: false,
|
||||
} satisfies Git.Result
|
||||
|
||||
const parsePathToken = (value: string) => {
|
||||
if (!value.startsWith('"')) return value.split("\t")[0]
|
||||
const match = /^"((?:\\.|[^"])*)"/.exec(value)
|
||||
return match?.[1]?.replace(/\\(["\\tnr])/g, (_all, char: string) => {
|
||||
if (char === "t") return "\t"
|
||||
if (char === "n") return "\n"
|
||||
if (char === "r") return "\r"
|
||||
return char
|
||||
})
|
||||
}
|
||||
|
||||
const diffPath = (value: string | undefined) => {
|
||||
if (!value || value === "/dev/null") return
|
||||
const file = parsePathToken(value)
|
||||
if (!file) return
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const splitPatch = (text: string) => {
|
||||
const starts = [...text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
|
||||
match[0].startsWith("\n") ? match.index + 1 : match.index,
|
||||
)
|
||||
if (starts.length === 0) return text.trim() ? [text] : []
|
||||
return starts.map((start, index) => text.slice(start, starts[index + 1] ?? text.length))
|
||||
}
|
||||
|
||||
const fileFromPatch = (patch: string) =>
|
||||
diffPath(/^\+\+\+ (.+)$/m.exec(patch)?.[1]) ?? diffPath(/^--- (.+)$/m.exec(patch)?.[1])
|
||||
|
||||
const statusFromPatch = (patch: string): Git.Kind => {
|
||||
if (/^--- \/dev\/null$/m.test(patch)) return "added"
|
||||
if (/^\+\+\+ \/dev\/null$/m.test(patch)) return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
const codeFromStatus = (status: Git.Kind) => {
|
||||
if (status === "added") return "A"
|
||||
if (status === "deleted") return "D"
|
||||
return "M"
|
||||
}
|
||||
|
||||
const statFromPatch = (file: string, patch: string) =>
|
||||
patch
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("+") || line.startsWith("-"))
|
||||
.filter((line) => !line.startsWith("+++") && !line.startsWith("---"))
|
||||
.reduce(
|
||||
(acc, line) => ({
|
||||
file,
|
||||
additions: acc.additions + (line.startsWith("+") ? 1 : 0),
|
||||
deletions: acc.deletions + (line.startsWith("-") ? 1 : 0),
|
||||
}),
|
||||
{ file, additions: 0, deletions: 0 } satisfies Git.Stat,
|
||||
)
|
||||
|
||||
const cap = (text: string, options?: Git.PatchOptions) => {
|
||||
const truncated = options?.maxOutputBytes !== undefined && Buffer.byteLength(text) > options.maxOutputBytes
|
||||
return { text: truncated ? text.slice(0, options.maxOutputBytes) : text, truncated } satisfies Git.Patch
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Git.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const patches = Effect.fn("SimulationGit.patches")(function* (cwd: string) {
|
||||
const directory = path.join(cwd, "_patches")
|
||||
const files = yield* fs.readDirectory(directory, { recursive: true }).pipe(Effect.catch(() => Effect.succeed([])))
|
||||
const text = yield* Effect.forEach(
|
||||
files
|
||||
.filter((file) => file.endsWith(".patch"))
|
||||
.toSorted((a, b) => a.localeCompare(b)),
|
||||
(file) => fs.readFileString(path.join(directory, file)).pipe(Effect.catch(() => Effect.succeed(""))),
|
||||
)
|
||||
return splitPatch(text.filter(Boolean).join("\n"))
|
||||
})
|
||||
|
||||
const items = Effect.fn("SimulationGit.items")(function* (cwd: string) {
|
||||
return (yield* patches(cwd)).flatMap((patch) => {
|
||||
const file = fileFromPatch(patch)
|
||||
if (!file) return []
|
||||
const status = statusFromPatch(patch)
|
||||
return [{ file, code: codeFromStatus(status), status } satisfies Git.Item]
|
||||
})
|
||||
})
|
||||
|
||||
const patchFor = Effect.fn("SimulationGit.patchFor")(function* (cwd: string, file: string, options?: Git.PatchOptions) {
|
||||
return cap(
|
||||
(yield* patches(cwd))
|
||||
.filter((patch) => fileFromPatch(patch) === file)
|
||||
.join(""),
|
||||
options,
|
||||
)
|
||||
})
|
||||
|
||||
return Git.Service.of({
|
||||
run: () => Effect.succeed(emptyResult),
|
||||
branch: () => Effect.succeed("main"),
|
||||
prefix: () => Effect.succeed(""),
|
||||
defaultBranch: () => Effect.succeed({ name: "main", ref: "main" }),
|
||||
hasHead: () => Effect.succeed(true),
|
||||
mergeBase: () => Effect.succeed("HEAD"),
|
||||
show: () => Effect.succeed(""),
|
||||
status: items,
|
||||
diff: items,
|
||||
stats: Effect.fn("SimulationGit.stats")(function* (cwd: string) {
|
||||
return (yield* patches(cwd)).flatMap((patch) => {
|
||||
const file = fileFromPatch(patch)
|
||||
if (!file) return []
|
||||
return [statFromPatch(file, patch)]
|
||||
})
|
||||
}),
|
||||
patch: (cwd, _ref, file, options) => patchFor(cwd, file, options),
|
||||
patchAll: Effect.fn("SimulationGit.patchAll")(function* (cwd: string, _ref: string, options?: Git.PatchOptions) {
|
||||
return cap((yield* patches(cwd)).join(""), options)
|
||||
}),
|
||||
patchUntracked: patchFor,
|
||||
statUntracked: Effect.fn("SimulationGit.statUntracked")(function* (cwd: string, file: string) {
|
||||
const patch = (yield* patches(cwd)).find((item) => fileFromPatch(item) === file)
|
||||
if (!patch) return
|
||||
return statFromPatch(file, patch)
|
||||
}),
|
||||
applyPatch: () => Effect.succeed(emptyResult),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export * as SimulationGit from "./git"
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "child_process"
|
||||
import { EventEmitter } from "events"
|
||||
import { PassThrough, type Readable, type Writable } from "stream"
|
||||
import {
|
||||
createMessageConnection,
|
||||
StreamMessageReader,
|
||||
StreamMessageWriter,
|
||||
type MessageConnection,
|
||||
} from "vscode-jsonrpc/node"
|
||||
import type * as LSPServer from "@/lsp/server"
|
||||
|
||||
// ─── Fake process ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `LSPClient.create` wraps the server handle's stdout/stdin with
|
||||
// `StreamMessageReader` / `StreamMessageWriter` from `vscode-jsonrpc/node`,
|
||||
// which expects real Node `Readable` / `Writable` streams. We satisfy that
|
||||
// contract with a pair of `PassThrough` streams wired in opposite directions:
|
||||
//
|
||||
// client.write(stdin) ──► [inbound] ──► server reads
|
||||
// client.read(stdout) ◄── [outbound] ◄── server writes
|
||||
//
|
||||
// Everything lives in-process; nothing is ever spawned.
|
||||
|
||||
interface FakeProcessHandles {
|
||||
readonly process: ChildProcessWithoutNullStreams
|
||||
readonly serverInput: Readable
|
||||
readonly serverOutput: Writable
|
||||
}
|
||||
|
||||
let fakePid = 50_000
|
||||
|
||||
function createFakeProcess(): FakeProcessHandles {
|
||||
const inbound = new PassThrough() // client.stdin → server reads
|
||||
const outbound = new PassThrough() // server writes → client.stdout
|
||||
const stderr = new PassThrough() // never written
|
||||
|
||||
const proc = new EventEmitter() as unknown as ChildProcessWithoutNullStreams & {
|
||||
exitCode: number | null
|
||||
signalCode: NodeJS.Signals | null
|
||||
pid: number
|
||||
stdin: Writable
|
||||
stdout: Readable
|
||||
stderr: Readable
|
||||
}
|
||||
proc.pid = ++fakePid
|
||||
proc.exitCode = null
|
||||
proc.signalCode = null
|
||||
proc.stdin = inbound
|
||||
proc.stdout = outbound
|
||||
proc.stderr = stderr
|
||||
|
||||
// `Process.stop` calls `proc.kill()`. Tear the streams down and emit `exit`
|
||||
// so any consumer that awaits `process.exited` resolves.
|
||||
Object.defineProperty(proc, "kill", {
|
||||
value: (_signal?: NodeJS.Signals | number) => {
|
||||
if (proc.exitCode !== null) return true
|
||||
proc.exitCode = 0
|
||||
proc.signalCode = null
|
||||
// Closing the streams unblocks the JSON-RPC reader/writer cleanly.
|
||||
inbound.end()
|
||||
outbound.end()
|
||||
stderr.end()
|
||||
proc.emit("exit", 0, null)
|
||||
proc.emit("close", 0, null)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
return { process: proc, serverInput: inbound, serverOutput: outbound }
|
||||
}
|
||||
|
||||
// ─── JSON-RPC server implementation ──────────────────────────────────────────
|
||||
//
|
||||
// We use the same `vscode-jsonrpc` library on the server side so the wire
|
||||
// framing is bit-for-bit identical to a real LSP server. The server is
|
||||
// a stub: it responds with valid but empty results to every request the
|
||||
// client makes, and accepts every notification silently.
|
||||
|
||||
const SERVER_CAPABILITIES = {
|
||||
textDocumentSync: {
|
||||
openClose: true,
|
||||
change: 2, // Incremental
|
||||
},
|
||||
hoverProvider: true,
|
||||
definitionProvider: true,
|
||||
referencesProvider: true,
|
||||
implementationProvider: true,
|
||||
documentSymbolProvider: true,
|
||||
workspaceSymbolProvider: true,
|
||||
callHierarchyProvider: true,
|
||||
diagnosticProvider: {
|
||||
interFileDependencies: false,
|
||||
workspaceDiagnostics: false,
|
||||
},
|
||||
} as const
|
||||
|
||||
function startFakeServer(connection: MessageConnection) {
|
||||
connection.onRequest("initialize", async () => ({
|
||||
capabilities: SERVER_CAPABILITIES,
|
||||
serverInfo: { name: "simulated-lsp", version: "0.0.0" },
|
||||
}))
|
||||
|
||||
// Notifications — silently accept.
|
||||
for (const method of [
|
||||
"initialized",
|
||||
"textDocument/didOpen",
|
||||
"textDocument/didChange",
|
||||
"textDocument/didClose",
|
||||
"textDocument/didSave",
|
||||
"workspace/didChangeWatchedFiles",
|
||||
"workspace/didChangeConfiguration",
|
||||
"$/setTrace",
|
||||
"$/cancelRequest",
|
||||
]) {
|
||||
connection.onNotification(method, () => {})
|
||||
}
|
||||
|
||||
// Diagnostics — empty pull response so the client's pull path resolves
|
||||
// immediately with no findings. We never push diagnostics.
|
||||
connection.onRequest("textDocument/diagnostic", async () => ({
|
||||
kind: "full",
|
||||
items: [],
|
||||
}))
|
||||
connection.onRequest("workspace/diagnostic", async () => ({
|
||||
items: [],
|
||||
}))
|
||||
|
||||
// Code intelligence — null/[] results are valid per the LSP spec.
|
||||
connection.onRequest("textDocument/hover", async () => null)
|
||||
connection.onRequest("textDocument/definition", async () => [])
|
||||
connection.onRequest("textDocument/references", async () => [])
|
||||
connection.onRequest("textDocument/implementation", async () => [])
|
||||
connection.onRequest("textDocument/documentSymbol", async () => [])
|
||||
connection.onRequest("workspace/symbol", async () => [])
|
||||
connection.onRequest("textDocument/prepareCallHierarchy", async () => [])
|
||||
connection.onRequest("callHierarchy/incomingCalls", async () => [])
|
||||
connection.onRequest("callHierarchy/outgoingCalls", async () => [])
|
||||
|
||||
// Shutdown / exit — the client doesn't call `shutdown` (it just kills the
|
||||
// process), but answer if it ever does.
|
||||
connection.onRequest("shutdown", async () => null)
|
||||
connection.onNotification("exit", () => {})
|
||||
|
||||
// Catch-all so unknown methods don't blow up the connection.
|
||||
connection.onRequest((method) => {
|
||||
// Unknown method: return null. Returning a typed error would also be
|
||||
// valid, but null keeps consumers happy without spurious failures.
|
||||
void method
|
||||
return null
|
||||
})
|
||||
connection.onNotification(() => {})
|
||||
|
||||
connection.listen()
|
||||
}
|
||||
|
||||
// ─── Public LSPServer.Info implementation ────────────────────────────────────
|
||||
|
||||
export const SimulatedTypescript: LSPServer.Info = {
|
||||
id: "typescript",
|
||||
// Match the file extensions of the real typescript server so the simulated
|
||||
// backend reports clients for the same files. The simulated root is just
|
||||
// the instance directory — we don't probe the filesystem here because the
|
||||
// simulated FS is in-memory and the only "project" is `/opencode`.
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json", ".md"],
|
||||
root: async (_file, ctx) => ctx.directory,
|
||||
async spawn(_root, _ctx) {
|
||||
// Real LSP server spawn is an actual `child_process.spawn` that crosses an
|
||||
// I/O boundary and forces the surrounding `await` to truly yield to the
|
||||
// microtask queue (and beyond). The simulated path is otherwise fully
|
||||
// synchronous, which lets the calling Effect fiber's context survive
|
||||
// `await server.spawn(...)` in cases where production would lose it.
|
||||
//
|
||||
// To make the simulation a faithful behavioral surrogate of the real
|
||||
// server (and surface bugs like #27880), force a real async yield here
|
||||
// via setTimeout so the awaiting fiber is fully torn down before we
|
||||
// resume.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const { process, serverInput, serverOutput } = createFakeProcess()
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(serverInput),
|
||||
new StreamMessageWriter(serverOutput),
|
||||
)
|
||||
startFakeServer(connection)
|
||||
|
||||
// When the client kills the process, the streams close and the server
|
||||
// connection's reader will emit `end`. Dispose explicitly to release
|
||||
// listeners.
|
||||
process.once("exit", () => {
|
||||
connection.dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
process,
|
||||
initialization: {},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const supportedServers: LSPServer.Info[] = [SimulatedTypescript]
|
||||
|
||||
export * as SimulationLsp from "./lsp"
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Schema } from "effect"
|
||||
import { Account } from "@/account/account"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { Discovery } from "@/skill/discovery"
|
||||
import { SimulationNetwork, type ResponseEntry } from "./network"
|
||||
|
||||
function trim(url: string) {
|
||||
return url.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
const GeneratedModel = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
release_date: Schema.String,
|
||||
attachment: Schema.Boolean,
|
||||
reasoning: Schema.Boolean,
|
||||
temperature: Schema.Boolean,
|
||||
tool_call: Schema.Boolean,
|
||||
limit: Schema.Struct({
|
||||
context: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
}),
|
||||
})
|
||||
|
||||
const GeneratedProviderCatalog = Schema.Record(
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
env: Schema.Array(Schema.String),
|
||||
id: Schema.String,
|
||||
models: Schema.Record(Schema.String, GeneratedModel),
|
||||
}),
|
||||
)
|
||||
|
||||
export function account(baseUrl: string): ResponseEntry[] {
|
||||
const base = trim(baseUrl)
|
||||
return [
|
||||
SimulationNetwork.jsonSchema({ method: "POST", url: `${base}/auth/device/code` }, Account.DeviceAuth),
|
||||
SimulationNetwork.jsonSchema({ method: "POST", url: `${base}/auth/device/token` }, Account.DeviceToken),
|
||||
SimulationNetwork.jsonSchema({ method: "GET", url: `${base}/api/orgs` }, Schema.Array(Account.Org)),
|
||||
SimulationNetwork.jsonSchema({ method: "GET", url: `${base}/api/user` }, Account.User),
|
||||
SimulationNetwork.jsonSchema({ method: "GET", url: `${base}/api/config` }, Account.RemoteConfig),
|
||||
]
|
||||
}
|
||||
|
||||
export function models(baseUrl = "https://models.dev"): ResponseEntry[] {
|
||||
return [SimulationNetwork.jsonSchema({ method: "GET", url: `${trim(baseUrl)}/api.json` }, GeneratedProviderCatalog)]
|
||||
}
|
||||
|
||||
export function share(baseUrl = "https://opncd.ai"): ResponseEntry[] {
|
||||
const base = trim(baseUrl)
|
||||
return [
|
||||
SimulationNetwork.jsonSchema({ method: "POST", url: `${base}/api/share` }, ShareNext.ShareSchema),
|
||||
SimulationNetwork.status({ method: "POST", url: /\/api\/share\/[^/]+\/sync$/ }, 204),
|
||||
SimulationNetwork.status({ method: "DELETE", url: /\/api\/share\/[^/]+$/ }, 204),
|
||||
SimulationNetwork.jsonSchema({ method: "POST", url: `${base}/api/shares` }, ShareNext.ShareSchema),
|
||||
SimulationNetwork.status({ method: "POST", url: /\/api\/shares\/[^/]+\/sync$/ }, 204),
|
||||
SimulationNetwork.status({ method: "DELETE", url: /\/api\/shares\/[^/]+$/ }, 204),
|
||||
]
|
||||
}
|
||||
|
||||
export function skills(baseUrl: string): ResponseEntry[] {
|
||||
const base = trim(baseUrl)
|
||||
return [
|
||||
SimulationNetwork.jsonSchema({ method: "GET", url: `${base}/index.json` }, Discovery.Index),
|
||||
SimulationNetwork.text({ method: "GET", url: /\/SKILL\.md$/ }, "# Generated Skill\n"),
|
||||
]
|
||||
}
|
||||
|
||||
export function installation(registryUrl = "https://registry.npmjs.org"): ResponseEntry[] {
|
||||
return [
|
||||
SimulationNetwork.text({ method: "GET", url: "https://opencode.ai/install" }, "#!/usr/bin/env bash\n"),
|
||||
SimulationNetwork.jsonSchema(
|
||||
{ method: "GET", url: "https://formulae.brew.sh/api/formula/opencode.json" },
|
||||
Installation.BrewFormula,
|
||||
),
|
||||
SimulationNetwork.jsonSchema(
|
||||
{ method: "GET", url: `${trim(registryUrl)}/opencode-ai/latest` },
|
||||
Installation.NpmPackage,
|
||||
),
|
||||
SimulationNetwork.jsonSchema(
|
||||
{ method: "GET", url: "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version" },
|
||||
Installation.ChocoPackage,
|
||||
),
|
||||
SimulationNetwork.jsonSchema(
|
||||
{ method: "GET", url: "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json" },
|
||||
Installation.ScoopManifest,
|
||||
),
|
||||
SimulationNetwork.jsonSchema(
|
||||
{ method: "GET", url: "https://api.github.com/repos/anomalyco/opencode/releases/latest" },
|
||||
Installation.GitHubRelease,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function defaults() {
|
||||
return [...models(), ...share(), ...installation()]
|
||||
}
|
||||
|
||||
export * as SimulationNetworkRoutes from "./network-routes"
|
||||
@@ -0,0 +1,362 @@
|
||||
import { Context, Effect, Layer, Ref, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
type UrlMatcher = string | RegExp | ((request: RequestInfo) => boolean)
|
||||
|
||||
export interface Matcher {
|
||||
readonly method?: string | readonly string[]
|
||||
readonly url: UrlMatcher
|
||||
}
|
||||
|
||||
export type RequestBody =
|
||||
| { readonly type: "empty" }
|
||||
| { readonly type: "text"; readonly text: string; readonly json?: unknown }
|
||||
| { readonly type: "bytes"; readonly bytes: Uint8Array }
|
||||
| { readonly type: "form"; readonly form: FormData }
|
||||
| { readonly type: "unknown"; readonly value: unknown }
|
||||
|
||||
export interface RequestInfo {
|
||||
readonly method: string
|
||||
readonly url: URL
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
readonly body: RequestBody
|
||||
}
|
||||
|
||||
export type ResponseEntry =
|
||||
| {
|
||||
readonly kind: "jsonSchema"
|
||||
readonly matcher: Matcher
|
||||
readonly status?: number
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly schema: Schema.Codec<unknown, unknown, unknown, never>
|
||||
readonly seed?: number | ((request: RequestInfo) => number)
|
||||
}
|
||||
| {
|
||||
readonly kind: "json"
|
||||
readonly matcher: Matcher
|
||||
readonly status?: number
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body: unknown | ((request: RequestInfo) => unknown)
|
||||
}
|
||||
| {
|
||||
readonly kind: "text"
|
||||
readonly matcher: Matcher
|
||||
readonly status?: number
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body: string | ((request: RequestInfo) => string)
|
||||
}
|
||||
| {
|
||||
readonly kind: "bytes"
|
||||
readonly matcher: Matcher
|
||||
readonly status?: number
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body: Uint8Array | ((request: RequestInfo) => Uint8Array)
|
||||
}
|
||||
| {
|
||||
readonly kind: "handler"
|
||||
readonly matcher: Matcher
|
||||
readonly handle: (request: RequestInfo) => Response | Promise<Response> | Effect.Effect<Response, SimulationNetworkError>
|
||||
}
|
||||
| {
|
||||
readonly kind: "status"
|
||||
readonly matcher: Matcher
|
||||
readonly status: number
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly entries?: readonly ResponseEntry[]
|
||||
readonly allowLoopback?: boolean
|
||||
}
|
||||
|
||||
interface State {
|
||||
readonly initialEntries: readonly ResponseEntry[]
|
||||
readonly entries: readonly ResponseEntry[]
|
||||
readonly allowLoopback: boolean
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
readonly allowLoopback: boolean
|
||||
readonly routes: ReadonlyArray<{
|
||||
readonly kind: ResponseEntry["kind"]
|
||||
readonly matcher: string
|
||||
readonly method?: string | readonly string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export class SimulationNetworkError extends Schema.TaggedErrorClass<SimulationNetworkError>()(
|
||||
"SimulationNetworkError",
|
||||
{
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
reason: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (entry: ResponseEntry) => Effect.Effect<void>
|
||||
readonly reset: () => Effect.Effect<void>
|
||||
readonly snapshot: () => Effect.Effect<Snapshot>
|
||||
readonly handle: (request: RequestInfo) => Effect.Effect<Response, SimulationNetworkError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SimulationNetwork") {}
|
||||
|
||||
function normalizeMatcher(matcher: Matcher | UrlMatcher): Matcher {
|
||||
if (typeof matcher === "object" && !(matcher instanceof RegExp) && "url" in matcher) return matcher
|
||||
return { url: matcher }
|
||||
}
|
||||
|
||||
function matchesUrl(matcher: UrlMatcher, request: RequestInfo) {
|
||||
if (typeof matcher === "string") return request.url.toString() === matcher
|
||||
if (matcher instanceof RegExp) return matcher.test(request.url.toString())
|
||||
return matcher(request)
|
||||
}
|
||||
|
||||
function matches(matcher: Matcher, request: RequestInfo) {
|
||||
const methods = matcher.method === undefined ? [] : Array.isArray(matcher.method) ? matcher.method : [matcher.method]
|
||||
if (methods.length > 0 && !methods.some((method) => method.toUpperCase() === request.method.toUpperCase())) return false
|
||||
return matchesUrl(matcher.url, request)
|
||||
}
|
||||
|
||||
function matcherLabel(matcher: Matcher) {
|
||||
if (typeof matcher.url === "string") return matcher.url
|
||||
if (matcher.url instanceof RegExp) return matcher.url.source
|
||||
return "<predicate>"
|
||||
}
|
||||
|
||||
function isLoopback(url: URL) {
|
||||
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||
}
|
||||
|
||||
function headers(input: Readonly<Record<string, string>> | undefined, contentType?: string) {
|
||||
return new Headers({ ...(contentType ? { "content-type": contentType } : {}), ...input })
|
||||
}
|
||||
|
||||
function seedFromRequest(request: RequestInfo) {
|
||||
return [...`${request.method} ${request.url}`].reduce((acc, char) => (acc * 31 + char.charCodeAt(0)) | 0, 1)
|
||||
}
|
||||
|
||||
function generated(schema: Schema.Codec<unknown, unknown, unknown, never>, seed: number) {
|
||||
const sample = FastCheck.sample(Schema.toArbitrary(schema), { seed, numRuns: 1 })[0]
|
||||
return Schema.encodeUnknownSync(schema)(sample)
|
||||
}
|
||||
|
||||
function response(entry: ResponseEntry, request: RequestInfo) {
|
||||
switch (entry.kind) {
|
||||
case "jsonSchema":
|
||||
return new Response(
|
||||
JSON.stringify(generated(entry.schema, typeof entry.seed === "function" ? entry.seed(request) : (entry.seed ?? seedFromRequest(request)))),
|
||||
{
|
||||
status: entry.status ?? 200,
|
||||
headers: headers(entry.headers, "application/json"),
|
||||
},
|
||||
)
|
||||
case "json":
|
||||
return new Response(JSON.stringify(typeof entry.body === "function" ? entry.body(request) : entry.body), {
|
||||
status: entry.status ?? 200,
|
||||
headers: headers(entry.headers, "application/json"),
|
||||
})
|
||||
case "text":
|
||||
return new Response(typeof entry.body === "function" ? entry.body(request) : entry.body, {
|
||||
status: entry.status ?? 200,
|
||||
headers: headers(entry.headers, "text/plain"),
|
||||
})
|
||||
case "bytes":
|
||||
return new Response((typeof entry.body === "function" ? entry.body(request) : entry.body).slice().buffer, {
|
||||
status: entry.status ?? 200,
|
||||
headers: headers(entry.headers, "application/octet-stream"),
|
||||
})
|
||||
case "handler":
|
||||
return entry.handle(request)
|
||||
case "status":
|
||||
return new Response(null, { status: entry.status, headers: headers(entry.headers) })
|
||||
}
|
||||
}
|
||||
|
||||
function responseEffect(entry: ResponseEntry, request: RequestInfo): Effect.Effect<Response, SimulationNetworkError> {
|
||||
const result = response(entry, request)
|
||||
if (Effect.isEffect(result)) return result
|
||||
if (result instanceof Promise) return Effect.promise(() => result)
|
||||
return Effect.succeed(result)
|
||||
}
|
||||
|
||||
function parseJson(text: string) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function asBytes(value: unknown) {
|
||||
if (value instanceof Uint8Array) return value
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
||||
if (typeof value === "string") return new TextEncoder().encode(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function requestBody(body: Parameters<typeof HttpClientResponse.fromWeb>[0]["body"]) {
|
||||
switch (body._tag) {
|
||||
case "Empty":
|
||||
return Effect.succeed({ type: "empty" } satisfies RequestBody)
|
||||
case "Raw": {
|
||||
const bytes = asBytes(body.body)
|
||||
if (!bytes) return Effect.succeed({ type: "unknown", value: body.body } satisfies RequestBody)
|
||||
const text = new TextDecoder().decode(bytes)
|
||||
return Effect.succeed({ type: "text", text, json: parseJson(text) } satisfies RequestBody)
|
||||
}
|
||||
case "Uint8Array": {
|
||||
const text = new TextDecoder().decode(body.body)
|
||||
if (body.contentType.includes("json") || body.contentType.startsWith("text/")) {
|
||||
return Effect.succeed({ type: "text", text, json: parseJson(text) } satisfies RequestBody)
|
||||
}
|
||||
return Effect.succeed({ type: "bytes", bytes: body.body } satisfies RequestBody)
|
||||
}
|
||||
case "FormData":
|
||||
return Effect.succeed({ type: "form", form: body.formData } satisfies RequestBody)
|
||||
case "Stream":
|
||||
return Stream.runCollect(body.stream).pipe(
|
||||
Effect.map((chunks) => {
|
||||
const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0))
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
const text = new TextDecoder().decode(bytes)
|
||||
if (body.contentType.includes("json") || body.contentType.startsWith("text/")) {
|
||||
return { type: "text", text, json: parseJson(text) } satisfies RequestBody
|
||||
}
|
||||
return { type: "bytes", bytes } satisfies RequestBody
|
||||
}),
|
||||
Effect.catch(() => Effect.succeed({ type: "unknown", value: body } satisfies RequestBody)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function toRequestInfo(
|
||||
method: string,
|
||||
url: URL,
|
||||
headers: Readonly<Record<string, string>>,
|
||||
body: RequestBody,
|
||||
): RequestInfo {
|
||||
return { method, url, headers, body }
|
||||
}
|
||||
|
||||
function toHttpClientError(request: Parameters<typeof HttpClientResponse.fromWeb>[0], error: SimulationNetworkError) {
|
||||
return new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request,
|
||||
description: `${error.reason}: ${error.url}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function make(options: Options = {}) {
|
||||
return Effect.gen(function* () {
|
||||
const state = yield* Ref.make<State>({
|
||||
initialEntries: options.entries ?? [],
|
||||
entries: options.entries ?? [],
|
||||
allowLoopback: options.allowLoopback ?? true,
|
||||
})
|
||||
|
||||
const register = Effect.fn("SimulationNetwork.register")(function* (entry: ResponseEntry) {
|
||||
yield* Ref.update(state, (current) => ({ ...current, entries: [...current.entries, entry] }))
|
||||
})
|
||||
|
||||
const reset = Effect.fn("SimulationNetwork.reset")(function* () {
|
||||
yield* Ref.update(state, (current) => ({ ...current, entries: current.initialEntries }))
|
||||
})
|
||||
|
||||
const snapshot = Effect.fn("SimulationNetwork.snapshot")(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
return {
|
||||
allowLoopback: current.allowLoopback,
|
||||
routes: current.entries.map((entry) => ({
|
||||
kind: entry.kind,
|
||||
matcher: matcherLabel(entry.matcher),
|
||||
...(entry.matcher.method === undefined ? {} : { method: entry.matcher.method }),
|
||||
})),
|
||||
} satisfies Snapshot
|
||||
})
|
||||
|
||||
const handle = Effect.fn("SimulationNetwork.handle")(function* (request: RequestInfo) {
|
||||
const current = yield* Ref.get(state)
|
||||
const entry = current.entries.find((entry) => matches(entry.matcher, request))
|
||||
if (entry) return yield* responseEffect(entry, request)
|
||||
if (current.allowLoopback && isLoopback(request.url)) {
|
||||
return yield* Effect.promise(() => fetch(request.url, { method: request.method, headers: request.headers }))
|
||||
}
|
||||
return yield* new SimulationNetworkError({
|
||||
method: request.method,
|
||||
url: request.url.toString(),
|
||||
reason: "No simulated network response registered",
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ register, reset, snapshot, handle })
|
||||
})
|
||||
}
|
||||
|
||||
export const serviceLayer = (options?: Options) => Layer.effect(Service, make(options))
|
||||
|
||||
export const httpClientLayer = Layer.effect(
|
||||
HttpClient.HttpClient,
|
||||
Effect.gen(function* () {
|
||||
const network = yield* Service
|
||||
return HttpClient.make((request, url) =>
|
||||
Effect.gen(function* () {
|
||||
const body = yield* requestBody(request.body)
|
||||
const response = yield* network
|
||||
.handle(toRequestInfo(request.method, url, request.headers, body))
|
||||
.pipe(Effect.mapError((error) => toHttpClientError(request, error)))
|
||||
return HttpClientResponse.fromWeb(request, response)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = (options?: Options) => {
|
||||
const service = serviceLayer(options)
|
||||
return Layer.mergeAll(service, httpClientLayer.pipe(Layer.provide(service)))
|
||||
}
|
||||
|
||||
export const denyUnknownLayer = layer({ allowLoopback: true })
|
||||
|
||||
export const text = (
|
||||
matcher: Matcher | UrlMatcher,
|
||||
body: string | ((request: RequestInfo) => string),
|
||||
options?: { status?: number; headers?: Record<string, string> },
|
||||
) =>
|
||||
({ kind: "text", matcher: normalizeMatcher(matcher), body, ...options }) satisfies ResponseEntry
|
||||
|
||||
export const json = (
|
||||
matcher: Matcher | UrlMatcher,
|
||||
body: unknown | ((request: RequestInfo) => unknown),
|
||||
options?: { status?: number; headers?: Record<string, string> },
|
||||
) =>
|
||||
({ kind: "json", matcher: normalizeMatcher(matcher), body, ...options }) satisfies ResponseEntry
|
||||
|
||||
export const jsonSchema = (
|
||||
matcher: Matcher | UrlMatcher,
|
||||
schema: Schema.Codec<unknown, unknown, unknown, never>,
|
||||
options?: { status?: number; headers?: Record<string, string>; seed?: number | ((request: RequestInfo) => number) },
|
||||
) => ({ kind: "jsonSchema", matcher: normalizeMatcher(matcher), schema, ...options }) satisfies ResponseEntry
|
||||
|
||||
export const bytes = (
|
||||
matcher: Matcher | UrlMatcher,
|
||||
body: Uint8Array | ((request: RequestInfo) => Uint8Array),
|
||||
options?: { status?: number; headers?: Record<string, string> },
|
||||
) => ({ kind: "bytes", matcher: normalizeMatcher(matcher), body, ...options }) satisfies ResponseEntry
|
||||
|
||||
export const handler = (
|
||||
matcher: Matcher | UrlMatcher,
|
||||
handle: (request: RequestInfo) => Response | Promise<Response> | Effect.Effect<Response, SimulationNetworkError>,
|
||||
) => ({ kind: "handler", matcher: normalizeMatcher(matcher), handle }) satisfies ResponseEntry
|
||||
|
||||
export const status = (matcher: Matcher | UrlMatcher, code: number, options?: { headers?: Record<string, string> }) =>
|
||||
({ kind: "status", matcher: normalizeMatcher(matcher), status: code, ...options }) satisfies ResponseEntry
|
||||
|
||||
export * as SimulationNetwork from "./network"
|
||||
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
LanguageModelV3,
|
||||
LanguageModelV3CallOptions,
|
||||
LanguageModelV3Content,
|
||||
LanguageModelV3FinishReason,
|
||||
LanguageModelV3StreamPart,
|
||||
} from "@ai-sdk/provider"
|
||||
import { simulateReadableStream } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SimulationDebugLog } from "./debug-log"
|
||||
import { Simulation, type LLMScript } from "./service"
|
||||
|
||||
const providerID = ProviderID.make("simulation")
|
||||
// Use a model id that contains "gpt-" (and not "oss" / "gpt-4") so the tool
|
||||
// registry's GPT-style gate enables `apply_patch` in the simulated chain.
|
||||
// See registry.ts:319-322 for the gating logic.
|
||||
const modelID = ModelID.make("gpt-mock")
|
||||
|
||||
const model: Provider.Model = {
|
||||
id: modelID,
|
||||
providerID,
|
||||
api: { id: modelID, url: "simulation://mock", npm: "simulation" },
|
||||
name: "Simulation Mock",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 128_000, output: 32_000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
variants: {},
|
||||
}
|
||||
|
||||
const provider: Provider.Info = {
|
||||
id: providerID,
|
||||
name: "Simulation",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: { [modelID]: model },
|
||||
}
|
||||
|
||||
const defaultScript: LLMScript = {
|
||||
steps: [[{ type: "text", content: "Simulation mock response." }]],
|
||||
finish: "stop",
|
||||
}
|
||||
|
||||
function nextScript(simulation: Simulation.Interface) {
|
||||
return Effect.runPromise(simulation.nextLLM().pipe(Effect.catch(() => Effect.succeed(defaultScript))))
|
||||
}
|
||||
|
||||
function text(script: LLMScript) {
|
||||
return script.steps[0]?.flatMap((item) => (item.type === "text" || item.type === "thinking" ? [item.content] : []))
|
||||
.join("") ?? ""
|
||||
}
|
||||
|
||||
function error(script: LLMScript) {
|
||||
return script.steps[0]?.find((item) => item.type === "error")
|
||||
}
|
||||
|
||||
function stream(script: LLMScript) {
|
||||
const chunks: LanguageModelV3StreamPart[] = [{ type: "stream-start", warnings: [] }]
|
||||
for (const [index, item] of (script.steps[0] ?? []).entries()) {
|
||||
if (item.type === "error") {
|
||||
chunks.push({ type: "error", error: new Error(item.message) })
|
||||
continue
|
||||
}
|
||||
const id = `simulation-${item.type}-${index + 1}`
|
||||
if (item.type === "thinking") {
|
||||
chunks.push(
|
||||
{ type: "reasoning-start", id },
|
||||
{ type: "reasoning-delta", id, delta: item.content },
|
||||
{ type: "reasoning-end", id },
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (item.type === "tool-call") {
|
||||
const input = JSON.stringify(item.input)
|
||||
chunks.push(
|
||||
{ type: "tool-input-start", id: item.toolCallId, toolName: item.toolName },
|
||||
{ type: "tool-input-delta", id: item.toolCallId, delta: input },
|
||||
{ type: "tool-input-end", id: item.toolCallId },
|
||||
{ type: "tool-call", toolCallId: item.toolCallId, toolName: item.toolName, input },
|
||||
)
|
||||
continue
|
||||
}
|
||||
chunks.push(
|
||||
{ type: "text-start", id },
|
||||
{ type: "text-delta", id, delta: item.content },
|
||||
{ type: "text-end", id },
|
||||
)
|
||||
}
|
||||
chunks.push({ type: "finish", finishReason: finishReason(script), usage: usage(script) })
|
||||
SimulationDebugLog.write("provider.stream.chunks", { chunks: chunks.map((chunk) => chunk.type) })
|
||||
|
||||
return simulateReadableStream({
|
||||
chunks,
|
||||
initialDelayInMs: 0,
|
||||
chunkDelayInMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function usage(script: LLMScript) {
|
||||
return {
|
||||
inputTokens: {
|
||||
total: script.usage?.inputTokens ?? 0,
|
||||
noCache: script.usage?.inputTokens ?? 0,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
},
|
||||
outputTokens: {
|
||||
total: script.usage?.outputTokens ?? text(script).length,
|
||||
text: script.usage?.outputTokens ?? text(script).length,
|
||||
reasoning: undefined,
|
||||
},
|
||||
raw: script.usage,
|
||||
}
|
||||
}
|
||||
|
||||
function finishReason(script: LLMScript): LanguageModelV3FinishReason {
|
||||
return { unified: script.finish === "unknown" ? "other" : (script.finish ?? "stop"), raw: script.finish }
|
||||
}
|
||||
|
||||
function language(simulation: Simulation.Interface): LanguageModelV3 {
|
||||
return {
|
||||
specificationVersion: "v3",
|
||||
provider: "simulation",
|
||||
modelId: modelID,
|
||||
supportedUrls: {},
|
||||
async doGenerate(_options: LanguageModelV3CallOptions) {
|
||||
const script = await nextScript(simulation)
|
||||
const err = error(script)
|
||||
if (err?.type === "error") throw new Error(err.message)
|
||||
const content: LanguageModelV3Content[] = []
|
||||
const textValue = text(script)
|
||||
if (textValue) content.push({ type: "text", text: textValue })
|
||||
for (const item of script.steps[0] ?? []) {
|
||||
if (item.type !== "tool-call") continue
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: item.toolCallId,
|
||||
toolName: item.toolName,
|
||||
input: JSON.stringify(item.input),
|
||||
})
|
||||
}
|
||||
return {
|
||||
content,
|
||||
finishReason: finishReason(script),
|
||||
usage: usage(script),
|
||||
warnings: [],
|
||||
}
|
||||
},
|
||||
async doStream(_options: LanguageModelV3CallOptions) {
|
||||
SimulationDebugLog.write("provider.doStream.start")
|
||||
const script = await nextScript(simulation)
|
||||
SimulationDebugLog.write("provider.doStream.script", { steps: script.steps.map((step) => step.map((item) => item.type)) })
|
||||
return { stream: stream(script) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Provider.Service,
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const lang = language(simulation)
|
||||
return Provider.Service.of({
|
||||
list: () => Effect.succeed({ [providerID]: provider }),
|
||||
getProvider: () => Effect.succeed(provider),
|
||||
getModel: () => Effect.succeed(model),
|
||||
getLanguage: () => Effect.succeed(lang),
|
||||
closest: () => Effect.succeed({ providerID, modelID }),
|
||||
getSmallModel: () => Effect.succeed(model),
|
||||
defaultModel: () => Effect.succeed({ providerID, modelID }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export * as SimulationProvider from "./provider"
|
||||
@@ -0,0 +1,203 @@
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Context, Effect, Layer, Ref, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { SimulationNetwork } from "./network"
|
||||
|
||||
export const FileContent = Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Struct({ encoding: Schema.Literal("base64"), data: Schema.String }),
|
||||
])
|
||||
|
||||
export const FilesystemSeedInput = Schema.Struct({
|
||||
files: Schema.Record(Schema.String, FileContent),
|
||||
})
|
||||
|
||||
export const FilesystemWriteInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
content: FileContent,
|
||||
})
|
||||
|
||||
export const NetworkRegisterInput = Schema.Union([
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("json"),
|
||||
url: Schema.String,
|
||||
method: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.Number),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
body: Schema.Json,
|
||||
}),
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("text"),
|
||||
url: Schema.String,
|
||||
method: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.Number),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
body: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("status"),
|
||||
url: Schema.String,
|
||||
method: Schema.optional(Schema.String),
|
||||
status: Schema.Number,
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}),
|
||||
])
|
||||
|
||||
export const LLMScriptAction = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("text"), content: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("thinking"), content: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("tool-call"),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
input: Schema.Json,
|
||||
}),
|
||||
])
|
||||
|
||||
export type LLMScriptAction = typeof LLMScriptAction.Type
|
||||
|
||||
export const LLMScript = Schema.Struct({
|
||||
steps: Schema.Array(Schema.Array(LLMScriptAction)),
|
||||
usage: Schema.optional(
|
||||
Schema.Struct({
|
||||
inputTokens: Schema.Number,
|
||||
outputTokens: Schema.Number,
|
||||
totalTokens: Schema.Number,
|
||||
}),
|
||||
),
|
||||
finish: Schema.optional(Schema.Literals(["stop", "tool-calls", "error", "length", "unknown"])),
|
||||
})
|
||||
|
||||
export type LLMScript = typeof LLMScript.Type
|
||||
|
||||
export const LLMEnqueueInput = Schema.Struct({
|
||||
scripts: Schema.Array(LLMScript),
|
||||
})
|
||||
|
||||
type FilePath = string
|
||||
|
||||
interface State {
|
||||
readonly files: readonly FilePath[]
|
||||
readonly networkRegistrations: readonly string[]
|
||||
readonly llmScripts: readonly LLMScript[]
|
||||
readonly consumedLLMScripts: number
|
||||
}
|
||||
|
||||
export class SimulationLLMError extends Schema.TaggedErrorClass<SimulationLLMError>()("SimulationLLMError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly reset: () => Effect.Effect<void>
|
||||
readonly seedFilesystem: (input: typeof FilesystemSeedInput.Type) => Effect.Effect<{ files: string[] }, unknown>
|
||||
readonly writeFile: (input: typeof FilesystemWriteInput.Type) => Effect.Effect<{ file: string }, unknown>
|
||||
readonly registerNetwork: (input: typeof NetworkRegisterInput.Type) => Effect.Effect<{ registered: string }, unknown>
|
||||
readonly enqueueLLM: (input: typeof LLMEnqueueInput.Type) => Effect.Effect<{ queued: number }>
|
||||
readonly nextLLM: () => Effect.Effect<LLMScript, SimulationLLMError>
|
||||
readonly snapshot: () => Effect.Effect<{
|
||||
files: readonly string[]
|
||||
networkRegistrations: readonly string[]
|
||||
llmQueued: number
|
||||
llmConsumed: number
|
||||
network: SimulationNetwork.Snapshot
|
||||
}>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Simulation") {}
|
||||
|
||||
function fileContent(content: typeof FileContent.Type) {
|
||||
if (typeof content === "string") return content
|
||||
return Uint8Array.from(Buffer.from(content.data, "base64"))
|
||||
}
|
||||
|
||||
function matcher(input: typeof NetworkRegisterInput.Type) {
|
||||
return input.method ? { method: input.method, url: input.url } : input.url
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const network = yield* SimulationNetwork.Service
|
||||
const empty: State = {
|
||||
files: [],
|
||||
networkRegistrations: [],
|
||||
llmScripts: [],
|
||||
consumedLLMScripts: 0,
|
||||
}
|
||||
const state = yield* Ref.make<State>(empty)
|
||||
|
||||
const reset = Effect.fn("Simulation.reset")(function* () {
|
||||
yield* network.reset()
|
||||
yield* Ref.set(state, empty)
|
||||
})
|
||||
|
||||
const seedFilesystem = Effect.fn("Simulation.seedFilesystem")(function* (input: typeof FilesystemSeedInput.Type) {
|
||||
const files = Object.keys(input.files)
|
||||
yield* Effect.forEach(
|
||||
Object.entries(input.files),
|
||||
([file, content]) => fs.writeWithDirs(path.isAbsolute(file) ? file : path.join("/opencode", file), fileContent(content)),
|
||||
)
|
||||
yield* Ref.update(state, (current) => ({ ...current, files: [...current.files, ...files] }))
|
||||
return { files }
|
||||
})
|
||||
|
||||
const writeFile = Effect.fn("Simulation.writeFile")(function* (input: typeof FilesystemWriteInput.Type) {
|
||||
yield* fs.writeWithDirs(path.isAbsolute(input.path) ? input.path : path.join("/opencode", input.path), fileContent(input.content))
|
||||
yield* Ref.update(state, (current) => ({ ...current, files: [...current.files, input.path] }))
|
||||
return { file: input.path }
|
||||
})
|
||||
|
||||
const registerNetwork = Effect.fn("Simulation.registerNetwork")(function* (input: typeof NetworkRegisterInput.Type) {
|
||||
switch (input.kind) {
|
||||
case "json":
|
||||
yield* network.register(
|
||||
SimulationNetwork.json(matcher(input), input.body, { status: input.status, headers: input.headers }),
|
||||
)
|
||||
break
|
||||
case "text":
|
||||
yield* network.register(
|
||||
SimulationNetwork.text(matcher(input), input.body, { status: input.status, headers: input.headers }),
|
||||
)
|
||||
break
|
||||
case "status":
|
||||
yield* network.register(SimulationNetwork.status(matcher(input), input.status, { headers: input.headers }))
|
||||
break
|
||||
}
|
||||
yield* Ref.update(state, (current) => ({
|
||||
...current,
|
||||
networkRegistrations: [...current.networkRegistrations, `${input.method ?? "*"} ${input.url}`],
|
||||
}))
|
||||
return { registered: input.url }
|
||||
})
|
||||
|
||||
const enqueueLLM = Effect.fn("Simulation.enqueueLLM")(function* (input: typeof LLMEnqueueInput.Type) {
|
||||
yield* Ref.update(state, (current) => ({ ...current, llmScripts: [...current.llmScripts, ...input.scripts] }))
|
||||
return { queued: input.scripts.length }
|
||||
})
|
||||
|
||||
const nextLLM = Effect.fn("Simulation.nextLLM")(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
const [script, ...rest] = current.llmScripts
|
||||
if (!script) return yield* new SimulationLLMError({ message: "No LLM script queued" })
|
||||
yield* Ref.set(state, { ...current, llmScripts: rest, consumedLLMScripts: current.consumedLLMScripts + 1 })
|
||||
return script
|
||||
})
|
||||
|
||||
const snapshot = Effect.fn("Simulation.snapshot")(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
return {
|
||||
files: current.files,
|
||||
networkRegistrations: current.networkRegistrations,
|
||||
llmQueued: current.llmScripts.length,
|
||||
llmConsumed: current.consumedLLMScripts,
|
||||
network: yield* network.snapshot(),
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ reset, seedFilesystem, writeFile, registerNetwork, enqueueLLM, nextLLM, snapshot })
|
||||
}),
|
||||
)
|
||||
|
||||
export * as Simulation from "./service"
|
||||
@@ -0,0 +1,104 @@
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Effect, Layer, Sink, Stream } from "effect"
|
||||
import * as PlatformError from "effect/PlatformError"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import {
|
||||
ChildProcessSpawner,
|
||||
ExitCode,
|
||||
make as makeSpawner,
|
||||
makeHandle,
|
||||
ProcessId,
|
||||
} from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Bash, type IFileSystem } from "just-bash"
|
||||
import path from "path"
|
||||
|
||||
export interface Options {
|
||||
readonly fs: IFileSystem
|
||||
readonly root: string
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const shellNames = new Set(["bash", "dash", "ksh", "sh", "zsh"])
|
||||
|
||||
function commandText(command: ChildProcess.StandardCommand) {
|
||||
if (command.options.shell) return command.command
|
||||
const index = command.args.findIndex((arg) => arg === "-c" || arg === "-lc")
|
||||
if (index >= 0) return command.args[index + 1]
|
||||
}
|
||||
|
||||
function isShell(command: ChildProcess.StandardCommand) {
|
||||
return Boolean(command.options.shell) || shellNames.has(Shell.name(command.command))
|
||||
}
|
||||
|
||||
function error(method: string, command: ChildProcess.Command, description: string) {
|
||||
return PlatformError.systemError({
|
||||
_tag: "PermissionDenied",
|
||||
module: "SimulationSpawner",
|
||||
method,
|
||||
description,
|
||||
pathOrDescriptor: command._tag === "StandardCommand" ? [command.command, ...command.args].join(" ") : "pipeline",
|
||||
})
|
||||
}
|
||||
|
||||
function output(value: string) {
|
||||
if (!value) return Stream.empty
|
||||
return Stream.make(encoder.encode(value))
|
||||
}
|
||||
|
||||
function handle(result: { stdout?: string; stderr?: string; exitCode?: number }) {
|
||||
const stdoutText = result.stdout ?? ""
|
||||
const stderrText = result.stderr ?? ""
|
||||
return makeHandle({
|
||||
pid: ProcessId(0),
|
||||
stdin: Sink.drain,
|
||||
stdout: output(stdoutText),
|
||||
stderr: output(stderrText),
|
||||
all: output(stdoutText + stderrText),
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty,
|
||||
isRunning: Effect.succeed(false),
|
||||
exitCode: Effect.succeed(ExitCode(result.exitCode ?? 0)),
|
||||
kill: () => Effect.void,
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
}
|
||||
|
||||
function cwd(options: Options, command: ChildProcess.StandardCommand) {
|
||||
const root = path.resolve(options.root)
|
||||
const resolved = path.resolve(root, command.options.cwd ?? root)
|
||||
if (resolved === root || AppFileSystem.contains(root, resolved)) return Effect.succeed(resolved)
|
||||
return Effect.fail(error("spawn", command, "Working directory is outside the simulated filesystem root"))
|
||||
}
|
||||
|
||||
export function make(options: Options) {
|
||||
const spawn = Effect.fn("SimulationSpawner.spawn")(function* (command: ChildProcess.Command) {
|
||||
if (command._tag !== "StandardCommand") return yield* error("spawn", command, "Piped commands are not supported")
|
||||
const workingDirectory = yield* cwd(options, command)
|
||||
if (Shell.name(command.command) === "git") {
|
||||
if (command.args[0] === "rev-parse" && command.args.includes("--git-common-dir")) return handle({ stdout: ".git\n" })
|
||||
if (command.args[0] === "rev-parse" && command.args.includes("--show-toplevel")) return handle({ stdout: `${workingDirectory}\n` })
|
||||
if (command.args[0] === "rev-parse") return handle({ stdout: "0000000000000000000000000000000000000000\n" })
|
||||
if (command.args[0] === "rev-list") return handle({ stdout: "0000000000000000000000000000000000000000\n" })
|
||||
if (command.args[0] === "config" && command.args.includes("core.bare")) return handle({ stdout: "false\n" })
|
||||
}
|
||||
if (!isShell(command)) return yield* error("spawn", command, "Only shell commands are supported in simulation")
|
||||
|
||||
const text = commandText(command)
|
||||
if (!text) return yield* error("spawn", command, "Shell command did not include command text")
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
new Bash({ fs: options.fs, cwd: workingDirectory }).exec(text, {
|
||||
env: Object.fromEntries(Object.entries(command.options.env ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
|
||||
}),
|
||||
)
|
||||
return handle(result)
|
||||
})
|
||||
|
||||
return makeSpawner(spawn)
|
||||
}
|
||||
|
||||
export const layer = (options: Options): Layer.Layer<ChildProcessSpawner> =>
|
||||
Layer.succeed(ChildProcessSpawner)(make(options))
|
||||
|
||||
export * as SimulationSpawner from "./spawner"
|
||||
@@ -5,6 +5,8 @@ import { tmpdir, withTestInstance } from "../fixture/fixture"
|
||||
import { LSPClient } from "@/lsp/client"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
function spawnFakeServer() {
|
||||
const { spawn } = require("child_process")
|
||||
@@ -16,6 +18,12 @@ function spawnFakeServer() {
|
||||
}
|
||||
}
|
||||
|
||||
// LSPClient.create is an Effect that yields AppFileSystem so the production
|
||||
// real-fs layer or a simulated one can satisfy file reads. Tests use the
|
||||
// default real-disk layer.
|
||||
const createClient = (input: Parameters<typeof LSPClient.create>[0]) =>
|
||||
Effect.runPromise(LSPClient.create(input).pipe(Effect.provide(AppFileSystem.defaultLayer)))
|
||||
|
||||
describe("LSPClient interop", () => {
|
||||
beforeEach(async () => {
|
||||
await Log.init({ print: true })
|
||||
@@ -27,7 +35,7 @@ describe("LSPClient interop", () => {
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
@@ -51,7 +59,7 @@ describe("LSPClient interop", () => {
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
@@ -75,7 +83,7 @@ describe("LSPClient interop", () => {
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
@@ -99,7 +107,7 @@ describe("LSPClient interop", () => {
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
@@ -127,7 +135,7 @@ describe("LSPClient interop", () => {
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
createClient({
|
||||
serverID: "fake",
|
||||
server: {
|
||||
...(handle as unknown as LSPServer.Handle),
|
||||
@@ -157,7 +165,7 @@ describe("LSPClient interop", () => {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
const client = await createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
@@ -201,7 +209,7 @@ describe("LSPClient interop", () => {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
const client = await createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
@@ -248,7 +256,7 @@ describe("LSPClient interop", () => {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
const client = await createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
@@ -296,7 +304,7 @@ describe("LSPClient interop", () => {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
const client = await createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
@@ -345,7 +353,7 @@ describe("LSPClient interop", () => {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
const client = await createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
@@ -399,7 +407,7 @@ describe("LSPClient interop", () => {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
const client = await createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
@@ -464,7 +472,7 @@ describe("LSPClient interop", () => {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
const client = await createClient({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
@@ -13,14 +14,22 @@ import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const experimentalTyIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalLspTy: true }))),
|
||||
LSP.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalLspTy: true })),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
|
||||
const disabledDownloadIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableLspDownload: true }))),
|
||||
LSP.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableLspDownload: true })),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { SimulationActions } from "../../../src/testing/simulation/actions"
|
||||
|
||||
describe("SimulationActions", () => {
|
||||
test("discovers focused editors and executes text actions", async () => {
|
||||
const [value, setValue] = createSignal("")
|
||||
const app = await testRender(
|
||||
() => <input focused onInput={setValue} width={20} />,
|
||||
{ width: 40, height: 8 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const items = SimulationActions.elements(app.renderer)
|
||||
expect(items.some((item) => item.editor)).toBe(true)
|
||||
|
||||
await SimulationActions.execute(app, { type: "typeText", text: "hello" })
|
||||
expect(value()).toBe("hello")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("discovers focusable elements and executes focus actions", async () => {
|
||||
let box: any
|
||||
const app = await testRender(
|
||||
() => <box ref={box} focusable style={{ width: 10, height: 3 }} />,
|
||||
{ width: 40, height: 8 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const target = SimulationActions.elements(app.renderer).find((item) => item.id === box.id)
|
||||
expect(target?.focusable).toBe(true)
|
||||
expect(box.focused).toBe(false)
|
||||
|
||||
await SimulationActions.execute(app, { type: "focus", target: box.num })
|
||||
expect(box.focused).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("discovers clickable elements and executes click actions", async () => {
|
||||
let clicked = 0
|
||||
const app = await testRender(
|
||||
() => <box onMouseDown={() => clicked++} style={{ width: 10, height: 3 }} />,
|
||||
{ width: 40, height: 8, useMouse: true },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const click = SimulationActions.actions(app.renderer).find((action) => action.type === "click")
|
||||
expect(click).toBeDefined()
|
||||
await SimulationActions.execute(app, click!)
|
||||
expect(clicked).toBe(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parsePatch } from "diff"
|
||||
import { SimulationFileGenerator } from "../../../src/testing/simulation/file-generator"
|
||||
|
||||
describe("SimulationFileGenerator", () => {
|
||||
test("generates deterministic txt and ts files", () => {
|
||||
const first = SimulationFileGenerator.generateFiles({ seed: 42, count: 8 })
|
||||
const second = SimulationFileGenerator.generateFiles({ seed: 42, count: 8 })
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(first.entries.length).toBe(8)
|
||||
expect(first.entries.some((entry) => entry.kind === "txt")).toBe(true)
|
||||
expect(first.entries.some((entry) => entry.kind === "ts")).toBe(true)
|
||||
expect(Object.keys(first.files)).toEqual(first.entries.map((entry) => entry.path))
|
||||
})
|
||||
|
||||
test("uses weights to steer file kind and tree depth", () => {
|
||||
const generated = SimulationFileGenerator.generateFiles({
|
||||
seed: 7,
|
||||
count: 6,
|
||||
maxDepth: 4,
|
||||
weights: { txt: 0, ts: 1, shallow: 0, deep: 1 },
|
||||
})
|
||||
|
||||
expect(generated.entries.every((entry) => entry.kind === "ts")).toBe(true)
|
||||
expect(generated.entries.every((entry) => entry.path.split("/").length > 2)).toBe(true)
|
||||
})
|
||||
|
||||
test("generates patch files for existing generated files", () => {
|
||||
const generated = SimulationFileGenerator.generateFiles({ seed: 3, count: 5 })
|
||||
const patches = SimulationFileGenerator.generatePatches(generated, { seed: 9, count: 3, patchDir: ".git/patches" })
|
||||
|
||||
expect(patches.patches.length).toBe(3)
|
||||
expect(Object.keys(patches.files)).toEqual(patches.patches.map((patch) => patch.path))
|
||||
expect(Object.keys(patches.patchedFiles)).toEqual(patches.patches.map((patch) => patch.target))
|
||||
for (const patch of patches.patches) {
|
||||
expect(generated.files[patch.target]).toBe(patch.before)
|
||||
expect(patch.after).not.toBe(patch.before)
|
||||
expect(patch.path.startsWith(".git/patches/")).toBe(true)
|
||||
expect(patch.patch.startsWith(`diff --git a/${patch.target} b/${patch.target}`)).toBe(true)
|
||||
expect(parsePatch(patch.patch)[0].oldFileName).toBe(`a/${patch.target}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { Bash, InMemoryFs } from "just-bash"
|
||||
import path from "path"
|
||||
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const root = "/simulation"
|
||||
const it = testEffect(
|
||||
SimulationFileSystem.layer({
|
||||
root,
|
||||
files: {
|
||||
"opencode.json": JSON.stringify({ model: "test/model" }),
|
||||
"README.md": "hello",
|
||||
"src/index.ts": "export const value = 1\n",
|
||||
"src/data.json": JSON.stringify({ ok: true }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
describe("SimulationFileSystem", () => {
|
||||
it.effect("reads seeded files and writes nested files", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* fs.readFileString(path.join(root, "README.md"))).toBe("hello")
|
||||
expect(yield* fs.readFileString(path.join(root, "src/index.ts"))).toBe("export const value = 1\n")
|
||||
expect(yield* fs.isDir(path.join(root, "src"))).toBe(true)
|
||||
yield* fs.writeWithDirs(path.join(root, "tmp", "result.txt"), "done")
|
||||
|
||||
expect(yield* fs.readFileString(path.join(root, "tmp", "result.txt"))).toBe("done")
|
||||
expect(yield* fs.isDir(path.join(root, "tmp"))).toBe(true)
|
||||
expect(yield* fs.isFile(path.join(root, "tmp", "result.txt"))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists directory entries and globs in memory", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* fs.readDirectoryEntries(path.join(root, "src"))).toEqual([
|
||||
{ name: "data.json", type: "file" },
|
||||
{ name: "index.ts", type: "file" },
|
||||
])
|
||||
expect(yield* fs.glob("**/*.ts", { cwd: root })).toEqual(["src/index.ts"])
|
||||
expect(yield* fs.globUp("*.md", path.join(root, "src"), root)).toEqual([path.join(root, "README.md")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("denies paths outside the simulated root", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const exit = yield* fs.readFileString("/etc/passwd").pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns no upward matches when search starts outside the simulated root", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* fs.up({ targets: [".opencode"], start: "/Users/james", stop: "/Users/james" })).toEqual([])
|
||||
expect(yield* fs.globUp("*.json", "/Users/james", "/Users/james")).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
const shared = new InMemoryFs()
|
||||
const sharedIt = testEffect(SimulationFileSystem.layer({ root, fs: shared }))
|
||||
|
||||
sharedIt.effect("shares the just-bash filesystem with Bash", () =>
|
||||
Effect.gen(function* () {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
const bash = new Bash({ fs: shared, cwd: root })
|
||||
|
||||
yield* appFs.writeWithDirs(path.join(root, "from-app.txt"), "hello from app")
|
||||
|
||||
expect((yield* Effect.promise(() => bash.exec("cat from-app.txt"))).stdout).toBe("hello from app")
|
||||
expect((yield* Effect.promise(() => bash.exec("printf 'hello from bash' > from-bash.txt"))).exitCode).toBe(0)
|
||||
expect(yield* appFs.readFileString(path.join(root, "from-bash.txt"))).toBe("hello from bash")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { ManagedRuntime, Layer } from "effect"
|
||||
import { InMemoryFs } from "just-bash"
|
||||
import { Git } from "../../../src/git"
|
||||
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
|
||||
import { SimulationGit } from "../../../src/testing/simulation/git"
|
||||
|
||||
const patch = `diff --git a/src/app.ts b/src/app.ts
|
||||
index 1111111..2222222 100644
|
||||
--- a/src/app.ts
|
||||
+++ b/src/app.ts
|
||||
@@ -1,2 +1,2 @@
|
||||
-export const name = "old"
|
||||
+export const name = "new"
|
||||
export const stable = true
|
||||
diff --git a/docs/readme.md b/docs/readme.md
|
||||
new file mode 100644
|
||||
index 0000000..3333333
|
||||
--- /dev/null
|
||||
+++ b/docs/readme.md
|
||||
@@ -0,0 +1,2 @@
|
||||
+# Demo
|
||||
+Notes
|
||||
`
|
||||
|
||||
async function withFakeGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service | AppFileSystem.Service, never>) => Promise<T>) {
|
||||
const fs = new InMemoryFs()
|
||||
const fsLayer = SimulationFileSystem.layer({ root: "/opencode", fs })
|
||||
const rt = ManagedRuntime.make(
|
||||
Layer.mergeAll(fsLayer, SimulationGit.layer.pipe(Layer.provide(fsLayer))),
|
||||
)
|
||||
try {
|
||||
return await body(rt)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe("SimulationGit", () => {
|
||||
test("loads diff data from _patches patch files", async () => {
|
||||
await withFakeGit(async (rt) => {
|
||||
await rt.runPromise(
|
||||
AppFileSystem.Service.use((fs) => fs.writeWithDirs("/opencode/_patches/changes.patch", patch)),
|
||||
)
|
||||
|
||||
const [status, diff, stats, all] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.status("/opencode"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.diff("/opencode", "HEAD"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.stats("/opencode", "HEAD"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.patchAll("/opencode", "HEAD"))),
|
||||
])
|
||||
|
||||
expect(status).toEqual([
|
||||
{ file: "src/app.ts", code: "M", status: "modified" },
|
||||
{ file: "docs/readme.md", code: "A", status: "added" },
|
||||
])
|
||||
expect(diff).toEqual(status)
|
||||
expect(stats).toEqual([
|
||||
{ file: "src/app.ts", additions: 1, deletions: 1 },
|
||||
{ file: "docs/readme.md", additions: 2, deletions: 0 },
|
||||
])
|
||||
expect(all).toEqual({ text: patch, truncated: false })
|
||||
})
|
||||
})
|
||||
|
||||
test("filters individual patches and keeps inert methods successful", async () => {
|
||||
await withFakeGit(async (rt) => {
|
||||
await rt.runPromise(
|
||||
AppFileSystem.Service.use((fs) => fs.writeWithDirs("/opencode/_patches/changes.patch", patch)),
|
||||
)
|
||||
|
||||
const [filePatch, missingPatch, branch, applied] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.patch("/opencode", "HEAD", "src/app.ts"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.patch("/opencode", "HEAD", "missing.ts"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.branch("/opencode"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.applyPatch("/opencode", "not applied"))),
|
||||
])
|
||||
|
||||
expect(filePatch.text).toContain("diff --git a/src/app.ts b/src/app.ts")
|
||||
expect(filePatch.text).not.toContain("docs/readme.md")
|
||||
expect(missingPatch).toEqual({ text: "", truncated: false })
|
||||
expect(branch).toBe("main")
|
||||
expect(applied.exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { TuiSimulation } from "../../../src/cli/cmd/tui/simulation"
|
||||
import { TuiSimulationMcp } from "../../../src/cli/cmd/tui/simulation-mcp"
|
||||
|
||||
describe("TuiSimulationMcp", () => {
|
||||
test("exposes simulation tools over MCP", async () => {
|
||||
const renderer = await TuiSimulation.createSimulationRenderer()
|
||||
const server = await TuiSimulationMcp.createSimulationMcpServer({
|
||||
mode: "remote",
|
||||
harness: TuiSimulationMcp.harnessFromSimulationRenderer(renderer),
|
||||
controlUrl: "http://127.0.0.1:1",
|
||||
})
|
||||
const client = new Client({ name: "simulation-test", version: "0.0.0" })
|
||||
const transport = new StreamableHTTPClientTransport(new URL(server.url!))
|
||||
|
||||
try {
|
||||
await client.connect(transport)
|
||||
const tools = await client.listTools()
|
||||
expect(tools.tools.map((tool) => tool.name)).toContain("simulation_ui_state_get")
|
||||
expect(tools.tools.map((tool) => tool.name)).toContain("simulation_control_llm_enqueue")
|
||||
|
||||
const screen = await client.callTool({ name: "simulation_screen_get", arguments: {} })
|
||||
expect(screen.content).toBeArray()
|
||||
} finally {
|
||||
await client.close()
|
||||
await server.stop()
|
||||
renderer.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { SimulationNetwork, type RequestInfo } from "../../../src/testing/simulation/network"
|
||||
import { SimulationNetworkRoutes } from "../../../src/testing/simulation/network-routes"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
SimulationNetwork.layer({
|
||||
allowLoopback: false,
|
||||
entries: [
|
||||
SimulationNetwork.json("https://models.dev/api.json", { openai: { id: "openai" } }),
|
||||
SimulationNetwork.text("https://example.com/page", "hello"),
|
||||
SimulationNetwork.json(/https:\/\/example\.com\/dynamic/, (request: RequestInfo) => ({
|
||||
method: request.method,
|
||||
query: request.url.searchParams.get("q"),
|
||||
})),
|
||||
SimulationNetwork.text(/https:\/\/example\.com\/echo-text/, (request: RequestInfo) =>
|
||||
`text:${request.method}:${request.url.searchParams.get("value")}`,
|
||||
),
|
||||
SimulationNetwork.bytes(/https:\/\/example\.com\/echo-bytes/, (request: RequestInfo) =>
|
||||
new TextEncoder().encode(`bytes:${request.url.searchParams.get("value")}`),
|
||||
),
|
||||
SimulationNetwork.json(
|
||||
{ method: "POST", url: "https://example.com/body" },
|
||||
(request: RequestInfo) => ({
|
||||
body: request.body.type === "text" ? request.body.json : null,
|
||||
}),
|
||||
),
|
||||
SimulationNetwork.status({ method: "GET", url: "https://example.com/method" }, 204),
|
||||
SimulationNetwork.handler({ method: "POST", url: "https://example.com/handler" }, (request: RequestInfo) =>
|
||||
new Response(JSON.stringify({ handled: request.body.type === "text" ? request.body.json : null }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
SimulationNetwork.jsonSchema(
|
||||
{ method: "GET", url: "https://example.com/schema" },
|
||||
Schema.Struct({ name: Schema.String, ok: Schema.Boolean }),
|
||||
),
|
||||
...SimulationNetworkRoutes.models("https://models.example"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
describe("SimulationNetwork", () => {
|
||||
it.effect("serves registered JSON responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://models.dev/api.json"))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({ openai: { id: "openai" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serves registered text responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://example.com/page"))
|
||||
|
||||
expect(response.headers["content-type"]).toContain("text/plain")
|
||||
expect(yield* response.text).toBe("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails unknown external URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const exit = yield* http.execute(HttpClientRequest.get("https://api.openai.com/v1/models")).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serves dynamic request-based responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.post("https://example.com/dynamic?q=test"))
|
||||
|
||||
expect(yield* response.json).toEqual({ method: "POST", query: "test" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serves dynamic text responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.put("https://example.com/echo-text?value=hello"))
|
||||
|
||||
expect(yield* response.text).toBe("text:PUT:hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serves dynamic byte responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://example.com/echo-bytes?value=hello"))
|
||||
|
||||
expect(new TextDecoder().decode(yield* response.arrayBuffer)).toBe("bytes:hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches methods before serving responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const ok = yield* http.execute(HttpClientRequest.get("https://example.com/method"))
|
||||
const miss = yield* http.execute(HttpClientRequest.post("https://example.com/method")).pipe(Effect.exit)
|
||||
|
||||
expect(ok.status).toBe(204)
|
||||
expect(Exit.isFailure(miss)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes parsed JSON request bodies to dynamic responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const request = HttpClientRequest.post("https://example.com/body").pipe(
|
||||
HttpClientRequest.bodyJsonUnsafe({ query: "hello" }),
|
||||
)
|
||||
const response = yield* http.execute(request)
|
||||
|
||||
expect(yield* response.json).toEqual({ body: { query: "hello" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("supports full response handlers", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const request = HttpClientRequest.post("https://example.com/handler").pipe(
|
||||
HttpClientRequest.bodyJsonUnsafe({ value: 42 }),
|
||||
)
|
||||
const response = yield* http.execute(request)
|
||||
|
||||
expect(yield* response.json).toEqual({ handled: { value: 42 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("generates JSON from registered schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://example.com/schema"))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.String, ok: Schema.Boolean }))(
|
||||
yield* response.json,
|
||||
)).toEqual(expect.objectContaining({ ok: expect.any(Boolean) }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers known schema-backed route families", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://models.example/api.json"))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(typeof (yield* response.json)).toBe("object")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can register responses after layer startup", () =>
|
||||
Effect.gen(function* () {
|
||||
const network = yield* SimulationNetwork.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
yield* network.register(SimulationNetwork.status("https://opencode.ai/ping", 204))
|
||||
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://opencode.ai/ping"))
|
||||
expect(response.status).toBe(204)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("register adds static JSON responses after startup", () =>
|
||||
Effect.gen(function* () {
|
||||
const network = yield* SimulationNetwork.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
yield* network.register(SimulationNetwork.json("https://opencode.ai/static", { ok: true }))
|
||||
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://opencode.ai/static"))
|
||||
expect(yield* response.json).toEqual({ ok: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can register dynamic responses after layer startup", () =>
|
||||
Effect.gen(function* () {
|
||||
const network = yield* SimulationNetwork.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
yield* network.register(
|
||||
SimulationNetwork.json("https://opencode.ai/runtime", (request: RequestInfo) => ({
|
||||
host: request.url.hostname,
|
||||
header: request.headers["x-test"],
|
||||
})),
|
||||
)
|
||||
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.get("https://opencode.ai/runtime").pipe(HttpClientRequest.setHeader("x-test", "ok")),
|
||||
)
|
||||
expect(yield* response.json).toEqual({ host: "opencode.ai", header: "ok" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("register respects method-specific matchers", () =>
|
||||
Effect.gen(function* () {
|
||||
const network = yield* SimulationNetwork.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
yield* network.register(SimulationNetwork.text({ method: "POST", url: "https://opencode.ai/method" }, "posted"))
|
||||
|
||||
const wrongMethod = yield* http.execute(HttpClientRequest.get("https://opencode.ai/method")).pipe(Effect.exit)
|
||||
const response = yield* http.execute(HttpClientRequest.post("https://opencode.ai/method"))
|
||||
|
||||
expect(Exit.isFailure(wrongMethod)).toBe(true)
|
||||
expect(yield* response.text).toBe("posted")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("register adds schema-generated responses after startup", () =>
|
||||
Effect.gen(function* () {
|
||||
const network = yield* SimulationNetwork.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const ResponseSchema = Schema.Struct({ id: Schema.String, enabled: Schema.Boolean })
|
||||
|
||||
yield* network.register(SimulationNetwork.jsonSchema("https://opencode.ai/generated", ResponseSchema))
|
||||
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://opencode.ai/generated"))
|
||||
const json = yield* response.json
|
||||
|
||||
const decoded = yield* Schema.decodeUnknownEffect(ResponseSchema)(json)
|
||||
expect(decoded).toEqual({ id: expect.any(String), enabled: expect.any(Boolean) })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "README.md", "content": "# Demo App\n\nA small simulated app for orientation testing.\n" },
|
||||
{ "type": "writeFile", "path": "src/app.ts", "content": "export const appName = 'demo-app'\nexport function boot() { return appName }\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Inspect the project files and produce a concise orientation." }, { "type": "text", "content": "This is a compact Bun/TypeScript demo app. The main entrypoint is `src/app.ts`, which exports `appName` and `boot()`. The README provides the human summary; the next best step is to add a small test around `boot()`." }]], "usage": { "inputTokens": 240, "outputTokens": 72, "totalTokens": 312 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "What is in this project? Give me a quick orientation." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1200 },
|
||||
{ "type": "typeText", "text": "!ls" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "!cat README.md" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/hide thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "The concise summary: a README and a TypeScript entrypoint define a tiny demo app; add a boot test before expanding behavior." }]], "usage": { "inputTokens": 150, "outputTokens": 28, "totalTokens": 178 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Summarize that without reasoning." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "docs/architecture.json", "content": "{\n \"entrypoints\": [\"src/app.ts\"],\n \"runtime\": \"bun\",\n \"features\": [{\"name\":\"boot\",\"status\":\"implemented\"}],\n \"risks\": []\n}\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Read structured architecture metadata and explain the fields." }, { "type": "text", "content": "`docs/architecture.json` records one entrypoint, Bun as the runtime, one implemented `boot` feature, and no current risks. It is a minimal baseline rather than a complete architecture document." }]], "usage": { "inputTokens": 260, "outputTokens": 54, "totalTokens": 314 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Read the architecture metadata and explain it." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1100 },
|
||||
{ "type": "typeText", "text": "!cat docs/architecture.json" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "docs/roadmap.md", "content": "# Roadmap\n\n| Milestone | Scope | Risk |\n| --- | --- | --- |\n| M1 | Add boot test | Low |\n| M2 | Document CLI usage | Medium |\n| M3 | Add config loading | Medium |\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "Roadmap order: M1 first because testing existing behavior is low risk; M2 second for usability; M3 last because config loading adds new behavior and edge cases." }]], "usage": { "inputTokens": 220, "outputTokens": 41, "totalTokens": 261 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Summarize the roadmap table and recommend an order." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "!cat docs/roadmap.md" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "test/app.test.ts", "content": "import { expect, test } from 'bun:test'\nimport { boot } from '../src/app'\n\ntest('boot returns the app name', () => {\n expect(boot()).toBe('demo-app')\n})\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "The generated test validates the boot contract exactly." }, { "type": "text", "content": "The generated `test/app.test.ts` verifies that `boot()` returns `demo-app`. Run `bun test test/app.test.ts` to validate the current behavior." }]], "usage": { "inputTokens": 200, "outputTokens": 37, "totalTokens": 237 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "What does the generated test cover, and how would you run it?" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1100 },
|
||||
{ "type": "typeText", "text": "!cat test/app.test.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/show thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Synthesize README, architecture metadata, roadmap, and test coverage." }, { "type": "text", "content": "Final orientation: this is a minimal Bun/TypeScript app with `src/app.ts` as entrypoint, architecture metadata in `docs/architecture.json`, a roadmap beginning with test coverage, and `test/app.test.ts` verifying `boot()`." }]], "usage": { "inputTokens": 260, "outputTokens": 54, "totalTokens": 314 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Give a final project orientation including the new metadata and test." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1100 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "typeText", "text": "!ls docs && ls test" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 800 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "src/counter.ts", "content": "export function increment(value: number) {\n return value + 2\n}\n" },
|
||||
{ "type": "writeFile", "path": "test/counter.test.ts", "content": "import { expect, test } from 'bun:test'\nimport { increment } from '../src/counter'\n\ntest('increments by one', () => {\n expect(increment(1)).toBe(2)\n})\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Counter adds two, while test expects plus one." }, { "type": "text", "content": "The bug is in `src/counter.ts`: `increment` returns `value + 2`, but the test expects `value + 1`. The smallest fix is changing that return expression only." }]], "usage": { "inputTokens": 310, "outputTokens": 52, "totalTokens": 362 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "The counter test fails. Triage the bug and suggest the smallest fix." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1200 },
|
||||
{ "type": "typeText", "text": "!cat src/counter.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "!cat test/counter.test.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/hide thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "Minimal fix: change `return value + 2` to `return value + 1` in `src/counter.ts`." }]], "usage": { "inputTokens": 120, "outputTokens": 22, "totalTokens": 142 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Restate the fix as a one-line recommendation." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "logs/test-output.txt", "content": "counter.test.ts:\nExpected: 2\nReceived: 3\nStack: increment(1) returned value + 2\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "The log confirms received value is one too high." }, { "type": "text", "content": "The failure log confirms the same issue numerically: expected `2`, received `3`. That maps directly to adding two instead of one." }]], "usage": { "inputTokens": 230, "outputTokens": 38, "totalTokens": 268 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Interpret this failure log and connect it to the code." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "!cat logs/test-output.txt" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "src/counter.ts", "content": "export function increment(value: number) {\n return value + 1\n}\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "The implementation now returns `value + 1`, so the original test should pass without changing the assertion." }]], "usage": { "inputTokens": 170, "outputTokens": 29, "totalTokens": 199 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Confirm the implementation fix after applying it." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "typeText", "text": "!cat src/counter.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "test/counter.edge.test.ts", "content": "import { expect, test } from 'bun:test'\nimport { increment } from '../src/counter'\n\ntest.each([[0, 1], [-1, 0], [41, 42]])('increments %p to %p', (input, output) => {\n expect(increment(input)).toBe(output)\n})\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "The edge test covers zero, negative, and positive values." }, { "type": "text", "content": "The extra table-driven test covers zero, a negative value, and a positive sentinel. It catches accidental `+2` regressions without broadening scope." }]], "usage": { "inputTokens": 210, "outputTokens": 40, "totalTokens": 250 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Review the extra edge test and explain its coverage." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "!cat test/counter.edge.test.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/show thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Produce final handoff referencing implementation, tests, and log." }, { "type": "text", "content": "Handoff: `src/counter.ts` now increments by one; `test/counter.test.ts` covers the original failure; `test/counter.edge.test.ts` adds table-driven coverage; `logs/test-output.txt` records the initial failure." }]], "usage": { "inputTokens": 230, "outputTokens": 52, "totalTokens": 282 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Give a handoff summary referencing every artifact." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1100 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "typeText", "text": "!ls src test logs" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 800 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "package.json", "content": "{\n \"scripts\": {\n \"test\": \"bun test\",\n \"typecheck\": \"bun typecheck\"\n }\n}\n" },
|
||||
{ "type": "writeFile", "path": "src/math.ts", "content": "export const add = (a: number, b: number) => a + b\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Render a tool-call-like plan as structured text." }, { "type": "text", "content": "Validation plan:\n\n```json\n{\"tool\":\"read\",\"input\":{\"filePath\":\"package.json\"}}\n```\n\nThen run `bun test` and `bun typecheck`." }]], "usage": { "inputTokens": 300, "outputTokens": 62, "totalTokens": 362 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Show me the validation plan and include the tool call you would make." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1200 },
|
||||
{ "type": "typeText", "text": "!cat package.json" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "The package scripts support the plan: `test` maps to `bun test` and `typecheck` maps to `bun typecheck`." }]], "usage": { "inputTokens": 150, "outputTokens": 29, "totalTokens": 179 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Does package.json support your validation plan?" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "/hide thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "test/math.test.ts", "content": "import { expect, test } from 'bun:test'\nimport { add } from '../src/math'\n\ntest('adds two numbers', () => {\n expect(add(1, 2)).toBe(3)\n})\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "Structured artifact:\n\n```json\n{\"tool\":\"write\",\"path\":\"test/math.test.ts\",\"purpose\":\"verify add(1,2)\"}\n```" }]], "usage": { "inputTokens": 200, "outputTokens": 45, "totalTokens": 245 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Render the generated test as a structured tool artifact." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "!cat test/math.test.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "reports/tool-transcript.json", "content": "{\n \"steps\": [\n {\"tool\":\"read\",\"target\":\"package.json\",\"status\":\"ok\"},\n {\"tool\":\"read\",\"target\":\"src/math.ts\",\"status\":\"ok\"},\n {\"tool\":\"write\",\"target\":\"test/math.test.ts\",\"status\":\"ok\"}\n ],\n \"next\": \"bun test test/math.test.ts\"\n}\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Explain transcript and next command." }, { "type": "text", "content": "The transcript records read/read/write tool-style steps, ending with a focused test file. The next command is `bun test test/math.test.ts`." }]], "usage": { "inputTokens": 190, "outputTokens": 38, "totalTokens": 228 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Explain the tool transcript and next command." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "typeText", "text": "!cat reports/tool-transcript.json" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "reports/coverage-notes.md", "content": "# Coverage Notes\n\n- add(1, 2) covers the happy path.\n- add(-1, 1) should be added if signed values matter.\n- add(0, 0) should be added if identity behavior matters.\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "Coverage notes separate required and optional work: the happy path test is required; signed and identity cases are useful follow-ups." }]], "usage": { "inputTokens": 170, "outputTokens": 32, "totalTokens": 202 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Review the coverage notes and separate must-have from optional." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "!cat reports/coverage-notes.md" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/show thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Close with generated artifacts and command." }, { "type": "text", "content": "Final checklist: `test/math.test.ts` validates `add`; `reports/tool-transcript.json` documents the flow; `reports/coverage-notes.md` lists optional cases; run `bun test test/math.test.ts`." }]], "usage": { "inputTokens": 230, "outputTokens": 50, "totalTokens": 280 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Close with a checklist of generated artifacts and command." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1100 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "notes/toggles.md", "content": "# Toggle Notes\n\nExercise prompt command toggles and verify responses remain readable.\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Start with thinking visible and explain test purpose." }, { "type": "text", "content": "This walkthrough checks thinking visibility toggles, shell output, prompt follow-ups, and sidebar open/close behavior through `ctrl+x b`." }]], "usage": { "inputTokens": 210, "outputTokens": 40, "totalTokens": 250 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "We are testing prompt command toggles. Explain the scenario." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1100 },
|
||||
{ "type": "typeText", "text": "/hide thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "Thinking should now be hidden. The visible answer should still be useful and concise." }]], "usage": { "inputTokens": 120, "outputTokens": 23, "totalTokens": 143 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Confirm thinking is hidden and continue." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "typeText", "text": "!cat notes/toggles.md" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "notes/command-palette.md", "content": "# Command Palette Exercise\n\nActions to simulate:\n- hide thinking\n- show thinking\n- read shell output\n- summarize transcript\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Script uses slash command text as command-palette stand-in." }, { "type": "text", "content": "The command-palette exercise documents hide/show thinking, shell reads, and transcript summaries. The script also toggles the sidebar with `ctrl+x b`." }]], "usage": { "inputTokens": 220, "outputTokens": 40, "totalTokens": 260 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Explain the command-palette exercise file." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "!cat notes/command-palette.md" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "notes/transcript.json", "content": "{\n \"mode\": \"command-toggle\",\n \"events\": [\"hide-thinking\", \"shell-cat\", \"show-thinking\", \"sidebar-toggle\"],\n \"expected\": {\"cwd\": \"/opencode\", \"status\": \"readable\"}\n}\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "The transcript JSON records expected events and asserts `/opencode` as the shell working directory. It is useful for deterministic replay checks." }]], "usage": { "inputTokens": 150, "outputTokens": 33, "totalTokens": 183 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Interpret the transcript JSON." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "typeText", "text": "!cat notes/transcript.json" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/show thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Show-thinking checkpoint should include reasoning." }, { "type": "text", "content": "Show-thinking checkpoint: reasoning is visible again, and the transcript includes toggles, shell reads, and sidebar interaction." }]], "usage": { "inputTokens": 150, "outputTokens": 35, "totalTokens": 185 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Give a show-thinking checkpoint summary." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "typeText", "text": "!ls notes" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Final summary references all notes." }, { "type": "text", "content": "Final walkthrough summary: `notes/toggles.md`, `notes/command-palette.md`, and `notes/transcript.json` document the command-toggle flow and expected shell context." }]], "usage": { "inputTokens": 190, "outputTokens": 39, "totalTokens": 229 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Summarize the notes directory artifacts." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "src/profile.ts", "content": "export interface Profile {\n id: string\n name: string\n}\n\nexport function label(profile: Profile) {\n return profile.id\n}\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "label should use name, not id." }, { "type": "text", "content": "The smallest change is in `src/profile.ts`: update `label(profile)` to return `profile.name` instead of `profile.id`." }]], "usage": { "inputTokens": 250, "outputTokens": 36, "totalTokens": 286 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "I want profile labels to show names, not ids. What should change?" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1100 },
|
||||
{ "type": "typeText", "text": "!cat src/profile.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/hide thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "Patch shape:\n```diff\n- return profile.id\n+ return profile.name\n```" }]], "usage": { "inputTokens": 115, "outputTokens": 27, "totalTokens": 142 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Render the edit as a tiny diff only." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "writeFile", "path": "src/profile.ts", "content": "export interface Profile {\n id: string\n name: string\n}\n\nexport function label(profile: Profile) {\n return profile.name\n}\n" },
|
||||
{ "type": "typeText", "text": "!cat src/profile.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "test/profile.test.ts", "content": "import { expect, test } from 'bun:test'\nimport { label, type Profile } from '../src/profile'\n\ntest('labels a profile with its name', () => {\n const profile: Profile = { id: 'u_123', name: 'Ada Lovelace' }\n expect(label(profile)).toBe('Ada Lovelace')\n})\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Different id/name values catch the bug." }, { "type": "text", "content": "The profile test is targeted: the id and name differ, so returning `profile.id` fails while returning `profile.name` passes." }]], "usage": { "inputTokens": 210, "outputTokens": 37, "totalTokens": 247 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Review the new profile test and explain why it catches the bug." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "!cat test/profile.test.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "docs/profile-change.json", "content": "{\n \"change\": \"profile-label-name\",\n \"files\": [\"src/profile.ts\", \"test/profile.test.ts\"],\n \"behavior\": {\"before\": \"id\", \"after\": \"name\"},\n \"risk\": \"low\"\n}\n" },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "The change metadata records a low-risk behavior shift from id labels to name labels, touching implementation and test files." }]], "usage": { "inputTokens": 170, "outputTokens": 31, "totalTokens": 201 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Interpret the profile change metadata." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "typeText", "text": "!cat docs/profile-change.json" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/show thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Final summary should mention implementation, test, and metadata." }, { "type": "text", "content": "Final handoff: `src/profile.ts` returns `profile.name`, `test/profile.test.ts` verifies id/name distinction, and `docs/profile-change.json` records the low-risk behavior change." }]], "usage": { "inputTokens": 210, "outputTokens": 44, "totalTokens": 254 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Give a complete final handoff summary." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "typeText", "text": "!ls src test docs" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 800 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "docs/design.md", "content": "# Queue Design\n\nJobs are pushed to an in-memory array and workers poll every second.\n" },
|
||||
{ "type": "writeFile", "path": "src/queue.ts", "content": "const jobs: string[] = []\n\nexport function push(job: string) { jobs.push(job) }\nexport function next() { return jobs.shift() }\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Identify reliability and latency risks." }, { "type": "text", "content": "Design review: the in-memory queue is simple and fast, but loses jobs on restart. Polling every second adds latency. Production needs durability, leases, and metrics." }]], "usage": { "inputTokens": 340, "outputTokens": 50, "totalTokens": 390 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Review the queue design and call out production risks." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1200 },
|
||||
{ "type": "typeText", "text": "!cat docs/design.md" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "!cat src/queue.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/hide thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "Production checklist: durable storage, retry metadata, visibility timeout or leases, queue-depth metrics, and lower-latency notifications if needed." }]], "usage": { "inputTokens": 160, "outputTokens": 33, "totalTokens": 193 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Turn that review into a production checklist." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "docs/risk-matrix.json", "content": "{\n \"risks\": [\n {\"name\":\"job-loss\",\"severity\":\"high\",\"mitigation\":\"durable storage\"},\n {\"name\":\"polling-latency\",\"severity\":\"medium\",\"mitigation\":\"notifications\"},\n {\"name\":\"duplicate-work\",\"severity\":\"medium\",\"mitigation\":\"leases\"}\n ]\n}\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Rank risks from matrix." }, { "type": "text", "content": "Highest risk: job loss because the queue is in-memory. Medium risks: polling latency and duplicate work, mitigated by notifications and leases." }]], "usage": { "inputTokens": 240, "outputTokens": 40, "totalTokens": 280 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Analyze the risk matrix and rank the risks." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1000 },
|
||||
{ "type": "typeText", "text": "!cat docs/risk-matrix.json" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "test/queue.test.ts", "content": "import { expect, test } from 'bun:test'\nimport { next, push } from '../src/queue'\n\ntest('returns jobs in fifo order', () => {\n push('first')\n push('second')\n expect(next()).toBe('first')\n expect(next()).toBe('second')\n})\n" },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "text", "content": "The FIFO test guards current behavior. It does not cover durability, retries, leases, or cross-process behavior." }]], "usage": { "inputTokens": 180, "outputTokens": 28, "totalTokens": 208 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Review the FIFO test and explain what it does not cover." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "typeText", "text": "!cat test/queue.test.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "writeFile", "path": "docs/decision-record.md", "content": "# ADR: Keep In-Memory Queue for Demo\n\nStatus: Accepted for demo only\n\nContext: The current queue is in-memory and non-durable.\n\nDecision: Keep it for demos, add FIFO tests, and document production risks.\n\nConsequences: Restart loses jobs; production needs durable storage.\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Summarize ADR context, decision, and consequences." }, { "type": "text", "content": "The ADR accepts the in-memory queue for demo use only, adds FIFO tests, and documents that production requires durable storage." }]], "usage": { "inputTokens": 220, "outputTokens": 33, "totalTokens": 253 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Summarize the ADR and its consequences." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 900 },
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "typeText", "text": "!cat docs/decision-record.md" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 700 },
|
||||
{ "type": "typeText", "text": "/show thinking" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 500 },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Final handoff references design doc, risk matrix, FIFO test, and ADR." }, { "type": "text", "content": "Final handoff: `docs/design.md` describes the queue, `docs/risk-matrix.json` ranks risks, `test/queue.test.ts` covers FIFO behavior, and `docs/decision-record.md` limits the design to demo use." }]], "usage": { "inputTokens": 250, "outputTokens": 52, "totalTokens": 302 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Give a final handoff summary of all artifacts." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1100 },
|
||||
{ "type": "typeText", "text": "!ls docs test src" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 800 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "writeFile", "path": ".git/HEAD", "content": "ref: refs/heads/main\n" },
|
||||
{ "type": "writeFile", "path": ".git/config", "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[branch \"main\"]\n" },
|
||||
{ "type": "writeFile", "path": "src/delta-23.txt", "content": "delta_23 note 1: alpha-41 glade-90.\ndelta_23 note 2: iris-73 juniper-33.\ndelta_23 note 3: ember-41 cedar-12." },
|
||||
{ "type": "writeFile", "path": "src/cedar-20.txt", "content": "cedar_20 note 1: cedar-94 field-8.\ncedar_20 note 2: delta-21 juniper-19.\ncedar_20 note 3: bravo-42 juniper-35.\ncedar_20 note 4: cedar-25 delta-87." },
|
||||
{ "type": "writeFile", "path": "src/bravo-86.txt", "content": "bravo_86 note 1: bravo-42 harbor-20.\nbravo_86 note 2: delta-92 cedar-97.\nbravo_86 note 3: juniper-72 juniper-90.\nbravo_86 note 4: cedar-15 field-91." },
|
||||
{ "type": "writeFile", "path": "src/bravo-11.ts", "content": "const bravo_11Defaults = {\n retries: 1,\n timeout: 220,\n label: \"iris-21\",\n} as const\n\nexport async function loadbravo_11(input: Partial<typeof bravo_11Defaults> = {}) {\n const config = { ...bravo_11Defaults, ...input }\n await Promise.resolve()\n return {\n ...config,\n ready: config.retries > 0 && config.timeout > 0,\n }\n}\n" },
|
||||
{ "type": "writeFile", "path": "src/iris-53.ts", "content": "export class iris_53Store {\n #items = new Map<string, number>()\n\n add(key: string, value: number) {\n this.#items.set(key, (this.#items.get(key) ?? 0) + value)\n return this\n }\n\n snapshot() {\n return Object.fromEntries(this.#items.entries())\n }\n}\n\nexport const iris_53StoreInstance = new iris_53Store().add(\"harbor-38\", 4)\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-2/delta-75.ts", "content": "export type delta_75Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n | { readonly type: \"deleted\"; readonly id: string }\n\nexport function delta_75Label(event: delta_75Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n return `deleted:${event.id}`\n}\n\nexport const delta_75Sample: delta_75Event = { type: \"created\", id: \"ember-76\", count: 11 }\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-4/harbor-79.ts", "content": "export type harbor_79Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n | { readonly type: \"deleted\"; readonly id: string }\n\nexport function harbor_79Label(event: harbor_79Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n return `deleted:${event.id}`\n}\n\nexport const harbor_79Sample: harbor_79Event = { type: \"created\", id: \"bravo-6\", count: 12 }\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-4/alpha-56.txt", "content": "alpha_56 note 1: iris-27 ember-92.\nalpha_56 note 2: iris-35 cedar-50.\nalpha_56 note 3: iris-71 glade-35.\nalpha_56 note 4: field-83 harbor-24.\nalpha_56 note 5: field-9 cedar-86.\nalpha_56 note 6: harbor-11 juniper-49." },
|
||||
{ "type": "writeFile", "path": "_patches/001-delta-75.ts.patch", "content": "diff --git a/src/dir-2/delta-75.ts b/src/dir-2/delta-75.ts\nindex 9ed8735..df7ce67 100644\n--- a/src/dir-2/delta-75.ts\n+++ b/src/dir-2/delta-75.ts\n@@ -1,12 +1,8 @@\n export type delta_75Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n- | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n- | { readonly type: \"deleted\"; readonly id: string }\n \n export function delta_75Label(event: delta_75Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n- if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n- return `deleted:${event.id}`\n }\n \n-export const delta_75Sample: delta_75Event = { type: \"created\", id: \"ember-76\", count: 11 }\n+\n" },
|
||||
{ "type": "writeFile", "path": "_patches/002-harbor-79.ts.patch", "content": "diff --git a/src/dir-4/harbor-79.ts b/src/dir-4/harbor-79.ts\nindex d7ebf78..5e2b8bf 100644\n--- a/src/dir-4/harbor-79.ts\n+++ b/src/dir-4/harbor-79.ts\n@@ -1,12 +1,9 @@\n+\n export type harbor_79Event =\n- | { readonly type: \"created\"; readonly id: string; readonly count: number }\n- | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n- | { readonly type: \"deleted\"; readonly id: string }\n \n export function harbor_79Label(event: harbor_79Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n- return `deleted:${event.id}`\n }\n \n export const harbor_79Sample: harbor_79Event = { type: \"created\", id: \"bravo-6\", count: 12 }\n" },
|
||||
{ "type": "writeFile", "path": "_patches/003-bravo-11.ts.patch", "content": "diff --git a/src/bravo-11.ts b/src/bravo-11.ts\nindex eaf26e4..58519e4 100644\n--- a/src/bravo-11.ts\n+++ b/src/bravo-11.ts\n@@ -1,5 +1,4 @@\n const bravo_11Defaults = {\n- retries: 1,\n timeout: 220,\n label: \"iris-21\",\n } as const\n@@ -12,3 +11,20 @@ export async function loadbravo_11(input: Partial<typeof bravo_11Defaults> = {})\n ready: config.retries > 0 && config.timeout > 0,\n }\n }\n+\n+export function generated370Normalize(input: readonly string[]) {\n+ return input.map((item) => item.trim()).filter(Boolean).join(\"|\")\n+}\n+\n+export const generated160Config = {\n+ id: \"alpha-85\",\n+ retries: 5,\n+ flags: [\"iris-67\", \"ember-55\"],\n+} as const\n+\n+export const generated595Config = {\n+ id: \"alpha-55\",\n+ retries: 3,\n+ flags: [\"harbor-26\", \"harbor-77\"],\n+} as const\n+\n" },
|
||||
{ "type": "writeFile", "path": "_patches/004-iris-53.ts.patch", "content": "diff --git a/src/iris-53.ts b/src/iris-53.ts\nindex 9497460..b8281a7 100644\n--- a/src/iris-53.ts\n+++ b/src/iris-53.ts\n@@ -1,9 +1,39 @@\n+\n+export function generated284Normalize(input: readonly string[]) {\n+ return input.map((item) => item.trim()).filter(Boolean).join(\",\")\n+}\n+\n+export type generated458State =\n+ | { readonly ok: true; readonly value: \"harbor-83\" }\n+ | { readonly ok: false; readonly reason: \"iris-97\" }\n+\n+export type generated838State =\n+ | { readonly ok: true; readonly value: \"harbor-34\" }\n+ | { readonly ok: false; readonly reason: \"delta-29\" }\n+\n+export const generated467Items = [\n+ { key: \"glade-76\", value: 11 },\n+ { key: \"alpha-80\", value: 9 },\n+ { key: \"field-83\", value: 16 },\n+] as const\n+\n+export const generated560Config = {\n+ id: \"alpha-44\",\n+ retries: 1,\n+ flags: [\"harbor-11\", \"field-37\"],\n+} as const\n+\n+export const generated713Items = [\n+ { key: \"juniper-98\", value: 2 },\n+ { key: \"juniper-83\", value: 47 },\n+ { key: \"bravo-78\", value: 33 },\n+] as const\n+\n export class iris_53Store {\n #items = new Map<string, number>()\n \n add(key: string, value: number) {\n this.#items.set(key, (this.#items.get(key) ?? 0) + value)\n- return this\n }\n \n snapshot() {\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Read the fake git diff from _patches and summarize the generated changes." }, { "type": "text", "content": "The generated project contains eight seeded files under src and four fake git patches under _patches. The patches remove several event variants from delta_75 and harbor_79, remove retries from bravo_11 while adding generated config helpers, and expand iris_53 with generated normalize/state/item exports while changing add() to stop returning this. Main risks: type errors from removed union variants, bravo_11 still referencing config.retries after deletion, and fluent chaining breakage in iris_53StoreInstance." }]], "usage": { "inputTokens": 520, "outputTokens": 96, "totalTokens": 616 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Review the generated project changes. Use the git diff from _patches and summarize the changed files, likely risks, and a recommended next step." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1200 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "pressKey", "key": "x", "modifiers": { "ctrl": true } },
|
||||
{ "type": "pressKey", "key": "b" },
|
||||
{ "type": "writeFile", "path": "src/greeting.ts", "content": "export function greet(name: string) {\n return `Hi, ${name}`\n}\n" },
|
||||
{
|
||||
"_comment": "Three scripts enqueued: (1) main session prompt — long explanation + apply_patch tool call; (2) post-tool-result follow-up text; (3) title-generation small-model call. Title gen fires in parallel with the main call, so script order is by FIFO race winner. With three scripts queued the post-tool follow-up always finds a real script instead of falling back to the default 'Simulation mock response.'",
|
||||
"type": "enqueueLLM",
|
||||
"scripts": [
|
||||
{
|
||||
"steps": [
|
||||
[
|
||||
{ "type": "text", "content": "Looking at `src/greeting.ts`, the function currently returns `\"Hi, …\"`. " },
|
||||
{ "type": "text", "content": "You asked for a friendlier greeting, so I'll change the prefix from `Hi` to `Hello`. " },
|
||||
{ "type": "text", "content": "I'll keep the template literal and the `name` interpolation untouched. " },
|
||||
{ "type": "text", "content": "Here's the plan: I'll use the `apply_patch` tool to replace the single return line. " },
|
||||
{ "type": "text", "content": "Patching `src/greeting.ts` now." },
|
||||
{
|
||||
"type": "tool-call",
|
||||
"toolCallId": "patch-greeting-1",
|
||||
"toolName": "apply_patch",
|
||||
"input": {
|
||||
"patchText": "*** Begin Patch\n*** Update File: /opencode/src/greeting.ts\n@@\n- return `Hi, ${name}`\n+ return `Hello, ${name}`\n*** End Patch\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"usage": { "inputTokens": 320, "outputTokens": 90, "totalTokens": 410 },
|
||||
"finish": "tool-calls"
|
||||
},
|
||||
{
|
||||
"steps": [
|
||||
[
|
||||
{ "type": "text", "content": "Done — `src/greeting.ts` now returns `Hello, ${name}`." }
|
||||
]
|
||||
],
|
||||
"usage": { "inputTokens": 140, "outputTokens": 16, "totalTokens": 156 },
|
||||
"finish": "stop"
|
||||
},
|
||||
{
|
||||
"steps": [
|
||||
[
|
||||
{ "type": "text", "content": "Friendlier greeting" }
|
||||
]
|
||||
],
|
||||
"usage": { "inputTokens": 20, "outputTokens": 4, "totalTokens": 24 },
|
||||
"finish": "stop"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "typeText", "text": "Make the greeting friendlier — use \"Hello\" instead of \"Hi\" in src/greeting.ts." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1500 },
|
||||
{ "type": "typeText", "text": "!cat src/greeting.ts" },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 800 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Generator for `09_diverse_llm_responses.json`.
|
||||
*
|
||||
* Produces a single simulation script that queues 1000 LLM responses with
|
||||
* maximum diversity across:
|
||||
* - finish reasons (stop, tool-calls, error, length, unknown)
|
||||
* - step shapes (single text, multi-text, thinking + text, text + tool-call,
|
||||
* multiple tool-calls, thinking-only, error-only)
|
||||
* - tool variety: edit, write, apply_patch, read, grep, glob, bash, todowrite,
|
||||
* webfetch, websearch, lsp, task, task_status, plan, question, skill,
|
||||
* repo_clone, repo_overview, invalid
|
||||
* - parameter shapes per tool (varied filePaths, patterns, commands, queries)
|
||||
*
|
||||
* The generator is deterministic (seeded RNG) so the same JSON is regenerated.
|
||||
*
|
||||
* Run with:
|
||||
* bun test/testing/simulation/scripts/09_diverse_llm_responses.gen.ts
|
||||
*
|
||||
* Output: `09_diverse_llm_responses.json` (next to this file).
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
const TOTAL = 1000
|
||||
const SEED = 0x09abcdef
|
||||
const OUTPUT_PATH = path.join(import.meta.dirname, "09_diverse_llm_responses.json")
|
||||
|
||||
// ─── Seeded RNG (mulberry32) ─────────────────────────────────────────────────
|
||||
function mulberry32(seed: number) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const rand = mulberry32(SEED)
|
||||
const pick = <T>(items: readonly T[]): T => items[Math.floor(rand() * items.length)]!
|
||||
const int = (min: number, max: number) => min + Math.floor(rand() * (max - min + 1))
|
||||
const maybe = (p: number) => rand() < p
|
||||
|
||||
// ─── Vocab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const FILE_NAMES = [
|
||||
"src/index.ts",
|
||||
"src/server.ts",
|
||||
"src/util/log.ts",
|
||||
"src/util/string.ts",
|
||||
"src/feature/auth.ts",
|
||||
"src/feature/cart.ts",
|
||||
"src/feature/checkout.tsx",
|
||||
"src/components/Button.tsx",
|
||||
"src/components/Modal.tsx",
|
||||
"src/lib/db.ts",
|
||||
"src/lib/cache.ts",
|
||||
"src/api/users.ts",
|
||||
"src/api/orders.ts",
|
||||
"test/index.test.ts",
|
||||
"test/auth.test.ts",
|
||||
"test/cart.test.ts",
|
||||
"scripts/build.ts",
|
||||
"scripts/deploy.sh",
|
||||
"config/eslint.json",
|
||||
"config/tsconfig.json",
|
||||
"package.json",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"docs/api.md",
|
||||
"docs/getting-started.md",
|
||||
"Dockerfile",
|
||||
".github/workflows/ci.yml",
|
||||
]
|
||||
|
||||
const DIR_NAMES = ["src", "src/feature", "src/util", "test", "scripts", "config", "docs"]
|
||||
|
||||
const PATTERNS = [
|
||||
"TODO",
|
||||
"FIXME",
|
||||
"console\\.log",
|
||||
"function\\s+\\w+",
|
||||
"import .* from",
|
||||
"export const",
|
||||
"throw new Error",
|
||||
"async\\s+function",
|
||||
"class\\s+\\w+",
|
||||
"interface\\s+\\w+",
|
||||
]
|
||||
|
||||
const GLOBS = [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"src/**/*.ts",
|
||||
"test/**/*.test.ts",
|
||||
"**/*.{ts,tsx}",
|
||||
"**/*.md",
|
||||
"**/*.json",
|
||||
"scripts/*.sh",
|
||||
]
|
||||
|
||||
const COMMANDS = [
|
||||
"ls -la",
|
||||
"pwd",
|
||||
"cat README.md",
|
||||
"wc -l src/index.ts",
|
||||
"git status",
|
||||
"git log --oneline -5",
|
||||
"git diff --stat",
|
||||
"bun install",
|
||||
"bun test",
|
||||
"bun run build",
|
||||
"npm run lint",
|
||||
"rg TODO",
|
||||
"find . -name '*.ts' -newer package.json",
|
||||
"echo 'hello'",
|
||||
"date",
|
||||
]
|
||||
|
||||
const WEB_URLS = [
|
||||
"https://example.com/api/data",
|
||||
"https://docs.opencode.ai/configuration",
|
||||
"https://github.com/anomalyco/opencode",
|
||||
"https://api.openai.com/v1/models",
|
||||
"https://registry.npmjs.org/effect",
|
||||
"https://nodejs.org/api/fs.html",
|
||||
"https://typescript.org/docs/handbook",
|
||||
]
|
||||
|
||||
const SEARCH_QUERIES = [
|
||||
"rust async error handling",
|
||||
"effect-ts schema validation",
|
||||
"react server components",
|
||||
"typescript discriminated unions",
|
||||
"bun sqlite performance",
|
||||
"lsp protocol initialize",
|
||||
"git rebase squash workflow",
|
||||
]
|
||||
|
||||
const SKILLS = ["customize-opencode", "effect", "improve-codebase-architecture", "gmail"]
|
||||
|
||||
const SUBAGENTS = ["explore", "general"]
|
||||
|
||||
const PLAIN_TEXT = [
|
||||
"Looking at the code now.",
|
||||
"Let me inspect the relevant files.",
|
||||
"I'll start by reading the entrypoint.",
|
||||
"Checking the test suite for related coverage.",
|
||||
"Tracing the call chain through the layer composition.",
|
||||
"This looks like a missing dependency in the layer graph.",
|
||||
"I'll add a small helper to factor out the duplication.",
|
||||
"Renaming the symbol everywhere it's used.",
|
||||
"Bumping the version in package.json.",
|
||||
"Adding a changelog entry.",
|
||||
"Running the formatter.",
|
||||
"Re-running the typecheck.",
|
||||
"All clean — no type errors.",
|
||||
"Tests pass locally.",
|
||||
"Drafting the PR description.",
|
||||
]
|
||||
|
||||
const THINKING = [
|
||||
"Need to figure out which layer is missing the dependency.",
|
||||
"The error stack points at instance-state.ts — likely missing InstanceRef.",
|
||||
"Best to check if the cache is being invalidated correctly.",
|
||||
"Tradeoff: inline vs extract helper. Inline is shorter.",
|
||||
"Race condition seems likely given the await boundary.",
|
||||
"Looking at the diff to spot the regression.",
|
||||
"Need to make sure the test asserts the post-condition.",
|
||||
"Let me re-read the spec to be sure.",
|
||||
]
|
||||
|
||||
const FINISH_REASONS: ("stop" | "tool-calls" | "length" | "unknown")[] = [
|
||||
"stop",
|
||||
"tool-calls",
|
||||
"stop",
|
||||
"tool-calls",
|
||||
"stop",
|
||||
"length",
|
||||
"stop",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
// ─── Tool-call generators ────────────────────────────────────────────────────
|
||||
|
||||
type ToolCall = {
|
||||
type: "tool-call"
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
let toolCallSeq = 0
|
||||
const nextToolCallId = () => `tc-${(++toolCallSeq).toString(36)}`
|
||||
|
||||
const TOOL_GENERATORS: ReadonlyArray<() => ToolCall> = [
|
||||
// edit
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "edit",
|
||||
input: {
|
||||
filePath: `/opencode/${pick(FILE_NAMES)}`,
|
||||
oldString: pick(["return null", "// TODO", "const x = 1", "throw new Error(\"!\")", "if (true)"]),
|
||||
newString: pick(["return undefined", "// fixed", "const x = 2", "throw new Error(\"unexpected\")", "if (cond)"]),
|
||||
...(maybe(0.2) ? { replaceAll: true } : {}),
|
||||
},
|
||||
}),
|
||||
// write
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "write",
|
||||
input: {
|
||||
filePath: `/opencode/${pick(FILE_NAMES)}`,
|
||||
content: pick([
|
||||
"export const value = 42\n",
|
||||
"// generated\nexport default {}\n",
|
||||
"TODO: fill in\n",
|
||||
'{\n "version": "0.0.1"\n}\n',
|
||||
]),
|
||||
},
|
||||
}),
|
||||
// apply_patch
|
||||
() => {
|
||||
const file = `/opencode/${pick(FILE_NAMES)}`
|
||||
const before = pick(["return null", "const x = 1", "// old"])
|
||||
const after = pick(["return undefined", "const x = 2", "// new"])
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "apply_patch",
|
||||
input: {
|
||||
patchText: `*** Begin Patch\n*** Update File: ${file}\n@@\n- ${before}\n+ ${after}\n*** End Patch\n`,
|
||||
},
|
||||
}
|
||||
},
|
||||
// read
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "read",
|
||||
input: {
|
||||
filePath: `/opencode/${pick(FILE_NAMES)}`,
|
||||
...(maybe(0.3) ? { offset: int(1, 50) } : {}),
|
||||
...(maybe(0.3) ? { limit: int(10, 200) } : {}),
|
||||
},
|
||||
}),
|
||||
// grep
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "grep",
|
||||
input: {
|
||||
pattern: pick(PATTERNS),
|
||||
...(maybe(0.5) ? { path: pick(DIR_NAMES) } : {}),
|
||||
...(maybe(0.5) ? { include: pick(["*.ts", "*.tsx", "*.{ts,tsx}", "*.md", "*.json"]) } : {}),
|
||||
},
|
||||
}),
|
||||
// glob
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "glob",
|
||||
input: {
|
||||
pattern: pick(GLOBS),
|
||||
...(maybe(0.3) ? { path: pick(DIR_NAMES) } : {}),
|
||||
},
|
||||
}),
|
||||
// bash
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "bash",
|
||||
input: {
|
||||
command: pick(COMMANDS),
|
||||
description: pick(["List files", "Show status", "Print working dir", "Run tests", "Lint code"]),
|
||||
...(maybe(0.2) ? { timeout: int(1000, 60000) } : {}),
|
||||
},
|
||||
}),
|
||||
// todowrite
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "todowrite",
|
||||
input: {
|
||||
todos: Array.from({ length: int(1, 4) }, () => ({
|
||||
content: pick([
|
||||
"Investigate failing test",
|
||||
"Refactor layer composition",
|
||||
"Add typecheck step",
|
||||
"Update docs",
|
||||
"Bump dependencies",
|
||||
]),
|
||||
status: pick(["pending", "in_progress", "completed", "cancelled"]),
|
||||
priority: pick(["high", "medium", "low"]),
|
||||
})),
|
||||
},
|
||||
}),
|
||||
// webfetch
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "webfetch",
|
||||
input: {
|
||||
url: pick(WEB_URLS),
|
||||
...(maybe(0.4) ? { format: pick(["markdown", "text", "html"]) } : {}),
|
||||
...(maybe(0.2) ? { timeout: int(5, 60) } : {}),
|
||||
},
|
||||
}),
|
||||
// websearch
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "websearch",
|
||||
input: {
|
||||
query: pick(SEARCH_QUERIES),
|
||||
...(maybe(0.3) ? { numResults: int(1, 10) } : {}),
|
||||
...(maybe(0.3) ? { type: pick(["auto", "fast", "deep"]) } : {}),
|
||||
},
|
||||
}),
|
||||
// lsp
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "lsp",
|
||||
input: {
|
||||
file: `/opencode/${pick(FILE_NAMES)}`,
|
||||
action: pick(["definition", "references", "hover", "documentSymbol", "implementation"]),
|
||||
...(maybe(0.5) ? { position: { line: int(0, 100), character: int(0, 80) } } : {}),
|
||||
},
|
||||
}),
|
||||
// task
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "task",
|
||||
input: {
|
||||
description: pick(["explore feature", "audit dependency graph", "find usage"]),
|
||||
prompt: pick([
|
||||
"Look through src/ and summarize the entry points.",
|
||||
"Find all call sites for `Bus.publish` and explain what they publish.",
|
||||
]),
|
||||
subagent_type: pick(SUBAGENTS),
|
||||
},
|
||||
}),
|
||||
// task_status
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "task_status",
|
||||
input: {
|
||||
task_id: `task-${int(1, 50).toString(36)}`,
|
||||
},
|
||||
}),
|
||||
// plan
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "plan",
|
||||
input: {
|
||||
summary: pick([
|
||||
"Refactor the layer graph to remove cycles",
|
||||
"Add diagnostic logging then propose a fix",
|
||||
"Extract a helper and add tests",
|
||||
]),
|
||||
},
|
||||
}),
|
||||
// question
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: pick(["Which directory should I scaffold under?", "Approve the rename?"]),
|
||||
header: pick(["Pick a directory", "Approve rename"]),
|
||||
options: [
|
||||
{ label: "Yes", description: "Approve" },
|
||||
{ label: "No", description: "Decline" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
// skill
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "skill",
|
||||
input: { name: pick(SKILLS) },
|
||||
}),
|
||||
// repo_clone
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "repo_clone",
|
||||
input: {
|
||||
url: pick(["https://github.com/anomalyco/opencode", "https://github.com/effect-ts/effect"]),
|
||||
...(maybe(0.4) ? { ref: pick(["main", "dev", "v1.0.0"]) } : {}),
|
||||
},
|
||||
}),
|
||||
// repo_overview
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "repo_overview",
|
||||
input: {
|
||||
...(maybe(0.5) ? { path: pick(DIR_NAMES) } : {}),
|
||||
...(maybe(0.3) ? { depth: int(1, 4) } : {}),
|
||||
},
|
||||
}),
|
||||
// invalid (sanity / fuzz)
|
||||
() => ({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: "invalid",
|
||||
input: {
|
||||
tool: pick(["foo", "bar", "definitely_not_a_tool"]),
|
||||
error: pick(["unknown tool", "missing argument"]),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
// ─── Step-shape generators ───────────────────────────────────────────────────
|
||||
|
||||
type Step = ReadonlyArray<
|
||||
| { type: "text"; content: string }
|
||||
| { type: "thinking"; content: string }
|
||||
| ToolCall
|
||||
>
|
||||
|
||||
const STEP_SHAPES: ReadonlyArray<() => Step> = [
|
||||
// shape 0: single text
|
||||
() => [{ type: "text", content: pick(PLAIN_TEXT) }],
|
||||
// shape 1: multi text
|
||||
() =>
|
||||
Array.from({ length: int(2, 5) }, () => ({
|
||||
type: "text" as const,
|
||||
content: pick(PLAIN_TEXT),
|
||||
})),
|
||||
// shape 2: thinking + text
|
||||
() => [
|
||||
{ type: "thinking" as const, content: pick(THINKING) },
|
||||
{ type: "text" as const, content: pick(PLAIN_TEXT) },
|
||||
],
|
||||
// shape 3: text + single tool-call
|
||||
() => [{ type: "text" as const, content: pick(PLAIN_TEXT) }, pick(TOOL_GENERATORS)()],
|
||||
// shape 4: thinking + text + tool-call
|
||||
() => [
|
||||
{ type: "thinking" as const, content: pick(THINKING) },
|
||||
{ type: "text" as const, content: pick(PLAIN_TEXT) },
|
||||
pick(TOOL_GENERATORS)(),
|
||||
],
|
||||
// shape 5: multiple tool calls in one step
|
||||
() => [
|
||||
{ type: "text" as const, content: pick(PLAIN_TEXT) },
|
||||
...Array.from({ length: int(2, 4) }, () => pick(TOOL_GENERATORS)()),
|
||||
],
|
||||
// shape 6: thinking only
|
||||
() => [{ type: "thinking" as const, content: pick(THINKING) }],
|
||||
// shape 7: long narrative (5-8 texts)
|
||||
() =>
|
||||
Array.from({ length: int(5, 8) }, () => ({
|
||||
type: "text" as const,
|
||||
content: pick(PLAIN_TEXT),
|
||||
})),
|
||||
// shape 8: solo tool-call (no preamble)
|
||||
() => [pick(TOOL_GENERATORS)()],
|
||||
]
|
||||
|
||||
// ─── Script generator ────────────────────────────────────────────────────────
|
||||
|
||||
type LLMScript = {
|
||||
steps: Step[]
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
|
||||
finish?: "stop" | "tool-calls" | "length" | "unknown"
|
||||
}
|
||||
|
||||
function makeScript(): LLMScript {
|
||||
const step = pick(STEP_SHAPES)()
|
||||
const hasToolCall = step.some((item) => item.type === "tool-call")
|
||||
const finish: LLMScript["finish"] = hasToolCall ? "tool-calls" : pick(FINISH_REASONS)
|
||||
const inputTokens = int(20, 600)
|
||||
const outputTokens = int(4, 250)
|
||||
return {
|
||||
steps: [step],
|
||||
usage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
|
||||
finish,
|
||||
}
|
||||
}
|
||||
|
||||
const scripts: LLMScript[] = Array.from({ length: TOTAL }, makeScript)
|
||||
|
||||
// ─── Output ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── User-driven consumption loop ────────────────────────────────────────────
|
||||
//
|
||||
// `enqueueLLM` only fills the backend queue. To actually consume responses we
|
||||
// need user messages that drive `doStream` calls. Each user turn typically
|
||||
// consumes 1-2 scripts (one for the main response, one if a tool-call comes
|
||||
// back and the loop fetches a follow-up; title-gen on the first turn eats one
|
||||
// more). We send TURNS user messages so the total consumed roughly matches
|
||||
// the queue size; the default "Simulation mock response." catches any
|
||||
// remainder.
|
||||
|
||||
const PROMPTS = [
|
||||
"Walk me through the project layout briefly.",
|
||||
"Find any TODOs in the codebase.",
|
||||
"Refactor the small helper in src/util/log.ts.",
|
||||
"Open src/index.ts and explain the entrypoint.",
|
||||
"Search for `console.log` usage.",
|
||||
"Run the test suite.",
|
||||
"Patch the greeting in src/greeting.ts to say Hello.",
|
||||
"Write a small note in docs/getting-started.md.",
|
||||
"Show me recent git activity.",
|
||||
"What's the LSP status?",
|
||||
"Plan a fix for the failing test.",
|
||||
"Summarize the changes so far.",
|
||||
"Look up the npm registry entry for `effect`.",
|
||||
"Search the web for `lsp protocol initialize`.",
|
||||
"Outline the next refactor step.",
|
||||
]
|
||||
|
||||
const TURNS = Math.ceil(TOTAL / 2) // ~2 LLM calls per turn on average
|
||||
|
||||
const userActions = Array.from({ length: TURNS }, (_, i) => [
|
||||
{ type: "typeText", text: PROMPTS[i % PROMPTS.length]! },
|
||||
{ type: "pressEnter" },
|
||||
// Small wait so the prompt loop can drain LLM calls before the next input.
|
||||
{ type: "wait", ms: 60 },
|
||||
]).flat()
|
||||
|
||||
const script = {
|
||||
_comment: `Generated by 09_diverse_llm_responses.gen.ts. ${TOTAL} LLM responses with diverse step shapes, tool calls (every registered tool kind), and finish reasons. Followed by ${TURNS} user turns that drive the backend to consume them. Seed: 0x${SEED.toString(16)}. Re-run the generator to regenerate.`,
|
||||
actions: [
|
||||
// Enter Build mode (skip default plan agent).
|
||||
{ type: "pressKey", key: "x", modifiers: { ctrl: true } },
|
||||
{ type: "pressKey", key: "b" },
|
||||
{ type: "enqueueLLM", scripts },
|
||||
...userActions,
|
||||
],
|
||||
}
|
||||
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(script, null, 2) + "\n")
|
||||
|
||||
// ─── Summary printed for the developer ───────────────────────────────────────
|
||||
|
||||
const byFinish = scripts.reduce(
|
||||
(acc, s) => {
|
||||
const k = s.finish ?? "stop"
|
||||
acc[k] = (acc[k] ?? 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
|
||||
const byTool: Record<string, number> = {}
|
||||
let totalToolCalls = 0
|
||||
for (const s of scripts) {
|
||||
for (const step of s.steps) {
|
||||
for (const item of step) {
|
||||
if (item.type === "tool-call") {
|
||||
byTool[item.toolName] = (byTool[item.toolName] ?? 0) + 1
|
||||
totalToolCalls++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Wrote ${OUTPUT_PATH}`)
|
||||
console.log(`Scripts: ${scripts.length}`)
|
||||
console.log(`Finish reasons:`, byFinish)
|
||||
console.log(`Tool calls (${totalToolCalls} total):`, byTool)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,888 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Simulation script generator.
|
||||
*
|
||||
* Builds a single simulation script (JSON) that:
|
||||
* 1. Enters Build mode (ctrl+x, b).
|
||||
* 2. Optionally seeds the simulated filesystem with a starter set of files.
|
||||
* 3. Queues N LLM responses chosen by step-shape and tool-kind weights.
|
||||
* 4. Drives the TUI through M user turns to consume those LLM responses.
|
||||
*
|
||||
* Critically: a stateful FS model tracks which paths exist at each step, so
|
||||
* `apply_patch`/`edit`/`read` tool calls only ever target real files. `write`
|
||||
* tool calls add new files into the model so later patches can target them.
|
||||
*
|
||||
* Usage:
|
||||
* bun test/testing/simulation/scripts/generate.ts [options]
|
||||
*
|
||||
* Options:
|
||||
* --out <path> Output JSON path. Default: ./generated.json
|
||||
* --total <n> Total LLM scripts to queue. Default: 1000
|
||||
* --turns <n> Number of user turns. Default: ceil(total / 2)
|
||||
* --seed <n> RNG seed (hex or decimal). Default: 0x09abcdef
|
||||
* --tools <list> Comma-separated tool kinds to include. Default: all.
|
||||
* --shapes <list> Comma-separated step shapes. Default: all.
|
||||
* --weight tool=<n> Override weight for a single tool. Repeatable.
|
||||
* --weight shape=<n> Override weight for a single shape. Repeatable.
|
||||
* --seed-files Pre-seed common files into the simulated FS.
|
||||
* Default: true. Pass --no-seed-files to disable.
|
||||
* --enable-titles Enqueue +1 short script per turn for title gen.
|
||||
* Default: true. Pass --no-enable-titles to disable.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* # Default: 1000 scripts, 500 turns, balanced tool mix
|
||||
* bun generate.ts --out diverse.json
|
||||
*
|
||||
* # Patch-heavy run: only apply_patch + read, 200 turns
|
||||
* bun generate.ts --total 600 --turns 200 \
|
||||
* --tools apply_patch,read --out patches.json
|
||||
*
|
||||
* # Bias towards bash + apply_patch
|
||||
* bun generate.ts --weight tool.apply_patch=10 --weight tool.bash=10
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CliOptions {
|
||||
out: string
|
||||
total: number
|
||||
turns: number
|
||||
seed: number
|
||||
tools: Set<string> | null
|
||||
shapes: Set<string> | null
|
||||
toolWeights: Record<string, number>
|
||||
shapeWeights: Record<string, number>
|
||||
seedFiles: boolean
|
||||
enableTitles: boolean
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const out: CliOptions = {
|
||||
out: "./generated.json",
|
||||
total: 1000,
|
||||
turns: -1,
|
||||
seed: 0x09abcdef,
|
||||
tools: null,
|
||||
shapes: null,
|
||||
toolWeights: {},
|
||||
shapeWeights: {},
|
||||
seedFiles: true,
|
||||
enableTitles: true,
|
||||
}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!
|
||||
const next = () => {
|
||||
const v = argv[++i]
|
||||
if (v === undefined) throw new Error(`${a} requires a value`)
|
||||
return v
|
||||
}
|
||||
switch (a) {
|
||||
case "--out":
|
||||
out.out = next()
|
||||
break
|
||||
case "--total":
|
||||
out.total = Number(next())
|
||||
break
|
||||
case "--turns":
|
||||
out.turns = Number(next())
|
||||
break
|
||||
case "--seed": {
|
||||
const v = next()
|
||||
out.seed = v.startsWith("0x") ? parseInt(v, 16) : Number(v)
|
||||
break
|
||||
}
|
||||
case "--tools":
|
||||
out.tools = new Set(
|
||||
next()
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
break
|
||||
case "--shapes":
|
||||
out.shapes = new Set(
|
||||
next()
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
break
|
||||
case "--weight": {
|
||||
// `tool.apply_patch=5` or `shape.toolCall=3`
|
||||
const value = next()
|
||||
const eq = value.indexOf("=")
|
||||
if (eq < 0) throw new Error(`--weight expects key=number, got ${value}`)
|
||||
const [k, v] = [value.slice(0, eq), Number(value.slice(eq + 1))]
|
||||
if (k.startsWith("tool.")) out.toolWeights[k.slice(5)] = v
|
||||
else if (k.startsWith("shape.")) out.shapeWeights[k.slice(6)] = v
|
||||
else throw new Error(`--weight key must start with tool. or shape., got ${k}`)
|
||||
break
|
||||
}
|
||||
case "--seed-files":
|
||||
out.seedFiles = true
|
||||
break
|
||||
case "--no-seed-files":
|
||||
out.seedFiles = false
|
||||
break
|
||||
case "--enable-titles":
|
||||
out.enableTitles = true
|
||||
break
|
||||
case "--no-enable-titles":
|
||||
out.enableTitles = false
|
||||
break
|
||||
case "--help":
|
||||
case "-h":
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${a}`)
|
||||
}
|
||||
}
|
||||
if (out.turns < 0) out.turns = Math.ceil(out.total / 2)
|
||||
return out
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Simulation script generator.
|
||||
|
||||
Usage:
|
||||
bun generate.ts [options]
|
||||
|
||||
Options:
|
||||
--out <path> Output JSON path (default ./generated.json)
|
||||
--total <n> Total LLM scripts to queue (default 1000)
|
||||
--turns <n> User turns (default ceil(total/2))
|
||||
--seed <n> RNG seed (hex or decimal, default 0x09abcdef)
|
||||
--tools <list> Comma-separated tool kinds to include
|
||||
--shapes <list> Comma-separated step shapes
|
||||
--weight tool.<id>=<n> Override weight for a tool
|
||||
--weight shape.<id>=<n> Override weight for a step shape
|
||||
--seed-files Pre-seed starter files (default on)
|
||||
--no-seed-files Skip pre-seeding files
|
||||
--enable-titles Pad queue for title-gen calls (default on)
|
||||
--no-enable-titles Don't pad for title-gen
|
||||
|
||||
Available tool kinds:
|
||||
apply_patch, edit, write, read, grep, glob, bash, todowrite, webfetch,
|
||||
websearch, lsp, task, task_status, plan, question, skill, repo_clone,
|
||||
repo_overview, invalid
|
||||
|
||||
Available step shapes:
|
||||
text, multiText, thinkText, textToolCall, thinkTextToolCall, multiToolCall,
|
||||
thinkOnly, longNarrative, soloToolCall
|
||||
`)
|
||||
}
|
||||
|
||||
// ─── Seeded RNG (mulberry32) ─────────────────────────────────────────────────
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Filesystem model ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Tracks the file paths that we've materialized so far. `apply_patch`/`edit`/
|
||||
// `read` only target paths in this set. `write` adds to the set. The pre-seed
|
||||
// step populates the initial set so the first random scripts have something to
|
||||
// patch.
|
||||
|
||||
class FsModel {
|
||||
private paths = new Set<string>()
|
||||
private seeded: { path: string; content: string }[] = []
|
||||
|
||||
seed(path: string, content: string) {
|
||||
this.paths.add(path)
|
||||
this.seeded.push({ path, content })
|
||||
}
|
||||
add(path: string) {
|
||||
this.paths.add(path)
|
||||
}
|
||||
has(path: string) {
|
||||
return this.paths.has(path)
|
||||
}
|
||||
all(): string[] {
|
||||
return [...this.paths]
|
||||
}
|
||||
seededWrites() {
|
||||
return this.seeded
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Vocab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const SEED_FILES: { path: string; content: string }[] = [
|
||||
{ path: "src/index.ts", content: "export function main() {\n return 0\n}\n" },
|
||||
{ path: "src/server.ts", content: "export const server = {\n start() {},\n}\n" },
|
||||
{ path: "src/util/log.ts", content: "export function log(msg: string) {\n console.log(msg)\n}\n" },
|
||||
{ path: "src/util/string.ts", content: "export const upper = (s: string) => s.toUpperCase()\n" },
|
||||
{ path: "src/feature/auth.ts", content: "export const auth = { user: null }\n" },
|
||||
{ path: "src/feature/cart.ts", content: "export const cart: string[] = []\n" },
|
||||
{ path: "src/lib/db.ts", content: "export const db = { query() { return null } }\n" },
|
||||
{ path: "src/lib/cache.ts", content: "export const cache = new Map<string, unknown>()\n" },
|
||||
{ path: "src/api/users.ts", content: "export const users = []\n" },
|
||||
{ path: "test/index.test.ts", content: "import { test } from 'bun:test'\ntest('ok', () => {})\n" },
|
||||
{ path: "test/auth.test.ts", content: "import { test } from 'bun:test'\ntest('auth', () => {})\n" },
|
||||
{ path: "README.md", content: "# project\n\nDescription.\n" },
|
||||
{ path: "docs/api.md", content: "# api\n\nDocs.\n" },
|
||||
{ path: "docs/getting-started.md", content: "# getting started\n\nWelcome.\n" },
|
||||
{ path: "package.json", content: '{\n "name": "project",\n "version": "0.1.0"\n}\n' },
|
||||
]
|
||||
|
||||
const NEW_FILE_CANDIDATES = [
|
||||
"src/feature/checkout.tsx",
|
||||
"src/components/Button.tsx",
|
||||
"src/components/Modal.tsx",
|
||||
"src/api/orders.ts",
|
||||
"test/cart.test.ts",
|
||||
"scripts/build.ts",
|
||||
"config/eslint.json",
|
||||
"CHANGELOG.md",
|
||||
"src/lib/format.ts",
|
||||
"src/lib/clock.ts",
|
||||
]
|
||||
|
||||
const PATTERNS = [
|
||||
"TODO",
|
||||
"FIXME",
|
||||
"console\\.log",
|
||||
"function\\s+\\w+",
|
||||
"import .* from",
|
||||
"export const",
|
||||
"throw new Error",
|
||||
"async\\s+function",
|
||||
"class\\s+\\w+",
|
||||
"interface\\s+\\w+",
|
||||
]
|
||||
|
||||
const GLOBS = ["**/*.ts", "**/*.tsx", "src/**/*.ts", "test/**/*.test.ts", "**/*.{ts,tsx}", "**/*.md", "**/*.json"]
|
||||
|
||||
const COMMANDS = [
|
||||
"ls -la",
|
||||
"pwd",
|
||||
"cat README.md",
|
||||
"wc -l src/index.ts",
|
||||
"git status",
|
||||
"git log --oneline -5",
|
||||
"git diff --stat",
|
||||
"bun install",
|
||||
"bun test",
|
||||
"bun run build",
|
||||
"rg TODO",
|
||||
"echo 'hello'",
|
||||
"date",
|
||||
]
|
||||
|
||||
const WEB_URLS = [
|
||||
"https://example.com/api/data",
|
||||
"https://docs.opencode.ai/configuration",
|
||||
"https://github.com/anomalyco/opencode",
|
||||
"https://api.openai.com/v1/models",
|
||||
"https://registry.npmjs.org/effect",
|
||||
"https://nodejs.org/api/fs.html",
|
||||
]
|
||||
|
||||
const SEARCH_QUERIES = [
|
||||
"rust async error handling",
|
||||
"effect-ts schema validation",
|
||||
"react server components",
|
||||
"typescript discriminated unions",
|
||||
"bun sqlite performance",
|
||||
"lsp protocol initialize",
|
||||
"git rebase squash workflow",
|
||||
]
|
||||
|
||||
const SKILLS = ["customize-opencode", "effect", "improve-codebase-architecture", "gmail"]
|
||||
const SUBAGENTS = ["explore", "general"]
|
||||
|
||||
const PLAIN_TEXT = [
|
||||
"Looking at the code now.",
|
||||
"Let me inspect the relevant files.",
|
||||
"I'll start by reading the entrypoint.",
|
||||
"Checking the test suite for related coverage.",
|
||||
"Tracing the call chain through the layer composition.",
|
||||
"This looks like a missing dependency in the layer graph.",
|
||||
"I'll add a small helper to factor out the duplication.",
|
||||
"Renaming the symbol everywhere it's used.",
|
||||
"Bumping the version in package.json.",
|
||||
"Adding a changelog entry.",
|
||||
"Running the formatter.",
|
||||
"Re-running the typecheck.",
|
||||
"All clean — no type errors.",
|
||||
"Tests pass locally.",
|
||||
"Drafting the PR description.",
|
||||
]
|
||||
|
||||
const THINKING = [
|
||||
"Need to figure out which layer is missing the dependency.",
|
||||
"The error stack points at instance-state.ts — likely missing InstanceRef.",
|
||||
"Best to check if the cache is being invalidated correctly.",
|
||||
"Tradeoff: inline vs extract helper. Inline is shorter.",
|
||||
"Race condition seems likely given the await boundary.",
|
||||
"Looking at the diff to spot the regression.",
|
||||
"Need to make sure the test asserts the post-condition.",
|
||||
]
|
||||
|
||||
const FINISH_REASONS: ("stop" | "tool-calls" | "length" | "unknown")[] = [
|
||||
"stop",
|
||||
"tool-calls",
|
||||
"stop",
|
||||
"tool-calls",
|
||||
"stop",
|
||||
"length",
|
||||
"stop",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
const PROMPTS = [
|
||||
"Walk me through the project layout briefly.",
|
||||
"Find any TODOs in the codebase.",
|
||||
"Refactor the small helper in src/util/log.ts.",
|
||||
"Open src/index.ts and explain the entrypoint.",
|
||||
"Search for `console.log` usage.",
|
||||
"Run the test suite.",
|
||||
"Patch the greeting in src/greeting.ts to say Hello.",
|
||||
"Write a small note in docs/getting-started.md.",
|
||||
"Show me recent git activity.",
|
||||
"What's the LSP status?",
|
||||
"Plan a fix for the failing test.",
|
||||
"Summarize the changes so far.",
|
||||
"Look up the npm registry entry for `effect`.",
|
||||
"Search the web for `lsp protocol initialize`.",
|
||||
"Outline the next refactor step.",
|
||||
]
|
||||
|
||||
// ─── Tool generators ─────────────────────────────────────────────────────────
|
||||
|
||||
type ToolCall = {
|
||||
type: "tool-call"
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface Helpers {
|
||||
rand: () => number
|
||||
pick: <T>(items: readonly T[]) => T
|
||||
int: (min: number, max: number) => number
|
||||
maybe: (p: number) => boolean
|
||||
nextToolCallId: () => string
|
||||
fs: FsModel
|
||||
}
|
||||
|
||||
const TOOL_KINDS = [
|
||||
"apply_patch",
|
||||
"edit",
|
||||
"write",
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"bash",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"lsp",
|
||||
"task",
|
||||
"task_status",
|
||||
"plan",
|
||||
"question",
|
||||
"skill",
|
||||
"repo_clone",
|
||||
"repo_overview",
|
||||
"invalid",
|
||||
] as const
|
||||
type ToolKind = (typeof TOOL_KINDS)[number]
|
||||
|
||||
// Tool generators take the helpers + return a ToolCall, or null if the tool
|
||||
// can't be produced right now (e.g. apply_patch needs an existing file).
|
||||
type ToolGen = (h: Helpers) => ToolCall | null
|
||||
|
||||
const TOOL_GENERATORS: Record<ToolKind, ToolGen> = {
|
||||
apply_patch: (h) => {
|
||||
const existing = h.fs.all()
|
||||
if (existing.length === 0) return null
|
||||
const file = `/opencode/${h.pick(existing)}`
|
||||
const before = h.pick(["export const", "return", "function", "import"])
|
||||
const after = h.pick(["export default", "return undefined", "async function", "import type"])
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "apply_patch",
|
||||
input: {
|
||||
patchText: `*** Begin Patch\n*** Update File: ${file}\n@@\n- ${before}\n+ ${after}\n*** End Patch\n`,
|
||||
},
|
||||
}
|
||||
},
|
||||
edit: (h) => {
|
||||
const existing = h.fs.all()
|
||||
if (existing.length === 0) return null
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "edit",
|
||||
input: {
|
||||
filePath: `/opencode/${h.pick(existing)}`,
|
||||
oldString: h.pick(["return null", "// TODO", "const x = 1", "if (true)"]),
|
||||
newString: h.pick(["return undefined", "// fixed", "const x = 2", "if (cond)"]),
|
||||
...(h.maybe(0.2) ? { replaceAll: true } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
write: (h) => {
|
||||
const candidates = NEW_FILE_CANDIDATES.filter((p) => !h.fs.has(p))
|
||||
const file = candidates.length > 0 ? h.pick(candidates) : h.pick(NEW_FILE_CANDIDATES)
|
||||
const content = h.pick([
|
||||
"export const value = 42\n",
|
||||
"// generated\nexport default {}\n",
|
||||
"TODO: fill in\n",
|
||||
'{\n "version": "0.0.1"\n}\n',
|
||||
])
|
||||
// Track the write so future apply_patch/edit can target it.
|
||||
h.fs.add(file)
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "write",
|
||||
input: { filePath: `/opencode/${file}`, content },
|
||||
}
|
||||
},
|
||||
read: (h) => {
|
||||
const existing = h.fs.all()
|
||||
if (existing.length === 0) return null
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "read",
|
||||
input: {
|
||||
filePath: `/opencode/${h.pick(existing)}`,
|
||||
...(h.maybe(0.3) ? { offset: h.int(1, 50) } : {}),
|
||||
...(h.maybe(0.3) ? { limit: h.int(10, 200) } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
grep: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "grep",
|
||||
input: {
|
||||
pattern: h.pick(PATTERNS),
|
||||
...(h.maybe(0.5) ? { path: h.pick(["src", "src/feature", "src/util", "test", "docs"]) } : {}),
|
||||
...(h.maybe(0.5) ? { include: h.pick(["*.ts", "*.tsx", "*.{ts,tsx}", "*.md"]) } : {}),
|
||||
},
|
||||
}),
|
||||
glob: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "glob",
|
||||
input: {
|
||||
pattern: h.pick(GLOBS),
|
||||
...(h.maybe(0.3) ? { path: h.pick(["src", "test", "docs"]) } : {}),
|
||||
},
|
||||
}),
|
||||
bash: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "bash",
|
||||
input: {
|
||||
command: h.pick(COMMANDS),
|
||||
description: h.pick(["List files", "Show status", "Print working dir", "Run tests"]),
|
||||
...(h.maybe(0.2) ? { timeout: h.int(1000, 60000) } : {}),
|
||||
},
|
||||
}),
|
||||
todowrite: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "todowrite",
|
||||
input: {
|
||||
todos: Array.from({ length: h.int(1, 4) }, () => ({
|
||||
content: h.pick([
|
||||
"Investigate failing test",
|
||||
"Refactor layer composition",
|
||||
"Add typecheck step",
|
||||
"Update docs",
|
||||
"Bump dependencies",
|
||||
]),
|
||||
status: h.pick(["pending", "in_progress", "completed", "cancelled"]),
|
||||
priority: h.pick(["high", "medium", "low"]),
|
||||
})),
|
||||
},
|
||||
}),
|
||||
webfetch: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "webfetch",
|
||||
input: {
|
||||
url: h.pick(WEB_URLS),
|
||||
...(h.maybe(0.4) ? { format: h.pick(["markdown", "text", "html"]) } : {}),
|
||||
...(h.maybe(0.2) ? { timeout: h.int(5, 60) } : {}),
|
||||
},
|
||||
}),
|
||||
websearch: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "websearch",
|
||||
input: {
|
||||
query: h.pick(SEARCH_QUERIES),
|
||||
...(h.maybe(0.3) ? { numResults: h.int(1, 10) } : {}),
|
||||
...(h.maybe(0.3) ? { type: h.pick(["auto", "fast", "deep"]) } : {}),
|
||||
},
|
||||
}),
|
||||
lsp: (h) => {
|
||||
const existing = h.fs.all().filter((p) => /\.(ts|tsx|js|jsx)$/.test(p))
|
||||
const file = existing.length > 0 ? `/opencode/${h.pick(existing)}` : "/opencode/src/index.ts"
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "lsp",
|
||||
input: {
|
||||
file,
|
||||
action: h.pick(["definition", "references", "hover", "documentSymbol", "implementation"]),
|
||||
...(h.maybe(0.5) ? { position: { line: h.int(0, 100), character: h.int(0, 80) } } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
task: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "task",
|
||||
input: {
|
||||
description: h.pick(["explore feature", "audit dependency graph", "find usage"]),
|
||||
prompt: h.pick([
|
||||
"Look through src/ and summarize the entry points.",
|
||||
"Find all call sites for `Bus.publish` and explain what they publish.",
|
||||
]),
|
||||
subagent_type: h.pick(SUBAGENTS),
|
||||
},
|
||||
}),
|
||||
task_status: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "task_status",
|
||||
input: { task_id: `task-${h.int(1, 50).toString(36)}` },
|
||||
}),
|
||||
plan: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "plan",
|
||||
input: {
|
||||
summary: h.pick([
|
||||
"Refactor the layer graph to remove cycles",
|
||||
"Add diagnostic logging then propose a fix",
|
||||
"Extract a helper and add tests",
|
||||
]),
|
||||
},
|
||||
}),
|
||||
question: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: h.pick(["Which directory should I scaffold under?", "Approve the rename?"]),
|
||||
header: h.pick(["Pick a directory", "Approve rename"]),
|
||||
options: [
|
||||
{ label: "Yes", description: "Approve" },
|
||||
{ label: "No", description: "Decline" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
skill: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "skill",
|
||||
input: { name: h.pick(SKILLS) },
|
||||
}),
|
||||
repo_clone: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "repo_clone",
|
||||
input: {
|
||||
url: h.pick(["https://github.com/anomalyco/opencode", "https://github.com/effect-ts/effect"]),
|
||||
...(h.maybe(0.4) ? { ref: h.pick(["main", "dev", "v1.0.0"]) } : {}),
|
||||
},
|
||||
}),
|
||||
repo_overview: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "repo_overview",
|
||||
input: {
|
||||
...(h.maybe(0.5) ? { path: h.pick(["src", "test", "docs"]) } : {}),
|
||||
...(h.maybe(0.3) ? { depth: h.int(1, 4) } : {}),
|
||||
},
|
||||
}),
|
||||
invalid: (h) => ({
|
||||
type: "tool-call",
|
||||
toolCallId: h.nextToolCallId(),
|
||||
toolName: "invalid",
|
||||
input: {
|
||||
tool: h.pick(["foo", "bar", "definitely_not_a_tool"]),
|
||||
error: h.pick(["unknown tool", "missing argument"]),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const DEFAULT_TOOL_WEIGHTS: Record<ToolKind, number> = {
|
||||
apply_patch: 4,
|
||||
edit: 3,
|
||||
write: 3,
|
||||
read: 4,
|
||||
grep: 3,
|
||||
glob: 3,
|
||||
bash: 2,
|
||||
todowrite: 2,
|
||||
webfetch: 2,
|
||||
websearch: 2,
|
||||
lsp: 2,
|
||||
task: 2,
|
||||
task_status: 1,
|
||||
plan: 2,
|
||||
question: 1,
|
||||
skill: 1,
|
||||
repo_clone: 1,
|
||||
repo_overview: 1,
|
||||
invalid: 1,
|
||||
}
|
||||
|
||||
// ─── Step shapes ─────────────────────────────────────────────────────────────
|
||||
|
||||
type StepItem =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "thinking"; content: string }
|
||||
| ToolCall
|
||||
|
||||
type ShapeKind =
|
||||
| "text"
|
||||
| "multiText"
|
||||
| "thinkText"
|
||||
| "textToolCall"
|
||||
| "thinkTextToolCall"
|
||||
| "multiToolCall"
|
||||
| "thinkOnly"
|
||||
| "longNarrative"
|
||||
| "soloToolCall"
|
||||
|
||||
interface ShapeContext {
|
||||
h: Helpers
|
||||
pickTool: () => ToolCall | null
|
||||
}
|
||||
|
||||
const SHAPE_BUILDERS: Record<ShapeKind, (ctx: ShapeContext) => StepItem[] | null> = {
|
||||
text: ({ h }) => [{ type: "text", content: h.pick(PLAIN_TEXT) }],
|
||||
multiText: ({ h }) =>
|
||||
Array.from({ length: h.int(2, 5) }, () => ({ type: "text" as const, content: h.pick(PLAIN_TEXT) })),
|
||||
thinkText: ({ h }) => [
|
||||
{ type: "thinking", content: h.pick(THINKING) },
|
||||
{ type: "text", content: h.pick(PLAIN_TEXT) },
|
||||
],
|
||||
textToolCall: ({ h, pickTool }) => {
|
||||
const tc = pickTool()
|
||||
if (!tc) return null
|
||||
return [{ type: "text", content: h.pick(PLAIN_TEXT) }, tc]
|
||||
},
|
||||
thinkTextToolCall: ({ h, pickTool }) => {
|
||||
const tc = pickTool()
|
||||
if (!tc) return null
|
||||
return [
|
||||
{ type: "thinking", content: h.pick(THINKING) },
|
||||
{ type: "text", content: h.pick(PLAIN_TEXT) },
|
||||
tc,
|
||||
]
|
||||
},
|
||||
multiToolCall: ({ h, pickTool }) => {
|
||||
const count = h.int(2, 4)
|
||||
const tcs: ToolCall[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tc = pickTool()
|
||||
if (tc) tcs.push(tc)
|
||||
}
|
||||
if (tcs.length === 0) return null
|
||||
return [{ type: "text", content: h.pick(PLAIN_TEXT) }, ...tcs]
|
||||
},
|
||||
thinkOnly: ({ h }) => [{ type: "thinking", content: h.pick(THINKING) }],
|
||||
longNarrative: ({ h }) =>
|
||||
Array.from({ length: h.int(5, 8) }, () => ({ type: "text" as const, content: h.pick(PLAIN_TEXT) })),
|
||||
soloToolCall: ({ pickTool }) => {
|
||||
const tc = pickTool()
|
||||
if (!tc) return null
|
||||
return [tc]
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_SHAPE_WEIGHTS: Record<ShapeKind, number> = {
|
||||
text: 3,
|
||||
multiText: 2,
|
||||
thinkText: 2,
|
||||
textToolCall: 5,
|
||||
thinkTextToolCall: 3,
|
||||
multiToolCall: 2,
|
||||
thinkOnly: 1,
|
||||
longNarrative: 2,
|
||||
soloToolCall: 2,
|
||||
}
|
||||
|
||||
// ─── Weighted picker ─────────────────────────────────────────────────────────
|
||||
|
||||
function weightedPicker<K extends string>(weights: Record<K, number>, rand: () => number) {
|
||||
const entries = Object.entries(weights) as [K, number][]
|
||||
const positive = entries.filter(([, w]) => w > 0)
|
||||
if (positive.length === 0) throw new Error("All weights are zero")
|
||||
const total = positive.reduce((sum, [, w]) => sum + w, 0)
|
||||
return () => {
|
||||
let r = rand() * total
|
||||
for (const [k, w] of positive) {
|
||||
r -= w
|
||||
if (r <= 0) return k
|
||||
}
|
||||
return positive[positive.length - 1]![0]
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2))
|
||||
|
||||
const rand = mulberry32(opts.seed)
|
||||
const pick = <T,>(items: readonly T[]): T => items[Math.floor(rand() * items.length)]!
|
||||
const int = (min: number, max: number) => min + Math.floor(rand() * (max - min + 1))
|
||||
const maybe = (p: number) => rand() < p
|
||||
|
||||
let toolCallSeq = 0
|
||||
const nextToolCallId = () => `tc-${(++toolCallSeq).toString(36)}`
|
||||
|
||||
const fs = new FsModel()
|
||||
if (opts.seedFiles) for (const f of SEED_FILES) fs.seed(f.path, f.content)
|
||||
|
||||
const helpers: Helpers = { rand, pick, int, maybe, nextToolCallId, fs }
|
||||
|
||||
const enabledTools = (TOOL_KINDS as readonly ToolKind[]).filter(
|
||||
(t) => !opts.tools || opts.tools.has(t),
|
||||
)
|
||||
if (enabledTools.length === 0) throw new Error("--tools filter excluded every tool")
|
||||
const toolWeights = Object.fromEntries(
|
||||
enabledTools.map((t) => [t, opts.toolWeights[t] ?? DEFAULT_TOOL_WEIGHTS[t]]),
|
||||
) as Record<ToolKind, number>
|
||||
const pickToolKind = weightedPicker(toolWeights, rand)
|
||||
|
||||
const enabledShapes = (Object.keys(DEFAULT_SHAPE_WEIGHTS) as ShapeKind[]).filter(
|
||||
(s) => !opts.shapes || opts.shapes.has(s),
|
||||
)
|
||||
if (enabledShapes.length === 0) throw new Error("--shapes filter excluded every shape")
|
||||
const shapeWeights = Object.fromEntries(
|
||||
enabledShapes.map((s) => [s, opts.shapeWeights[s] ?? DEFAULT_SHAPE_WEIGHTS[s]]),
|
||||
) as Record<ShapeKind, number>
|
||||
const pickShapeKind = weightedPicker(shapeWeights, rand)
|
||||
|
||||
// Resolve a tool, retrying up to N times if a generator returns null (e.g.
|
||||
// apply_patch with no existing files). If every retry fails, fall back to
|
||||
// `text` step shape via returning null upstream.
|
||||
const tryPickTool = (): ToolCall | null => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const tc = TOOL_GENERATORS[pickToolKind() as ToolKind](helpers)
|
||||
if (tc) return tc
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type LLMScript = {
|
||||
steps: StepItem[][]
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
|
||||
finish?: "stop" | "tool-calls" | "length" | "unknown"
|
||||
}
|
||||
|
||||
function makeScript(): LLMScript {
|
||||
// Try up to 4 different shapes; if none yields a step (e.g. all
|
||||
// tool-shape variants returned null), fall back to a `text` step.
|
||||
let step: StepItem[] | null = null
|
||||
for (let i = 0; i < 4 && !step; i++) {
|
||||
const shape = pickShapeKind() as ShapeKind
|
||||
step = SHAPE_BUILDERS[shape]({ h: helpers, pickTool: tryPickTool })
|
||||
}
|
||||
if (!step) step = [{ type: "text", content: pick(PLAIN_TEXT) }]
|
||||
const hasToolCall = step.some((item) => item.type === "tool-call")
|
||||
const finish: LLMScript["finish"] = hasToolCall ? "tool-calls" : pick(FINISH_REASONS)
|
||||
const inputTokens = int(20, 600)
|
||||
const outputTokens = int(4, 250)
|
||||
return {
|
||||
steps: [step],
|
||||
usage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
|
||||
finish,
|
||||
}
|
||||
}
|
||||
|
||||
// Title-gen padding: each user turn the small-model gets invoked too. To
|
||||
// keep the user-visible follow-up text from falling back to the default
|
||||
// "Simulation mock response.", pad the queue by `turns` extra short scripts.
|
||||
const titlePadding = opts.enableTitles ? opts.turns : 0
|
||||
const scripts: LLMScript[] = Array.from({ length: opts.total + titlePadding }, makeScript)
|
||||
|
||||
// ─── User actions ──────────────────────────────────────────────────────────
|
||||
|
||||
const userActions = Array.from({ length: opts.turns }, (_, i) => [
|
||||
{ type: "typeText", text: PROMPTS[i % PROMPTS.length]! },
|
||||
{ type: "pressEnter" },
|
||||
{ type: "wait", ms: 60 },
|
||||
]).flat()
|
||||
|
||||
// Seed-file writes go FIRST so the FS exists before user turns run.
|
||||
const seedWrites = fs.seededWrites().map((f) => ({
|
||||
type: "writeFile",
|
||||
path: f.path,
|
||||
content: f.content,
|
||||
}))
|
||||
|
||||
const script = {
|
||||
_comment: `Generated by generate.ts. Seed: 0x${opts.seed.toString(16)}. Total LLM scripts: ${opts.total} (+${titlePadding} title padding = ${scripts.length}). Turns: ${opts.turns}. Tools enabled: ${enabledTools.length}. Pre-seeded files: ${fs.seededWrites().length}.`,
|
||||
actions: [
|
||||
{ type: "pressKey", key: "x", modifiers: { ctrl: true } },
|
||||
{ type: "pressKey", key: "b" },
|
||||
...seedWrites,
|
||||
{ type: "enqueueLLM", scripts },
|
||||
...userActions,
|
||||
],
|
||||
}
|
||||
|
||||
const outPath = path.resolve(opts.out)
|
||||
writeFileSync(outPath, JSON.stringify(script, null, 2) + "\n")
|
||||
|
||||
// ─── Summary ───────────────────────────────────────────────────────────────
|
||||
|
||||
const byFinish: Record<string, number> = {}
|
||||
for (const s of scripts) {
|
||||
const k = s.finish ?? "stop"
|
||||
byFinish[k] = (byFinish[k] ?? 0) + 1
|
||||
}
|
||||
const byTool: Record<string, number> = {}
|
||||
let totalToolCalls = 0
|
||||
for (const s of scripts) {
|
||||
for (const step of s.steps) {
|
||||
for (const item of step) {
|
||||
if (item.type === "tool-call") {
|
||||
byTool[item.toolName] = (byTool[item.toolName] ?? 0) + 1
|
||||
totalToolCalls++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`Wrote ${outPath}`)
|
||||
console.log(`Total scripts: ${scripts.length} (${opts.total} primary + ${titlePadding} title padding)`)
|
||||
console.log(`User turns: ${opts.turns}`)
|
||||
console.log(`Seeded files: ${fs.seededWrites().length}`)
|
||||
console.log(`Finish reasons:`, byFinish)
|
||||
console.log(`Tool calls (${totalToolCalls} total):`, byTool)
|
||||
}
|
||||
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Simulation script runner.
|
||||
*
|
||||
* Drives a simulation script against a running `bun dev simulate` process via
|
||||
* the simulation MCP HTTP endpoint. Loads the script, steps through it in
|
||||
* configurable chunks, and checks the in-memory log buffer after each chunk.
|
||||
* Stops on the first error (configurable level) and prints the failing entries
|
||||
* plus the most recent step that was executed.
|
||||
*
|
||||
* Usage:
|
||||
* bun test/testing/simulation/scripts/run.ts <script.json> [options]
|
||||
*
|
||||
* Options:
|
||||
* --mcp <url> MCP endpoint. Default: http://127.0.0.1:43110/mcp
|
||||
* --chunk <n> Actions per step batch. Default: 3
|
||||
* --max-steps <n> Hard cap on step calls. Default: unlimited.
|
||||
* --level <lvl> Stop on entries at or above this level.
|
||||
* One of DEBUG | INFO | WARN | ERROR. Default: ERROR.
|
||||
* --message-includes <s> Only stop when matching message substring.
|
||||
* --service-includes <s> Only stop when matching tag.service substring.
|
||||
* --reset Reset simulation state + restart TUI before loading.
|
||||
* --no-reset Skip the reset/restart (default).
|
||||
* --keep-going Don't stop on errors; print them and continue.
|
||||
* --quiet Suppress per-batch progress output.
|
||||
* --json Emit a single JSON summary at the end.
|
||||
* --check-every <n> Check logs only every N batches. Default: 1.
|
||||
*
|
||||
* Example:
|
||||
* bun test/testing/simulation/scripts/run.ts patches.json \
|
||||
* --reset --chunk 3 --level ERROR
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
scriptPath: string
|
||||
mcpUrl: string
|
||||
chunk: number
|
||||
maxSteps: number
|
||||
level: "DEBUG" | "INFO" | "WARN" | "ERROR"
|
||||
messageIncludes?: string
|
||||
serviceIncludes?: string
|
||||
reset: boolean
|
||||
keepGoing: boolean
|
||||
quiet: boolean
|
||||
json: boolean
|
||||
checkEvery: number
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
const out: Options = {
|
||||
scriptPath: argv[0]!,
|
||||
mcpUrl: "http://127.0.0.1:43110/mcp",
|
||||
chunk: 3,
|
||||
maxSteps: -1,
|
||||
level: "ERROR",
|
||||
reset: false,
|
||||
keepGoing: false,
|
||||
quiet: false,
|
||||
json: false,
|
||||
checkEvery: 1,
|
||||
}
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i]!
|
||||
const next = () => {
|
||||
const v = argv[++i]
|
||||
if (v === undefined) throw new Error(`${a} requires a value`)
|
||||
return v
|
||||
}
|
||||
switch (a) {
|
||||
case "--mcp":
|
||||
out.mcpUrl = next()
|
||||
break
|
||||
case "--chunk":
|
||||
out.chunk = Number(next())
|
||||
break
|
||||
case "--max-steps":
|
||||
out.maxSteps = Number(next())
|
||||
break
|
||||
case "--level": {
|
||||
const v = next().toUpperCase()
|
||||
if (!["DEBUG", "INFO", "WARN", "ERROR"].includes(v)) {
|
||||
throw new Error(`--level must be DEBUG|INFO|WARN|ERROR, got ${v}`)
|
||||
}
|
||||
out.level = v as Options["level"]
|
||||
break
|
||||
}
|
||||
case "--message-includes":
|
||||
out.messageIncludes = next()
|
||||
break
|
||||
case "--service-includes":
|
||||
out.serviceIncludes = next()
|
||||
break
|
||||
case "--reset":
|
||||
out.reset = true
|
||||
break
|
||||
case "--no-reset":
|
||||
out.reset = false
|
||||
break
|
||||
case "--keep-going":
|
||||
out.keepGoing = true
|
||||
break
|
||||
case "--quiet":
|
||||
out.quiet = true
|
||||
break
|
||||
case "--json":
|
||||
out.json = true
|
||||
break
|
||||
case "--check-every":
|
||||
out.checkEvery = Number(next())
|
||||
break
|
||||
case "--help":
|
||||
case "-h":
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${a}`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Simulation script runner.
|
||||
|
||||
Usage:
|
||||
bun run.ts <script.json> [options]
|
||||
|
||||
Options:
|
||||
--mcp <url> MCP endpoint (default http://127.0.0.1:43110/mcp)
|
||||
--chunk <n> Actions per step batch (default 3)
|
||||
--max-steps <n> Hard cap on step calls (default unlimited)
|
||||
--level <lvl> Stop level: DEBUG|INFO|WARN|ERROR (default ERROR)
|
||||
--message-includes <s> Only stop when message includes substring
|
||||
--service-includes <s> Only stop when tag.service includes substring
|
||||
--reset Reset sim state + restart TUI before load
|
||||
--no-reset Skip reset (default)
|
||||
--keep-going Don't stop on errors; continue to end
|
||||
--quiet Suppress per-batch progress
|
||||
--json Emit JSON summary at the end
|
||||
--check-every <n> Check logs every N batches (default 1)
|
||||
`)
|
||||
}
|
||||
|
||||
// ─── MCP client ──────────────────────────────────────────────────────────────
|
||||
|
||||
let rpcId = 0
|
||||
|
||||
async function mcpCall(mcpUrl: string, name: string, args: Record<string, unknown> = {}) {
|
||||
const response = await fetch(mcpUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: ++rpcId,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: args },
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`)
|
||||
const text = await response.text()
|
||||
// The MCP server may respond with a single JSON line or with SSE-style
|
||||
// chunks. Find the first `{"result"` line either way.
|
||||
const jsonLine = text
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^data:\s*/, ""))
|
||||
.find((line) => line.trim().startsWith('{"result"') || line.trim().startsWith('{"error"'))
|
||||
if (!jsonLine) throw new Error(`${name}: no JSON-RPC response in:\n${text.slice(0, 500)}`)
|
||||
const envelope = JSON.parse(jsonLine)
|
||||
if (envelope.error) {
|
||||
throw new Error(`${name}: ${envelope.error.message ?? JSON.stringify(envelope.error)}`)
|
||||
}
|
||||
const content = envelope.result?.content?.[0]?.text
|
||||
if (typeof content !== "string") {
|
||||
throw new Error(`${name}: unexpected MCP envelope:\n${JSON.stringify(envelope).slice(0, 500)}`)
|
||||
}
|
||||
// Tool result `text` payloads are JSON-encoded.
|
||||
return JSON.parse(content)
|
||||
}
|
||||
|
||||
// ─── Logic ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LogEntry {
|
||||
time: string
|
||||
level: "DEBUG" | "INFO" | "WARN" | "ERROR"
|
||||
tags: Record<string, unknown>
|
||||
message: string
|
||||
}
|
||||
|
||||
async function run(opts: Options) {
|
||||
const log = (msg: string) => {
|
||||
if (!opts.quiet) console.log(msg)
|
||||
}
|
||||
|
||||
if (opts.reset) {
|
||||
log("Resetting simulation state...")
|
||||
await mcpCall(opts.mcpUrl, "simulation_control_reset")
|
||||
log("Restarting TUI...")
|
||||
await mcpCall(opts.mcpUrl, "simulation_restart")
|
||||
}
|
||||
|
||||
log("Clearing log buffer...")
|
||||
await mcpCall(opts.mcpUrl, "simulation_log_clear")
|
||||
|
||||
log(`Loading script: ${opts.scriptPath}`)
|
||||
const loaded = await mcpCall(opts.mcpUrl, "simulation_script_load", {
|
||||
path: opts.scriptPath,
|
||||
replace: true,
|
||||
})
|
||||
log(` id=${loaded.id} total=${loaded.total}`)
|
||||
|
||||
const total = loaded.total as number
|
||||
let batches = 0
|
||||
let cursor = 0
|
||||
let stopReason: "completed" | "max-steps" | "error" = "completed"
|
||||
let stoppingEntries: LogEntry[] | undefined
|
||||
let stoppingExecuted: unknown[] | undefined
|
||||
|
||||
while (cursor < total) {
|
||||
if (opts.maxSteps > 0 && batches >= opts.maxSteps) {
|
||||
stopReason = "max-steps"
|
||||
break
|
||||
}
|
||||
|
||||
const step = await mcpCall(opts.mcpUrl, "simulation_script_step", { steps: opts.chunk })
|
||||
cursor = step.state.cursor as number
|
||||
batches++
|
||||
if (!opts.quiet) {
|
||||
const lastKinds = (step.executed as { type: string }[]).map((a) => a.type).join(",")
|
||||
log(`batch ${batches}: cursor ${cursor}/${total} — ${lastKinds}`)
|
||||
}
|
||||
|
||||
if (batches % opts.checkEvery !== 0 && cursor < total) continue
|
||||
|
||||
const logResp = await mcpCall(opts.mcpUrl, "simulation_log_get", {
|
||||
level: opts.level,
|
||||
...(opts.messageIncludes ? { messageIncludes: opts.messageIncludes } : {}),
|
||||
...(opts.serviceIncludes ? { serviceIncludes: opts.serviceIncludes } : {}),
|
||||
})
|
||||
const entries = (logResp.entries ?? []) as LogEntry[]
|
||||
if (entries.length > 0) {
|
||||
if (opts.keepGoing) {
|
||||
if (!opts.quiet) {
|
||||
console.log(
|
||||
` ⚠ ${entries.length} ${opts.level} entries — continuing (--keep-going)`,
|
||||
)
|
||||
}
|
||||
// Clear so we only see new ones in subsequent batches.
|
||||
await mcpCall(opts.mcpUrl, "simulation_log_clear")
|
||||
} else {
|
||||
stopReason = "error"
|
||||
stoppingEntries = entries
|
||||
stoppingExecuted = step.executed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const status = await mcpCall(opts.mcpUrl, "simulation_script_status")
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
stopReason,
|
||||
batches,
|
||||
cursor,
|
||||
total,
|
||||
status: status.status,
|
||||
stoppingEntries,
|
||||
stoppingExecuted,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log(`Stopped: ${stopReason}`)
|
||||
console.log(`Batches: ${batches}`)
|
||||
console.log(`Cursor: ${cursor}/${total}`)
|
||||
if (stopReason === "error" && stoppingEntries) {
|
||||
console.log()
|
||||
console.log(`${stoppingEntries.length} ${opts.level} entries — failing batch executed:`)
|
||||
console.log(JSON.stringify(stoppingExecuted, null, 2))
|
||||
console.log()
|
||||
console.log(`${opts.level} entries:`)
|
||||
console.log(JSON.stringify(stoppingEntries, null, 2))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await run(parseArgs(process.argv.slice(2)))
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { streamText, tool, jsonSchema } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Provider } from "../../../src/provider/provider"
|
||||
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
|
||||
import { SimulationNetwork } from "../../../src/testing/simulation/network"
|
||||
import { SimulationProvider } from "../../../src/testing/simulation/provider"
|
||||
import { Simulation } from "../../../src/testing/simulation/service"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const fsLayer = SimulationFileSystem.layer({ root: "/opencode" })
|
||||
const networkLayer = SimulationNetwork.layer({ allowLoopback: false })
|
||||
const simulationLayer = Simulation.layer.pipe(Layer.provide(fsLayer), Layer.provide(networkLayer))
|
||||
const providerLayer = SimulationProvider.layer.pipe(Layer.provide(simulationLayer))
|
||||
const it = testEffect(Layer.mergeAll(fsLayer, networkLayer, simulationLayer, providerLayer))
|
||||
|
||||
describe("Simulation", () => {
|
||||
it.effect("seeds files into the simulated filesystem", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* simulation.seedFilesystem({ files: { "opencode.json": "{}" } })).toEqual({
|
||||
files: ["opencode.json"],
|
||||
})
|
||||
expect(yield* fs.readFileString("/opencode/opencode.json")).toBe("{}")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("writes one file into the simulated filesystem", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* simulation.writeFile({ path: "src/generated.txt", content: "generated" })).toEqual({ file: "src/generated.txt" })
|
||||
expect(yield* fs.readFileString("/opencode/src/generated.txt")).toBe("generated")
|
||||
expect((yield* simulation.snapshot()).files).toContain("src/generated.txt")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers network responses through control state", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
expect(
|
||||
yield* simulation.registerNetwork({
|
||||
kind: "json",
|
||||
method: "GET",
|
||||
url: "https://example.com/data",
|
||||
body: { ok: true },
|
||||
}),
|
||||
).toEqual({ registered: "https://example.com/data" })
|
||||
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://example.com/data"))
|
||||
expect(yield* response.json).toEqual({ ok: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("snapshots and resets simulation state", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
yield* simulation.seedFilesystem({ files: { "README.md": "hello" } })
|
||||
yield* simulation.registerNetwork({ kind: "text", url: "https://example.com/page", body: "hello" })
|
||||
|
||||
const snapshot = yield* simulation.snapshot()
|
||||
expect(snapshot.files).toEqual(["README.md"])
|
||||
expect(snapshot.networkRegistrations).toEqual(["* https://example.com/page"])
|
||||
expect(snapshot.network.routes.some((route) => route.matcher === "https://example.com/page")).toBe(true)
|
||||
|
||||
yield* simulation.reset()
|
||||
|
||||
expect((yield* simulation.snapshot()).files).toEqual([])
|
||||
const exit = yield* http.execute(HttpClientRequest.get("https://example.com/page")).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("queues and consumes LLM scripts", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
|
||||
expect(
|
||||
yield* simulation.enqueueLLM({
|
||||
scripts: [{ steps: [[{ type: "text", content: "hello" }]], finish: "stop" }],
|
||||
}),
|
||||
).toEqual({ queued: 1 })
|
||||
expect((yield* simulation.snapshot()).llmQueued).toBe(1)
|
||||
expect(yield* simulation.nextLLM()).toEqual({ steps: [[{ type: "text", content: "hello" }]], finish: "stop" })
|
||||
expect((yield* simulation.snapshot()).llmConsumed).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("simulation provider consumes queued text scripts", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
|
||||
yield* simulation.enqueueLLM({ scripts: [{ steps: [[{ type: "text", content: "assistant text" }]] }] })
|
||||
|
||||
const result = yield* Effect.promise(() => language.doGenerate({ prompt: [], abortSignal: undefined }))
|
||||
expect(result.content).toEqual([{ type: "text", text: "assistant text" }])
|
||||
expect((yield* simulation.snapshot()).llmConsumed).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("simulation provider returns a default response when no script is queued", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
|
||||
const result = yield* Effect.promise(() => language.doGenerate({ prompt: [], abortSignal: undefined }))
|
||||
expect(result.content).toEqual([{ type: "text", text: "Simulation mock response." }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("simulation provider streams a default response when no script is queued", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
|
||||
const result = yield* Effect.promise(() => language.doStream({ prompt: [], abortSignal: undefined }))
|
||||
const reader = result.stream.getReader()
|
||||
const parts: unknown[] = []
|
||||
while (true) {
|
||||
const next = yield* Effect.promise(() => reader.read())
|
||||
if (next.done) break
|
||||
parts.push(next.value)
|
||||
}
|
||||
|
||||
expect(parts).toContainEqual({ type: "text-delta", id: "simulation-text-1", delta: "Simulation mock response." })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("simulation provider streams queued script actions", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
|
||||
yield* simulation.enqueueLLM({
|
||||
scripts: [
|
||||
{
|
||||
steps: [
|
||||
[
|
||||
{ type: "thinking", content: "thinking" },
|
||||
{ type: "text", content: "answer" },
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = yield* Effect.promise(() => language.doStream({ prompt: [], abortSignal: undefined }))
|
||||
const reader = result.stream.getReader()
|
||||
const parts: unknown[] = []
|
||||
while (true) {
|
||||
const next = yield* Effect.promise(() => reader.read())
|
||||
if (next.done) break
|
||||
parts.push(next.value)
|
||||
}
|
||||
|
||||
expect(parts).toEqual([
|
||||
{ type: "stream-start", warnings: [] },
|
||||
{ type: "reasoning-start", id: "simulation-thinking-1" },
|
||||
{ type: "reasoning-delta", id: "simulation-thinking-1", delta: "thinking" },
|
||||
{ type: "reasoning-end", id: "simulation-thinking-1" },
|
||||
{ type: "text-start", id: "simulation-text-2" },
|
||||
{ type: "text-delta", id: "simulation-text-2", delta: "answer" },
|
||||
{ type: "text-end", id: "simulation-text-2" },
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: { unified: "stop", raw: undefined },
|
||||
usage: {
|
||||
inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: "thinkinganswer".length, text: "thinkinganswer".length, reasoning: undefined },
|
||||
raw: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect((yield* simulation.snapshot()).llmConsumed).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("simulation provider streams queued tool-call actions", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
|
||||
yield* simulation.enqueueLLM({
|
||||
scripts: [
|
||||
{
|
||||
steps: [
|
||||
[
|
||||
{ type: "text", content: "I'll write that file for you" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "write",
|
||||
input: { filePath: "/opencode/hello.txt", content: "hi" },
|
||||
},
|
||||
],
|
||||
],
|
||||
finish: "tool-calls",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = yield* Effect.promise(() => language.doStream({ prompt: [], abortSignal: undefined }))
|
||||
const reader = result.stream.getReader()
|
||||
const parts: unknown[] = []
|
||||
while (true) {
|
||||
const next = yield* Effect.promise(() => reader.read())
|
||||
if (next.done) break
|
||||
parts.push(next.value)
|
||||
}
|
||||
|
||||
const expectedInput = JSON.stringify({ filePath: "/opencode/hello.txt", content: "hi" })
|
||||
expect(parts).toContainEqual({ type: "tool-input-start", id: "call-1", toolName: "write" })
|
||||
expect(parts).toContainEqual({ type: "tool-input-delta", id: "call-1", delta: expectedInput })
|
||||
expect(parts).toContainEqual({ type: "tool-input-end", id: "call-1" })
|
||||
expect(parts).toContainEqual({ type: "tool-call", toolCallId: "call-1", toolName: "write", input: expectedInput })
|
||||
const finish = parts.find((p: any) => p?.type === "finish") as any
|
||||
expect(finish?.finishReason).toEqual({ unified: "tool-calls", raw: "tool-calls" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("simulation provider doGenerate returns tool-call content", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
|
||||
yield* simulation.enqueueLLM({
|
||||
scripts: [
|
||||
{
|
||||
steps: [
|
||||
[
|
||||
{ type: "text", content: "writing now" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-9",
|
||||
toolName: "write",
|
||||
input: { filePath: "/opencode/a.txt", content: "x" },
|
||||
},
|
||||
],
|
||||
],
|
||||
finish: "tool-calls",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = yield* Effect.promise(() => language.doGenerate({ prompt: [], abortSignal: undefined }))
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "writing now" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-9",
|
||||
toolName: "write",
|
||||
input: JSON.stringify({ filePath: "/opencode/a.txt", content: "x" }),
|
||||
},
|
||||
])
|
||||
expect(result.finishReason).toEqual({ unified: "tool-calls", raw: "tool-calls" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("simulation model advertises toolcall capability", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||
expect(model.capabilities.toolcall).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("AI SDK streamText invokes tool.execute when the simulated provider emits a tool-call", () =>
|
||||
Effect.gen(function* () {
|
||||
const simulation = yield* Simulation.Service
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.defaultModel().pipe(Effect.flatMap((item) => provider.getModel(item.providerID, item.modelID)))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
|
||||
yield* simulation.enqueueLLM({
|
||||
scripts: [
|
||||
{
|
||||
steps: [
|
||||
[
|
||||
{ type: "text", content: "writing now" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-execute-1",
|
||||
toolName: "write",
|
||||
input: { filePath: "/opencode/from-tool.txt", content: "hello from tool" },
|
||||
},
|
||||
],
|
||||
],
|
||||
finish: "tool-calls",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const executed: Array<{ filePath: string; content: string }> = []
|
||||
const result = streamText({
|
||||
model: language,
|
||||
prompt: "please write the file",
|
||||
tools: {
|
||||
write: tool({
|
||||
description: "Write a file",
|
||||
inputSchema: jsonSchema<{ filePath: string; content: string }>({
|
||||
type: "object",
|
||||
properties: { filePath: { type: "string" }, content: { type: "string" } },
|
||||
required: ["filePath", "content"],
|
||||
}),
|
||||
execute(args) {
|
||||
executed.push(args)
|
||||
return Promise.resolve({ ok: true, path: args.filePath })
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// Drain the stream so streamText runs to completion.
|
||||
yield* Effect.promise(async () => {
|
||||
for await (const _ of result.fullStream) void _
|
||||
})
|
||||
|
||||
expect(executed).toEqual([{ filePath: "/opencode/from-tool.txt", content: "hello from tool" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { InMemoryFs } from "just-bash"
|
||||
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
|
||||
import { SimulationSpawner } from "../../../src/testing/simulation/spawner"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const root = "/opencode"
|
||||
const fs = new InMemoryFs()
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(SimulationFileSystem.layer({ root, fs }), SimulationSpawner.layer({ root, fs })),
|
||||
)
|
||||
|
||||
describe("SimulationSpawner", () => {
|
||||
it.effect("runs shell commands through just-bash", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make("printf 'hello' > file.txt && cat file.txt", [], { cwd: root, shell: "/bin/bash" }),
|
||||
).pipe(Effect.scoped)
|
||||
|
||||
expect(yield* Stream.mkString(Stream.decodeText(handle.stdout))).toBe("hello")
|
||||
expect(Number(yield* handle.exitCode)).toBe(0)
|
||||
expect(yield* Effect.promise(() => fs.readFile("/opencode/file.txt"))).toBe("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("extracts shell command text from -lc", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const handle = yield* spawner.spawn(ChildProcess.make("bash", ["-lc", "printf ok"], { cwd: root })).pipe(Effect.scoped)
|
||||
|
||||
expect(yield* Stream.mkString(Stream.decodeText(handle.stdout))).toBe("ok")
|
||||
expect(Number(yield* handle.exitCode)).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fakes git discovery commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const [common, topLevel, revision] = yield* Effect.all(
|
||||
[
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-parse", "--git-common-dir"], { cwd: root })).pipe(Effect.scoped),
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-parse", "--show-toplevel"], { cwd: root })).pipe(Effect.scoped),
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-list", "--max-parents=0", "HEAD"], { cwd: root })).pipe(Effect.scoped),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
|
||||
expect(yield* Stream.mkString(Stream.decodeText(common.stdout))).toBe(".git\n")
|
||||
expect(yield* Stream.mkString(Stream.decodeText(topLevel.stdout))).toBe("/opencode\n")
|
||||
expect(yield* Stream.mkString(Stream.decodeText(revision.stdout))).toBe("0000000000000000000000000000000000000000\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported non-shell commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const exit = yield* spawner.spawn(ChildProcess.make("git", ["status"], { cwd: root })).pipe(Effect.scoped, Effect.exit)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects shell commands outside the simulation root", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const exit = yield* spawner.spawn(ChildProcess.make("printf blocked", [], { cwd: "/tmp", shell: "/bin/bash" })).pipe(
|
||||
Effect.scoped,
|
||||
Effect.exit,
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
# Diff Viewer
|
||||
|
||||
## Goal
|
||||
|
||||
Add a full-screen TUI diff viewer opened by `/diff` and by a keybinding. The first version should be intentionally small: open the surface, fetch a raw VCS patch, parse it with `@pierre/diffs`, render a basic readable diff, and keep the architecture ready for tree navigation, split/unified layout, and larger patches.
|
||||
|
||||
## Reference Insights
|
||||
|
||||
The useful lesson from `hunk` is not its full architecture, but its separation between data, render rows, geometry, and navigation. It normalizes patches into file models, derives stable hunk and row cursors, caches expensive measurements, and preserves viewport position through stable anchors when layout changes.
|
||||
|
||||
The useful lesson from `ghui` is that a TUI diff viewer should treat scroll math as a first-class data problem. It precomputes per-file stacked offsets, uses binary search for sticky headers, keeps selected anchors visible, and avoids quadratic whitespace or wrapping work by capping expensive algorithms.
|
||||
|
||||
The useful lesson from `@pierre/diffs` is that we should not parse Git patches ourselves. It already provides `parsePatchFiles`, `parseDiffFromFile`, `FileDiffMetadata`, hunk metadata, line counts for unified and split views, cache keys, syntax highlighting helpers, and windowed iteration utilities like `iterateOverDiff`.
|
||||
|
||||
## Existing opencode Shape
|
||||
|
||||
The backend already has most of the raw data path:
|
||||
|
||||
- `Vcs.diffRaw()` returns current uncommitted changes as a raw patch from `Git.patchAll()` plus untracked files.
|
||||
- `GET /vcs/diff/raw` exposes that as `text/x-diff`.
|
||||
- `GET /vcs/diff?mode=git|branch` returns structured `Vcs.FileDiff[]`, but for the viewer we should start with the raw patch because it maps directly to `@pierre/diffs` parsing.
|
||||
- Existing inline tool diffs use the OpenTUI `<diff>` renderable for edit and patch tool output.
|
||||
- Slash commands are keymap commands with `slashName`, registered through `useBindings()` and surfaced by the command palette.
|
||||
- Keybindings are declared in `src/cli/cmd/tui/config/keybind.ts`, then mapped to command names through `CommandMap`.
|
||||
|
||||
## Proposed Minimal Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
Start with the existing endpoint:
|
||||
|
||||
- `sdk.client.instance.vcsDiffRaw()` or the generated equivalent for `GET /vcs/diff/raw`.
|
||||
- Keep `Vcs.diffRaw()` unchanged at first.
|
||||
- Later add query support for `mode=git|branch`, staged-only, pathspecs, context lines, and max bytes only when the UI needs them.
|
||||
|
||||
This keeps the first pass limited to uncommitted working-tree diffs and avoids introducing a new service before there is a concrete need.
|
||||
|
||||
### Frontend State
|
||||
|
||||
Add a small route-local state holder for the viewer, likely in a new `routes/diff` area or a top-level overlay component:
|
||||
|
||||
```ts
|
||||
type DiffViewerState = {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
error?: string
|
||||
raw: string
|
||||
files: DiffFileModel[]
|
||||
layout: "split" | "unified"
|
||||
selectedFile: number
|
||||
selectedHunk: number
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
type DiffFileModel = {
|
||||
id: string
|
||||
path: string
|
||||
previousPath?: string
|
||||
patch: string
|
||||
metadata: FileDiffMetadata
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
```
|
||||
|
||||
Do not add global persistence or cross-session sharing in the first version. If the viewer can be reopened in the same TUI process with fresh state, that is enough.
|
||||
|
||||
### Parsing
|
||||
|
||||
Create a tiny adapter around `@pierre/diffs`:
|
||||
|
||||
```ts
|
||||
parsePatchFiles(raw, `vcs:${hash}`)
|
||||
.flatMap((patch) => patch.files)
|
||||
.map((metadata, index) => buildDiffFileModel(metadata, rawChunk, index))
|
||||
```
|
||||
|
||||
The adapter should own only opencode-specific concerns:
|
||||
|
||||
- normalize display paths from `a/` and `b/` prefixes if the parsed metadata keeps them.
|
||||
- compute additions and deletions from `metadata.hunks[*].hunkContent`.
|
||||
- preserve each file patch chunk so the current OpenTUI `<diff>` renderable can render the first basic version.
|
||||
- assign stable IDs from path plus index, not from array position alone.
|
||||
|
||||
Avoid building a custom parser, whitespace minimizer, or line-level diff engine initially.
|
||||
|
||||
### Rendering V1
|
||||
|
||||
Use the existing OpenTUI `<diff>` renderable for the first visible version. It already supports unified/split, line numbers, wrapping, colors, and syntax style.
|
||||
|
||||
The full-screen surface should contain:
|
||||
|
||||
- header: `Diff`, file count, additions/deletions, current layout, refresh hint.
|
||||
- left pane: flat file list first, nested tree later.
|
||||
- right pane: stacked file sections, each with a file header and `<diff>` body.
|
||||
- footer: core key hints.
|
||||
|
||||
The nested tree is important, but the first implementation can render a flat file list with indentation-ready data. Build the tree data model in the adapter only after the flat list works.
|
||||
|
||||
### Navigation
|
||||
|
||||
Start with simple indexes:
|
||||
|
||||
- `j`/`down`: next hunk or next file when at the final hunk.
|
||||
- `k`/`up`: previous hunk or previous file.
|
||||
- `J`: next file.
|
||||
- `K`: previous file.
|
||||
- `tab`: toggle focus between file list and diff pane.
|
||||
- `s`: toggle split/unified.
|
||||
- `r`: refresh raw patch.
|
||||
- `escape`/`q`: close.
|
||||
|
||||
The first version can scroll selected files into view. Hunk-perfect scrolling can come after a row geometry layer exists.
|
||||
|
||||
### Geometry Layer
|
||||
|
||||
Add this only after V1 rendering works.
|
||||
|
||||
```ts
|
||||
type DiffSectionGeometry = {
|
||||
fileId: string
|
||||
top: number
|
||||
headerHeight: number
|
||||
bodyHeight: number
|
||||
bottom: number
|
||||
hunkAnchors: readonly { hunkIndex: number; top: number; height: number }[]
|
||||
}
|
||||
```
|
||||
|
||||
Use `metadata.unifiedLineCount`, `metadata.splitLineCount`, and the current `<diff>` measured height where available. Initially estimate body height from `layout === "split" ? metadata.splitLineCount : metadata.unifiedLineCount`. Refine later for wrapping and custom row rendering.
|
||||
|
||||
This layer enables:
|
||||
|
||||
- sticky file headers.
|
||||
- fast file lookup by scroll offset with binary search.
|
||||
- jump-to-hunk without scanning DOM/renderables.
|
||||
- preserving viewport anchors when toggling layout.
|
||||
|
||||
### Performance Rules
|
||||
|
||||
- Parse raw patch once per fetched patch string.
|
||||
- Do not hold both many transformed row formats and raw patches until a performance measurement justifies it.
|
||||
- Cache by patch hash plus layout plus width for derived geometry.
|
||||
- Prefer windowing before custom rendering large diffs.
|
||||
- Do not syntax-highlight lines outside the visible window if we move away from OpenTUI `<diff>` into custom row rendering.
|
||||
- Cap raw patch response size before attempting to render huge diffs; show skipped files rather than blocking the TUI.
|
||||
- Keep tree building linear in path count.
|
||||
- Keep navigation data as arrays and maps, not recursive lookup at keypress time.
|
||||
|
||||
## Future Rendering Direction
|
||||
|
||||
The existing `<diff>` renderable is the pragmatic starting point. If we hit limitations for tree-aware navigation, sticky headers, inline annotations, or partial rendering, move to a custom terminal row renderer backed by `@pierre/diffs` metadata and utilities:
|
||||
|
||||
- use `iterateOverDiff` to produce only visible rows.
|
||||
- use `renderDiffWithHighlighter` or lower-level highlighting utilities for styled spans.
|
||||
- maintain stable row anchors for layout toggles.
|
||||
- virtualize by file section and visible row window.
|
||||
|
||||
Do not start here. The project should earn this complexity through measured needs.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Add a `diff.open` keymap command with `slashName: "diff"` that opens a full-screen placeholder.
|
||||
- [x] Add a default keybinding for `diff.open` after choosing the key sequence.
|
||||
- [x] Build the full-screen shell with header, left pane placeholder, right pane placeholder, and footer hints.
|
||||
- [ ] Fetch raw patch data from `GET /vcs/diff/raw` when the viewer opens.
|
||||
- [ ] Show loading, empty, and error states.
|
||||
- [ ] Add a small `parseDiffPatch(raw)` adapter using `@pierre/diffs.parsePatchFiles`.
|
||||
- [ ] Split the raw patch into per-file chunks or derive enough patch text for `<diff>` rendering.
|
||||
- [ ] Render stacked file sections with the existing OpenTUI `<diff>` renderable.
|
||||
- [ ] Add split/unified layout toggle using existing `diff_style` behavior as guidance, but keep viewer-local state.
|
||||
- [ ] Add basic file navigation and selected-file highlighting.
|
||||
- [ ] Add basic hunk navigation based on `FileDiffMetadata.hunks`.
|
||||
- [ ] Add refresh and close commands.
|
||||
- [ ] Add tests for the patch parser adapter with added, deleted, modified, renamed, binary, and untracked file patches.
|
||||
- [ ] Add simulation coverage that `/diff` opens the full-screen viewer and renders an empty state.
|
||||
- [ ] Add simulation coverage for a generated patch rendering at least one file and hunk.
|
||||
- [ ] Convert the flat file list into a nested tree model.
|
||||
- [ ] Add tree expand/collapse state and keyboard navigation.
|
||||
- [ ] Add section geometry estimates from `metadata.unifiedLineCount` and `metadata.splitLineCount`.
|
||||
- [ ] Preserve scroll anchor when toggling split/unified.
|
||||
- [ ] Add sticky file header in the diff pane.
|
||||
- [ ] Add patch byte limits and skipped-file UI for huge diffs.
|
||||
- [ ] Profile a large patch before introducing custom row virtualization.
|
||||
- [ ] If needed, replace `<diff>` bodies with a windowed custom renderer using `@pierre/diffs.iterateOverDiff`.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
- Default keybinding for opening the viewer.
|
||||
- Whether `/diff` should default to uncommitted changes only or allow immediate mode selection for branch diff.
|
||||
- Whether branch diff should use `Vcs.diffRaw(mode=branch)` or a separate raw patch endpoint when we add it.
|
||||
- Whether staged-only diffs are required in the first useful release.
|
||||
- Whether tree selection should follow scroll position immediately or only after explicit file navigation.
|
||||
Reference in New Issue
Block a user