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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 21 additions & 19 deletions documentor/FORK.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions packages/opencode/src/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function make(input: {

const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) {
const started = performance.now()
const requestedSessionID = sessionIdFromMeta(params._meta)
const snapshot = yield* directorySnapshot(params.cwd)
const selected = selectDefaultModel(snapshot)
const variant = selectVariant(snapshot, selected)
Expand All @@ -172,6 +173,7 @@ export function make(input: {
input.sdk.session.create(
{
directory: params.cwd,
...(requestedSessionID ? { id: requestedSessionID } : {}),
...(modeId ? { agent: modeId } : {}),
model: {
providerID: selected.providerID,
Expand Down Expand Up @@ -839,6 +841,17 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
return { name, args: rest.join(" ").trim() }
}

// A client may name the session it creates in `_meta.opencode.sessionId`, so that the chat it shows before
// opencode answers and the session opencode stores share one id. Only an id with the "ses" prefix is used; anything
// else gets an opencode-minted id.
function sessionIdFromMeta(meta: unknown) {
if (typeof meta !== "object" || meta === null) return undefined
const opencode = (meta as { opencode?: unknown }).opencode
if (typeof opencode !== "object" || opencode === null) return undefined
const sessionId = (opencode as { sessionId?: unknown }).sessionId
return typeof sessionId === "string" && sessionId.startsWith("ses") ? sessionId : undefined
}

// A client may name the user message its prompt creates, so that its own copy and opencode's share one id.
// Only an id that is already an opencode message id (prefix "msg") is used; ACP clients that send UUIDs get an
// opencode-minted id. An empty prompt creates no message (SessionPrompt.prompt), so it names none.
Expand Down
3 changes: 1 addition & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1043,8 +1043,7 @@ const layer = Layer.effect(
})
}

yield* sessions.updateMessage(info)
for (const part of parts) yield* sessions.updatePart(part)
yield* sessions.saveMessage(info, parts)

return { info, parts }
}, Effect.scoped)
Expand Down
25 changes: 25 additions & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export type GlobalInfo = Types.DeepMutable<Schema.Schema.Type<typeof GlobalInfo>

export const CreateInput = Schema.optional(
Schema.Struct({
id: Schema.optional(SessionID),
parentID: Schema.optional(SessionID),
title: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
Expand Down Expand Up @@ -414,6 +415,7 @@ export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly listGlobal: (input?: GlobalListInput) => Effect.Effect<GlobalInfo[]>
readonly create: (input?: {
id?: SessionID
parentID?: SessionID
title?: string
agent?: string
Expand Down Expand Up @@ -457,6 +459,7 @@ export interface Interface {
partID: PartID
}) => Effect.Effect<SessionV1.Part | undefined>
readonly updatePart: <T extends SessionV1.Part>(part: T) => Effect.Effect<T>
readonly saveMessage: <T extends SessionV1.Info>(msg: T, parts: readonly SessionV1.Part[]) => Effect.Effect<T>
readonly updatePartDelta: (input: {
sessionID: SessionID
messageID: MessageID
Expand Down Expand Up @@ -642,6 +645,19 @@ const layer: Layer.Layer<
return part
}).pipe(Effect.withSpan("Session.updatePart"))

// A message and its parts are published in one transaction, so their rows commit together. A reader between
// the message row and its last part would otherwise see a user message without its text.
const saveMessage = <T extends SessionV1.Info>(msg: T, parts: readonly SessionV1.Part[]): Effect.Effect<T> =>
db
.transaction(() =>
Effect.gen(function* () {
yield* updateMessage(msg)
for (const part of parts) yield* updatePart(part)
return msg
}),
)
.pipe(Effect.orDie, Effect.withSpan("Session.saveMessage"))

const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) {
const row = yield* db
.select()
Expand All @@ -665,6 +681,7 @@ const layer: Layer.Layer<
})

const create = Effect.fn("Session.create")(function* (input?: {
id?: SessionID
parentID?: SessionID
title?: string
agent?: string
Expand All @@ -675,7 +692,14 @@ const layer: Layer.Layer<
}) {
const ctx = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
// A caller-chosen id names a new session, never an existing one. The projector refuses a duplicate row as
// well; checking first turns that into an error that names the id instead of a failed projection.
if (input?.id !== undefined) {
const existing = yield* get(input.id).pipe(Effect.option)
if (existing._tag === "Some") return yield* Effect.die(new Error(`Session already exists: ${input.id}`))
}
return yield* createNext({
id: input?.id,
parentID: input?.parentID,
directory: ctx.directory,
path: sessionPath(ctx.worktree, ctx.directory),
Expand Down Expand Up @@ -933,6 +957,7 @@ const layer: Layer.Layer<
removeMessage,
removePart,
updatePart,
saveMessage,
getPart,
updatePartDelta,
findMessage,
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/test/acp/service-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,64 @@ describe("ACP service sessions", () => {
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
})

function sessionIdSdk(creates: Array<{ id?: string }>) {
return {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: (input: { id?: string }) => {
creates.push(input)
return Promise.resolve({ data: { id: input.id ?? "ses_minted" } })
},
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
}

it("documentor: creates the session under the id named in _meta.opencode.sessionId", async () => {
const creates: Array<{ id?: string }> = []
const service = ACPService.make({ sdk: sessionIdSdk(creates) })

const result = await Effect.runPromise(
service.newSession({
cwd: "/workspace",
mcpServers: [],
_meta: { opencode: { sessionId: "ses_fe0123456789ClientChosen1" } },
}),
)

expect(creates[0]?.id).toBe("ses_fe0123456789ClientChosen1")
expect(result.sessionId).toBe("ses_fe0123456789ClientChosen1")
})

it("documentor: lets opencode mint the session id when _meta names none or one without the ses prefix", async () => {
const creates: Array<{ id?: string }> = []
const service = ACPService.make({ sdk: sessionIdSdk(creates) })

await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.newSession({
cwd: "/workspace",
mcpServers: [],
_meta: { opencode: { sessionId: "00000000-0000-4000-8000-000000000001" } },
}),
)

expect(creates.map((input) => input.id)).toEqual([undefined, undefined])
})

it("does not scan last-used sessions when resolving the new session default", async () => {
const historyCalls: string[] = []
const sdk = {
Expand Down
85 changes: 85 additions & 0 deletions packages/opencode/test/session/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,88 @@ describe("Session", () => {
}),
)
})

describe("documentor: caller-chosen session id", () => {
it.instance("creates the session under the id the caller names", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const id = "ses_fe0123456789CallerChosen1" as SessionID

const info = yield* session.create({ id })

expect(info.id).toBe(id)
expect((yield* session.get(id)).id).toBe(id)
yield* session.remove(id)
}),
)

it.instance("refuses an id that names an existing session and leaves that session as it was", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const existing = yield* session.create({ title: "kept" })

const exit = yield* session.create({ id: existing.id, title: "replacement" }).pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)
expect((yield* session.get(existing.id)).title).toBe("kept")
yield* session.remove(existing.id)
}),
)
})

describe("documentor: a message and its parts are saved together", () => {
const userMessage = (sessionID: SessionID, id: MessageID) =>
({
id,
sessionID,
role: "user",
time: { created: Date.now() },
agent: "user",
model: { providerID: "test", modelID: "test" },
tools: {},
mode: "",
}) as unknown as SessionV1.Info

const textPart = (sessionID: SessionID, messageID: MessageID, text: string) =>
({ id: PartID.ascending(), messageID, sessionID, type: "text", text }) as SessionV1.Part

it.instance("stores the message with every part", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const info = yield* session.create({})
const messageID = MessageID.ascending()

yield* session.saveMessage(userMessage(info.id, messageID), [
textPart(info.id, messageID, "<system-message>context</system-message>"),
textPart(info.id, messageID, "Find me my passport"),
])

const [stored] = yield* session.messages({ sessionID: info.id })
expect(stored?.info.id).toBe(messageID)
expect(stored?.parts.map((part) => (part as SessionV1.TextPart).text)).toEqual([
"<system-message>context</system-message>",
"Find me my passport",
])
yield* session.remove(info.id)
}),
)

it.instance("stores none of it when a part cannot be stored", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const info = yield* session.create({})
const messageID = MessageID.ascending()

const exit = yield* session
.saveMessage(userMessage(info.id, messageID), [
textPart(info.id, messageID, "<system-message>context</system-message>"),
textPart(info.id, MessageID.ascending(), "a part of a message that does not exist"),
])
.pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)
expect(yield* session.messages({ sessionID: info.id })).toEqual([])
yield* session.remove(info.id)
}),
)
})
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3411,6 +3411,7 @@ export class Session2 extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
id?: string
parentID?: string
title?: string
agent?: string
Expand All @@ -3434,6 +3435,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "id" },
{ in: "body", key: "parentID" },
{ in: "body", key: "title" },
{ in: "body", key: "agent" },
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9475,6 +9475,7 @@ export type SessionListResponse = SessionListResponses[keyof SessionListResponse

export type SessionCreateData = {
body?: {
id?: string
parentID?: string
title?: string
agent?: string
Expand Down
Loading