Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Dax Raad
2026-03-11 23:24:38 -04:00
130 changed files with 1514 additions and 989 deletions
+1
View File
@@ -36,6 +36,7 @@
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@standard-schema/spec": "1.0.0",
"@tsconfig/bun": "catalog:",
+4
View File
@@ -108,6 +108,10 @@ const allTargets: {
arch: "x64",
avx2: false,
},
{
os: "win32",
arch: "arm64",
},
{
os: "win32",
arch: "x64",
+3 -3
View File
@@ -11,7 +11,7 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Session } = await import("../src/session")
const { Identifier } = await import("../src/id/id")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
await Instance.provide({
@@ -19,8 +19,8 @@ const seed = async () => {
init: InstanceBootstrap,
fn: async () => {
const session = await Session.create({ title })
const messageID = Identifier.descending("message")
const partID = Identifier.descending("part")
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: session.id,
+2 -2
View File
@@ -3,13 +3,13 @@ import { Schema } from "effect"
import { withStatics } from "@/util/schema"
export const AccountID = Schema.String.pipe(
Schema.brand("AccountId"),
Schema.brand("AccountID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe(
Schema.brand("OrgId"),
Schema.brand("OrgID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type OrgID = Schema.Schema.Type<typeof OrgID>
+3 -3
View File
@@ -4,7 +4,7 @@ import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { Session } from "../../../session"
import type { MessageV2 } from "../../../session/message-v2"
import { Identifier } from "../../../id/id"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission/next"
@@ -113,7 +113,7 @@ function parseToolParams(input?: string) {
async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = Identifier.ascending("message")
const messageID = MessageID.ascending()
const model = agent.model ?? (await Provider.defaultModel())
const now = Date.now()
const message: MessageV2.Assistant = {
@@ -150,7 +150,7 @@ async function createToolContext(agent: Agent.Info) {
return {
sessionID: session.id,
messageID,
callID: Identifier.ascending("part"),
callID: PartID.ascending(),
agent: agent.name,
abort: new AbortController().signal,
messages: [],
+4 -3
View File
@@ -1,5 +1,6 @@
import type { Argv } from "yargs"
import { Session } from "../../session"
import { SessionID } from "../../session/schema"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
@@ -17,7 +18,7 @@ export const ExportCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
let sessionID = args.sessionID
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
if (!sessionID) {
@@ -58,7 +59,7 @@ export const ExportCommand = cmd({
throw new UI.CancelledError()
}
sessionID = selectedSession as string
sessionID = selectedSession
prompts.outro("Exporting session...", {
output: process.stderr,
@@ -67,7 +68,7 @@ export const ExportCommand = cmd({
try {
const sessionInfo = await Session.get(sessionID!)
const messages = await Session.messages({ sessionID: sessionID! })
const messages = await Session.messages({ sessionID: sessionInfo.id })
const exportData = {
info: sessionInfo,
+8 -7
View File
@@ -22,7 +22,8 @@ import { ModelsDev } from "../../provider/models"
import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap"
import { Session } from "../../session"
import { Identifier } from "../../id/id"
import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema"
import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
@@ -481,7 +482,7 @@ export const GithubRunCommand = cmd({
let octoRest: Octokit
let octoGraph: typeof graphql
let gitConfig: string
let session: { id: string; title: string; version: string }
let session: { id: SessionID; title: string; version: string }
let shareId: string | undefined
let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
@@ -934,7 +935,7 @@ export const GithubRunCommand = cmd({
const result = await SessionPrompt.prompt({
sessionID: session.id,
messageID: Identifier.ascending("message"),
messageID: MessageID.ascending(),
variant,
model: {
providerID,
@@ -943,13 +944,13 @@ export const GithubRunCommand = cmd({
// agent is omitted - server will use default_agent from config or fall back to "build"
parts: [
{
id: Identifier.ascending("part"),
id: PartID.ascending(),
type: "text",
text: message,
},
...files.flatMap((f) => [
{
id: Identifier.ascending("part"),
id: PartID.ascending(),
type: "file" as const,
mime: f.mime,
url: `data:${f.mime};base64,${f.content}`,
@@ -988,7 +989,7 @@ export const GithubRunCommand = cmd({
console.log("Requesting summary from agent...")
const summary = await SessionPrompt.prompt({
sessionID: session.id,
messageID: Identifier.ascending("message"),
messageID: MessageID.ascending(),
variant,
model: {
providerID,
@@ -997,7 +998,7 @@ export const GithubRunCommand = cmd({
tools: { "*": false }, // Disable all tools to force text response
parts: [
{
id: Identifier.ascending("part"),
id: PartID.ascending(),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
+25 -8
View File
@@ -1,6 +1,8 @@
import type { Argv } from "yargs"
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "../../session"
import { SessionID, MessageID, PartID } from "../../session/schema"
import { WorkspaceID } from "../../control-plane/schema"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { Database } from "../../storage/db"
@@ -152,7 +154,20 @@ export const ImportCommand = cmd({
return
}
const row = Session.toRow({ ...exportData.info, projectID: Instance.project.id })
const row = Session.toRow({
...exportData.info,
id: SessionID.make(exportData.info.id),
parentID: exportData.info.parentID ? SessionID.make(exportData.info.parentID) : undefined,
workspaceID: exportData.info.workspaceID ? WorkspaceID.make(exportData.info.workspaceID) : undefined,
projectID: Instance.project.id,
revert: exportData.info.revert
? {
...exportData.info.revert,
messageID: MessageID.make(exportData.info.revert.messageID),
partID: exportData.info.revert.partID ? PartID.make(exportData.info.revert.partID) : undefined,
}
: undefined,
})
Database.use((db) =>
db
.insert(SessionTable)
@@ -162,28 +177,30 @@ export const ImportCommand = cmd({
)
for (const msg of exportData.messages) {
const { id: _mid, sessionID: _msid, ...msgData } = msg.info
Database.use((db) =>
db
.insert(MessageTable)
.values({
id: msg.info.id,
session_id: exportData.info.id,
id: MessageID.make(msg.info.id),
session_id: row.id,
time_created: msg.info.time?.created ?? Date.now(),
data: msg.info,
data: msgData,
})
.onConflictDoNothing()
.run(),
)
for (const part of msg.parts) {
const { id: _pid, sessionID: _psid, messageID: _pmid, ...partData } = part
Database.use((db) =>
db
.insert(PartTable)
.values({
id: part.id,
message_id: msg.info.id,
session_id: exportData.info.id,
data: part,
id: PartID.make(part.id),
message_id: MessageID.make(msg.info.id),
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.run(),
+4 -2
View File
@@ -1,6 +1,7 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { SessionID } from "../../session/schema"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "../../util/locale"
@@ -57,13 +58,14 @@ export const SessionDeleteCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessionID = SessionID.make(args.sessionID)
try {
await Session.get(args.sessionID)
await Session.get(sessionID)
} catch {
UI.error(`Session not found: ${args.sessionID}`)
process.exit(1)
}
await Session.remove(args.sessionID)
await Session.remove(sessionID)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
})
},
@@ -9,7 +9,7 @@ import { EmptyBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { Identifier } from "@/id/id"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
@@ -561,7 +561,7 @@ export function Prompt(props: PromptProps) {
sessionID = res.data.id
}
const messageID = Identifier.ascending("message")
const messageID = MessageID.ascending()
let inputText = store.prompt.input
// Expand pasted text inline before submitting
@@ -624,7 +624,7 @@ export function Prompt(props: PromptProps) {
parts: nonTextParts
.filter((x) => x.type === "file")
.map((x) => ({
id: Identifier.ascending("part"),
id: PartID.ascending(),
...x,
})),
})
@@ -639,12 +639,12 @@ export function Prompt(props: PromptProps) {
variant,
parts: [
{
id: Identifier.ascending("part"),
id: PartID.ascending(),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
id: PartID.ascending(),
...x,
})),
],
+2 -1
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionID } from "@/session/schema"
import z from "zod"
export const TuiEvent = {
@@ -42,7 +43,7 @@ export const TuiEvent = {
SessionSelect: BusEvent.define(
"tui.session.select",
z.object({
sessionID: z.string().regex(/^ses/).describe("Session ID to navigate to"),
sessionID: SessionID.zod.describe("Session ID to navigate to"),
}),
),
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
@@ -14,9 +15,9 @@ export namespace Command {
"command.executed",
z.object({
name: z.string(),
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
arguments: z.string(),
messageID: Identifier.schema("message"),
messageID: MessageID.zod,
}),
),
}
@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id"
const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceID"))
export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
})),
)
+2 -2
View File
@@ -1,9 +1,9 @@
import z from "zod"
import { Identifier } from "@/id/id"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
export const WorkspaceInfo = z.object({
id: Identifier.schema("workspace"),
id: WorkspaceID.zod,
type: z.string(),
branch: z.string().nullable(),
name: z.string().nullable(),
@@ -1,13 +1,14 @@
import { Context } from "../util/context"
import type { WorkspaceID } from "./schema"
interface Context {
workspaceID?: string
workspaceID?: WorkspaceID
}
const context = Context.create<Context>("workspace")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID?: string; fn: () => R }): Promise<R> {
async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, async () => {
return input.fn()
})
@@ -4,6 +4,7 @@ import { InstanceBootstrap } from "../../project/bootstrap"
import { SessionRoutes } from "../../server/routes/session"
import { WorkspaceServerRoutes } from "./routes"
import { WorkspaceContext } from "../workspace-context"
import { WorkspaceID } from "../schema"
export namespace WorkspaceServer {
export function App() {
@@ -20,9 +21,9 @@ export namespace WorkspaceServer {
return new Hono()
.use(async (c, next) => {
const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory")
if (workspaceID == null) {
if (rawWorkspaceID == null) {
throw new Error("workspaceID parameter is required")
}
if (raw == null) {
@@ -38,7 +39,7 @@ export namespace WorkspaceServer {
})()
return WorkspaceContext.provide({
workspaceID,
workspaceID: WorkspaceID.make(rawWorkspaceID),
async fn() {
return Instance.provide({
directory,
@@ -1,9 +1,10 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import type { WorkspaceID } from "./schema"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().primaryKey(),
id: text().$type<WorkspaceID>().primaryKey(),
type: text().notNull(),
branch: text(),
name: text(),
@@ -1,5 +1,4 @@
import z from "zod"
import { Identifier } from "@/id/id"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
@@ -10,6 +9,7 @@ import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
export namespace Workspace {
@@ -46,7 +46,7 @@ export namespace Workspace {
}
const CreateInput = z.object({
id: Identifier.schema("workspace").optional(),
id: WorkspaceID.zod.optional(),
type: Info.shape.type,
branch: Info.shape.branch,
projectID: ProjectID.zod,
@@ -54,7 +54,7 @@ export namespace Workspace {
})
export const create = fn(CreateInput, async (input) => {
const id = Identifier.ascending("workspace", input.id)
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.type)
const config = await adaptor.configure({ ...input, id, name: null, directory: null })
@@ -94,13 +94,13 @@ export namespace Workspace {
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
}
export const get = fn(Identifier.schema("workspace"), async (id) => {
export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
})
export const remove = fn(Identifier.schema("workspace"), async (id) => {
export const remove = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
const info = fromRow(row)
+1
View File
@@ -100,6 +100,7 @@ export namespace Ripgrep {
},
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
+8 -7
View File
@@ -1,11 +1,12 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionID, MessageID } from "@/session/schema"
import z from "zod"
import { Log } from "../util/log"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import { Instance } from "../project/instance"
import { Wildcard } from "../util/wildcard"
import { PermissionID } from "./schema"
export namespace Permission {
const log = Log.create({ service: "permission" })
@@ -21,11 +22,11 @@ export namespace Permission {
export const Info = z
.object({
id: z.string(),
id: PermissionID.zod,
type: z.string(),
pattern: z.union([z.string(), z.array(z.string())]).optional(),
sessionID: z.string(),
messageID: z.string(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
callID: z.string().optional(),
message: z.string(),
metadata: z.record(z.string(), z.any()),
@@ -43,8 +44,8 @@ export namespace Permission {
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: z.string(),
permissionID: z.string(),
sessionID: SessionID.zod,
permissionID: PermissionID.zod,
response: z.string(),
}),
),
@@ -117,7 +118,7 @@ export namespace Permission {
const keys = toKeys(input.pattern, input.type)
if (covered(keys, approvedForSession)) return
const info: Info = {
id: Identifier.ascending("permission"),
id: PermissionID.ascending(),
type: input.type,
pattern: input.pattern,
sessionID: input.sessionID,
+9 -8
View File
@@ -1,7 +1,8 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Identifier } from "@/id/id"
import { SessionID, MessageID } from "@/session/schema"
import { PermissionID } from "./schema"
import { Instance } from "@/project/instance"
import { Database, eq } from "@/storage/db"
import { PermissionTable } from "@/session/session.sql"
@@ -68,15 +69,15 @@ export namespace PermissionNext {
export const Request = z
.object({
id: Identifier.schema("permission"),
sessionID: Identifier.schema("session"),
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: z.string(),
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
@@ -100,8 +101,8 @@ export namespace PermissionNext {
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: z.string(),
requestID: z.string(),
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
@@ -142,7 +143,7 @@ export namespace PermissionNext {
if (rule.action === "deny")
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
const id = input.id ?? PermissionID.ascending()
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
@@ -163,7 +164,7 @@ export namespace PermissionNext {
export const reply = fn(
z.object({
requestID: Identifier.schema("permission"),
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
}),
@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
const permissionIdSchema = Schema.String.pipe(Schema.brand("PermissionID"))
export type PermissionID = typeof permissionIdSchema.Type
export const PermissionID = permissionIdSchema.pipe(
withStatics((schema: typeof permissionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("permission", id)),
zod: Identifier.schema("permission").pipe(z.custom<PermissionID>()),
})),
)
+1 -1
View File
@@ -3,7 +3,7 @@ import z from "zod"
import { withStatics } from "@/util/schema"
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectId"))
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
export type ProjectID = typeof projectIdSchema.Type
+6 -6
View File
@@ -2,12 +2,12 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
export namespace Pty {
const log = Log.create({ service: "pty" })
@@ -40,7 +40,7 @@ export namespace Pty {
export const Info = z
.object({
id: Identifier.schema("pty"),
id: PtyID.zod,
title: z.string(),
command: z.string(),
args: z.array(z.string()),
@@ -77,8 +77,8 @@ export namespace Pty {
export const Event = {
Created: BusEvent.define("pty.created", z.object({ info: Info })),
Updated: BusEvent.define("pty.updated", z.object({ info: Info })),
Exited: BusEvent.define("pty.exited", z.object({ id: Identifier.schema("pty"), exitCode: z.number() })),
Deleted: BusEvent.define("pty.deleted", z.object({ id: Identifier.schema("pty") })),
Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })),
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
}
interface ActiveSession {
@@ -118,7 +118,7 @@ export namespace Pty {
}
export async function create(input: CreateInput) {
const id = Identifier.create("pty", false)
const id = PtyID.ascending()
const command = input.command || Shell.preferred()
const args = input.args || []
if (command.endsWith("sh")) {
@@ -234,7 +234,7 @@ export namespace Pty {
}
}
session.subscribers.clear()
Bus.publish(Event.Deleted, { id })
Bus.publish(Event.Deleted, { id: session.info.id })
}
export function resize(id: string, cols: number, rows: number) {
+17
View File
@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
const ptyIdSchema = Schema.String.pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type
export const PtyID = ptyIdSchema.pipe(
withStatics((schema: typeof ptyIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("pty", id)),
zod: Identifier.schema("pty").pipe(z.custom<PtyID>()),
})),
)
+12 -11
View File
@@ -1,9 +1,10 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Identifier } from "@/id/id"
import { SessionID, MessageID } from "@/session/schema"
import { Instance } from "@/project/instance"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
export namespace Question {
const log = Log.create({ service: "question" })
@@ -33,12 +34,12 @@ export namespace Question {
export const Request = z
.object({
id: Identifier.schema("question"),
sessionID: Identifier.schema("session"),
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: z.string(),
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
@@ -65,16 +66,16 @@ export namespace Question {
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: z.string(),
requestID: z.string(),
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: z.string(),
requestID: z.string(),
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
@@ -95,12 +96,12 @@ export namespace Question {
})
export async function ask(input: {
sessionID: string
sessionID: SessionID
questions: Info[]
tool?: { messageID: string; callID: string }
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
const s = await state()
const id = Identifier.ascending("question")
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
+17
View File
@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID"))
export type QuestionID = typeof questionIdSchema.Type
export const QuestionID = questionIdSchema.pipe(
withStatics((schema: typeof questionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)),
zod: Identifier.schema("question").pipe(z.custom<QuestionID>()),
})),
)
@@ -2,6 +2,7 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { PermissionNext } from "@/permission/next"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -28,7 +29,7 @@ export const PermissionRoutes = lazy(() =>
validator(
"param",
z.object({
requestID: z.string(),
requestID: PermissionID.zod,
}),
),
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
+6 -5
View File
@@ -3,6 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -72,7 +73,7 @@ export const PtyRoutes = lazy(() =>
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
const info = Pty.get(c.req.valid("param").ptyID)
if (!info) {
@@ -99,7 +100,7 @@ export const PtyRoutes = lazy(() =>
...errors(400),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput),
async (c) => {
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
@@ -124,7 +125,7 @@ export const PtyRoutes = lazy(() =>
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
await Pty.remove(c.req.valid("param").ptyID)
return c.json(true)
@@ -148,9 +149,9 @@ export const PtyRoutes = lazy(() =>
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("param", z.object({ ptyID: PtyID.zod })),
upgradeWebSocket((c) => {
const id = c.req.param("ptyID")
const id = PtyID.zod.parse(c.req.param("ptyID"))
const cursor = (() => {
const value = c.req.query("cursor")
if (!value) return
@@ -1,6 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema"
import { Question } from "../../question"
import z from "zod"
import { errors } from "../error"
@@ -51,7 +52,7 @@ export const QuestionRoutes = lazy(() =>
validator(
"param",
z.object({
requestID: z.string(),
requestID: QuestionID.zod,
}),
),
validator("json", Question.Reply),
@@ -86,7 +87,7 @@ export const QuestionRoutes = lazy(() =>
validator(
"param",
z.object({
requestID: z.string(),
requestID: QuestionID.zod,
}),
),
async (c) => {
+27 -25
View File
@@ -1,6 +1,7 @@
import { Hono } from "hono"
import { stream } from "hono/streaming"
import { describeRoute, validator, resolver } from "hono-openapi"
import { SessionID, MessageID, PartID } from "@/session/schema"
import z from "zod"
import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
@@ -14,6 +15,7 @@ import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -173,7 +175,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
sessionID: SessionID.zod,
}),
),
async (c) => {
@@ -258,7 +260,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
}),
),
validator(
@@ -309,7 +311,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
sessionID: SessionID.zod,
}),
),
validator("json", Session.initialize.schema.omit({ sessionID: true })),
@@ -372,7 +374,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
}),
),
async (c) => {
@@ -401,7 +403,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
}),
),
async (c) => {
@@ -502,7 +504,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
sessionID: SessionID.zod,
}),
),
validator(
@@ -561,7 +563,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
sessionID: SessionID.zod,
}),
),
validator(
@@ -605,8 +607,8 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
),
async (c) => {
@@ -640,8 +642,8 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
),
async (c) => {
@@ -674,9 +676,9 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
partID: z.string().meta({ description: "Part ID" }),
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
),
async (c) => {
@@ -709,9 +711,9 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
partID: z.string().meta({ description: "Part ID" }),
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
),
validator("json", MessageV2.Part),
@@ -753,7 +755,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
sessionID: SessionID.zod,
}),
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
@@ -785,7 +787,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
sessionID: SessionID.zod,
}),
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
@@ -825,7 +827,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
sessionID: SessionID.zod,
}),
),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
@@ -857,7 +859,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
sessionID: SessionID.zod,
}),
),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
@@ -889,7 +891,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
}),
),
validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
@@ -924,7 +926,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
}),
),
async (c) => {
@@ -955,8 +957,8 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: z.string(),
permissionID: z.string(),
sessionID: SessionID.zod,
permissionID: PermissionID.zod,
}),
),
validator("json", z.object({ response: PermissionNext.Reply })),
+3 -2
View File
@@ -22,6 +22,7 @@ import { Flag } from "../flag/flag"
import { Command } from "../command"
import { Global } from "../global"
import { WorkspaceContext } from "../control-plane/workspace-context"
import { WorkspaceID } from "../control-plane/schema"
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session"
@@ -190,7 +191,7 @@ export namespace Server {
)
.use(async (c, next) => {
if (c.req.path === "/log") return next()
const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
(() => {
@@ -203,7 +204,7 @@ export namespace Server {
)
return WorkspaceContext.provide({
workspaceID,
workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined,
async fn() {
return Instance.provide({
directory,
+13 -13
View File
@@ -1,7 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Session } from "."
import { Identifier } from "../id/id"
import { SessionID, MessageID, PartID } from "./schema"
import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
@@ -22,7 +22,7 @@ export namespace SessionCompaction {
Compacted: BusEvent.define(
"session.compacted",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
}),
),
}
@@ -55,7 +55,7 @@ export namespace SessionCompaction {
// goes backwards through parts until there are 40_000 tokens worth of tool
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune(input: { sessionID: string }) {
export async function prune(input: { sessionID: SessionID }) {
const config = await Config.get()
if (config.compaction?.prune === false) return
log.info("pruning")
@@ -99,9 +99,9 @@ export namespace SessionCompaction {
}
export async function process(input: {
parentID: string
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: string
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
@@ -133,7 +133,7 @@ export namespace SessionCompaction {
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
: await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
const msg = (await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "assistant",
parentID: input.parentID,
sessionID: input.sessionID,
@@ -236,7 +236,7 @@ When constructing the summary, try to stick to this template:
if (replay) {
const original = replay.info as MessageV2.User
const replayMsg = await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
@@ -255,14 +255,14 @@ When constructing the summary, try to stick to this template:
: part
await Session.updatePart({
...replayPart,
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: replayMsg.id,
sessionID: input.sessionID,
})
}
} else {
const continueMsg = await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
@@ -275,7 +275,7 @@ When constructing the summary, try to stick to this template:
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
@@ -295,7 +295,7 @@ When constructing the summary, try to stick to this template:
export const create = fn(
z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
agent: z.string(),
model: z.object({
providerID: z.string(),
@@ -306,7 +306,7 @@ When constructing the summary, try to stick to this template:
}),
async (input) => {
const msg = await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
@@ -316,7 +316,7 @@ When constructing the summary, try to stick to this template:
},
})
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
+45 -44
View File
@@ -7,7 +7,6 @@ import z from "zod"
import { type ProviderMetadata } from "ai"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
@@ -24,6 +23,8 @@ import { Command } from "../command"
import { Snapshot } from "@/snapshot"
import { WorkspaceContext } from "../control-plane/workspace-context"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
@@ -119,12 +120,12 @@ export namespace Session {
export const Info = z
.object({
id: Identifier.schema("session"),
id: SessionID.zod,
slug: z.string(),
projectID: ProjectID.zod,
workspaceID: z.string().optional(),
workspaceID: WorkspaceID.zod.optional(),
directory: z.string(),
parentID: Identifier.schema("session").optional(),
parentID: SessionID.zod.optional(),
summary: z
.object({
additions: z.number(),
@@ -149,8 +150,8 @@ export namespace Session {
permission: PermissionNext.Ruleset.optional(),
revert: z
.object({
messageID: z.string(),
partID: z.string().optional(),
messageID: MessageID.zod,
partID: PartID.zod.optional(),
snapshot: z.string().optional(),
diff: z.string().optional(),
})
@@ -201,14 +202,14 @@ export namespace Session {
Diff: BusEvent.define(
"session.diff",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
diff: Snapshot.FileDiff.array(),
}),
),
Error: BusEvent.define(
"session.error",
z.object({
sessionID: z.string().optional(),
sessionID: SessionID.zod.optional(),
error: MessageV2.Assistant.shape.error,
}),
),
@@ -217,10 +218,10 @@ export namespace Session {
export const create = fn(
z
.object({
parentID: Identifier.schema("session").optional(),
parentID: SessionID.zod.optional(),
title: z.string().optional(),
permission: Info.shape.permission,
workspaceID: Identifier.schema("workspace").optional(),
workspaceID: WorkspaceID.zod.optional(),
})
.optional(),
async (input) => {
@@ -236,8 +237,8 @@ export namespace Session {
export const fork = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message").optional(),
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
}),
async (input) => {
const original = await get(input.sessionID)
@@ -249,11 +250,11 @@ export namespace Session {
title,
})
const msgs = await messages({ sessionID: input.sessionID })
const idMap = new Map<string, string>()
const idMap = new Map<string, MessageID>()
for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
const newID = Identifier.ascending("message")
const newID = MessageID.ascending()
idMap.set(msg.info.id, newID)
const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
@@ -267,7 +268,7 @@ export namespace Session {
for (const part of msg.parts) {
await updatePart({
...part,
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: cloned.id,
sessionID: session.id,
})
@@ -277,7 +278,7 @@ export namespace Session {
},
)
export const touch = fn(Identifier.schema("session"), async (sessionID) => {
export const touch = fn(SessionID.zod, async (sessionID) => {
const now = Date.now()
Database.use((db) => {
const row = db
@@ -293,15 +294,15 @@ export namespace Session {
})
export async function createNext(input: {
id?: string
id?: SessionID
title?: string
parentID?: string
workspaceID?: string
parentID?: SessionID
workspaceID?: WorkspaceID
directory: string
permission?: PermissionNext.Ruleset
}) {
const result: Info = {
id: Identifier.descending("session", input.id),
id: SessionID.descending(input.id),
slug: Slug.create(),
version: Installation.VERSION,
projectID: Instance.project.id,
@@ -342,13 +343,13 @@ export namespace Session {
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const get = fn(Identifier.schema("session"), async (id) => {
export const get = fn(SessionID.zod, async (id) => {
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
return fromRow(row)
})
export const share = fn(Identifier.schema("session"), async (id) => {
export const share = fn(SessionID.zod, async (id) => {
const cfg = await Config.get()
if (cfg.share === "disabled") {
throw new Error("Sharing is disabled in configuration")
@@ -364,7 +365,7 @@ export namespace Session {
return share
})
export const unshare = fn(Identifier.schema("session"), async (id) => {
export const unshare = fn(SessionID.zod, async (id) => {
// Use ShareNext to remove the share (same as share function uses ShareNext to create)
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
@@ -378,7 +379,7 @@ export namespace Session {
export const setTitle = fn(
z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
title: z.string(),
}),
async (input) => {
@@ -399,7 +400,7 @@ export namespace Session {
export const setArchived = fn(
z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
time: z.number().optional(),
}),
async (input) => {
@@ -420,7 +421,7 @@ export namespace Session {
export const setPermission = fn(
z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
permission: PermissionNext.Ruleset,
}),
async (input) => {
@@ -441,7 +442,7 @@ export namespace Session {
export const setRevert = fn(
z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
revert: Info.shape.revert,
summary: Info.shape.summary,
}),
@@ -467,7 +468,7 @@ export namespace Session {
},
)
export const clearRevert = fn(Identifier.schema("session"), async (sessionID) => {
export const clearRevert = fn(SessionID.zod, async (sessionID) => {
return Database.use((db) => {
const row = db
.update(SessionTable)
@@ -487,7 +488,7 @@ export namespace Session {
export const setSummary = fn(
z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
summary: Info.shape.summary,
}),
async (input) => {
@@ -511,7 +512,7 @@ export namespace Session {
},
)
export const diff = fn(Identifier.schema("session"), async (sessionID) => {
export const diff = fn(SessionID.zod, async (sessionID) => {
try {
return await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
} catch {
@@ -521,7 +522,7 @@ export namespace Session {
export const messages = fn(
z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
limit: z.number().optional(),
}),
async (input) => {
@@ -537,7 +538,7 @@ export namespace Session {
export function* list(input?: {
directory?: string
workspaceID?: string
workspaceID?: WorkspaceID
roots?: boolean
start?: number
search?: string
@@ -647,7 +648,7 @@ export namespace Session {
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
export const children = fn(SessionID.zod, async (parentID) => {
const project = Instance.project
const rows = Database.use((db) =>
db
@@ -659,7 +660,7 @@ export namespace Session {
return rows.map(fromRow)
})
export const remove = fn(Identifier.schema("session"), async (sessionID) => {
export const remove = fn(SessionID.zod, async (sessionID) => {
const project = Instance.project
try {
const session = await get(sessionID)
@@ -705,8 +706,8 @@ export namespace Session {
export const removeMessage = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
async (input) => {
// CASCADE delete handles parts automatically
@@ -727,9 +728,9 @@ export namespace Session {
export const removePart = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
partID: Identifier.schema("part"),
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
async (input) => {
Database.use((db) => {
@@ -775,9 +776,9 @@ export namespace Session {
export const updatePartDelta = fn(
z.object({
sessionID: z.string(),
messageID: z.string(),
partID: z.string(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
field: z.string(),
delta: z.string(),
}),
@@ -873,10 +874,10 @@ export namespace Session {
export const initialize = fn(
z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
modelID: z.string(),
providerID: z.string(),
messageID: Identifier.schema("message"),
messageID: MessageID.zod,
}),
async (input) => {
await SessionPrompt.command({
+20 -20
View File
@@ -1,8 +1,8 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID, PartID } from "./schema"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { Identifier } from "../id/id"
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
@@ -78,9 +78,9 @@ export namespace MessageV2 {
export type OutputFormat = z.infer<typeof Format>
const PartBase = z.object({
id: z.string(),
sessionID: z.string(),
messageID: z.string(),
id: PartID.zod,
sessionID: SessionID.zod,
messageID: MessageID.zod,
})
export const SnapshotPart = PartBase.extend({
@@ -343,8 +343,8 @@ export namespace MessageV2 {
export type ToolPart = z.infer<typeof ToolPart>
const Base = z.object({
id: z.string(),
sessionID: z.string(),
id: MessageID.zod,
sessionID: SessionID.zod,
})
export const User = Base.extend({
@@ -410,7 +410,7 @@ export namespace MessageV2 {
APIError.Schema,
])
.optional(),
parentID: z.string(),
parentID: MessageID.zod,
modelID: z.string(),
providerID: z.string(),
/**
@@ -457,8 +457,8 @@ export namespace MessageV2 {
Removed: BusEvent.define(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
),
PartUpdated: BusEvent.define(
@@ -470,9 +470,9 @@ export namespace MessageV2 {
PartDelta: BusEvent.define(
"message.part.delta",
z.object({
sessionID: z.string(),
messageID: z.string(),
partID: z.string(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
field: z.string(),
delta: z.string(),
}),
@@ -480,9 +480,9 @@ export namespace MessageV2 {
PartRemoved: BusEvent.define(
"message.part.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
partID: z.string(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
),
}
@@ -698,7 +698,7 @@ export namespace MessageV2 {
// media (images, PDFs) in tool results
if (media.length > 0) {
result.push({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "user",
parts: [
{
@@ -728,7 +728,7 @@ export namespace MessageV2 {
)
}
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
export const stream = fn(SessionID.zod, async function* (sessionID) {
const size = 50
let offset = 0
while (true) {
@@ -781,7 +781,7 @@ export namespace MessageV2 {
}
})
export const parts = fn(Identifier.schema("message"), async (message_id) => {
export const parts = fn(MessageID.zod, async (message_id) => {
const rows = Database.use((db) =>
db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
)
@@ -792,8 +792,8 @@ export namespace MessageV2 {
export const get = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
async (input): Promise<WithParts> => {
const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get())
+2 -1
View File
@@ -1,4 +1,5 @@
import z from "zod"
import { SessionID } from "./schema"
import { NamedError } from "@opencode-ai/util/error"
export namespace Message {
@@ -142,7 +143,7 @@ export namespace Message {
error: z
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
.optional(),
sessionID: z.string(),
sessionID: SessionID.zod,
tool: z.record(
z.string(),
z
+10 -9
View File
@@ -1,6 +1,5 @@
import { MessageV2 } from "./message-v2"
import { Log } from "@/util/log"
import { Identifier } from "@/id/id"
import { Session } from "."
import { Agent } from "@/agent/agent"
import { Snapshot } from "@/snapshot"
@@ -15,6 +14,8 @@ import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { Question } from "@/question"
import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -25,7 +26,7 @@ export namespace SessionProcessor {
export function create(input: {
assistantMessage: MessageV2.Assistant
sessionID: string
sessionID: SessionID
model: Provider.Model
abort: AbortSignal
}) {
@@ -64,7 +65,7 @@ export namespace SessionProcessor {
continue
}
const reasoningPart = {
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
type: "reasoning" as const,
@@ -110,7 +111,7 @@ export namespace SessionProcessor {
case "tool-input-start":
const part = await Session.updatePart({
id: toolcalls[value.id]?.id ?? Identifier.ascending("part"),
id: toolcalls[value.id]?.id ?? PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
type: "tool",
@@ -233,7 +234,7 @@ export namespace SessionProcessor {
case "start-step":
snapshot = await Snapshot.track()
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
snapshot,
@@ -251,7 +252,7 @@ export namespace SessionProcessor {
input.assistantMessage.cost += usage.cost
input.assistantMessage.tokens = usage.tokens
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
reason: value.finishReason,
snapshot: await Snapshot.track(),
messageID: input.assistantMessage.id,
@@ -265,7 +266,7 @@ export namespace SessionProcessor {
const patch = await Snapshot.patch(snapshot)
if (patch.files.length) {
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
type: "patch",
@@ -289,7 +290,7 @@ export namespace SessionProcessor {
case "text-start":
currentText = {
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
type: "text",
@@ -388,7 +389,7 @@ export namespace SessionProcessor {
const patch = await Snapshot.patch(snapshot)
if (patch.files.length) {
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
type: "patch",
+28 -28
View File
@@ -3,7 +3,7 @@ import os from "os"
import fs from "fs/promises"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { Identifier } from "../id/id"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import { Log } from "../util/log"
import { SessionRevert } from "./revert"
@@ -84,14 +84,14 @@ export namespace SessionPrompt {
},
)
export function assertNotBusy(sessionID: string) {
export function assertNotBusy(sessionID: SessionID) {
const match = state()[sessionID]
if (match) throw new Session.BusyError(sessionID)
}
export const PromptInput = z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message").optional(),
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
model: z
.object({
providerID: z.string(),
@@ -254,7 +254,7 @@ export namespace SessionPrompt {
return s[sessionID].abort.signal
}
export function cancel(sessionID: string) {
export function cancel(sessionID: SessionID) {
log.info("cancel", { sessionID })
const s = state()
const match = s[sessionID]
@@ -269,7 +269,7 @@ export namespace SessionPrompt {
}
export const LoopInput = z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
resume_existing: z.boolean().optional(),
})
export const loop = fn(LoopInput, async (input) => {
@@ -354,7 +354,7 @@ export namespace SessionPrompt {
const taskTool = await TaskTool.init()
const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model
const assistantMessage = (await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "assistant",
parentID: lastUser.id,
sessionID,
@@ -379,7 +379,7 @@ export namespace SessionPrompt {
},
})) as MessageV2.Assistant
let part = (await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: assistantMessage.id,
sessionID: assistantMessage.sessionID,
type: "tool",
@@ -448,7 +448,7 @@ export namespace SessionPrompt {
})
const attachments = result?.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
id: PartID.ascending(),
sessionID,
messageID: assistantMessage.id,
}))
@@ -503,7 +503,7 @@ export namespace SessionPrompt {
// If we create assistant messages w/ out user ones following mid loop thinking signatures
// will be missing and it can cause errors for models like gemini for example
const summaryUserMsg: MessageV2.User = {
id: Identifier.ascending("message"),
id: MessageID.ascending(),
sessionID,
role: "user",
time: {
@@ -514,7 +514,7 @@ export namespace SessionPrompt {
}
await Session.updateMessage(summaryUserMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: summaryUserMsg.id,
sessionID,
type: "text",
@@ -567,7 +567,7 @@ export namespace SessionPrompt {
const processor = SessionProcessor.create({
assistantMessage: (await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
parentID: lastUser.id,
role: "assistant",
mode: agent.name,
@@ -731,7 +731,7 @@ export namespace SessionPrompt {
throw new Error("Impossible")
})
async function lastModel(sessionID: string) {
async function lastModel(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
@@ -813,7 +813,7 @@ export namespace SessionPrompt {
...result,
attachments: result.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
@@ -916,7 +916,7 @@ export namespace SessionPrompt {
output: truncated.content,
attachments: attachments.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
@@ -970,7 +970,7 @@ export namespace SessionPrompt {
const variant = input.variant ?? (agent.variant && full?.variants?.[agent.variant] ? agent.variant : undefined)
const info: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),
id: input.messageID ?? MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: {
@@ -988,7 +988,7 @@ export namespace SessionPrompt {
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
...part,
id: part.id ?? Identifier.ascending("part"),
id: part.id ? PartID.make(part.id) : PartID.ascending(),
})
const parts = await Promise.all(
@@ -1334,7 +1334,7 @@ export namespace SessionPrompt {
if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
if (input.agent.name === "plan") {
userMessage.parts.push({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
@@ -1345,7 +1345,7 @@ export namespace SessionPrompt {
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
userMessage.parts.push({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
@@ -1365,7 +1365,7 @@ export namespace SessionPrompt {
const exists = await Filesystem.exists(plan)
if (exists) {
const part = await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
@@ -1384,7 +1384,7 @@ export namespace SessionPrompt {
const exists = await Filesystem.exists(plan)
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
const part = await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
@@ -1467,7 +1467,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
export const ShellInput = z.object({
sessionID: Identifier.schema("session"),
sessionID: SessionID.zod,
agent: z.string(),
model: z
.object({
@@ -1504,7 +1504,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const agent = await Agent.get(input.agent)
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
id: MessageID.ascending(),
sessionID: input.sessionID,
time: {
created: Date.now(),
@@ -1519,7 +1519,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
await Session.updateMessage(userMsg)
const userPart: MessageV2.Part = {
type: "text",
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: input.sessionID,
text: "The following tool was executed by the user",
@@ -1528,7 +1528,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
await Session.updatePart(userPart)
const msg: MessageV2.Assistant = {
id: Identifier.ascending("message"),
id: MessageID.ascending(),
sessionID: input.sessionID,
parentID: userMsg.id,
mode: input.agent,
@@ -1554,7 +1554,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
await Session.updateMessage(msg)
const part: MessageV2.Part = {
type: "tool",
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: msg.id,
sessionID: input.sessionID,
tool: "bash",
@@ -1718,8 +1718,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
export const CommandInput = z.object({
messageID: Identifier.schema("message").optional(),
sessionID: Identifier.schema("session"),
messageID: MessageID.zod.optional(),
sessionID: SessionID.zod,
agent: z.string().optional(),
model: z.string().optional(),
arguments: z.string(),
+5 -5
View File
@@ -1,5 +1,5 @@
import z from "zod"
import { Identifier } from "../id/id"
import { SessionID, MessageID, PartID } from "./schema"
import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2"
import { Session } from "."
@@ -15,9 +15,9 @@ export namespace SessionRevert {
const log = Log.create({ service: "session.revert" })
export const RevertInput = z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
partID: Identifier.schema("part").optional(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod.optional(),
})
export type RevertInput = z.infer<typeof RevertInput>
@@ -79,7 +79,7 @@ export namespace SessionRevert {
return session
}
export async function unrevert(input: { sessionID: string }) {
export async function unrevert(input: { sessionID: SessionID }) {
log.info("unreverting", input)
SessionPrompt.assertNotBusy(input.sessionID)
const session = await Session.get(input.sessionID)
+41
View File
@@ -0,0 +1,41 @@
import { Schema } from "effect"
import z from "zod"
import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id"
const sessionIdSchema = Schema.String.pipe(Schema.brand("SessionID"))
export type SessionID = typeof sessionIdSchema.Type
export const SessionID = sessionIdSchema.pipe(
withStatics((schema: typeof sessionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
descending: (id?: string) => schema.makeUnsafe(Identifier.descending("session", id)),
zod: Identifier.schema("session").pipe(z.custom<SessionID>()),
})),
)
const messageIdSchema = Schema.String.pipe(Schema.brand("MessageID"))
export type MessageID = typeof messageIdSchema.Type
export const MessageID = messageIdSchema.pipe(
withStatics((schema: typeof messageIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("message", id)),
zod: Identifier.schema("message").pipe(z.custom<MessageID>()),
})),
)
const partIdSchema = Schema.String.pipe(Schema.brand("PartID"))
export type PartID = typeof partIdSchema.Type
export const PartID = partIdSchema.pipe(
withStatics((schema: typeof partIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("part", id)),
zod: Identifier.schema("part").pipe(z.custom<PartID>()),
})),
)
+12 -7
View File
@@ -4,6 +4,8 @@ import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot"
import type { PermissionNext } from "../permission/next"
import type { ProjectID } from "../project/schema"
import type { SessionID, MessageID, PartID } from "./schema"
import type { WorkspaceID } from "../control-plane/schema"
import { Timestamps } from "../storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
@@ -12,13 +14,13 @@ type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
export const SessionTable = sqliteTable(
"session",
{
id: text().primaryKey(),
id: text().$type<SessionID>().primaryKey(),
project_id: text()
.$type<ProjectID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
workspace_id: text(),
parent_id: text(),
workspace_id: text().$type<WorkspaceID>(),
parent_id: text().$type<SessionID>(),
slug: text().notNull(),
directory: text().notNull(),
title: text().notNull(),
@@ -28,7 +30,7 @@ export const SessionTable = sqliteTable(
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
...Timestamps,
time_compacting: integer(),
@@ -44,8 +46,9 @@ export const SessionTable = sqliteTable(
export const MessageTable = sqliteTable(
"message",
{
id: text().primaryKey(),
id: text().$type<MessageID>().primaryKey(),
session_id: text()
.$type<SessionID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
...Timestamps,
@@ -57,11 +60,12 @@ export const MessageTable = sqliteTable(
export const PartTable = sqliteTable(
"part",
{
id: text().primaryKey(),
id: text().$type<PartID>().primaryKey(),
message_id: text()
.$type<MessageID>()
.notNull()
.references(() => MessageTable.id, { onDelete: "cascade" }),
session_id: text().notNull(),
session_id: text().$type<SessionID>().notNull(),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<PartData>(),
},
@@ -72,6 +76,7 @@ export const TodoTable = sqliteTable(
"todo",
{
session_id: text()
.$type<SessionID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
content: text().notNull(),
+5 -4
View File
@@ -1,6 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Instance } from "@/project/instance"
import { SessionID } from "./schema"
import z from "zod"
export namespace SessionStatus {
@@ -28,7 +29,7 @@ export namespace SessionStatus {
Status: BusEvent.define(
"session.status",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
status: Info,
}),
),
@@ -36,7 +37,7 @@ export namespace SessionStatus {
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
}),
),
}
@@ -46,7 +47,7 @@ export namespace SessionStatus {
return data
})
export function get(sessionID: string) {
export function get(sessionID: SessionID) {
return (
state()[sessionID] ?? {
type: "idle",
@@ -58,7 +59,7 @@ export namespace SessionStatus {
return state()
}
export function set(sessionID: string, status: Info) {
export function set(sessionID: SessionID, status: Info) {
Bus.publish(Event.Status, {
sessionID,
status,
+6 -5
View File
@@ -4,6 +4,7 @@ import { Session } from "."
import { MessageV2 } from "./message-v2"
import { Identifier } from "@/id/id"
import { SessionID, MessageID } from "./schema"
import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage"
@@ -68,8 +69,8 @@ export namespace SessionSummary {
export const summarize = fn(
z.object({
sessionID: z.string(),
messageID: z.string(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
async (input) => {
const all = await Session.messages({ sessionID: input.sessionID })
@@ -80,7 +81,7 @@ export namespace SessionSummary {
},
)
async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) {
async function summarizeSession(input: { sessionID: SessionID; messages: MessageV2.WithParts[] }) {
const diffs = await computeDiff({ messages: input.messages })
await Session.setSummary({
sessionID: input.sessionID,
@@ -113,8 +114,8 @@ export namespace SessionSummary {
export const diff = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message").optional(),
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
}),
async (input) => {
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
+4 -3
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionID } from "./schema"
import z from "zod"
import { Database, eq, asc } from "../storage/db"
import { TodoTable } from "./session.sql"
@@ -18,13 +19,13 @@ export namespace Todo {
Updated: BusEvent.define(
"todo.updated",
z.object({
sessionID: z.string(),
sessionID: SessionID.zod,
todos: z.array(Info),
}),
),
}
export function update(input: { sessionID: string; todos: Info[] }) {
export function update(input: { sessionID: SessionID; todos: Info[] }) {
Database.transaction((db) => {
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
@@ -43,7 +44,7 @@ export namespace Todo {
Bus.publish(Event.Updated, input)
}
export function get(sessionID: string) {
export function get(sessionID: SessionID) {
const rows = Database.use((db) =>
db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
)
+6 -5
View File
@@ -3,6 +3,7 @@ import { Account } from "@/account"
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import type { SessionID } from "@/session/schema"
import { MessageV2 } from "@/session/message-v2"
import { Database, eq } from "@/storage/db"
import { SessionShareTable } from "./share.sql"
@@ -109,7 +110,7 @@ export namespace ShareNext {
})
}
export async function create(sessionID: string) {
export async function create(sessionID: SessionID) {
if (disabled) return { id: "", url: "", secret: "" }
log.info("creating share", { sessionID })
const req = await request()
@@ -140,7 +141,7 @@ export namespace ShareNext {
return result
}
function get(sessionID: string) {
function get(sessionID: SessionID) {
const row = Database.use((db) =>
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
)
@@ -186,7 +187,7 @@ export namespace ShareNext {
}
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
async function sync(sessionID: string, data: Data[]) {
async function sync(sessionID: SessionID, data: Data[]) {
if (disabled) return
const existing = queue.get(sessionID)
if (existing) {
@@ -225,7 +226,7 @@ export namespace ShareNext {
queue.set(sessionID, { timeout, data: dataMap })
}
export async function remove(sessionID: string) {
export async function remove(sessionID: SessionID) {
if (disabled) return
log.info("removing share", { sessionID })
const share = get(sessionID)
@@ -248,7 +249,7 @@ export namespace ShareNext {
Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
}
async function fullSync(sessionID: string) {
async function fullSync(sessionID: SessionID) {
log.info("full sync", { sessionID })
const session = await Session.get(sessionID)
const diffs = await Session.diff(sessionID)
+4 -4
View File
@@ -31,7 +31,7 @@ export const BatchTool = Tool.define("batch", async () => {
},
async execute(params, ctx) {
const { Session } = await import("../session")
const { Identifier } = await import("../id/id")
const { PartID } = await import("../session/schema")
const toolCalls = params.tool_calls.slice(0, 25)
const discardedCalls = params.tool_calls.slice(25)
@@ -42,7 +42,7 @@ export const BatchTool = Tool.define("batch", async () => {
const executeCall = async (call: (typeof toolCalls)[0]) => {
const callStartTime = Date.now()
const partID = Identifier.ascending("part")
const partID = PartID.ascending()
try {
if (DISALLOWED.has(call.tool)) {
@@ -79,7 +79,7 @@ export const BatchTool = Tool.define("batch", async () => {
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
}))
@@ -134,7 +134,7 @@ export const BatchTool = Tool.define("batch", async () => {
// Add discarded calls as errors
const now = Date.now()
for (const call of discardedCalls) {
const partID = Identifier.ascending("part")
const partID = PartID.ascending()
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
+6 -6
View File
@@ -4,12 +4,12 @@ import { Tool } from "./tool"
import { Question } from "../question"
import { Session } from "../session"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Provider } from "../provider/provider"
import { Instance } from "../project/instance"
import { type SessionID, MessageID, PartID } from "../session/schema"
import EXIT_DESCRIPTION from "./plan-exit.txt"
async function getLastModel(sessionID: string) {
async function getLastModel(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
@@ -44,7 +44,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: {
@@ -55,7 +55,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
@@ -102,7 +102,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: {
@@ -113,7 +113,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
+17
View File
@@ -0,0 +1,17 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
const toolIdSchema = Schema.String.pipe(Schema.brand("ToolID"))
export type ToolID = typeof toolIdSchema.Type
export const ToolID = toolIdSchema.pipe(
withStatics((schema: typeof toolIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("tool", id)),
zod: Identifier.schema("tool").pipe(z.custom<ToolID>()),
})),
)
+3 -2
View File
@@ -2,6 +2,7 @@ import { Tool } from "./tool"
import DESCRIPTION from "./task.txt"
import z from "zod"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
@@ -65,7 +66,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const session = await iife(async () => {
if (params.task_id) {
const found = await Session.get(params.task_id).catch(() => {})
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
if (found) return found
}
@@ -116,7 +117,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
},
})
const messageID = Identifier.ascending("message")
const messageID = MessageID.ascending()
function cancel() {
SessionPrompt.cancel(session.id)
+3 -2
View File
@@ -2,6 +2,7 @@ import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission/next"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncation"
export namespace Tool {
@@ -14,8 +15,8 @@ export namespace Tool {
}
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
sessionID: SessionID
messageID: MessageID
agent: string
abort: AbortSignal
callID?: string
+2 -1
View File
@@ -7,6 +7,7 @@ import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
import { ToolID } from "./schema"
export namespace Truncate {
export const MAX_LINES = 2000
@@ -90,7 +91,7 @@ export namespace Truncate {
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const id = Identifier.ascending("tool")
const id = ToolID.ascending()
const filepath = path.join(DIR, id)
await Filesystem.write(filepath, text)
+9 -1
View File
@@ -114,8 +114,16 @@ export namespace Filesystem {
}
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
// Also resolves symlinks so that callers using the result as a cache key
// always get the same canonical path for a given physical directory.
export function resolve(p: string): string {
return normalizePath(pathResolve(windowsPath(p)))
const resolved = pathResolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e) {
if (isEnoent(e)) return normalizePath(resolved)
throw e
}
}
export function windowsPath(p: string): string {
@@ -1,13 +1,14 @@
import { test, expect, describe } from "bun:test"
import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
// Helper to create minimal valid parts
function createTextPart(text: string): MessageV2.Part {
return {
id: "1",
sessionID: "s",
messageID: "m",
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
type: "text" as const,
text,
}
@@ -15,9 +16,9 @@ function createTextPart(text: string): MessageV2.Part {
function createReasoningPart(text: string): MessageV2.Part {
return {
id: "1",
sessionID: "s",
messageID: "m",
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
type: "reasoning" as const,
text,
time: { start: 0 },
@@ -27,9 +28,9 @@ function createReasoningPart(text: string): MessageV2.Part {
function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): MessageV2.Part {
if (status === "completed") {
return {
id: "1",
sessionID: "s",
messageID: "m",
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
type: "tool" as const,
callID: "c1",
tool,
@@ -44,9 +45,9 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
}
}
return {
id: "1",
sessionID: "s",
messageID: "m",
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
type: "tool" as const,
callID: "c1",
tool,
@@ -60,18 +61,18 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
function createStepStartPart(): MessageV2.Part {
return {
id: "1",
sessionID: "s",
messageID: "m",
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
type: "step-start" as const,
}
}
function createStepFinishPart(): MessageV2.Part {
return {
id: "1",
sessionID: "s",
messageID: "m",
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
type: "step-finish" as const,
reason: "done",
cost: 0,
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Identifier } from "../../src/id/id"
import { WorkspaceID } from "../../src/control-plane/schema"
import { Hono } from "hono"
import { tmpdir } from "../fixture/fixture"
import { Project } from "../../src/project/project"
@@ -64,8 +64,8 @@ async function setup(state: State) {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const id1 = Identifier.descending("workspace")
const id2 = Identifier.descending("workspace")
const id1 = WorkspaceID.ascending()
const id2 = WorkspaceID.ascending()
Database.use((db) =>
db
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Identifier } from "../../src/id/id"
import { WorkspaceID } from "../../src/control-plane/schema"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
import { Project } from "../../src/project/project"
@@ -52,8 +52,8 @@ describe("control-plane/workspace.startSyncing", () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const id1 = Identifier.descending("workspace")
const id2 = Identifier.descending("workspace")
const id1 = WorkspaceID.ascending()
const id2 = WorkspaceID.ascending()
Database.use((db) =>
db
@@ -2,12 +2,13 @@ import { describe, test, expect } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
const projectRoot = path.join(__dirname, "../..")
const ctx = {
sessionID: "test",
messageID: "",
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: new AbortController().signal,
+22 -20
View File
@@ -1,8 +1,10 @@
import { test, expect } from "bun:test"
import os from "os"
import { PermissionNext } from "../../src/permission/next"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
// fromConfig tests
@@ -462,7 +464,7 @@ test("ask - resolves immediately when action is allow", async () => {
directory: tmp.path,
fn: async () => {
const result = await PermissionNext.ask({
sessionID: "session_test",
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
@@ -481,7 +483,7 @@ test("ask - throws RejectedError when action is deny", async () => {
fn: async () => {
await expect(
PermissionNext.ask({
sessionID: "session_test",
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["rm -rf /"],
metadata: {},
@@ -499,7 +501,7 @@ test("ask - returns pending promise when action is ask", async () => {
directory: tmp.path,
fn: async () => {
const promise = PermissionNext.ask({
sessionID: "session_test",
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
@@ -521,8 +523,8 @@ test("reply - once resolves the pending ask", async () => {
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test1",
sessionID: "session_test",
id: PermissionID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
@@ -531,7 +533,7 @@ test("reply - once resolves the pending ask", async () => {
})
await PermissionNext.reply({
requestID: "permission_test1",
requestID: PermissionID.make("per_test1"),
reply: "once",
})
@@ -546,8 +548,8 @@ test("reply - reject throws RejectedError", async () => {
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test2",
sessionID: "session_test",
id: PermissionID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
@@ -556,7 +558,7 @@ test("reply - reject throws RejectedError", async () => {
})
await PermissionNext.reply({
requestID: "permission_test2",
requestID: PermissionID.make("per_test2"),
reply: "reject",
})
@@ -571,8 +573,8 @@ test("reply - always persists approval and resolves", async () => {
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_test3",
sessionID: "session_test",
id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
@@ -581,7 +583,7 @@ test("reply - always persists approval and resolves", async () => {
})
await PermissionNext.reply({
requestID: "permission_test3",
requestID: PermissionID.make("per_test3"),
reply: "always",
})
@@ -594,7 +596,7 @@ test("reply - always persists approval and resolves", async () => {
fn: async () => {
// Stored approval should allow without asking
const result = await PermissionNext.ask({
sessionID: "session_test2",
sessionID: SessionID.make("session_test2"),
permission: "bash",
patterns: ["ls"],
metadata: {},
@@ -612,8 +614,8 @@ test("reply - reject cancels all pending for same session", async () => {
directory: tmp.path,
fn: async () => {
const askPromise1 = PermissionNext.ask({
id: "permission_test4a",
sessionID: "session_same",
id: PermissionID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
metadata: {},
@@ -622,8 +624,8 @@ test("reply - reject cancels all pending for same session", async () => {
})
const askPromise2 = PermissionNext.ask({
id: "permission_test4b",
sessionID: "session_same",
id: PermissionID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
patterns: ["foo.ts"],
metadata: {},
@@ -637,7 +639,7 @@ test("reply - reject cancels all pending for same session", async () => {
// Reject the first one
await PermissionNext.reply({
requestID: "permission_test4a",
requestID: PermissionID.make("per_test4a"),
reply: "reject",
})
@@ -655,7 +657,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
fn: async () => {
await expect(
PermissionNext.ask({
sessionID: "session_test",
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
metadata: {},
@@ -676,7 +678,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
directory: tmp.path,
fn: async () => {
const result = await PermissionNext.ask({
sessionID: "session_test",
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "ls -la", "pwd"],
metadata: {},
@@ -1,7 +1,9 @@
import { test, expect } from "bun:test"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
test("ask - returns pending promise", async () => {
await using tmp = await tmpdir({ git: true })
@@ -9,7 +11,7 @@ test("ask - returns pending promise", async () => {
directory: tmp.path,
fn: async () => {
const promise = Question.ask({
sessionID: "ses_test",
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
@@ -43,7 +45,7 @@ test("ask - adds to pending list", async () => {
]
Question.ask({
sessionID: "ses_test",
sessionID: SessionID.make("ses_test"),
questions,
})
@@ -73,7 +75,7 @@ test("reply - resolves the pending ask with answers", async () => {
]
const askPromise = Question.ask({
sessionID: "ses_test",
sessionID: SessionID.make("ses_test"),
questions,
})
@@ -97,7 +99,7 @@ test("reply - removes from pending list", async () => {
directory: tmp.path,
fn: async () => {
Question.ask({
sessionID: "ses_test",
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
@@ -130,7 +132,7 @@ test("reply - does nothing for unknown requestID", async () => {
directory: tmp.path,
fn: async () => {
await Question.reply({
requestID: "que_unknown",
requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]],
})
// Should not throw
@@ -146,7 +148,7 @@ test("reject - throws RejectedError", async () => {
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
sessionID: "ses_test",
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
@@ -173,7 +175,7 @@ test("reject - removes from pending list", async () => {
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
sessionID: "ses_test",
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
@@ -203,7 +205,7 @@ test("reject - does nothing for unknown requestID", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reject("que_unknown")
await Question.reject(QuestionID.make("que_unknown"))
// Should not throw
},
})
@@ -236,7 +238,7 @@ test("ask - handles multiple questions", async () => {
]
const askPromise = Question.ask({
sessionID: "ses_test",
sessionID: SessionID.make("ses_test"),
questions,
})
@@ -261,7 +263,7 @@ test("list - returns all pending requests", async () => {
directory: tmp.path,
fn: async () => {
Question.ask({
sessionID: "ses_test1",
sessionID: SessionID.make("ses_test1"),
questions: [
{
question: "Question 1?",
@@ -272,7 +274,7 @@ test("list - returns all pending requests", async () => {
})
Question.ask({
sessionID: "ses_test2",
sessionID: SessionID.make("ses_test2"),
questions: [
{
question: "Question 2?",
+9 -8
View File
@@ -11,6 +11,7 @@ import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema"
describe("session.llm.hasToolCalls", () => {
test("returns false for empty messages array", () => {
@@ -265,7 +266,7 @@ describe("session.llm.stream", () => {
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(providerID, model.id)
const sessionID = "session-test-1"
const sessionID = SessionID.make("session-test-1")
const agent = {
name: "test",
mode: "primary",
@@ -276,7 +277,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: "user-1",
id: MessageID.make("user-1"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -395,7 +396,7 @@ describe("session.llm.stream", () => {
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel("openai", model.id)
const sessionID = "session-test-2"
const sessionID = SessionID.make("session-test-2")
const agent = {
name: "test",
mode: "primary",
@@ -405,7 +406,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: "user-2",
id: MessageID.make("user-2"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -517,7 +518,7 @@ describe("session.llm.stream", () => {
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(providerID, model.id)
const sessionID = "session-test-3"
const sessionID = SessionID.make("session-test-3")
const agent = {
name: "test",
mode: "primary",
@@ -528,7 +529,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: "user-3",
id: MessageID.make("user-3"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -618,7 +619,7 @@ describe("session.llm.stream", () => {
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(providerID, model.id)
const sessionID = "session-test-4"
const sessionID = SessionID.make("session-test-4")
const agent = {
name: "test",
mode: "primary",
@@ -629,7 +630,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: "user-4",
id: MessageID.make("user-4"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -2,8 +2,9 @@ import { describe, expect, test } from "bun:test"
import { APICallError } from "ai"
import { MessageV2 } from "../../src/session/message-v2"
import type { Provider } from "../../src/provider/provider"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
const sessionID = "session"
const sessionID = SessionID.make("session")
const model: Provider.Model = {
id: "test-model",
providerID: "test",
@@ -97,9 +98,9 @@ function assistantInfo(
function basePart(messageID: string, id: string) {
return {
id,
id: PartID.make(id),
sessionID,
messageID,
messageID: MessageID.make(messageID),
}
}
@@ -6,7 +6,7 @@ import { SessionCompaction } from "../../src/session/compaction"
import { MessageV2 } from "../../src/session/message-v2"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { Identifier } from "../../src/id/id"
import { MessageID, PartID } from "../../src/session/schema"
import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..")
@@ -24,7 +24,7 @@ describe("revert + compact workflow", () => {
// Create a user message
const userMsg1 = await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
@@ -39,7 +39,7 @@ describe("revert + compact workflow", () => {
// Add a text part to the user message
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMsg1.id,
sessionID,
type: "text",
@@ -48,7 +48,7 @@ describe("revert + compact workflow", () => {
// Create an assistant response message
const assistantMsg1: MessageV2.Assistant = {
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
@@ -76,7 +76,7 @@ describe("revert + compact workflow", () => {
// Add a text part to the assistant message
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: assistantMsg1.id,
sessionID,
type: "text",
@@ -85,7 +85,7 @@ describe("revert + compact workflow", () => {
// Create another user message
const userMsg2 = await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
@@ -99,7 +99,7 @@ describe("revert + compact workflow", () => {
})
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMsg2.id,
sessionID,
type: "text",
@@ -108,7 +108,7 @@ describe("revert + compact workflow", () => {
// Create another assistant response
const assistantMsg2: MessageV2.Assistant = {
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
@@ -135,7 +135,7 @@ describe("revert + compact workflow", () => {
await Session.updateMessage(assistantMsg2)
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: assistantMsg2.id,
sessionID,
type: "text",
@@ -200,7 +200,7 @@ describe("revert + compact workflow", () => {
// Create initial messages
const userMsg = await Session.updateMessage({
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
@@ -214,7 +214,7 @@ describe("revert + compact workflow", () => {
})
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: userMsg.id,
sessionID,
type: "text",
@@ -222,7 +222,7 @@ describe("revert + compact workflow", () => {
})
const assistantMsg: MessageV2.Assistant = {
id: Identifier.ascending("message"),
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
@@ -249,7 +249,7 @@ describe("revert + compact workflow", () => {
await Session.updateMessage(assistantMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID: assistantMsg.id,
sessionID,
type: "text",
@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2"
import { Identifier } from "../../src/id/id"
import { MessageID, PartID } from "../../src/session/schema"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
@@ -81,7 +81,7 @@ describe("step-finish token propagation via Bus event", () => {
fn: async () => {
const session = await Session.create({})
const messageID = Identifier.ascending("message")
const messageID = MessageID.ascending()
await Session.updateMessage({
id: messageID,
sessionID: session.id,
@@ -107,7 +107,7 @@ describe("step-finish token propagation via Bus event", () => {
}
const partInput = {
id: Identifier.ascending("part"),
id: PartID.ascending(),
messageID,
sessionID: session.id,
type: "step-finish" as const,
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionID, MessageID } from "../../src/session/schema"
describe("structured-output.OutputFormat", () => {
test("parses text format", () => {
@@ -95,8 +96,8 @@ describe("structured-output.StructuredOutputError", () => {
describe("structured-output.UserMessage", () => {
test("user message accepts outputFormat", () => {
const result = MessageV2.User.safeParse({
id: "test-id",
sessionID: "test-session",
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "user",
time: { created: Date.now() },
agent: "default",
@@ -111,8 +112,8 @@ describe("structured-output.UserMessage", () => {
test("user message works without outputFormat (optional)", () => {
const result = MessageV2.User.safeParse({
id: "test-id",
sessionID: "test-session",
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "user",
time: { created: Date.now() },
agent: "default",
@@ -124,10 +125,10 @@ describe("structured-output.UserMessage", () => {
describe("structured-output.AssistantMessage", () => {
const baseAssistantMessage = {
id: "test-id",
sessionID: "test-session",
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "assistant" as const,
parentID: "parent-id",
parentID: MessageID.ascending(),
modelID: "claude-3",
providerID: "anthropic",
mode: "default",
@@ -11,6 +11,7 @@ import { ProjectTable } from "../../src/project/project.sql"
import { ProjectID } from "../../src/project/schema"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
import { SessionShareTable } from "../../src/share/share.sql"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
// Test fixtures
const fixtures = {
@@ -220,7 +221,7 @@ describe("JSON to SQLite migration", () => {
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe("ses_test456def")
expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc"))
expect(sessions[0].slug).toBe("test-session")
expect(sessions[0].title).toBe("Test Session Title")
@@ -254,11 +255,11 @@ describe("JSON to SQLite migration", () => {
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe("msg_test789ghi")
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1)
expect(parts[0].id).toBe("prt_testabc123")
expect(parts[0].id).toBe(PartID.make("prt_testabc123"))
})
test("migrates legacy parts without ids in body", async () => {
@@ -294,16 +295,16 @@ describe("JSON to SQLite migration", () => {
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe("msg_test789ghi")
expect(messages[0].session_id).toBe("ses_test456def")
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
expect(messages[0].data).not.toHaveProperty("id")
expect(messages[0].data).not.toHaveProperty("sessionID")
const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1)
expect(parts[0].id).toBe("prt_testabc123")
expect(parts[0].message_id).toBe("msg_test789ghi")
expect(parts[0].session_id).toBe("ses_test456def")
expect(parts[0].id).toBe(PartID.make("prt_testabc123"))
expect(parts[0].message_id).toBe(MessageID.make("msg_test789ghi"))
expect(parts[0].session_id).toBe(SessionID.make("ses_test456def"))
expect(parts[0].data).not.toHaveProperty("id")
expect(parts[0].data).not.toHaveProperty("messageID")
expect(parts[0].data).not.toHaveProperty("sessionID")
@@ -335,8 +336,8 @@ describe("JSON to SQLite migration", () => {
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe("msg_from_filename") // Uses filename, not JSON id
expect(messages[0].session_id).toBe("ses_test456def")
expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
})
test("uses paths for part id and messageID when JSON has different values", async () => {
@@ -373,8 +374,8 @@ describe("JSON to SQLite migration", () => {
const db = drizzle({ client: sqlite })
const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1)
expect(parts[0].id).toBe("prt_from_filename") // Uses filename, not JSON id
expect(parts[0].message_id).toBe("msg_realmsgid") // Uses parent dir, not JSON messageID
expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
expect(parts[0].message_id).toBe(MessageID.make("msg_realmsgid")) // Uses parent dir, not JSON messageID
})
test("skips orphaned sessions (no parent project)", async () => {
@@ -426,7 +427,7 @@ describe("JSON to SQLite migration", () => {
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe("ses_migrated")
expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
expect(sessions[0].project_id).toBe(ProjectID.make(gitBasedProjectID)) // Uses directory, not stale JSON
})
@@ -458,7 +459,7 @@ describe("JSON to SQLite migration", () => {
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe("ses_from_filename") // Uses filename, not JSON id
expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc"))
})
@@ -4,10 +4,11 @@ import * as fs from "fs/promises"
import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx = {
sessionID: "test",
messageID: "",
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+3 -2
View File
@@ -7,10 +7,11 @@ import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission/next"
import { Truncate } from "../../src/tool/truncation"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
sessionID: "test",
messageID: "",
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+3 -2
View File
@@ -5,10 +5,11 @@ import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
sessionID: "test-edit-session",
messageID: "",
sessionID: SessionID.make("ses_test-edit-session"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
@@ -4,10 +4,11 @@ import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission/next"
import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: "test",
messageID: "",
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+3 -2
View File
@@ -3,10 +3,11 @@ import path from "path"
import { GrepTool } from "../../src/tool/grep"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
sessionID: "test",
messageID: "",
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+3 -2
View File
@@ -2,10 +2,11 @@ import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import { z } from "zod"
import { QuestionTool } from "../../src/tool/question"
import * as QuestionModule from "../../src/question"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
sessionID: "test-session",
messageID: "test-message",
sessionID: SessionID.make("ses_test-session"),
messageID: MessageID.make("test-message"),
callID: "test-call",
agent: "test-agent",
abort: AbortSignal.any([]),
+3 -2
View File
@@ -6,12 +6,13 @@ import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ctx = {
sessionID: "test",
messageID: "",
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+3 -2
View File
@@ -6,10 +6,11 @@ import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: "test",
messageID: "",
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+3 -2
View File
@@ -2,12 +2,13 @@ import { describe, expect, test } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
const projectRoot = path.join(import.meta.dir, "../..")
const ctx = {
sessionID: "test",
messageID: "message",
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("message"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+3 -2
View File
@@ -4,10 +4,11 @@ import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
sessionID: "test-write-session",
messageID: "",
sessionID: SessionID.make("ses_test-write-session"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
@@ -502,5 +502,57 @@ describe("filesystem", () => {
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves symlinked directory to canonical path", async () => {
await using tmp = await tmpdir()
const target = path.join(tmp.path, "real")
await fs.mkdir(target)
const link = path.join(tmp.path, "link")
await fs.symlink(target, link)
expect(Filesystem.resolve(link)).toBe(Filesystem.resolve(target))
})
test("returns unresolved path when target does not exist", async () => {
await using tmp = await tmpdir()
const missing = path.join(tmp.path, "does-not-exist-" + Date.now())
const result = Filesystem.resolve(missing)
expect(result).toBe(Filesystem.normalizePath(path.resolve(missing)))
})
test("throws ELOOP on symlink cycle", async () => {
await using tmp = await tmpdir()
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
await fs.symlink(b, a)
await fs.symlink(a, b)
expect(() => Filesystem.resolve(a)).toThrow()
})
// Windows: chmod(0o000) is a no-op, so EACCES cannot be triggered
test("throws EACCES on permission-denied symlink target", async () => {
if (process.platform === "win32") return
if (process.getuid?.() === 0) return // skip when running as root
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "restricted")
await fs.mkdir(dir)
const link = path.join(tmp.path, "link")
await fs.symlink(dir, link)
await fs.chmod(dir, 0o000)
try {
expect(() => Filesystem.resolve(path.join(link, "child"))).toThrow()
} finally {
await fs.chmod(dir, 0o755)
}
})
// Windows: traversing through a file throws ENOENT (not ENOTDIR),
// which resolve() catches as a fallback instead of rethrowing
test("rethrows non-ENOENT errors", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir()
const file = path.join(tmp.path, "not-a-directory")
await fs.writeFile(file, "x")
expect(() => Filesystem.resolve(path.join(file, "child"))).toThrow()
})
})
})