In-App Coding Agent
Archived spec & plan — status: partially shipped (audited 2026-07-03).
Status: Partially shipped — verified against the codebase on 2026-07-03 by an automated audit.
The original spec/plan for "In-App Coding Agent" (Phase 1 Coding Runner) was superseded by ADR-019 (Unified Agent Engine) on 2026-06-27. The unified agent engine foundation was implemented:
@oxagen/agent-enginepackage shipped with the full CLI agent pipeline (evaluate/enhance/route/execute/judge/revise), workspace abstraction, and tools. However, the user-facing "in-app coding agent" features remained incomplete: noagent.coding.session.*contracts, nocoding_sessionsdatabase schema, no Inngest orchestration for persistent sandbox workspaces with connected-repo editing, and no API/MCP/CLI surfaces or in-app UI integration for triggering and monitoring cloud coding sessions.
Implementation evidence
- /Users/macanderson/Workspaces/oxagen-platform/packages/agent-engine/src/index.ts — agent-engine package exports full pipeline, workspace, tools, router, evaluator, fleet orchestrator
- /Users/macanderson/Workspaces/oxagen-platform/docs/adr/ADR-019-unified-agent-engine.md — ADR accepted 2026-06-27 superseding narrow plan scope
- /Users/macanderson/Workspaces/oxagen-platform/docs/superpowers/plans/2026-06-27-in-app-coding-agent-phase1.md — plan header states SUPERSEDED by unified-agent-engine-plan and ADR-019
Known gaps at time of archive
- agent.coding.session.start / .get / .cancel contracts in packages/oxagen/src/contracts/
- coding_sessions and coding_session_events schema in packages/database/src/schema/
- Atlas migrations for persistent coding sessions in packages/database/atlas/migrations/
- Inngest functions for agent.coding-session.execute orchestration in packages/inngest-functions/
- API routes apps/api/src/routes/v1/agent.coding.ts for session start/get/cancel
- MCP tools apps/mcp/src/tools/agent.coding.session.*.ts
- CLI command apps/cli/src/commands/coding.ts (oxagen code) for invoking cloud runner
- In-app UI surfaces (diff viewer, session progress tray, @repo mention composer)
- SandboxWorkspace backend in @oxagen/coding-agent or agent-engine (sandbox.ts)
- docs/capabilities/ documentation for agent.coding.session.* capabilities
Source documents (archived verbatim below)
docs/superpowers/specs/2026-06-26-agentic-coding-in-app-agent-design.mddocs/superpowers/plans/2026-06-27-in-app-coding-agent-phase1.md
Spec — 2026-06-26-agentic-coding-in-app-agent-design.md
Design: Agentic coding in the in-app agent
Date: 2026-06-26
Branch: feat/agentic-coding-runner
Status: Approved design — implementation plan to follow.
Goal
Let the in-app agent modify code in GitHub repos that are connected as knowledge
sources: a user @-mentions a connected repo, describes a change, and the system
checks out the repo in a cloud sandbox, makes the edits with a Claude-Code-style
agentic loop, runs builds/tests, pushes a branch, opens a pull request, and then
monitors CI and autonomously fixes failures until green (capped).
Decisions (locked)
| Decision | Choice | Consequence |
|---|---|---|
| Execution runtime | Persistent Vercel Sandbox | Clone-once, many edit/build/test cycles in a Firecracker microVM; Vercel-native, ejectable later. Extends the existing 3-driver packages/sandbox abstraction. |
| Git identity | Acting user's OAuth token | Branches/commits/PRs authored as the human. Requires upgrading the GitHub OAuth scope to include write (repo or fine-grained contents:write + pull_requests:write) — Phase 2 only. |
| Autonomy posture | Fully autonomous | Push + ready (non-draft) PR + CI auto-fix loop run with no human gate, bounded by hard caps (iterations, wall-clock, files-changed, credit budget). |
| First slice | Coding runner first | Phase 1 produces a diff + summary only; GitHub writes (Phase 2) and CI loop (Phase 3) layer on top. |
| CLI convergence | One shared engine | Extract the CLI's existing coding loop into @oxagen/coding-agent; both CLI and cloud runner consume it. |
| Phase-1 streaming | Poll-based background-task tray, live SSE tail as fast follow | Ships faster on existing infra; richer live view is additive. |
Current-state findings (what already exists)
- Repo connection: GitHub repos connect via OAuth (user-to-server token), ingested
by Inngest into Neo4j as
:SourceFile/:SourceSymbol+ embeddings. Full file blobs are not stored — content is fetched on-demand by SHA.repo.*contracts (configure/sync/pause/resume/metrics) are read/sync only.- Connector:
packages/ingestion/src/connectors/github/index.ts - OAuth tokens (KMS-encrypted):
ingestion.oauth_accountsinpackages/database/src/schema/ingestion.ts - Connections:
ingestion.source_connections(statusconnected,deliveryConfighasinstallationId/owner/repo/defaultBranch) - Webhook:
POST /webhooks/github/app—apps/api/src/routes/v1/github-webhook.ts(today excludescheck_run/workflow_run)
- Connector:
- In-app agent:
apps/api/src/routes/v1/chat.stream.ts→streamAgentReply()(packages/ai/src/stream.ts) → AI SDKstreamTextwithmaterializeTools()(packages/agent/src/runtime/materialize-tools.ts). Multi-step tool loop already works. Client consumer:apps/app/src/components/chat/use-tool-stream.ts. - Sandbox:
agent.code.executewired with a 3-driver abstraction (packages/sandbox/src/{vercel,modal,docker}.ts), but single-shot & stateless — not a persistent repo workspace. - Background jobs: Inngest
agent.background-task.execute(packages/inngest-functions/src/functions/agent.background-task.execute.ts) with abackgroundTasksstatus row, cancel viacancelOn, and a polling tray UI (apps/app/src/components/chat/background-task-tray.tsx, 2–30s backoff). - Secrets: org-scoped KMS-encrypted credentials in Postgres, retrievable server-side
(
packages/plugins/src/credentials/,AUTH_TOKEN_ENCRYPTION_KEY). - CLI: a real FS-coupled coding loop (
apps/cli/src/agent/loop.ts,tools.ts:read_file/write_file/edit_file/list_dir/glob/grep/code_graph/bash,stopWhen: stepCountIs(32)) — coupled to Nodefs+ Ink, not reusable server-side as-is.
Gaps (what we build)
- Zero GitHub write capability — no Octokit anywhere; no branch/commit/PR creation, no CI/check-run reads, OAuth scopes are read-oriented.
- No persistent coding workspace — sandbox can't clone-once-then-iterate.
- No CI-feedback loop — nothing watches check runs and feeds failures back.
- No
@-mention UX — composer is a plain textarea.
Phasing
| Phase | Deliverable | Autonomy |
|---|---|---|
| 1 — Coding runner | Persistent sandbox + shared coding engine → diff + summary against a connected repo, streamed into chat as a background task. No GitHub writes. | n/a |
| 2 — VCS write layer | Octokit wrapper + OAuth scope upgrade → push branch (user token), open ready PR. | push + PR autonomous |
| 3 — CI auto-fix loop | Watch check runs (webhook + Octokit) → feed failures to the runner → fix & re-push, capped at N. | full loop, no gate |
Cross-cutting — @-mention UX | Composer @repo autocomplete → structured mention resolving to a connectionId. | — |
Phase 1 — the coding runner (detailed)
@oxagen/coding-agent (new package)
The engine, filesystem-agnostic so CLI and cloud share it.
Workspaceinterface:readFile/writeFile/editFile/list/glob/grep/exec/diff. Backends:LocalWorkspace— Nodefs+child_process; consumed byapps/cli(refactored).SandboxWorkspace— backed by the persistent Vercel Sandbox.MemoryWorkspace— in-memory, for deterministic unit tests.
runCodingAgent({ workspace, instruction, model, repoContext, caps }): a tool-usingstreamTextloop via@oxagen/ai(so metering/telemetry/credit-charging come for free). Tools are bound to theWorkspace. Thecode_graphtool queries the existing Neo4j:SourceFile/:SourceSymbolingestion for symbol-level repo awareness without re-indexing. Emits a typed event stream:text,reasoning,tool-call,file-edit,command-output,final-diff. Terminates onstopWhen(step cap) or explicit completion.- Caps:
maxSteps,maxWallClockMs,maxFilesChanged, credit/token budget.
packages/sandbox (extended)
Add a persistent createWorkspace() (clone-once, many exec) alongside the existing
single-shot run(). Vercel Sandbox supports multi-command sessions, so this is additive
and does not change agent.code.execute behavior.
Contracts (full parity: contract → API → MCP → CLI → docs)
agent.coding.session.start—{ connectionId, instruction, baseBranch? }→{ sessionId }. Async.agent.coding.session.get—{ sessionId }→ status + event list +diff+changedFiles+ summary.agent.coding.session.cancel—{ sessionId }→ cancels via InngestcancelOn.
Surfaces: agent, api, mcp, cli. Docs in docs/capabilities/.
Orchestration
- Chat agent (or API/MCP/CLI) invokes
agent.coding.session.start. - Handler (
packages/handlers) inserts acoding_sessionsrow (org+workspace scoped, RLS) and emits an Inngest event. Thin — orchestration lives in Inngest. - Inngest fn (
packages/inngest-functions):- Resolve repo ref from
source_connections; fetch + KMS-decrypt the user OAuth token. - Provision a persistent Vercel Sandbox;
git clone(shallow, scoped token in remote URL). - Run
runCodingAgentwith aSandboxWorkspace; persist each emitted event tocoding_session_events. git diff→ persistdiff+changedFiles+ summary; mark row terminal; tear down sandbox.- Each step that touches
scopedSession/withTenantDbis wrapped inrunInTenantScope(Inngest step-scope gotcha — ALS does not cross step boundaries).
- Resolve repo ref from
- In-app surface: the chat stream emits a
background-taskevent; the existing tray pollsagent.coding.session.getand renders progress + the final diff. (SSE live token-tail = fast follow.)
Database
coding_sessions—publicId,orgId,workspaceId,connectionId,instruction,baseBranch,status(pending|running|completed|failed|cancelled),inngestRunId,diff,changedFiles(jsonb),summary,failureReason, timestamps.coding_session_events— append-only event log (kind, payload jsonb, seq, ts).- RLS policies +
oxagen_appgrants; migration inpackages/database/migrations/.
CLI refactor
Refactor apps/cli/src/agent/ to consume @oxagen/coding-agent via LocalWorkspace,
deleting the now-duplicated loop/tool code. One engine, two filesystems. Preserves CLI
behavior (verified by the existing/added CLI agent tests).
Phase 2 — VCS write layer (sketch)
- New
@oxagen/github(Octokit wrapper): branch create, push (git from sandbox using the user token in the remote), open PR, read PR status. - OAuth scope upgrade: incremental re-consent on the connection to obtain write scope; store upgraded token. Guard: never push to the default branch — always a new branch + PR.
- Contracts:
repo.pr.open,repo.pr.get(parity). IAM-gated by a repo-write role.
Phase 3 — CI auto-fix loop (sketch)
- Extend
POST /webhooks/github/appto routecheck_run/workflow_runevents to the active session (currently excluded by the connector). - Fallback: poll Octokit checks API.
- On failure: resume/re-provision the sandbox, feed failing logs to
runCodingAgent, fix, re-push. Cap iterations (default 3). On green or cap, finalize. Fully autonomous — no gate.
@-mention UX (cross-cutting)
Enhance apps/app/src/components/chat/message-composer.tsx: typing @ opens an autocomplete
of connected repos (and later files). Selection inserts a structured mention token resolving
to a connectionId, passed as repo context to the agent. Uses @oxagen/ui (coss/Base UI).
Component boundaries
| Unit | Responsibility | Depends on |
|---|---|---|
@oxagen/coding-agent | Engine + Workspace abstraction + tools | @oxagen/ai, Neo4j (code_graph) |
packages/sandbox (ext.) | Persistent Vercel Sandbox workspace | @vercel/sandbox |
packages/handlers | agent.coding.session.* handlers (thin) | DB, Inngest |
packages/inngest-functions | Long-running session executor | sandbox, coding-agent, DB |
packages/database | coding_sessions + events schema, RLS | — |
apps/cli | LocalWorkspace consumer (refactored) | @oxagen/coding-agent |
apps/app | Background-task render of session; @repo mention | chat stream, tray |
Error handling & guardrails (invariants — hold even under full autonomy)
- Tenancy/IAM: session org+workspace scoped via
withTenantDb; gated by a capability + repo-write IAM role (high-risk).runInTenantScopeper Inngest step. - Isolation: untrusted repo code runs only in the Vercel Sandbox; egress controls; only a short-lived scoped git token enters the sandbox; secrets never broadly injected.
- Caps: max steps, max wall-clock, max files changed, credit/token budget via
invoke(); never push to default branch (Phase 2). - Audit: every session + tool call → ClickHouse ("instrument everything").
- Cancel:
agent.coding.session.cancel→ InngestcancelOn. - Billing: metered through
invoke()/ credits.
Testing
- Unit: engine loop against
MemoryWorkspace(deterministic, no sandbox); each tool; diff extraction; CLI parity after refactor. - Integration: handler + contract parity (
check:manifest); RLS scoping oncoding_sessions; Inngest step tenant-scope. - E2E: chat → start session against a seeded connected repo → assert a non-empty diff
renders in the tray (Playwright + screenshot to
apps/app/e2e/screenshots/). - Gate: per-package
test:unitonly during work; fullpnpm gatepre-merge. Coverage ratchet ≤90 with ≥2.5% headroom.
Out of scope (YAGNI)
- Multi-repo changes in a single session.
- Non-GitHub VCS (GitLab/Bitbucket).
- Installation-token identity (explicitly chose user-OAuth identity).
- Storing full repo blobs at rest (clone on demand).
- Human approval gates (explicitly chose full autonomy; caps replace gates).
Open follow-ups (tracked, not blocking)
- Phase 2 OAuth write-scope upgrade UX + token refresh.
- SSE live token-tail for "watch it code".
- Default-branch protection enforcement test.
Document — 2026-06-27-in-app-coding-agent-phase1.md
In-App Coding Agent — Phase 1 (Coding Runner) Implementation Plan
⚠️ SUPERSEDED (2026-06-27) by
2026-06-27-unified-agent-engine-plan.mdanddocs/adr/ADR-019-unified-agent-engine.md. This plan shared only the inner loop+tools, which would have shipped two diverging agents. Its Stage-C task content (sandbox workspace, coding_sessions schema, contracts/handlers/Inngest, surfaces, diff tray) is still valid and is referenced by the new plan; its narrow engine scope is replaced by the shared@oxagen/agent-engine. Kept for that task detail.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stand up a server-side, sandboxed agentic coding runner that checks out a connected GitHub repo into a persistent Vercel Sandbox, makes edits with a Claude-Code-style tool loop, and returns a unified diff + summary — invokable end-to-end via a metered, tenant-scoped capability and surfaced live in chat. No GitHub writes yet (Phase 2).
Architecture: Extract the CLI's existing filesystem-coupled coding loop into a new @oxagen/coding-agent package behind a Workspace abstraction with three backends (LocalWorkspace for the CLI, SandboxWorkspace for the cloud, MemoryWorkspace for tests). Orchestrate the cloud run as a durable Inngest job (agent.coding.session.*) that provisions a persistent Vercel Sandbox, clones the repo with the connection's KMS-decrypted OAuth token, runs runCodingAgent, persists a typed event log + final diff to coding_sessions/coding_session_events, and tears the sandbox down. The in-app agent calls it as an async tool; the existing background-task tray polls for progress and renders the diff.
Tech Stack: TypeScript 6.0.3 (no any), Vercel AI SDK (ai@6.0.x, streamText/stepCountIs), @vercel/sandbox, Drizzle (Postgres), Inngest, Zod 3.25.76, Vitest 2.1.9.
Global Constraints
- No
any. Use precise types orunknown. (standard-no-any-type) - All LLM calls go through
@oxagen/ai. Never importstreamText/generateTextdirectly fromaiinside a handler/route. The engine lives in@oxagen/coding-agentand is the single seam; it may useaidirectly only there, mirroring howapps/clialready does, but the server orchestration invokes the engine, notai. (ai-gateway-only, single-ai-chokepoint) - Raw
db()is banned. UsewithTenantDb/withSystemDb/scopedSession. (rls-tenancy-enforcement) - Every Inngest
step.runthat touches the DB wrapsrunInTenantScope({ orgId, workspaceId }, ...). ALS does not cross step boundaries. (inngest-step-tenant-scope) bootstrapEntitlementRuntime()must be called at startup of any new runtime that invokes capability-gated handlers. The Inngest runtime already does; verify.- Capability parity: new user-facing action → contract in
packages/oxagen/src/contracts/→ API route → MCP tool → CLI command →docs/capabilities/. Runpnpm check:manifest. - Instrument everything: token usage + duration + surface origin on every agent/LLM/tool call, emitted to ClickHouse. (instrument-everything)
- Migrations live in
packages/database/atlas/migrations/, never inapps/. Every new table:ENABLE+FORCE ROW LEVEL SECURITY, atenant_isolationpolicy, andoxagen_appgrants. Never edit an applied migration — add a new forward one. (migration-immutability-cross-db-checksum) - Tests: new code requires new tests. Run only the narrowest implicated test (
pnpm --filter <pkg> test:unit -- <file>); NEVER run the whole suite. Coverage ratchet ≤90 with ≥2.5% headroom. - Sandbox gating: the runner requires
SANDBOX_ENABLED=trueandSANDBOX_DRIVER=vercel. Guard withisSandboxAvailable()and fail closed with a clean message when unavailable. - Branch:
feat/in-app-coding-agentin the isolated worktree/Users/macanderson/oxagen-coding-runner. Commit + push frequently. Never commit tomain.
File Structure
New package @oxagen/coding-agent (packages/coding-agent/):
package.json,tsconfig.json,vitest.config.ts,.eslintignore— scaffold.src/types.ts—Workspace,CommandResult,CodingEvent,CodeGraphProvider,RunCodingAgentOptions,RunCodingAgentResult.src/workspaces/local.ts—LocalWorkspace(node:fs + child_process).src/workspaces/memory.ts—MemoryWorkspace(in-memory, test-only).src/tools.ts—buildWorkspaceTools(workspace, codeGraph?, { readOnly })(the 8 tools, rebased toWorkspace).src/engine.ts—runCodingAgent(opts)(the loop + typed events).src/index.ts— barrel.
Extend packages/sandbox/:
src/types.ts:addSandboxWorkspaceHandle+createWorkspacetoSandboxDriver.src/vercel.ts:implementcreateWorkspace.src/docker.ts,src/modal.ts:addcreateWorkspace(docker real; modal throws "unsupported in Phase 1").
@oxagen/coding-agent cloud adapter:
src/workspaces/sandbox.ts—SandboxWorkspaceimplementingWorkspaceover aSandboxWorkspaceHandle.
Database (packages/database/):
src/schema/coding.ts—codingSessions,codingSessionEventstables.src/schema/index.ts— export them.atlas/migrations/<ts>_coding_sessions.sql— DDL + RLS + grants.
Contracts (packages/oxagen/src/contracts/):
agent.coding.session.start.ts,agent.coding.session.get.ts,agent.coding.session.cancel.ts+index.tsregistration.
Handlers (packages/handlers/src/):
agent.coding.session.start.ts,agent.coding.session.get.ts,agent.coding.session.cancel.ts+register.tswiring.
Inngest (packages/inngest-functions/src/):
inngest.ts— addagent/coding.session.start+agent/coding.session.cancelevents.functions/agent.coding-session.execute.ts— the orchestrator.functions/index.ts— register.
Surfaces:
apps/mcp/src/tools/agent.coding.session.{start,get,cancel}.ts.apps/api/src/routes/v1/agent.coding.ts+ mount inapps/api/src/app.ts.apps/cli/src/commands/code.ts(newoxagen codecommand) + refactorapps/cli/src/agent/{loop,tools}.tsto consume@oxagen/coding-agent.
App:
apps/app/src/components/chat/...— render the coding session in the background-task tray (diff view).
Docs: docs/capabilities/agent.coding.session.start.md (+ get/cancel), _index.md.
Task 1: Scaffold @oxagen/coding-agent + the Workspace contract
Files:
- Create:
packages/coding-agent/package.json,tsconfig.json,vitest.config.ts,.eslintignore - Create:
packages/coding-agent/src/types.ts - Create:
packages/coding-agent/src/index.ts - Test:
packages/coding-agent/src/types.test.ts
Interfaces:
-
Produces:
interface CommandResult { exitCode: number; stdout: string; stderr: string; timedOut: boolean }interface Workspace { root: string; readFile(p: string, opts?: { offset?: number; limit?: number }): Promise<string>; writeFile(p: string, content: string): Promise<void>; editFile(p: string, oldString: string, newString: string): Promise<void>; list(dir?: string): Promise<string[]>; glob(pattern: string): Promise<string[]>; grep(pattern: string, opts?: { path?: string; glob?: string }): Promise<string[]>; exec(command: string, opts?: { timeoutMs?: number }): Promise<CommandResult>; diff(): Promise<string> }type CodingEvent = { type: "text"; delta: string } | { type: "tool-call"; name: string; input: unknown } | { type: "tool-result"; name: string; output: string } | { type: "file-edit"; path: string; bytes: number } | { type: "command"; command: string; exitCode: number } | { type: "final-diff"; diff: string; changedFiles: string[] }interface CodeGraphProvider { query(operation: "search" | "file_symbols" | "dependents" | "imports", query: string, limit?: number): Promise<string> }interface RunCodingAgentOptions { workspace: Workspace; instruction: string; model?: string; system?: string; history?: import("ai").ModelMessage[]; maxSteps?: number; readOnly?: boolean; codeGraph?: CodeGraphProvider; signal?: AbortSignal; onEvent?: (e: CodingEvent) => void }interface RunCodingAgentResult { text: string; steps: number; diff: string; changedFiles: string[]; usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; messages: import("ai").ModelMessage[] }
-
Step 1: Write the package scaffold
packages/coding-agent/package.json:
{
"name": "@oxagen/coding-agent",
"version": "0.5.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src --max-warnings 0",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"ai": "6.0.48",
"zod": "3.25.76"
},
"devDependencies": {
"@types/node": "25.9.1",
"@vitest/coverage-v8": "2.1.9",
"eslint": "8.57.1",
"typescript": "6.0.3",
"vitest": "2.1.9"
}
}NOTE: pin
aito the exact version already in the lockfile — readapps/cli/package.json'saiversion and match it verbatim instead of6.0.48if it differs.
packages/coding-agent/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist" },
"include": ["src/**/*.ts"]
}packages/coding-agent/vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
clearMocks: true,
environment: "node",
globals: false,
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
thresholds: { lines: 85, branches: 80, functions: 80, statements: 85 },
},
},
});packages/coding-agent/.eslintignore:
node_modules
dist
coverage-
Step 2: Write
src/types.ts— exactly the interfaces in the "Produces" block above. Each as an exportedinterface/type. No implementations. -
Step 3: Write
src/index.ts
export * from "./types";
export { LocalWorkspace } from "./workspaces/local";
export { MemoryWorkspace } from "./workspaces/memory";
export { buildWorkspaceTools } from "./tools";
export { runCodingAgent } from "./engine";The non-existent imports will fail typecheck until later tasks land — that is expected; this task's gate is only Step 5's compile of
types.ts. Temporarily comment the not-yet-created exports and uncomment them in the task that creates each.
- Step 4: Write the failing test
src/types.test.ts
import { describe, it, expect } from "vitest";
import type { Workspace, CodingEvent } from "./types";
describe("types", () => {
it("CodingEvent union includes final-diff", () => {
const e: CodingEvent = { type: "final-diff", diff: "", changedFiles: [] };
expect(e.type).toBe("final-diff");
});
it("Workspace shape is structurally usable", () => {
const w: Pick<Workspace, "root"> = { root: "/tmp/repo" };
expect(w.root).toBe("/tmp/repo");
});
});- Step 5: Install + typecheck + test
Run:
pnpm i --no-frozen-lockfile
pnpm --filter @oxagen/coding-agent typecheck
pnpm --filter @oxagen/coding-agent test:unitExpected: typecheck passes for types.ts/types.test.ts (with the not-yet-created barrel exports commented out); test PASS.
- Step 6: Commit
git add packages/coding-agent pnpm-lock.yaml
git commit -m "feat(coding-agent): scaffold package + Workspace contract"Task 2: MemoryWorkspace (in-memory backend for deterministic tests)
Files:
- Create:
packages/coding-agent/src/workspaces/memory.ts - Test:
packages/coding-agent/src/workspaces/memory.test.ts
Interfaces:
-
Consumes:
Workspace,CommandResultfrom../types. -
Produces:
class MemoryWorkspace implements Workspacewith constructor(files: Record<string, string>, root?: string).execsupports a registered command map viaonExec(fn: (cmd: string) => CommandResult); default returns{ exitCode: 0, stdout: "", stderr: "", timedOut: false }.diff()returns a synthetic unified diff of changed-vs-initial files. -
Step 1: Write the failing test
src/workspaces/memory.test.ts
import { describe, it, expect } from "vitest";
import { MemoryWorkspace } from "./memory";
describe("MemoryWorkspace", () => {
it("reads and writes files", async () => {
const ws = new MemoryWorkspace({ "a.txt": "hello" });
expect(await ws.readFile("a.txt")).toBe("hello");
await ws.writeFile("b.txt", "world");
expect(await ws.readFile("b.txt")).toBe("world");
});
it("editFile replaces a unique substring and rejects non-unique", async () => {
const ws = new MemoryWorkspace({ "a.txt": "foo bar foo" });
await expect(ws.editFile("a.txt", "foo", "x")).rejects.toThrow(/unique|appears/i);
await ws.editFile("a.txt", "bar", "baz");
expect(await ws.readFile("a.txt")).toBe("foo baz foo");
});
it("glob matches ** and * patterns", async () => {
const ws = new MemoryWorkspace({ "src/x.ts": "", "src/deep/y.ts": "", "z.js": "" });
expect((await ws.glob("src/**/*.ts")).sort()).toEqual(["src/deep/y.ts", "src/x.ts"]);
});
it("grep returns file:line:text hits", async () => {
const ws = new MemoryWorkspace({ "a.ts": "const a = 1\nconst b = 2" });
expect(await ws.grep("const b")).toEqual(["a.ts:2:const b = 2"]);
});
it("diff reports changed files", async () => {
const ws = new MemoryWorkspace({ "a.txt": "1" });
await ws.writeFile("a.txt", "2");
const d = await ws.diff();
expect(d).toContain("a.txt");
});
});- Step 2: Run test to verify it fails
Run: pnpm --filter @oxagen/coding-agent test:unit -- memory.test.ts
Expected: FAIL — MemoryWorkspace not found.
- Step 3: Implement
src/workspaces/memory.ts
Implement against the Workspace interface. Reuse the CLI's globToRegExp algorithm (copy from apps/cli/src/agent/tools.ts:50-72) for glob/grep. Track an initial snapshot in the constructor and a mutable files map. editFile mirrors the CLI's count-must-equal-1 rule (tools.ts:159-172). diff() emits a minimal unified diff listing each path whose content differs from initial (header --- a/<path> / +++ b/<path> is sufficient for tests). exec returns the injected handler's result or the zero-default.
import type { CommandResult, Workspace } from "../types";
// Minimal glob → regex (copied from apps/cli/src/agent/tools.ts globToRegExp).
function globToRegExp(pattern: string): RegExp {
let re = "";
for (let i = 0; i < pattern.length; i++) {
const c = pattern[i];
if (c === "*") {
if (pattern[i + 1] === "*") { re += ".*"; i++; if (pattern[i + 1] === "/") i++; }
else re += "[^/]*";
} else if (c === "?") re += "[^/]";
else if (".+^${}()|[]\\".includes(c as string)) re += "\\" + c;
else re += c;
}
return new RegExp("^" + re + "$");
}
export class MemoryWorkspace implements Workspace {
readonly root: string;
private files: Map<string, string>;
private initial: Map<string, string>;
private execHandler: (cmd: string) => CommandResult = () => ({
exitCode: 0, stdout: "", stderr: "", timedOut: false,
});
constructor(files: Record<string, string> = {}, root = "/repo") {
this.root = root;
this.files = new Map(Object.entries(files));
this.initial = new Map(Object.entries(files));
}
onExec(fn: (cmd: string) => CommandResult): void { this.execHandler = fn; }
async readFile(p: string, opts?: { offset?: number; limit?: number }): Promise<string> {
const text = this.files.get(p);
if (text === undefined) throw new Error(`ENOENT: ${p}`);
if (opts?.offset == null && opts?.limit == null) return text;
const lines = text.split("\n");
const start = opts?.offset ? opts.offset - 1 : 0;
const end = opts?.limit ? start + opts.limit : lines.length;
return lines.slice(start, end).join("\n");
}
async writeFile(p: string, content: string): Promise<void> { this.files.set(p, content); }
async editFile(p: string, oldString: string, newString: string): Promise<void> {
const text = this.files.get(p);
if (text === undefined) throw new Error(`ENOENT: ${p}`);
const count = text.split(oldString).length - 1;
if (count === 0) throw new Error(`old_string not found in ${p}`);
if (count > 1) throw new Error(`old_string appears ${count} times in ${p}; must be unique`);
this.files.set(p, text.replace(oldString, newString));
}
async list(dir = "."): Promise<string[]> {
const prefix = dir === "." ? "" : dir.replace(/\/$/, "") + "/";
const names = new Set<string>();
for (const path of this.files.keys()) {
if (!path.startsWith(prefix)) continue;
const rest = path.slice(prefix.length).split("/")[0];
if (rest) names.add(rest);
}
return [...names].sort();
}
async glob(pattern: string): Promise<string[]> {
const re = globToRegExp(pattern);
return [...this.files.keys()].filter((p) => re.test(p)).sort();
}
async grep(pattern: string, opts?: { path?: string; glob?: string }): Promise<string[]> {
const re = new RegExp(pattern);
const fileRe = opts?.glob ? globToRegExp(opts.glob.includes("/") ? opts.glob : `**/${opts.glob}`) : null;
const hits: string[] = [];
for (const [path, text] of this.files) {
if (opts?.path && !path.startsWith(opts.path)) continue;
if (fileRe && !fileRe.test(path)) continue;
text.split("\n").forEach((line, i) => {
if (re.test(line)) hits.push(`${path}:${i + 1}:${line.slice(0, 200)}`);
});
}
return hits;
}
async exec(command: string): Promise<CommandResult> { return this.execHandler(command); }
async diff(): Promise<string> {
const out: string[] = [];
const paths = new Set([...this.files.keys(), ...this.initial.keys()]);
for (const p of [...paths].sort()) {
if (this.files.get(p) !== this.initial.get(p)) {
out.push(`--- a/${p}`, `+++ b/${p}`);
}
}
return out.join("\n");
}
}- Step 4: Run test to verify it passes
Run: pnpm --filter @oxagen/coding-agent test:unit -- memory.test.ts
Expected: PASS (5 tests).
- Step 5: Commit
git add packages/coding-agent/src/workspaces/memory.ts packages/coding-agent/src/workspaces/memory.test.ts
git commit -m "feat(coding-agent): MemoryWorkspace in-memory backend"Task 3: buildWorkspaceTools — the 8 coding tools, rebased to Workspace
Files:
- Create:
packages/coding-agent/src/tools.ts - Test:
packages/coding-agent/src/tools.test.ts
Interfaces:
-
Consumes:
Workspace,CodeGraphProvider,CodingEventfrom../types;tool,ToolSetfromai;zfromzod. -
Produces:
function buildWorkspaceTools(workspace: Workspace, opts?: { readOnly?: boolean; codeGraph?: CodeGraphProvider; onEvent?: (e: CodingEvent) => void }): ToolSet. Tools:read_file,write_file,edit_file,list_dir,glob,grep,code_graph,bash— same names/schemas asapps/cli/src/agent/tools.ts, butexecutedelegates to theWorkspacemethods.read_onlydeleteswrite_file/edit_file/bash.code_graphis omitted when nocodeGraphprovider is supplied. Each mutating/command tool callsonEvent(file-edit,command). -
Step 1: Write the failing test
src/tools.test.ts
import { describe, it, expect, vi } from "vitest";
import { MemoryWorkspace } from "./workspaces/memory";
import { buildWorkspaceTools } from "./tools";
import type { CodingEvent } from "./types";
async function run(tool: unknown, input: unknown): Promise<string> {
// AI SDK tool().execute signature: (input, { toolCallId, messages }) => result
return (tool as { execute: (i: unknown, o: unknown) => Promise<string> }).execute(input, {});
}
describe("buildWorkspaceTools", () => {
it("read_file returns file content", async () => {
const ws = new MemoryWorkspace({ "a.ts": "hello" });
const tools = buildWorkspaceTools(ws);
expect(await run(tools.read_file, { path: "a.ts" })).toBe("hello");
});
it("edit_file emits a file-edit event and mutates the workspace", async () => {
const ws = new MemoryWorkspace({ "a.ts": "foo" });
const events: CodingEvent[] = [];
const tools = buildWorkspaceTools(ws, { onEvent: (e) => events.push(e) });
await run(tools.edit_file, { path: "a.ts", old_string: "foo", new_string: "bar" });
expect(await ws.readFile("a.ts")).toBe("bar");
expect(events.some((e) => e.type === "file-edit" && e.path === "a.ts")).toBe(true);
});
it("readOnly withholds mutating tools", () => {
const ws = new MemoryWorkspace({});
const tools = buildWorkspaceTools(ws, { readOnly: true });
expect(tools.write_file).toBeUndefined();
expect(tools.edit_file).toBeUndefined();
expect(tools.bash).toBeUndefined();
expect(tools.read_file).toBeDefined();
});
it("code_graph is present only with a provider", () => {
const ws = new MemoryWorkspace({});
expect(buildWorkspaceTools(ws).code_graph).toBeUndefined();
const withGraph = buildWorkspaceTools(ws, {
codeGraph: { query: async () => "result" },
});
expect(withGraph.code_graph).toBeDefined();
});
it("bash delegates to workspace.exec and emits a command event", async () => {
const ws = new MemoryWorkspace({});
ws.onExec(() => ({ exitCode: 0, stdout: "ok", stderr: "", timedOut: false }));
const events: CodingEvent[] = [];
const tools = buildWorkspaceTools(ws, { onEvent: (e) => events.push(e) });
const out = await run(tools.bash, { command: "echo ok" });
expect(out).toContain("ok");
expect(events.some((e) => e.type === "command")).toBe(true);
});
});- Step 2: Run test to verify it fails
Run: pnpm --filter @oxagen/coding-agent test:unit -- tools.test.ts
Expected: FAIL — buildWorkspaceTools not found.
- Step 3: Implement
src/tools.ts
Port apps/cli/src/agent/tools.ts verbatim in shape, replacing every fs/execAsync/walk call with the corresponding Workspace method, keeping clip() (MAX_OUTPUT 30_000) and the identical tool description/inputSchema. read_file→workspace.readFile; write_file→workspace.writeFile then onEvent({type:"file-edit"}); edit_file→workspace.editFile then onEvent; list_dir→workspace.list; glob→workspace.glob; grep→workspace.grep; bash→workspace.exec then onEvent({type:"command", command, exitCode}); code_graph→opts.codeGraph.query. Wrap each execute body in try/catch returning a string error (same as the CLI). Delete the three mutating tools when opts.readOnly. Delete code_graph when !opts.codeGraph.
- Step 4: Run test to verify it passes
Run: pnpm --filter @oxagen/coding-agent test:unit -- tools.test.ts
Expected: PASS (5 tests).
- Step 5: Commit
git add packages/coding-agent/src/tools.ts packages/coding-agent/src/tools.test.ts
git commit -m "feat(coding-agent): workspace-bound coding tools"Task 4: runCodingAgent — the engine loop with typed events + diff
Files:
- Create:
packages/coding-agent/src/engine.ts - Test:
packages/coding-agent/src/engine.test.ts
Interfaces:
-
Consumes:
RunCodingAgentOptions,RunCodingAgentResult,CodingEventfrom../types;buildWorkspaceToolsfrom./tools;streamText,stepCountIs,ModelMessagefromai. -
Produces:
async function runCodingAgent(opts: RunCodingAgentOptions): Promise<RunCodingAgentResult>. Mirrorsapps/cli/src/agent/loop.ts:99-179(streamText+stopWhen: stepCountIs(maxSteps ?? 32)+onStepFinishforwarding tool calls), but: (a) builds tools frombuildWorkspaceTools(workspace, { readOnly, codeGraph, onEvent }); (b) streams text deltas toonEvent({type:"text"}); (c) after the loop, callsworkspace.diff(), deriveschangedFilesfrom the diff, and emitsonEvent({type:"final-diff"}); (d) returns{ text, steps, diff, changedFiles, usage, messages }. The model is injected viaopts.model(resolved by the caller; the engine accepts a model id string and passes it tostreamText({ model })). The default system prompt is a constant; callers override viaopts.system. -
Step 1: Write the failing test
src/engine.test.ts
Mock ai's streamText so the test is deterministic (no network). The mock returns a fake stream that "edits" a file through the tool, then yields text.
import { describe, it, expect, vi } from "vitest";
import { MemoryWorkspace } from "./workspaces/memory";
import type { CodingEvent } from "./types";
vi.mock("ai", async () => {
const actual = await vi.importActual<typeof import("ai")>("ai");
return {
...actual,
streamText: vi.fn((cfg: { tools: Record<string, { execute: (i: unknown, o: unknown) => Promise<unknown> }> }) => {
return {
// Simulate the model editing a file via the edit_file tool, then replying.
textStream: (async function* () {
await cfg.tools.edit_file.execute({ path: "a.ts", old_string: "foo", new_string: "bar" }, {});
yield "done";
})(),
steps: Promise.resolve([{}]),
usage: Promise.resolve({ inputTokens: 1, outputTokens: 2, totalTokens: 3 }),
response: Promise.resolve({ messages: [] }),
};
}),
};
});
import { runCodingAgent } from "./engine";
describe("runCodingAgent", () => {
it("runs the loop, applies edits, and returns a diff", async () => {
const ws = new MemoryWorkspace({ "a.ts": "foo" });
const events: CodingEvent[] = [];
const result = await runCodingAgent({
workspace: ws,
instruction: "rename foo to bar",
model: "anthropic/claude-opus-4-8",
onEvent: (e) => events.push(e),
});
expect(await ws.readFile("a.ts")).toBe("bar");
expect(result.diff).toContain("a.ts");
expect(result.changedFiles).toContain("a.ts");
expect(result.usage.totalTokens).toBe(3);
expect(events.some((e) => e.type === "final-diff")).toBe(true);
});
});- Step 2: Run test to verify it fails
Run: pnpm --filter @oxagen/coding-agent test:unit -- engine.test.ts
Expected: FAIL — runCodingAgent not found.
- Step 3: Implement
src/engine.ts
import { streamText, stepCountIs, type ModelMessage } from "ai";
import { buildWorkspaceTools } from "./tools";
import type { CodingEvent, RunCodingAgentOptions, RunCodingAgentResult } from "./types";
const DEFAULT_SYSTEM =
"You are an expert software engineer working in a checked-out repository. " +
"Use the provided tools to read, search, and edit files and run commands. " +
"Make the smallest correct change that satisfies the request, run the repo's " +
"tests or build when relevant, and stop when the task is complete.";
// Parse `git diff` output for the set of changed file paths (b/<path> headers).
export function changedFilesFromDiff(diff: string): string[] {
const files = new Set<string>();
for (const line of diff.split("\n")) {
const m = /^\+\+\+ b\/(.+)$/.exec(line);
if (m && m[1] !== "/dev/null") files.add(m[1]);
}
return [...files].sort();
}
export async function runCodingAgent(opts: RunCodingAgentOptions): Promise<RunCodingAgentResult> {
const onEvent = opts.onEvent ?? (() => undefined);
const tools = buildWorkspaceTools(opts.workspace, {
readOnly: opts.readOnly,
codeGraph: opts.codeGraph,
onEvent,
});
const messages: ModelMessage[] = [
...(opts.history ?? []),
{ role: "user", content: opts.instruction },
];
let streamError: unknown = null;
const result = streamText({
model: opts.model ?? "anthropic/claude-opus-4-8",
system: opts.system ?? DEFAULT_SYSTEM,
messages,
tools,
stopWhen: stepCountIs(opts.maxSteps ?? 32),
abortSignal: opts.signal,
onError: ({ error }) => { streamError = error; },
onStepFinish: ({ toolCalls }) => {
for (const tc of toolCalls ?? []) {
const call = tc as { toolName: string; input?: unknown; args?: unknown };
onEvent({ type: "tool-call", name: call.toolName, input: call.input ?? call.args });
}
},
});
let text = "";
try {
for await (const delta of result.textStream) {
text += delta;
onEvent({ type: "text", delta });
}
} catch (err) {
streamError ??= err;
}
if (streamError) throw streamError instanceof Error ? streamError : new Error(String(streamError));
const steps = (await result.steps).length;
const usage = await result.usage;
const response = await result.response;
const diff = await opts.workspace.diff();
const changedFiles = changedFilesFromDiff(diff);
onEvent({ type: "final-diff", diff, changedFiles });
return {
text,
steps,
diff,
changedFiles,
usage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
totalTokens: usage.totalTokens,
},
messages: [...messages, ...response.messages],
};
}Add a
changedFilesFromDiffunit test inline (realgit diffhas+++ b/pathlines;MemoryWorkspace.diff()emits them too).
- Step 4: Run test to verify it passes
Run: pnpm --filter @oxagen/coding-agent test:unit -- engine.test.ts
Expected: PASS.
- Step 5: Uncomment the barrel exports in
src/index.ts, typecheck, commit
pnpm --filter @oxagen/coding-agent typecheck
git add packages/coding-agent/src/engine.ts packages/coding-agent/src/engine.test.ts packages/coding-agent/src/index.ts
git commit -m "feat(coding-agent): runCodingAgent engine with typed events + diff"Task 5: LocalWorkspace + refactor the CLI onto the shared engine
Files:
- Create:
packages/coding-agent/src/workspaces/local.ts - Test:
packages/coding-agent/src/workspaces/local.test.ts - Modify:
apps/cli/src/agent/loop.ts(delegate torunCodingAgentwith aLocalWorkspace) - Modify:
apps/cli/src/agent/tools.ts→ delete (replaced by@oxagen/coding-agent), updating imports - Modify:
apps/cli/package.json(add@oxagen/coding-agentdep)
Interfaces:
-
Consumes:
Workspace,CommandResult; nodefs/child_process. -
Produces:
class LocalWorkspace implements Workspaceconstructed with(cwd: string). Reuses the exact algorithms in today'sapps/cli/src/agent/tools.ts(walk/glob/grep/exec).diff()runsgit -C <cwd> diff. -
Step 1: Write the failing test
src/workspaces/local.test.ts— create a temp dir withnode:fs, write files, assertreadFile/writeFile/editFile/glob/grep/exec/listbehave like the CLI did, anddiff()returns""for a non-git dir (guard) or a real diff in agit init'd temp repo.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalWorkspace } from "./local";
let dir: string;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "lw-")); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
describe("LocalWorkspace", () => {
it("reads, writes, edits, globs, greps", async () => {
await mkdir(join(dir, "src"), { recursive: true });
await writeFile(join(dir, "src/a.ts"), "const a = 1\n");
const ws = new LocalWorkspace(dir);
expect(await ws.readFile("src/a.ts")).toContain("const a = 1");
await ws.writeFile("src/b.ts", "const b = 2\n");
expect(await ws.glob("src/**/*.ts")).toEqual(["src/a.ts", "src/b.ts"]);
expect(await ws.grep("const b")).toEqual(["src/b.ts:1:const b = 2"]);
await ws.editFile("src/a.ts", "const a = 1", "const a = 99");
expect(await ws.readFile("src/a.ts")).toContain("const a = 99");
const r = await ws.exec("echo hi");
expect(r.stdout.trim()).toBe("hi");
});
});-
Step 2: Run + verify fail.
pnpm --filter @oxagen/coding-agent test:unit -- local.test.ts→ FAIL. -
Step 3: Implement
src/workspaces/local.tsby lifting the algorithms fromapps/cli/src/agent/tools.ts(walk,globToRegExp,IGNORE_DIRS, theread_file/write_file/edit_file/list_dir/glob/grep/bashbodies) intoWorkspacemethods overthis.cwd.execusespromisify(exec)with{ cwd, timeout, maxBuffer: 10*1024*1024, shell: "/bin/bash" }returningCommandResult.diff()runsgit -C <cwd> diffviaexec, returningstdout(empty string on non-zero exit, e.g. not a git repo). -
Step 4: Run + verify pass. Expected: PASS.
-
Step 5: Refactor the CLI loop. In
apps/cli/src/agent/loop.ts, replacebuildTools(cwd, …)usage withrunCodingAgentfrom@oxagen/coding-agent:- Keep the CLI's
RunAgentOptions/RunAgentResultpublic shape and all CLI-specific behavior (gateway key check, project memory recall/remember, system prompt assembly viabuildSystemPrompt, error normalization vianormalizeAgentError). - Inside
runAgent, constructconst workspace = new LocalWorkspace(cwd), build the samesystemstring, then callrunCodingAgent({ workspace, instruction: opts.prompt, model: resolveModelId(opts.model), system, history: opts.history, maxSteps: opts.maxSteps, readOnly: opts.readOnly, signal: opts.signal, codeGraph: <CLI local code-graph provider wrapping queryCodeGraph(cwd, …)>, onEvent: (e) => { if (e.type === "text") opts.onText?.(e.delta); if (e.type === "tool-call") opts.onToolCall?.(e.name, e.input); } }). - Map
RunCodingAgentResult→RunAgentResult(text,steps,messages,usage). Preserve the memoryremember(...)calls. - Wrap the call in the existing try/catch and rethrow via
normalizeAgentError. - Delete
apps/cli/src/agent/tools.tsand update any other importer (grep -rl "agent/tools" apps/cli/src) to import from@oxagen/coding-agentor the new local provider. - Add
"@oxagen/coding-agent": "workspace:*"toapps/cli/package.jsondependencies.
- Keep the CLI's
-
Step 6: Run the CLI's agent tests (the existing ones —
looperror-normalization, plus any tool tests now pointing at the package). Run:
pnpm i --no-frozen-lockfile
pnpm --filter @oxagen/cli typecheck
pnpm --filter @oxagen/cli test:unit -- loopExpected: PASS, no behavior change. (If a deleted-tools test file remains, move it under @oxagen/coding-agent or delete it.)
- Step 7: Commit
git add packages/coding-agent apps/cli pnpm-lock.yaml
git commit -m "refactor(cli): consume @oxagen/coding-agent (LocalWorkspace); one shared engine"Task 6: Persistent sandbox workspace (packages/sandbox + SandboxWorkspace)
Files:
- Modify:
packages/sandbox/src/types.ts(addSandboxWorkspaceHandle+createWorkspacetoSandboxDriver) - Modify:
packages/sandbox/src/vercel.ts(implementcreateWorkspace) - Modify:
packages/sandbox/src/docker.ts,src/modal.ts(implement/stubcreateWorkspace) - Test:
packages/sandbox/src/vercel.workspace.test.ts - Create:
packages/coding-agent/src/workspaces/sandbox.ts(SandboxWorkspace implements Workspace) - Test:
packages/coding-agent/src/workspaces/sandbox.test.ts - Modify:
packages/coding-agent/package.json(add@oxagen/sandboxdep)
Interfaces:
-
Produces in
@oxagen/sandbox:interface SandboxWorkspaceHandle { exec(command: string, opts?: { timeoutMs?: number; cwd?: string }): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean }>; readFile(path: string): Promise<string>; writeFile(path: string, content: string): Promise<void>; stop(): Promise<void> }createWorkspace(opts: { networkPolicy?: "allow" | "deny"; env?: Record<string,string>; timeoutMs?: number }): Promise<SandboxWorkspaceHandle>added toSandboxDriver(optional; Vercel + Docker implement it, Modal throwsSandboxUnsupportedError).
-
Produces in
@oxagen/coding-agent:class SandboxWorkspace implements Workspaceconstructed with(handle: SandboxWorkspaceHandle, root: string).glob/grep/list/diffrun shell (find,grep -rn,ls,git -C <root> diff) viahandle.exec;readFile/writeFile/editFileviahandle.readFile/writeFile. -
Step 1: Write the failing test
packages/sandbox/src/vercel.workspace.test.tsusing the existingSandboxFactorymock seam (createVercelSandbox(config, sandboxImpl)).
import { describe, it, expect, vi } from "vitest";
import { createVercelSandbox, type SandboxFactory } from "./vercel";
function fakeSandbox() {
return {
fs: {
writeFile: vi.fn(async () => undefined),
readFile: vi.fn(async () => "file-content"),
},
runCommand: vi.fn(async () => ({
exitCode: 0,
stdout: async () => "out",
stderr: async () => "",
})),
stop: vi.fn(async () => undefined),
};
}
describe("vercel createWorkspace", () => {
it("creates a persistent sandbox, execs multiple commands, then stops once", async () => {
const sb = fakeSandbox();
const impl: SandboxFactory = { create: vi.fn(async () => sb) as never };
const driver = createVercelSandbox({}, impl);
const ws = await driver.createWorkspace!({ networkPolicy: "allow" });
const a = await ws.exec("git --version");
const b = await ws.exec("ls");
expect(a.exitCode).toBe(0);
expect(b.stdout).toBe("out");
expect(impl.create).toHaveBeenCalledTimes(1); // ONE microVM reused
expect(sb.stop).not.toHaveBeenCalled();
await ws.stop();
expect(sb.stop).toHaveBeenCalledTimes(1);
});
});-
Step 2: Run + verify fail.
pnpm --filter @oxagen/sandbox test:unit -- vercel.workspace.test.ts→ FAIL (createWorkspaceundefined). -
Step 3: Implement.
types.ts: addSandboxWorkspaceHandleandcreateWorkspace?toSandboxDriver.vercel.ts: implementcreateWorkspace— callSandboxImpl.create({ runtime: "node24", networkPolicy, timeout: opts.timeoutMs ?? 1_800_000, env: opts.env, resources: { vcpus: 2 }, ...(credentials ?? {}) })ONCE, return a handle whoseexec(command)doessandbox.runCommand({ cmd: "/bin/sh", args: ["-c", command], env: opts.env })and resolves stdout/stderr (the SDK returns asyncstdout()/stderr()),readFile→sandbox.fs.readFile,writeFile→sandbox.fs.writeFile,stop→sandbox.stop(). ReuseMAX_OUTPUT_CHARStruncation. Apply a per-exectimedOutheuristic only if needed (default false).docker.ts: implementcreateWorkspaceagainst a long-lived container (reuse the hardened config; keep the container running,execviacontainer.exec).modal.ts:async createWorkspace() { throw new SandboxUnsupportedError("Modal driver does not support persistent workspaces in Phase 1"); }(define/export the error if absent).
-
Step 4: Run + verify pass. Expected: PASS,
createcalled once,stoponce. -
Step 5: Implement + test
SandboxWorkspacein@oxagen/coding-agent. Add"@oxagen/sandbox": "workspace:*"to itspackage.json. Test with a fakeSandboxWorkspaceHandle(recordexeccommands; assertglobissues afind,grepissuesgrep -rn,diffissuesgit -C <root> diff).
// sandbox.test.ts sketch
import { describe, it, expect, vi } from "vitest";
import { SandboxWorkspace } from "./sandbox";
const handle = (execImpl: (c: string) => Promise<{exitCode:number;stdout:string;stderr:string;timedOut:boolean}>) => ({
exec: vi.fn(execImpl),
readFile: vi.fn(async () => "x"),
writeFile: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
});
describe("SandboxWorkspace", () => {
it("diff shells out to git diff", async () => {
const h = handle(async () => ({ exitCode: 0, stdout: "diff --git a", stderr: "", timedOut: false }));
const ws = new SandboxWorkspace(h, "/repo");
expect(await ws.diff()).toContain("diff --git");
expect(h.exec).toHaveBeenCalledWith(expect.stringContaining("git -C /repo diff"), expect.anything());
});
});Implement SandboxWorkspace accordingly (parse grep -rn/find output into the same file:line:text / path-list shapes the tools expect).
- Step 6: Run both packages' new tests + typecheck + commit
pnpm --filter @oxagen/sandbox test:unit -- vercel.workspace.test.ts
pnpm --filter @oxagen/coding-agent test:unit -- sandbox.test.ts
pnpm --filter @oxagen/sandbox typecheck && pnpm --filter @oxagen/coding-agent typecheck
git add packages/sandbox packages/coding-agent pnpm-lock.yaml
git commit -m "feat(sandbox): persistent Vercel/Docker workspace + SandboxWorkspace adapter"Task 7: Database — coding_sessions + coding_session_events
Files:
- Create:
packages/database/src/schema/coding.ts - Modify:
packages/database/src/schema/index.ts(export) - Create:
packages/database/atlas/migrations/<timestamp>_coding_sessions.sql - Test:
packages/database/src/schema/coding.test.ts(shape assertions, optional but include a column-presence test)
Interfaces:
-
Produces:
schema.codingSessionsandschema.codingSessionEventsDrizzle tables.codingSessionscolumns:idMixin("cds")+auditMixin()+orgScopeMixin()+connectionId uuid,instruction text,baseBranch text,status text(CHECK pending|running|completed|failed|cancelled),inngestRunId text,diff text,changedFiles jsonb,summary text,failureReason text,startedAt,completedAt.codingSessionEvents:idMixin("cse")+orgScopeMixin()+sessionId uuid(FK→coding_sessions.id),seq integer,kind text,payload jsonb,createdAt. -
Step 1: Write the schema
src/schema/coding.tsfollowing the verbatimbackgroundTaskspattern (mixins,textstatus + CHECK, indexes on(org_id, workspace_id, status)and(session_id, seq)). -
Step 2: Export from
src/schema/index.ts:export { codingSessions, codingSessionEvents } from "./coding"; -
Step 3: Write the migration
atlas/migrations/<timestamp>_coding_sessions.sql(timestampdate +%Y%m%d%H%M%S, after the latest existing file). TwoCREATE TABLEin schemaagent, both withENABLE+FORCE ROW LEVEL SECURITY, thetenant_isolationpolicy (copy verbatim from the extractedbackground_tasksexample), theoxagen_appgrantsDO $$block, indexes, and an FKcoding_session_events.session_id → coding_sessions(id) ON DELETE CASCADE. -
Step 4: Validate locally
Run (confirm local target; never prod):
unset DATABASE_URL
pnpm db:lint-migrations
pnpm --filter @oxagen/database typecheckExpected: migration name/checksum lint passes; types compile.
- Step 5: Apply + verify with a query (local Postgres :5433)
unset DATABASE_URL
pnpm db:migrate
psql postgresql://oxagen:oxagen@localhost:5433/oxagen -c "\d agent.coding_sessions" -c "SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname='coding_sessions';"Expected: table exists with all columns; relrowsecurity and relforcerowsecurity both t.
- Step 6: Commit
git add packages/database/src/schema/coding.ts packages/database/src/schema/index.ts packages/database/atlas/migrations/*coding_sessions.sql packages/database/src/schema/coding.test.ts
git commit -m "feat(database): coding_sessions + coding_session_events (RLS + grants)"Task 8: Contracts — agent.coding.session.{start,get,cancel}
Files:
- Create:
packages/oxagen/src/contracts/agent.coding.session.start.ts - Create:
packages/oxagen/src/contracts/agent.coding.session.get.ts - Create:
packages/oxagen/src/contracts/agent.coding.session.cancel.ts - Modify:
packages/oxagen/src/contracts/index.ts(import + export +contractsarray) - Test:
packages/oxagen/src/contracts/agent.coding.session.test.ts
Interfaces:
-
Produces three
registerCapability(...)contracts (shape per the verbatimagent.task.background.startexample):agent.coding.session.start—mode: "async",surfaces: ["api","mcp","agent","cli"],scoped: true,agent: { requiresApproval: false, riskLevel: "high", category: "coding" }(full autonomy → no approval gate; risk high → IAM-gated),defaultRolesorg Owner/Admin allow + workspace Owner/Member allow.input: z.object({ connectionId: z.string(), instruction: z.string().min(1), baseBranch: z.string().optional(), maxSteps: z.number().int().min(1).max(200).optional() }).output: z.object({ sessionId: z.string(), inngestRunId: z.string() }).agent.coding.session.get—mode: "sync", same surfaces,input: z.object({ sessionId: z.string() }),output: z.object({ sessionId, status, instruction, baseBranch: z.string().nullable(), diff: z.string().nullable(), changedFiles: z.array(z.string()), summary: z.string().nullable(), failureReason: z.string().nullable(), events: z.array(z.object({ seq: z.number(), kind: z.string(), payload: z.unknown(), createdAt: z.string() })), createdAt, startedAt: z.string().nullable(), completedAt: z.string().nullable() }).agent.coding.session.cancel—mode: "sync",input: z.object({ sessionId: z.string() }),output: z.object({ sessionId: z.string(), status: z.string() }).
-
Step 1: Write the three contract files using the extracted
registerCapabilityshape verbatim (adjust name/domain"agent"/input/output). Exporttype ...Input/Output = z.output<...>from each. -
Step 2: Register in
contracts/index.ts— add the three imports, add to theexport { … }block, add to theexport const contracts = [ … ]array (alphabetical neighborhood of otheragent.*). -
Step 3: Write the test
agent.coding.session.test.ts
import { describe, it, expect } from "vitest";
import { agentCodingSessionStart } from "./agent.coding.session.start";
describe("agent.coding.session.start contract", () => {
it("rejects empty instruction", () => {
const r = agentCodingSessionStart.input.safeParse({ connectionId: "c1", instruction: "" });
expect(r.success).toBe(false);
});
it("accepts a valid request", () => {
const r = agentCodingSessionStart.input.safeParse({ connectionId: "c1", instruction: "do x" });
expect(r.success).toBe(true);
});
it("is async + high risk + no approval (full autonomy)", () => {
expect(agentCodingSessionStart.mode).toBe("async");
expect(agentCodingSessionStart.agent?.riskLevel).toBe("high");
expect(agentCodingSessionStart.agent?.requiresApproval).toBe(false);
});
});- Step 4: Regenerate contracts + verify manifest
pnpm check:contracts
pnpm check:manifest
pnpm --filter @oxagen/oxagen test:unit -- agent.coding.session.test.tsExpected: contracts generate; manifest shows the three new capabilities (api/mcp gaps will close in Task 10).
- Step 5: Commit
git add packages/oxagen/src/contracts
git commit -m "feat(contracts): agent.coding.session.{start,get,cancel}"Task 9: Handlers + Inngest orchestrator
Files:
- Create:
packages/handlers/src/agent.coding.session.start.ts - Create:
packages/handlers/src/agent.coding.session.get.ts - Create:
packages/handlers/src/agent.coding.session.cancel.ts - Modify:
packages/handlers/src/register.ts(three lazy registrations) - Modify:
packages/inngest-functions/src/inngest.ts(two events) - Create:
packages/inngest-functions/src/functions/agent.coding-session.execute.ts - Modify:
packages/inngest-functions/src/functions/index.ts(register) - Test:
packages/handlers/src/agent.coding.session.start.test.ts - Test:
packages/inngest-functions/src/functions/agent.coding-session.execute.test.ts
Interfaces:
-
starthandler: inserts acoding_sessionsrow (statuspending, viawithTenantDb), sendsagent/coding.session.startevent with{ orgId, workspaceId, sessionId, connectionId, instruction, baseBranch, maxSteps }, updates row withinngestRunId, returns{ sessionId, inngestRunId }. (Verbatim shape ofagent.task.background.starthandler.) -
gethandler: reads the row + ordered events viawithTenantDb, maps to the contract output (timestamps → ISO strings). -
cancelhandler: sendsagent/coding.session.cancelevent, sets row statuscancelled, returns{ sessionId, status: "cancelled" }. -
Inngest
agent.coding-session.execute:concurrency { limit: 3, key: "event.data.orgId" },retries: 0,cancelOn: [{ event: "agent/coding.session.cancel", if: "event.data.sessionId == async.data.sessionId && event.data.orgId == async.data.orgId" }]. Steps (each DB step wrapped inrunInTenantScope):mark-running.resolve-connection— readsource_connections+ the linkedoauth_accounts, KMS-decrypt the token (via@oxagen/crypto/credential service), derive{ owner, repo, defaultBranch }fromdeliveryConfig. Fail closed withfailureReasonif notconnectedor token missing.run-agent— not astep.run(it streams; runs in the function body): guardisSandboxAvailable()(else mark failed "sandbox disabled");const handle = await getSandbox().createWorkspace({ networkPolicy: "allow", timeoutMs });await handle.exec("git clone --depth 1 --branch <base> https://x-access-token:<token>@github.com/<owner>/<repo>.git /repo");const ws = new SandboxWorkspace(handle, "/repo");await runCodingAgent({ workspace: ws, instruction, model: <resolved>, maxSteps, codeGraph: <Neo4j provider for connectionId>, onEvent: (e) => persistEvent(...) }); capture{ diff, changedFiles, text };await handle.stop()infinally. Persist each event by appending tocoding_session_events(batch/throttle writes; e.g. flush every N events or on tool/diff events).finalize— update rowstatus: "completed",diff,changedFiles,summary: text.slice(0, 2000),completedAt.
- On any throw:
mark-failedwithfailureReason, ensurehandle.stop().
-
The token MUST be redacted from any persisted event/log (
x-access-token:<token>never written tocoding_session_events). -
Step 1: Write the
starthandler test (mockwithTenantDb+ event client; assert a row insert and an event send). Run → FAIL. -
Step 2: Implement the three handlers (verbatim
agent.task.background.starthandler shape). Register all three inregister.tswith lazy imports. -
Step 3: Add the two Inngest events to
inngest.tsEventstype with the data shapes above. -
Step 4: Write the orchestrator test
agent.coding-session.execute.test.ts— inject a fake sandbox driver viasetSandboxForTests(...)and a stubbedrunCodingAgent(mock@oxagen/coding-agent), assert: clone command includes the token,SandboxWorkspaceused, row finalizedcompletedwith the diff,handle.stop()called, and the persisted events never contain the raw token. Run → FAIL. -
Step 5: Implement
agent.coding-session.execute.ts(createFunction shape verbatim fromagent.background-task.execute.ts; steps as specified;runInTenantScopearound every DB step). Register infunctions/index.ts. -
Step 6: Run the new tests + typecheck
pnpm --filter @oxagen/handlers test:unit -- agent.coding.session.start.test.ts
pnpm --filter @oxagen/inngest-functions test:unit -- agent.coding-session.execute.test.ts
pnpm --filter @oxagen/handlers typecheck && pnpm --filter @oxagen/inngest-functions typecheckExpected: PASS.
- Step 7: Commit
git add packages/handlers packages/inngest-functions
git commit -m "feat(coding): session handlers + sandboxed Inngest orchestrator"Task 10: Surfaces — MCP tools, API route, CLI command (parity)
Files:
- Create:
apps/mcp/src/tools/agent.coding.session.start.ts,.get.ts,.cancel.ts - Create:
apps/api/src/routes/v1/agent.coding.ts; Modify:apps/api/src/app.ts(mount) - Create:
apps/cli/src/commands/code.ts(theoxagen code "<instruction>" --repo <connection>command); Modify the CLI command registry to register it - Test:
apps/api/src/routes/v1/agent.coding.test.ts
Interfaces:
-
MCP tools: verbatim from the extracted
agent.execution.record.tsMCP pattern (schema fromcontract.input.shape, metadata, default export callinginvoke(contract.name, args, ctx, { surface: "mcp" })). -
API route: a Hono router exposing
POST /agent/coding/sessions(start),GET /agent/coding/sessions/:id(get),POST /agent/coding/sessions/:id/cancel— eachbuildContext→invoke(...)→ return parsed output. Mount inapp.tsalongside the other v1 routes. -
CLI command:
oxagen code— resolves the workspace/connection, calls the APIPOST /agent/coding/sessions, then pollsGET .../:idand renders streamed events + the final diff in the terminal. -
Step 1: Write the API route test
agent.coding.test.ts(mockinvoke; assert 200 + output shape for start/get, 4xx on missing fields). Run → FAIL. -
Step 2: Implement the three MCP tool files (one per capability).
-
Step 3: Implement the API route + mount in
app.ts. -
Step 4: Implement the CLI
codecommand (reuse the CLI's existing API client + the streamed-render helpers used by other commands). -
Step 5: Verify parity + tests
pnpm check:manifest
pnpm --filter @oxagen/api test:unit -- agent.coding.test.ts
pnpm --filter @oxagen/api typecheck && pnpm --filter @oxagen/mcp typecheck && pnpm --filter @oxagen/cli typecheckExpected: manifest shows api+mcp+cli covered for all three capabilities; tests PASS.
- Step 6: Commit
git add apps/mcp apps/api apps/cli
git commit -m "feat(coding): MCP + API + CLI surfaces for agent.coding.session.*"Task 11: In-app surface — coding session in the chat background-task tray
Files:
- Modify:
apps/app/src/components/chat/background-task-tray.tsx(render a coding-session card with status + changed-file count + a "view diff" affordance) - Create:
apps/app/src/components/chat/coding-session-diff.tsx(a read-only unified-diff viewer using@/components/ui/*) - Modify:
apps/app/src/app/api/v1/chat/stream/translate-stream.ts(if needed, map the codingbackground-taskevent so the tray recognizeskind: "coding") - Test:
apps/app/src/components/chat/coding-session-diff.test.tsx - E2E:
apps/app/e2e/coding-session.spec.ts+ screenshots
Interfaces:
-
The in-app agent already calls
agent.coding.session.startas an async tool (it is materialized via the "agent" surface — verify it appears inmaterializeTools). The existing tray pollsagent.coding.session.get(same shape asagent.task.background.read). The new diff card consumes{ status, changedFiles, diff }from the poll result. -
Step 1: Write the diff-viewer component test (
coding-session-diff.test.tsx): renders added/removed lines from a sample unified diff with correct roles/classes. Run → FAIL. -
Step 2: Implement
coding-session-diff.tsx(pure presentational; parse+/-/context lines; cite changed files by path, never a UUID). -
Step 3: Wire the tray to render the diff card when
kind === "coding"and status is terminal; show live event text while running. -
Step 4: Component test PASS.
-
Step 5: E2E — seed a connected repo (fixture) + a stubbed sandbox/engine (so CI doesn't hit GitHub/Vercel Sandbox); drive chat → start a coding session → assert the tray shows a non-empty diff; screenshot to
apps/app/e2e/screenshots/. Delete+recreate the screenshots dir each run; ensure it's gitignored.
pnpm --filter @oxagen/app test:unit -- coding-session-diff.test.tsx
# E2E per the app's existing playwright invocation (single spec):
pnpm --filter @oxagen/app exec playwright test e2e/coding-session.spec.ts- Step 6: Commit
git add apps/app
git commit -m "feat(app): coding session diff card in chat background-task tray"Task 12: Docs, manifest sync, and Phase-1 gate
Files:
-
Create:
docs/capabilities/agent.coding.session.start.md,.get.md,.cancel.md; Modify:docs/capabilities/_index.md -
Modify:
.env.example(documentSANDBOX_ENABLED,SANDBOX_DRIVER,VERCEL_SANDBOX_*if not already present) -
Step 1: Write the three capability docs (purpose, input/output, surfaces, risk, example) and add them to
_index.md. -
Step 2: Sync env example + lockfile.
pnpm i --no-frozen-lockfile
pnpm env:check- Step 3: Dispatch
test-completeness-judgeover the full diff; re-run until APPROVED. - Step 4: Run the pre-merge gate (ONLY now, once, for the whole body of work — not per task):
pnpm build
pnpm gateExpected: lint (0 warnings) + typecheck + coverage + tests + migrations all green. Bump each touched package's coverage threshold up to min(90, floor(coverage − 2.5)) only if headroom holds.
- Step 5: Push + open PR
git push
gh pr create --base main --head feat/in-app-coding-agent --title "feat: in-app agentic coding runner (Phase 1)" --body "<summary + spec link + risks + rollback>"
gh run watch- Step 6: File Linear follow-ups for Phase 2 (VCS write layer + OAuth write-scope upgrade), Phase 3 (CI auto-fix loop),
@-mention UX, and the SSE live token-tail — each with model/effort header, spec link, acceptance checklist (per linear-ticket-discipline).
Phases 2–3 roadmap (NOT in this plan — separate specs/plans)
- Phase 2 — VCS write layer:
@oxagen/githubOctokit wrapper; OAuth write-scope upgrade (incremental re-consent on the connection);repo.pr.open/repo.pr.getcontracts; push branch from the sandbox using the user token; open a ready PR. Guard: never push to the default branch. - Phase 3 — CI auto-fix loop: extend
POST /webhooks/github/appto routecheck_run/workflow_runto the active session (currently excluded); on failure, resume the sandbox, feed failing logs torunCodingAgent, fix, re-push, cap at N=3; finalize on green or cap. Fully autonomous, no gate. - Cross-cutting —
@-mention UX: composer@repoautocomplete → structured mention resolving to aconnectionId.
Self-Review notes
- Spec coverage: every Phase-1 spec section maps to a task — engine (T1–T4), CLI convergence (T5), persistent sandbox (T6), DB (T7), contracts (T8), orchestration + KMS token + tenant scope + caps (T9), parity surfaces (T10), in-app streaming via tray (T11), docs/guardrails/gate (T12). Phases 2–3 +
@-mention are explicitly deferred (matches the spec's phasing). - Type consistency:
Workspace/CommandResult/CodingEvent/RunCodingAgentOptions/RunCodingAgentResultdefined in T1 are consumed unchanged in T2–T6, T9;SandboxWorkspaceHandledefined in T6 is consumed bySandboxWorkspace(T6) and the orchestrator (T9); contract input/output types (T8) are consumed by handlers (T9) and surfaces (T10). - Guardrail coverage: RLS+grants (T7), tenant-scoped steps + KMS decrypt + token redaction + sandbox-availability fail-closed + caps (T9), IAM via high-risk contract + defaultRoles (T8), instrumentation via
@oxagen/ai/invoketelemetry (engine + handlers), metering viainvoke()(T9/T10).