feat(core): add embedded v2 session runtime and tool foundation (#30632)
This commit is contained in:
parent
c35267776a
commit
76ee87ead8
215 changed files with 31398 additions and 3332 deletions
|
|
@ -1,274 +1,31 @@
|
|||
import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
status: Status
|
||||
started_at: number
|
||||
completed_at?: number
|
||||
output?: string
|
||||
error?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Active = {
|
||||
info: Info
|
||||
done: Deferred.Deferred<Info>
|
||||
scope: Scope.Closeable
|
||||
token: object
|
||||
pending: number
|
||||
next: number
|
||||
output?: { sequence: number; text: string }
|
||||
}
|
||||
|
||||
type State = {
|
||||
jobs: SynchronizedRef.SynchronizedRef<Map<string, Active>>
|
||||
scope: Scope.Scope
|
||||
}
|
||||
|
||||
type FinishResult = {
|
||||
info?: Info
|
||||
done?: Deferred.Deferred<Info>
|
||||
scope?: Scope.Closeable
|
||||
}
|
||||
|
||||
export type StartInput = {
|
||||
id?: string
|
||||
type: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
export type ExtendInput = {
|
||||
id: string
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
export type WaitInput = {
|
||||
id: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export type WaitResult = {
|
||||
info?: Info
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly start: (input: StartInput) => Effect.Effect<Info>
|
||||
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
|
||||
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
|
||||
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
|
||||
|
||||
function snapshot(job: Active): Info {
|
||||
return {
|
||||
...job.info,
|
||||
...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export {
|
||||
Service,
|
||||
type ExtendInput,
|
||||
type Info,
|
||||
type Interface,
|
||||
type StartInput,
|
||||
type Status,
|
||||
type WaitInput,
|
||||
type WaitResult,
|
||||
} from "@opencode-ai/core/background-job"
|
||||
|
||||
/** Keeps the legacy service instance-scoped while sharing the core registry engine. */
|
||||
export const layer = Layer.effect(
|
||||
CoreBackgroundJob.Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("BackgroundJob.state")(function* () {
|
||||
return {
|
||||
jobs: yield* SynchronizedRef.make(new Map()),
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const settle = Effect.fn("BackgroundJob.settle")(function* (
|
||||
id: string,
|
||||
token: object,
|
||||
sequence: number,
|
||||
exit: Exit.Exit<string, unknown>,
|
||||
) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result = yield* SynchronizedRef.modify(s.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const pending = job.pending - 1
|
||||
const output =
|
||||
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
|
||||
? { sequence, text: exit.value }
|
||||
: job.output
|
||||
if (Exit.isSuccess(exit) && pending > 0) {
|
||||
return [{}, new Map(jobs).set(id, { ...job, pending, output })]
|
||||
}
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
: Cause.hasInterruptsOnly(exit.cause)
|
||||
? "cancelled"
|
||||
: "error"
|
||||
const next = {
|
||||
...job,
|
||||
pending: 0,
|
||||
output,
|
||||
info: {
|
||||
...job.info,
|
||||
status,
|
||||
completed_at,
|
||||
...(output ? { output: output.text } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) {
|
||||
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(s.scope, { startImmediately: true }))
|
||||
}
|
||||
return result.info
|
||||
const state = yield* InstanceState.make(() => CoreBackgroundJob.make)
|
||||
return CoreBackgroundJob.Service.of({
|
||||
list: () => InstanceState.useEffect(state, (jobs) => jobs.list()),
|
||||
get: (id) => InstanceState.useEffect(state, (jobs) => jobs.get(id)),
|
||||
start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)),
|
||||
extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)),
|
||||
wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)),
|
||||
cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)),
|
||||
})
|
||||
|
||||
const fork = Effect.fn("BackgroundJob.fork")(function* (
|
||||
scope: Scope.Scope,
|
||||
id: string,
|
||||
token: object,
|
||||
sequence: number,
|
||||
run: Effect.Effect<string, unknown>,
|
||||
) {
|
||||
return yield* run.pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)),
|
||||
onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)),
|
||||
}),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
|
||||
return Array.from((yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).values())
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => a.started_at - b.started_at)
|
||||
})
|
||||
|
||||
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
|
||||
const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id)
|
||||
if (!job) return
|
||||
return snapshot(job)
|
||||
})
|
||||
|
||||
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const id = input.id ?? Identifier.ascending("job")
|
||||
const started_at = yield* Clock.currentTimeMillis
|
||||
const done = yield* Deferred.make<Info>()
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
s.jobs,
|
||||
Effect.fnUntraced(function* (jobs) {
|
||||
const existing = jobs.get(id)
|
||||
if (existing?.info.status === "running") return [snapshot(existing), jobs] as const
|
||||
const scope = yield* Scope.fork(s.scope, "parallel")
|
||||
const token = {}
|
||||
yield* fork(scope, id, token, 0, restore(input.run))
|
||||
const job = {
|
||||
info: {
|
||||
id,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
status: "running" as const,
|
||||
started_at,
|
||||
metadata: input.metadata,
|
||||
},
|
||||
done,
|
||||
scope,
|
||||
token,
|
||||
pending: 1,
|
||||
next: 1,
|
||||
}
|
||||
return [snapshot(job), new Map(jobs).set(id, job)] as const
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
s.jobs,
|
||||
Effect.fnUntraced(function* (jobs) {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job || job.info.status !== "running") return [false, jobs] as const
|
||||
yield* fork(job.scope, input.id, job.token, job.next, restore(input.run))
|
||||
return [
|
||||
true,
|
||||
new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
pending: job.pending + 1,
|
||||
next: job.next + 1,
|
||||
}),
|
||||
] as const
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
|
||||
const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(input.id)
|
||||
if (!job) return { timedOut: false }
|
||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
|
||||
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
|
||||
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
|
||||
if (info._tag === "Some") return { info: info.value, timedOut: false }
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
})
|
||||
|
||||
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
(yield* InstanceState.get(state)).jobs,
|
||||
(jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
pending: 0,
|
||||
info: {
|
||||
...job.info,
|
||||
status: "cancelled" as const,
|
||||
completed_at,
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
},
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
return result.info
|
||||
})
|
||||
|
||||
return Service.of({ list, get, start, extend, wait, cancel })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ function activeAssistant(messages: SessionMessage[]) {
|
|||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
}
|
||||
|
||||
function ownedAssistant(messages: SessionMessage[], messageID: string) {
|
||||
const message = messages.find((message) => message.type === "assistant" && message.id === messageID)
|
||||
return message?.type === "assistant" ? message : undefined
|
||||
}
|
||||
|
||||
function activeCompaction(messages: SessionMessage[]) {
|
||||
const index = messages.findIndex((message) => message.type === "compaction")
|
||||
if (index < 0) return
|
||||
|
|
@ -37,8 +42,8 @@ function latestTool(assistant: SessionMessageAssistant | undefined, callID?: str
|
|||
)
|
||||
}
|
||||
|
||||
function latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
function latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID)
|
||||
}
|
||||
|
||||
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
|
|
@ -72,6 +77,26 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "session.next.agent.switched":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
type: "agent-switched",
|
||||
agent: event.properties.agent,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.model.switched":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
type: "model-switched",
|
||||
model: event.properties.model,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
|
|
@ -80,6 +105,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
agents: event.properties.prompt.agents,
|
||||
references: event.properties.prompt.references,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
|
|
@ -133,7 +159,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.step.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.properties.timestamp
|
||||
currentAssistant.finish = event.properties.finish
|
||||
|
|
@ -145,7 +171,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.step.failed":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.properties.timestamp
|
||||
currentAssistant.finish = "error"
|
||||
|
|
@ -154,24 +180,24 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.text.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({ type: "text", text: "" })
|
||||
activeAssistant(draft)?.content.push({ type: "text", id: event.properties.textID, text: "" })
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft))
|
||||
const match = latestText(activeAssistant(draft), event.properties.textID)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft))
|
||||
const match = latestText(activeAssistant(draft), event.properties.textID)
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
type: "tool",
|
||||
id: event.properties.callID,
|
||||
name: event.properties.name,
|
||||
|
|
@ -182,15 +208,19 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.tool.input.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
|
||||
if (match?.state.status === "pending") match.state.input += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
|
||||
if (match?.state.status === "pending") match.state.input = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.called":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
|
||||
if (!match) return
|
||||
match.time.ran = event.properties.timestamp
|
||||
match.provider = event.properties.provider
|
||||
|
|
@ -199,7 +229,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.tool.progress":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.structured = event.properties.structured
|
||||
match.state.content = [...event.properties.content]
|
||||
|
|
@ -207,13 +237,14 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.tool.success":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.properties.structured,
|
||||
content: [...event.properties.content],
|
||||
result: event.properties.result,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.time.completed = event.properties.timestamp
|
||||
|
|
@ -221,14 +252,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.tool.failed":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
|
||||
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.properties.error,
|
||||
input: match.state.input,
|
||||
structured: match.state.structured,
|
||||
content: match.state.content,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
structured: match.state.status === "running" ? match.state.structured : {},
|
||||
content: match.state.status === "running" ? match.state.content : [],
|
||||
result: event.properties.result,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.time.completed = event.properties.timestamp
|
||||
|
|
@ -240,6 +272,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
type: "reasoning",
|
||||
id: event.properties.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.properties.providerMetadata,
|
||||
})
|
||||
})
|
||||
break
|
||||
|
|
@ -252,7 +285,10 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
case "session.next.reasoning.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
if (match) match.text = event.properties.text
|
||||
if (match) {
|
||||
match.text = event.properties.text
|
||||
if (event.properties.providerMetadata !== undefined) match.providerMetadata = event.properties.providerMetadata
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.next.retried":
|
||||
|
|
|
|||
|
|
@ -1087,7 +1087,8 @@ function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
|
|||
return (content ?? [])
|
||||
.map((item) => {
|
||||
if (item.type === "text") return item.text.trim()
|
||||
return `[file ${item.name ?? item.uri}]`
|
||||
const source = item.source.type === "data" ? "inline data" : item.source.type === "url" ? item.source.url : item.source.uri
|
||||
return `[file ${item.name ?? source}]`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
|
|
|
|||
|
|
@ -389,7 +389,7 @@ export const layer = Layer.effect(
|
|||
type: event.type,
|
||||
data: event.data,
|
||||
},
|
||||
{ publish: true },
|
||||
{ publish: true, ownerID: space.id },
|
||||
)
|
||||
.pipe(Effect.provideService(WorkspaceRef, space.id)),
|
||||
{ discard: true },
|
||||
|
|
@ -434,7 +434,7 @@ export const layer = Layer.effect(
|
|||
if (payload.type === "server.heartbeat") return
|
||||
|
||||
if (payload.type === "sync" && payload.syncEvent) {
|
||||
const failed = yield* events.replay(payload.syncEvent, { publish: true }).pipe(
|
||||
const failed = yield* events.replay(payload.syncEvent, { publish: true, ownerID: space.id }).pipe(
|
||||
Effect.as(false),
|
||||
Effect.catchCause((error) =>
|
||||
Effect.sync(() => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ProviderGroup } from "./v2/provider"
|
|||
import { SessionGroup } from "./v2/session"
|
||||
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
|
||||
import { FileSystemGroup } from "./v2/fs"
|
||||
import { QuestionGroup, SessionQuestionGroup } from "./v2/question"
|
||||
|
||||
export const V2Api = HttpApi.make("v2")
|
||||
.add(SessionGroup)
|
||||
|
|
@ -15,6 +16,8 @@ export const V2Api = HttpApi.make("v2")
|
|||
.add(SessionPermissionGroup)
|
||||
.add(PermissionSavedGroup)
|
||||
.add(FileSystemGroup)
|
||||
.add(QuestionGroup)
|
||||
.add(SessionQuestionGroup)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
|||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
|
||||
|
|
@ -43,16 +45,18 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
|
|||
| PermissionV2.Service
|
||||
| ProjectReference.Service
|
||||
| FileSystem.Service
|
||||
| QuestionV2.Service
|
||||
}
|
||||
>()("@opencode/ExperimentalHttpApiV2Location") {}
|
||||
|
||||
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||
const query = new URL(request.url, "http://localhost").searchParams
|
||||
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
|
||||
return {
|
||||
directory: AbsolutePath.make(
|
||||
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
|
||||
),
|
||||
workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"],
|
||||
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
|
|||
.add(
|
||||
HttpApiEndpoint.get("models", "/api/model", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(ModelV2.Info),
|
||||
success: Schema.Array(ModelV2.PublicInfo),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
|
|||
.add(
|
||||
HttpApiEndpoint.get("providers", "/api/provider", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(ProviderV2.Info),
|
||||
success: Schema.Array(ProviderV2.PublicInfo),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
|
|
@ -25,7 +25,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
|
|||
HttpApiEndpoint.get("provider", "/api/provider/:providerID", {
|
||||
params: { providerID: ProviderV2.ID },
|
||||
query: LocationQuery,
|
||||
success: ProviderV2.Info,
|
||||
success: ProviderV2.PublicInfo,
|
||||
error: [ProviderNotFoundError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { QuestionNotFoundError, SessionNotFoundError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
|
||||
export const QuestionGroup = HttpApiGroup.make("v2.question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("questionRequests", "/api/question/request", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(QuestionV2.Request),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.question.request.list",
|
||||
summary: "List pending question requests",
|
||||
description: "Retrieve pending question requests for a location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "v2 questions", description: "Experimental v2 question routes." }))
|
||||
.middleware(V2LocationMiddleware)
|
||||
.middleware(V2Authorization)
|
||||
|
||||
export const SessionQuestionGroup = HttpApiGroup.make("v2.session.question")
|
||||
.add(
|
||||
HttpApiEndpoint.post("questionRequestReply", "/api/session/:sessionID/question/request/:requestID/reply", {
|
||||
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
|
||||
payload: QuestionV2.Reply,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, QuestionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.reply",
|
||||
summary: "Reply to pending question request",
|
||||
description: "Answer a pending question request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("questionRequestReject", "/api/session/:sessionID/question/request/:requestID/reject", {
|
||||
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, QuestionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.reject",
|
||||
summary: "Reject pending question request",
|
||||
description: "Reject a pending question request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({ title: "v2 session questions", description: "Experimental v2 session question routes." }),
|
||||
)
|
||||
.middleware(V2Authorization)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
|
|
@ -8,6 +9,7 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
|||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ConflictError,
|
||||
InvalidCursorError,
|
||||
InvalidRequestError,
|
||||
ServiceUnavailableError,
|
||||
|
|
@ -110,16 +112,18 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
|||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.Struct({
|
||||
id: SessionMessage.ID.pipe(Schema.optional),
|
||||
prompt: Prompt,
|
||||
delivery: SessionV2.Delivery.pipe(Schema.optional),
|
||||
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
success: SessionMessage.Message,
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
success: SessionMessage.User,
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.prompt",
|
||||
summary: "Send v2 message",
|
||||
description: "Create a v2 session message and queue it for the agent loop.",
|
||||
description: "Durably admit one v2 session input and schedule agent-loop execution unless resume is false.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
|||
last: payload.at(-1)?.seq,
|
||||
directory: ctx.payload.directory,
|
||||
})
|
||||
yield* events.replayAll(payload)
|
||||
const ownerID = yield* InstanceState.workspaceID
|
||||
yield* events.replayAll(payload, { ownerID, strictOwner: true })
|
||||
log.info("sync replay complete", {
|
||||
sessionID: source,
|
||||
events: payload.length,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Layer } from "effect"
|
||||
import { layer as v2LocationLayer } from "../groups/v2/location"
|
||||
import { messageHandlers } from "./v2/message"
|
||||
|
|
@ -9,6 +15,18 @@ import { providerHandlers } from "./v2/provider"
|
|||
import { sessionHandlers } from "./v2/session"
|
||||
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission"
|
||||
import { fileSystemHandlers } from "./v2/fs"
|
||||
import { questionHandlers, sessionQuestionHandlers } from "./v2/question"
|
||||
|
||||
const routedSessions = SessionV2.layer.pipe(
|
||||
Layer.provide(SessionProjector.layer),
|
||||
Layer.provide(SessionExecutionLocal.layer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(SessionStore.layer),
|
||||
Layer.provide(EventV2.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
export const v2Handlers = Layer.mergeAll(
|
||||
sessionHandlers,
|
||||
|
|
@ -19,9 +37,11 @@ export const v2Handlers = Layer.mergeAll(
|
|||
sessionPermissionHandlers,
|
||||
savedPermissionHandlers,
|
||||
fileSystemHandlers,
|
||||
questionHandlers,
|
||||
sessionQuestionHandlers,
|
||||
).pipe(
|
||||
Layer.provide(v2LocationLayer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(PermissionSaved.layer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
Layer.provide(routedSessions),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
|
|
@ -10,7 +9,6 @@ const DefaultMessagesLimit = 50
|
|||
|
||||
const Cursor = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
time: Schema.Finite,
|
||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
||||
})
|
||||
|
|
@ -19,9 +17,7 @@ const decodeCursor = Schema.decodeUnknownSync(Cursor)
|
|||
|
||||
const cursor = {
|
||||
encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") {
|
||||
return Buffer.from(
|
||||
JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), order, direction }),
|
||||
).toString("base64url")
|
||||
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
|
||||
},
|
||||
decode(input: string) {
|
||||
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
|
||||
|
|
@ -47,7 +43,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
|
|||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
||||
order,
|
||||
cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
|
|
@ -18,7 +19,7 @@ export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (
|
|||
const catalog = yield* Catalog.Service
|
||||
const pluginBoot = yield* PluginBoot.Service
|
||||
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||
return yield* catalog.model.available()
|
||||
return (yield* catalog.model.available()).map(ModelV2.toPublic)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
|
|
@ -19,7 +20,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid
|
|||
const catalog = yield* Catalog.Service
|
||||
const pluginBoot = yield* PluginBoot.Service
|
||||
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||
return yield* catalog.provider.available()
|
||||
return (yield* catalog.provider.available()).map(ProviderV2.toPublic)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
|
@ -29,6 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid
|
|||
const pluginBoot = yield* PluginBoot.Service
|
||||
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||
return yield* catalog.provider.get(ctx.params.providerID).pipe(
|
||||
Effect.map(ProviderV2.toPublic),
|
||||
Effect.catchTag("CatalogV2.ProviderNotFound", (error) =>
|
||||
Effect.fail(
|
||||
new ProviderNotFoundError({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { QuestionNotFoundError, SessionNotFoundError } from "../../errors"
|
||||
|
||||
function missingRequest(id: QuestionV2.ID) {
|
||||
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
|
||||
}
|
||||
|
||||
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers.handle(
|
||||
"questionRequests",
|
||||
Effect.fn(function* () {
|
||||
return yield* (yield* QuestionV2.Service).list()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const sessionQuestionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
|
||||
const withSessionQuestion = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: QuestionV2.Request["sessionID"],
|
||||
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
return yield* use(yield* QuestionV2.Service)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: QuestionV2.Request["sessionID"],
|
||||
requestID: QuestionV2.ID,
|
||||
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
return yield* withSessionQuestion(sessionID, (question) =>
|
||||
Effect.gen(function* () {
|
||||
const request = (yield* question.list()).find((request) => request.id === requestID)
|
||||
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
|
||||
return yield* use(question)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"questionRequestReply",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
||||
question
|
||||
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
|
||||
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"questionRequestReject",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
||||
question
|
||||
.reject(ctx.params.requestID)
|
||||
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -3,7 +3,13 @@ import { DateTime, Effect } from "effect"
|
|||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { SessionsCursor } from "../../groups/v2/session"
|
||||
import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
import {
|
||||
ConflictError,
|
||||
InvalidCursorError,
|
||||
ServiceUnavailableError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
} from "../../errors"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
|
|
@ -61,8 +67,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
|||
return yield* session
|
||||
.prompt({
|
||||
sessionID: ctx.params.sessionID,
|
||||
id: ctx.payload.id,
|
||||
prompt: ctx.payload.prompt,
|
||||
delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery,
|
||||
delivery: ctx.payload.delivery,
|
||||
resume: ctx.payload.resume,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
|
|
@ -73,11 +81,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
|||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
||||
Effect.catchTag("Session.PromptConflictError", (error) =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: `V2 session ${error.operation} is not available yet`,
|
||||
service: `v2.session.${error.operation}`,
|
||||
new ConflictError({
|
||||
message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`,
|
||||
resource: error.messageID,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
|||
if (operation.requestBody) {
|
||||
// The legacy OpenAPI surface never marked request bodies as required.
|
||||
// Keep that SDK surface stable while the HttpApi spec is tightened.
|
||||
delete operation.requestBody.required
|
||||
if (!isV2Api) delete operation.requestBody.required
|
||||
const body = operation.requestBody.content?.["application/json"]
|
||||
if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema))
|
||||
if (path === "/experimental/workspace" && method === "post") {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { ProviderTransform } from "@/provider/transform"
|
|||
import { errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { asSchema, type ModelMessage, type Tool } from "ai"
|
||||
import { Effect } from "effect"
|
||||
import { Cause, Effect, FiberSet, Queue } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
|
||||
import { LLMRequest, Tool as NativeTool, ToolFailure, ToolRuntime, toDefinitions, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
|
||||
import type { LLMClientShape } from "@opencode-ai/llm/route"
|
||||
import { LLMNative } from "./native-request"
|
||||
|
||||
|
|
@ -78,8 +78,8 @@ export function stream(input: StreamInput): StreamResult {
|
|||
// OpenAI's official wire field names, so this is identity, not translation
|
||||
// — if a field ever needs to differ between the two surfaces, the
|
||||
// translation belongs here, not split across both packages.
|
||||
const stream = input.llmClient.stream({
|
||||
request: LLMNative.request({
|
||||
const tools = nativeTools(input.tools, input)
|
||||
const request = LLMNative.request({
|
||||
model: input.model,
|
||||
apiKey: current.apiKey,
|
||||
baseURL: current.baseURL,
|
||||
|
|
@ -91,9 +91,45 @@ export function stream(input: StreamInput): StreamResult {
|
|||
maxOutputTokens: input.maxOutputTokens,
|
||||
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
|
||||
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
|
||||
}),
|
||||
tools: nativeTools(input.tools, input),
|
||||
})
|
||||
const stream = Stream.scoped(
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const settlements = yield* FiberSet.make<void>()
|
||||
const results = yield* Queue.unbounded<LLMEvent, Cause.Done>()
|
||||
const provider = input.llmClient
|
||||
.stream(
|
||||
LLMRequest.update(request, {
|
||||
tools: [...request.tools, ...toDefinitions(tools)],
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Stream.flatMap((event) =>
|
||||
event.type !== "tool-call" || event.providerExecuted
|
||||
? Stream.make(event)
|
||||
: Stream.make(event).pipe(
|
||||
Stream.concat(
|
||||
Stream.fromEffectDrain(
|
||||
ToolRuntime.dispatch(tools, event).pipe(
|
||||
Effect.flatMap((dispatched) => Queue.offerAll(results, dispatched.events)),
|
||||
Effect.catchCause((cause) => Queue.failCause(results, cause)),
|
||||
Effect.asVoid,
|
||||
FiberSet.run(settlements, { startImmediately: true }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Stream.concat(
|
||||
Stream.fromEffectDrain(
|
||||
FiberSet.awaitEmpty(settlements).pipe(Effect.andThen(Queue.end(results)), Effect.asVoid),
|
||||
),
|
||||
),
|
||||
)
|
||||
return provider.pipe(Stream.concat(Stream.fromQueue(results)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
...current,
|
||||
|
|
@ -128,7 +164,7 @@ export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput
|
|||
name,
|
||||
// Tool execution remains opencode-owned. The native runtime only adapts
|
||||
// the @opencode-ai/llm tool call back into the AI SDK Tool.execute shape.
|
||||
nativeTool({
|
||||
NativeTool.make({
|
||||
description: item.description ?? "",
|
||||
jsonSchema: nativeSchema(item.inputSchema),
|
||||
execute: (args: unknown, ctx) =>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||
import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import type { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
|
|
@ -65,11 +67,13 @@ export interface Interface {
|
|||
}
|
||||
|
||||
type ToolCall = {
|
||||
assistantMessageID?: EventV2.ID
|
||||
partID: SessionV1.ToolPart["id"]
|
||||
messageID: SessionV1.ToolPart["messageID"]
|
||||
sessionID: SessionV1.ToolPart["sessionID"]
|
||||
done: Deferred.Deferred<void>
|
||||
inputEnded: boolean
|
||||
raw: string
|
||||
}
|
||||
|
||||
interface ProcessorContext extends Input {
|
||||
|
|
@ -79,7 +83,9 @@ interface ProcessorContext extends Input {
|
|||
blocked: boolean
|
||||
needsCompaction: boolean
|
||||
currentText: SessionV1.TextPart | undefined
|
||||
currentTextID: string | undefined
|
||||
reasoningMap: Record<string, SessionV1.ReasoningPart>
|
||||
v2AssistantMessageID: EventV2.ID | undefined
|
||||
}
|
||||
|
||||
type StreamEvent = LLMEvent
|
||||
|
|
@ -119,7 +125,9 @@ export const layer = Layer.effect(
|
|||
blocked: false,
|
||||
needsCompaction: false,
|
||||
currentText: undefined,
|
||||
currentTextID: undefined,
|
||||
reasoningMap: {},
|
||||
v2AssistantMessageID: undefined,
|
||||
}
|
||||
let aborted = false
|
||||
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
|
||||
|
|
@ -136,6 +144,32 @@ export const layer = Layer.effect(
|
|||
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
|
||||
if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID
|
||||
ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: ModelV2.ID.make(ctx.model.id),
|
||||
providerID: ProviderV2.ID.make(ctx.model.providerID),
|
||||
variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"),
|
||||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})).id
|
||||
return ctx.v2AssistantMessageID
|
||||
})
|
||||
|
||||
const requireV2AssistantMessage = (toolCall?: ToolCall) =>
|
||||
toolCall?.assistantMessageID === undefined
|
||||
? Effect.die("V2 tool settlement has no owning assistant message")
|
||||
: Effect.succeed(toolCall.assistantMessageID)
|
||||
|
||||
const currentV2AssistantMessage = () =>
|
||||
ctx.v2AssistantMessageID === undefined
|
||||
? Effect.die("V2 step settlement has no owning assistant message")
|
||||
: Effect.succeed(ctx.v2AssistantMessageID)
|
||||
|
||||
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
||||
const call = ctx.toolcalls[toolCallID]
|
||||
if (!call) return undefined
|
||||
|
|
@ -220,6 +254,7 @@ export const layer = Layer.effect(
|
|||
sessionID: ctx.sessionID,
|
||||
reasoningID,
|
||||
text: ctx.reasoningMap[reasoningID].text,
|
||||
providerMetadata: ctx.reasoningMap[reasoningID].metadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
|
|
@ -230,6 +265,27 @@ export const layer = Layer.effect(
|
|||
delete ctx.reasoningMap[reasoningID]
|
||||
})
|
||||
|
||||
const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () {
|
||||
if (!flags.experimentalEventSystem) return
|
||||
if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
textID: ctx.currentTextID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) =>
|
||||
events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID,
|
||||
text: part.text,
|
||||
providerMetadata: part.metadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -251,9 +307,11 @@ export const layer = Layer.effect(
|
|||
return { call: ctx.toolcalls[input.id], part }
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined
|
||||
if (assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: input.id,
|
||||
name: input.name,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
|
|
@ -270,11 +328,13 @@ export const layer = Layer.effect(
|
|||
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
|
||||
} satisfies SessionV1.ToolPart)
|
||||
ctx.toolcalls[input.id] = {
|
||||
assistantMessageID,
|
||||
done: yield* Deferred.make<void>(),
|
||||
partID: part.id,
|
||||
messageID: part.messageID,
|
||||
sessionID: part.sessionID,
|
||||
inputEnded: false,
|
||||
raw: "",
|
||||
}
|
||||
return { call: ctx.toolcalls[input.id], part }
|
||||
})
|
||||
|
|
@ -311,6 +371,7 @@ export const layer = Layer.effect(
|
|||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
providerMetadata: value.providerMetadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
|
|
@ -331,6 +392,14 @@ export const layer = Layer.effect(
|
|||
if (!(value.id in ctx.reasoningMap)) return
|
||||
ctx.reasoningMap[value.id].text += value.text
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* session.updatePartDelta({
|
||||
sessionID: ctx.reasoningMap[value.id].sessionID,
|
||||
messageID: ctx.reasoningMap[value.id].messageID,
|
||||
|
|
@ -355,18 +424,32 @@ export const layer = Layer.effect(
|
|||
return
|
||||
|
||||
case "tool-input-delta":
|
||||
// AI SDK emits a final `tool-call` with the parsed `input`; accumulating
|
||||
// delta fragments into `state.raw` is redundant work for no current consumer.
|
||||
{
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
const assistantMessageID = flags.experimentalEventSystem ? yield* requireV2AssistantMessage(toolCall.call) : undefined
|
||||
if (assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.toolcalls[value.id] = { ...toolCall.call, raw: toolCall.call.raw + value.text }
|
||||
}
|
||||
return
|
||||
|
||||
case "tool-input-end": {
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
text: "",
|
||||
text: toolCall.call.raw,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
|
|
@ -383,18 +466,22 @@ export const layer = Layer.effect(
|
|||
if (!toolCall.call.inputEnded) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
text: "",
|
||||
text: toolCall.call.raw,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
tool: value.name,
|
||||
input,
|
||||
|
|
@ -453,6 +540,27 @@ export const layer = Layer.effect(
|
|||
|
||||
case "tool-result": {
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
if (!toolCall && value.result.type === "error") return
|
||||
if (value.result.type === "error") {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
error: { type: "unknown", message: errorMessage(value.result.value) },
|
||||
result: value.result,
|
||||
provider: {
|
||||
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* failToolCall(value.id, value.result.value)
|
||||
return
|
||||
}
|
||||
const rawOutput = toolResultOutput(value)
|
||||
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
|
||||
attachment.mime.startsWith("image/")
|
||||
|
|
@ -477,24 +585,42 @@ export const layer = Layer.effect(
|
|||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
const content = [
|
||||
ToolOutput.text({ type: "text", text: output.output }),
|
||||
...(output.attachments?.map((item: SessionV1.FilePart) =>
|
||||
ToolOutput.file({ type: "file", source: toolFileSourceFromUri(item.url), mime: item.mime, name: item.filename }),
|
||||
) ?? []),
|
||||
]
|
||||
const unsupported = content.find((item) => item.type === "file" && item.source.type !== "data")
|
||||
if (unsupported?.type === "file") {
|
||||
const error = new Error(`Tool attachment source "${unsupported.source.type}" must be materialized before durable V2 settlement`)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: error.message,
|
||||
},
|
||||
provider: {
|
||||
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* failToolCall(value.id, error)
|
||||
return
|
||||
} else yield* events.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
structured: output.metadata,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: output.output,
|
||||
},
|
||||
...(output.attachments?.map((item: SessionV1.FilePart) => ({
|
||||
type: "file" as const,
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
})) ?? []),
|
||||
],
|
||||
content,
|
||||
result: value.result,
|
||||
provider: {
|
||||
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
|
|
@ -507,8 +633,10 @@ export const layer = Layer.effect(
|
|||
const toolCall = yield* readToolCall(value.id)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
error: {
|
||||
type: "unknown",
|
||||
|
|
@ -516,6 +644,7 @@ export const layer = Layer.effect(
|
|||
},
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
|
|
@ -532,17 +661,7 @@ export const layer = Layer.effect(
|
|||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: ModelV2.ID.make(ctx.model.id),
|
||||
providerID: ProviderV2.ID.make(ctx.model.providerID),
|
||||
variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"),
|
||||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* ensureV2AssistantMessage()
|
||||
}
|
||||
}
|
||||
yield* session.updatePart({
|
||||
|
|
@ -567,12 +686,14 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
finish: value.reason,
|
||||
cost: usage.cost,
|
||||
tokens: usage.tokens,
|
||||
snapshot: completedSnapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
ctx.v2AssistantMessageID = undefined
|
||||
}
|
||||
}
|
||||
ctx.assistantMessage.finish = value.reason
|
||||
|
|
@ -625,6 +746,7 @@ export const layer = Layer.effect(
|
|||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
textID: value.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -637,6 +759,7 @@ export const layer = Layer.effect(
|
|||
time: { start: Date.now() },
|
||||
metadata: value.providerMetadata,
|
||||
}
|
||||
ctx.currentTextID = value.id
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
return
|
||||
|
||||
|
|
@ -644,6 +767,14 @@ export const layer = Layer.effect(
|
|||
if (!ctx.currentText) return
|
||||
ctx.currentText.text += value.text
|
||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
textID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* session.updatePartDelta({
|
||||
sessionID: ctx.currentText.sessionID,
|
||||
messageID: ctx.currentText.messageID,
|
||||
|
|
@ -673,6 +804,7 @@ export const layer = Layer.effect(
|
|||
sessionID: ctx.sessionID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
textID: value.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -683,6 +815,7 @@ export const layer = Layer.effect(
|
|||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
ctx.currentText = undefined
|
||||
ctx.currentTextID = undefined
|
||||
return
|
||||
|
||||
case "finish":
|
||||
|
|
@ -711,6 +844,7 @@ export const layer = Layer.effect(
|
|||
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
ctx.currentText = undefined
|
||||
ctx.currentTextID = undefined
|
||||
}
|
||||
|
||||
for (const part of Object.values(ctx.reasoningMap)) {
|
||||
|
|
@ -732,6 +866,16 @@ export const layer = Layer.effect(
|
|||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match) continue
|
||||
const part = match.part
|
||||
if (flags.experimentalEventSystem && match.call.assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: match.call.assistantMessageID,
|
||||
callID: toolCallID,
|
||||
error: { type: "unknown", message: "Tool execution aborted" },
|
||||
provider: { executed: part.metadata?.providerExecuted === true },
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
const end = Date.now()
|
||||
const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
|
||||
yield* session.updatePart({
|
||||
|
|
@ -753,6 +897,7 @@ export const layer = Layer.effect(
|
|||
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
|
||||
slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
|
||||
const error = parse(e)
|
||||
yield* flushV2Fragments()
|
||||
if (SessionV1.ContextOverflowError.isInstance(error)) {
|
||||
ctx.needsCompaction = true
|
||||
yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
|
||||
|
|
@ -763,6 +908,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(e),
|
||||
|
|
@ -787,6 +933,7 @@ export const layer = Layer.effect(
|
|||
return yield* Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
ctx.currentText = undefined
|
||||
ctx.currentTextID = undefined
|
||||
ctx.reasoningMap = {}
|
||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||
const stream = llm.stream(streamInput)
|
||||
|
|
@ -826,7 +973,8 @@ export const layer = Layer.effect(
|
|||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
: Effect.void
|
||||
return event.pipe(
|
||||
return flushV2Fragments().pipe(
|
||||
Effect.andThen(event),
|
||||
Effect.andThen(
|
||||
status.set(ctx.sessionID, {
|
||||
type: "retry",
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
|||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
|
||||
import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
|
@ -1191,12 +1191,13 @@ export const layer = Layer.effect(
|
|||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
prompt: {
|
||||
delivery: "steer",
|
||||
prompt: new Prompt({
|
||||
text: nextPrompt.text.join("\n"),
|
||||
files: nextPrompt.files,
|
||||
agents: nextPrompt.agents,
|
||||
references: nextPrompt.references,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
for (const text of nextPrompt.synthetic) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
|
||||
import { createWriteStream, existsSync, statSync } from "fs"
|
||||
import { realpathSync } from "fs"
|
||||
import { dirname, isAbsolute, join, relative, resolve as pathResolve, win32 } from "path"
|
||||
import { dirname, isAbsolute, join, resolve as pathResolve, win32 } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
// Fast sync version for metadata checks
|
||||
|
|
@ -163,13 +164,11 @@ export function windowsPath(p: string): string {
|
|||
)
|
||||
}
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = relative(a, b)
|
||||
const relB = relative(b, a)
|
||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||
return FSUtil.overlaps(a, b)
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
return !relative(parent, child).startsWith("..")
|
||||
return FSUtil.contains(parent, child)
|
||||
}
|
||||
|
||||
export async function findUp(
|
||||
|
|
|
|||
133
packages/opencode/test/cli/tui/sync-v2.test.tsx
Normal file
133
packages/opencode/test/cli/tui/sync-v2.test.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2"
|
||||
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function global(payload: Event): GlobalEvent {
|
||||
return { directory, project: "proj_test", payload }
|
||||
}
|
||||
|
||||
test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
events.emit(
|
||||
global({
|
||||
id: "agent-1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", timestamp: 0, agent: "build" },
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "model-1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "assistant-1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "input-1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "assistant-1",
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "failed-1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "assistant-1",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
return assistant?.type === "assistant" && assistant.content[0]?.type === "tool" && assistant.content[0].state.status === "error"
|
||||
})
|
||||
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
const tool = assistant.content[0]
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (tool?.type !== "tool") return
|
||||
expect(tool.state.status).toBe("error")
|
||||
if (tool.state.status !== "error") return
|
||||
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
|
||||
expect(tool.state.input).toEqual({})
|
||||
expect(tool.state.structured).toEqual({})
|
||||
expect(tool.state.content).toEqual([])
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
|
||||
"assistant",
|
||||
"model-switched",
|
||||
"agent-switched",
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
@ -40,7 +40,15 @@ export function callAuthProbe(scenario: ActiveScenario, credentials: "missing" |
|
|||
})
|
||||
}
|
||||
|
||||
const appCache: Partial<Record<string, BackendApp>> = {}
|
||||
type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
|
||||
|
||||
const appCache: Partial<Record<string, CachedApp>> = {}
|
||||
|
||||
export async function disposeApps() {
|
||||
const apps = Object.values(appCache)
|
||||
for (const key of Object.keys(appCache)) delete appCache[key]
|
||||
await Promise.all(apps.flatMap((app) => app === undefined ? [] : [app.dispose()]))
|
||||
}
|
||||
|
||||
function app(modules: Runtime, options: CallOptions) {
|
||||
const username = options.auth?.username
|
||||
|
|
@ -48,7 +56,7 @@ function app(modules: Runtime, options: CallOptions) {
|
|||
const cacheKey = `${username ?? ""}:${password ?? ""}`
|
||||
if (appCache[cacheKey]) return appCache[cacheKey]
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
const web = HttpRouter.toWebHandler(
|
||||
modules.HttpApiApp.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
|
|
@ -57,10 +65,11 @@ function app(modules: Runtime, options: CallOptions) {
|
|||
),
|
||||
),
|
||||
{ disableLogger: true, memoMap: modules.memoMap },
|
||||
).handler
|
||||
)
|
||||
return (appCache[cacheKey] = {
|
||||
dispose: web.dispose,
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return handler(
|
||||
return web.handler(
|
||||
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
|
||||
modules.HttpApiApp.context,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { color, printHeader, printResults } from "./report"
|
||||
import { coverageResult, parseOptions, routeKey, routeKeys, selectedScenarios } from "./routing"
|
||||
import { runScenario } from "./runner"
|
||||
import { disposeApps } from "./backend"
|
||||
import { runtime } from "./runtime"
|
||||
import { type Scenario } from "./types"
|
||||
|
||||
|
|
@ -621,6 +622,7 @@ const scenarios: Scenario[] = [
|
|||
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
|
||||
http.protected.get("/api/question/request", "v2.question.request.list").json(200, array),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
|
||||
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
|
||||
|
|
@ -641,6 +643,29 @@ const scenarios: Scenario[] = [
|
|||
body: { reply: "once" },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/question/request/{requestID}/reply", "v2.session.question.reply")
|
||||
.seeded((ctx) => ctx.session({ title: "Question reply owner" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/question/request/{requestID}/reply", {
|
||||
sessionID: ctx.state.id,
|
||||
requestID: "que_httpapi_missing",
|
||||
}),
|
||||
headers: ctx.headers(),
|
||||
body: { answers: [] },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/question/request/{requestID}/reject", "v2.session.question.reject")
|
||||
.seeded((ctx) => ctx.session({ title: "Question reject owner" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/question/request/{requestID}/reject", {
|
||||
sessionID: ctx.state.id,
|
||||
requestID: "que_httpapi_missing",
|
||||
}),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
|
||||
http.protected
|
||||
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
|
||||
|
|
@ -1393,7 +1418,7 @@ const llmScenarios = new Set([
|
|||
])
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => cleanupExercisePaths)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => disposeApps()).pipe(Effect.andThen(cleanupExercisePaths)))
|
||||
const options = parseOptions(Bun.argv.slice(2))
|
||||
const modules = yield* Effect.promise(() => runtime())
|
||||
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { Config } from "../../../src/config/config"
|
|||
|
||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../../src/session/schema"
|
||||
import { call, callAuthProbe } from "./backend"
|
||||
import { call, callAuthProbe, disposeApps } from "./backend"
|
||||
import { original } from "./environment"
|
||||
import { runtime } from "./runtime"
|
||||
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
|
||||
|
|
@ -259,6 +259,7 @@ const resetState = Effect.promise(async () => {
|
|||
const modules = await runtime()
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await disposeApps()
|
||||
await modules.disposeAllInstances()
|
||||
await modules.resetDatabase()
|
||||
await Bun.sleep(25)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export type Runtime = {
|
|||
PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"]
|
||||
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
|
||||
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
|
||||
memoMap: (typeof import("@opencode-ai/core/effect/memo-map"))["memoMap"]
|
||||
memoMap: import("effect").Layer.MemoMap
|
||||
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
|
||||
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
|
||||
Session: (typeof import("../../../src/session/session"))["Session"]
|
||||
|
|
@ -22,7 +22,7 @@ export function runtime() {
|
|||
const publicApi = await import("../../../src/server/routes/instance/httpapi/public")
|
||||
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
|
||||
const appRuntime = await import("../../../src/effect/app-runtime")
|
||||
const memoMap = await import("@opencode-ai/core/effect/memo-map")
|
||||
const { Layer } = await import("effect")
|
||||
const instanceRef = await import("../../../src/effect/instance-ref")
|
||||
const instanceStore = await import("../../../src/project/instance-store")
|
||||
const session = await import("../../../src/session/session")
|
||||
|
|
@ -36,7 +36,7 @@ export function runtime() {
|
|||
PublicApi: publicApi.PublicApi,
|
||||
HttpApiApp: httpApiServer.HttpApiApp,
|
||||
AppLayer: appRuntime.AppLayer,
|
||||
memoMap: memoMap.memoMap,
|
||||
memoMap: Layer.makeMemoMapUnsafe(),
|
||||
InstanceRef: instanceRef.InstanceRef,
|
||||
InstanceStore: instanceStore.InstanceStore,
|
||||
Session: session.Session,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Schema } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
|
||||
type OpenApiSchema = {
|
||||
readonly $ref?: string
|
||||
readonly items?: OpenApiSchema
|
||||
readonly properties?: Record<string, OpenApiSchema>
|
||||
}
|
||||
|
||||
type OpenApiSpec = {
|
||||
readonly components?: { readonly schemas?: Record<string, OpenApiSchema> }
|
||||
readonly paths: Record<
|
||||
string,
|
||||
{
|
||||
readonly get?: {
|
||||
readonly responses?: Record<string, { readonly content?: Record<string, { schema?: OpenApiSchema }> }>
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
function responseSchema(spec: OpenApiSpec, path: string) {
|
||||
return spec.paths[path]?.get?.responses?.["200"]?.content?.["application/json"]?.schema
|
||||
}
|
||||
|
||||
function componentName(ref: string | undefined) {
|
||||
return ref?.replace("#/components/schemas/", "")
|
||||
}
|
||||
|
||||
describe("PublicApi v2 catalog redaction", () => {
|
||||
test("routes use redacted provider and model DTO schemas", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
const provider = responseSchema(spec, "/api/provider/{providerID}")
|
||||
const providers = responseSchema(spec, "/api/provider")
|
||||
const models = responseSchema(spec, "/api/model")
|
||||
|
||||
expect(componentName(provider?.$ref)).toBe("ProviderV2PublicInfo")
|
||||
expect(componentName(providers?.items?.$ref)).toBe("ProviderV2PublicInfo")
|
||||
expect(componentName(models?.items?.$ref)).toBe("ModelV2PublicInfo")
|
||||
|
||||
const providerProperties = spec.components?.schemas?.ProviderV2PublicInfo?.properties
|
||||
const modelProperties = spec.components?.schemas?.ModelV2PublicInfo?.properties
|
||||
expect(providerProperties).not.toHaveProperty("request")
|
||||
expect(modelProperties).not.toHaveProperty("request")
|
||||
expect(JSON.stringify(providerProperties)).not.toMatch(/settings|headers|body|data/)
|
||||
expect(JSON.stringify(modelProperties)).not.toMatch(/settings|headers|body/)
|
||||
})
|
||||
|
||||
test("DTOs sanitize provider and model API URLs", () => {
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const providers = [
|
||||
new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(providerID),
|
||||
api: {
|
||||
type: "native",
|
||||
url: "https://provider-user:provider-password@example.com:8443/provider/v1?api_key=provider-secret#fragment",
|
||||
settings: {},
|
||||
},
|
||||
}),
|
||||
new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(providerID),
|
||||
api: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai",
|
||||
url: "https://provider-aisdk-user:provider-aisdk-password@example.com:8444/provider/aisdk?api_key=provider-aisdk-secret#fragment",
|
||||
},
|
||||
}),
|
||||
].map((provider) => Schema.encodeSync(ProviderV2.PublicInfo)(ProviderV2.toPublic(provider)))
|
||||
const models = [
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(providerID, ModelV2.ID.make("native")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("native"),
|
||||
type: "native",
|
||||
url: "https://native-user:native-password@example.com:9443/native/v1?api_key=native-secret#fragment",
|
||||
settings: {},
|
||||
},
|
||||
}),
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(providerID, ModelV2.ID.make("aisdk")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("aisdk"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai",
|
||||
url: "https://aisdk-user:aisdk-password@example.com:10443/aisdk/v1?api_key=aisdk-secret#fragment",
|
||||
},
|
||||
}),
|
||||
].map((model) => Schema.encodeSync(ModelV2.PublicInfo)(ModelV2.toPublic(model)))
|
||||
|
||||
expect(providers.map((provider) => provider.api)).toEqual([
|
||||
{ type: "native", url: "https://example.com:8443" },
|
||||
{ type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:8444" },
|
||||
])
|
||||
expect(models.map((model) => model.api)).toEqual([
|
||||
{ id: "native", type: "native", url: "https://example.com:9443" },
|
||||
{ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:10443" },
|
||||
])
|
||||
expect(JSON.stringify({ providers, models })).not.toMatch(/user|password|api_key|secret|fragment/)
|
||||
})
|
||||
|
||||
test("DTOs omit malformed API URLs", () => {
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const provider = Schema.encodeSync(ProviderV2.PublicInfo)(
|
||||
ProviderV2.toPublic(
|
||||
new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(providerID),
|
||||
api: { type: "native", url: "not a url?api_key=provider-secret", settings: {} },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const modelID = ModelV2.ID.make("aisdk")
|
||||
const model = Schema.encodeSync(ModelV2.PublicInfo)(
|
||||
ModelV2.toPublic(
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(providerID, modelID),
|
||||
api: { id: modelID, type: "aisdk", package: "@ai-sdk/openai", url: "model-secret" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(provider.api).toEqual({ type: "native" })
|
||||
expect(model.api).toEqual({ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai" })
|
||||
expect(JSON.stringify({ provider, model })).not.toMatch(/secret|api_key/)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,7 +3,7 @@ import { OpenApi } from "effect/unstable/httpapi"
|
|||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
|
||||
type Method = "get" | "post" | "put" | "delete" | "patch"
|
||||
type OpenApiSchema = { readonly $ref?: string }
|
||||
type OpenApiSchema = { readonly $ref?: string; readonly anyOf?: ReadonlyArray<OpenApiSchema> }
|
||||
type OpenApiResponse = {
|
||||
readonly description?: string
|
||||
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
||||
|
|
@ -16,6 +16,7 @@ type OpenApiOperation = {
|
|||
readonly schema?: { readonly type?: string }
|
||||
}>
|
||||
readonly responses?: Record<string, OpenApiResponse>
|
||||
readonly requestBody?: { readonly required?: boolean }
|
||||
readonly security?: unknown
|
||||
}
|
||||
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
|
||||
|
|
@ -44,6 +45,12 @@ function componentName(ref: string) {
|
|||
return ref.replace("#/components/schemas/", "")
|
||||
}
|
||||
|
||||
function componentNames(response: OpenApiResponse | undefined) {
|
||||
const schema = response?.content?.["application/json"]?.schema
|
||||
if (!schema) return []
|
||||
return [schema, ...(schema.anyOf ?? [])].flatMap((item) => (item.$ref ? [componentName(item.$ref)] : []))
|
||||
}
|
||||
|
||||
function isBuiltInEndpointError(name: string) {
|
||||
return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
|
||||
}
|
||||
|
|
@ -71,6 +78,18 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("preserves required request bodies for v2 mutations", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const path of [
|
||||
"/api/session/{sessionID}/prompt",
|
||||
"/api/session/{sessionID}/permission/request/{requestID}/reply",
|
||||
"/api/session/{sessionID}/question/request/{requestID}/reply",
|
||||
]) {
|
||||
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("does not rewrite /api endpoint errors to legacy error components", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
const refs = v2Operations(spec)
|
||||
|
|
@ -139,7 +158,6 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
|||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["post", "/api/session/{sessionID}/prompt"],
|
||||
["post", "/api/session/{sessionID}/compact"],
|
||||
["post", "/api/session/{sessionID}/wait"],
|
||||
] as const) {
|
||||
|
|
@ -191,6 +209,15 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
|||
"QuestionNotFoundError",
|
||||
)
|
||||
}
|
||||
for (const route of [
|
||||
["post", "/api/session/{sessionID}/question/request/{requestID}/reply"],
|
||||
["post", "/api/session/{sessionID}/question/request/{requestID}/reject"],
|
||||
] as const) {
|
||||
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
|
||||
"SessionNotFoundError",
|
||||
"QuestionNotFoundError",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("documents MCP server not-found errors", () => {
|
||||
|
|
|
|||
|
|
@ -60,13 +60,20 @@ type TestScope = Scope.Scope | TestServices
|
|||
function client(
|
||||
serverPath: ServerPath,
|
||||
directory?: string,
|
||||
input?: { password?: string; username?: string; headers?: Record<string, string> },
|
||||
input?: {
|
||||
password?: string
|
||||
username?: string
|
||||
headers?: Record<string, string>
|
||||
workspaceID?: string
|
||||
onRequest?: (request: Request) => void
|
||||
},
|
||||
) {
|
||||
return serverFetch(serverPath, input).pipe(
|
||||
Effect.map((fetch) =>
|
||||
createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
experimental_workspaceID: input?.workspaceID,
|
||||
headers: input?.headers,
|
||||
fetch,
|
||||
}),
|
||||
|
|
@ -74,7 +81,10 @@ function client(
|
|||
)
|
||||
}
|
||||
|
||||
function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
||||
function serverFetch(
|
||||
serverPath: ServerPath,
|
||||
input?: { password?: string; username?: string; onRequest?: (request: Request) => void },
|
||||
) {
|
||||
return HttpServer.HttpServer.use((server) =>
|
||||
Effect.sync(() => {
|
||||
void serverPath
|
||||
|
|
@ -84,6 +94,7 @@ function serverFetch(serverPath: ServerPath, input?: { password?: string; userna
|
|||
return Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) => {
|
||||
const source = request instanceof Request ? request : new Request(request, init)
|
||||
input?.onRequest?.(source)
|
||||
const url = new URL(source.url)
|
||||
return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source))
|
||||
},
|
||||
|
|
@ -367,6 +378,31 @@ describe("HttpApi SDK", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
httpapi(
|
||||
"routes configured SDK directory and workspace for v2 location GETs",
|
||||
withProject("raw", { setup: writeStandardFiles }, ({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const workspaceID = "wrk_sdk"
|
||||
let request: Request | undefined
|
||||
const sdk = yield* client("raw", directory, {
|
||||
workspaceID,
|
||||
onRequest: (value) => (request = value),
|
||||
})
|
||||
const file = yield* call(() => sdk.v2.fs.read({ path: "hello.txt" }))
|
||||
const url = new URL(request!.url)
|
||||
|
||||
expect(file.response.status).toBe(200)
|
||||
expect(file.data).toMatchObject({ content: "hello" })
|
||||
expect(url.searchParams.get("directory")).toBe(directory)
|
||||
expect(url.searchParams.get("workspace")).toBe(workspaceID)
|
||||
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
|
||||
expect(request!.headers.has("x-opencode-directory")).toBe(false)
|
||||
expect(request!.headers.has("x-opencode-workspace")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* client(serverPath)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { Session } from "@/session/session"
|
|||
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
|
@ -129,7 +129,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
|||
(info) => Workspace.use.remove(info.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
||||
const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) =>
|
||||
Effect.gen(function* () {
|
||||
const message = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.create(),
|
||||
|
|
@ -151,6 +151,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
|||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type: message.type,
|
||||
seq,
|
||||
time_created: time,
|
||||
data: {
|
||||
time: { created: time },
|
||||
|
|
@ -162,6 +163,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
|||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return message
|
||||
})
|
||||
|
||||
const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
|
||||
|
|
@ -174,6 +176,7 @@ const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
|
|||
id: SessionMessage.ID.create(),
|
||||
session_id: sessionID,
|
||||
type: "assistant",
|
||||
seq: time,
|
||||
time_created: time,
|
||||
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
||||
},
|
||||
|
|
@ -441,8 +444,8 @@ describe("session HttpApi", () => {
|
|||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const session = yield* createSession({ title: "v2 cursor" })
|
||||
yield* insertLegacyAssistantMessage(session.id, 1)
|
||||
yield* insertLegacyAssistantMessage(session.id, 2)
|
||||
const firstMessage = yield* insertLegacyAssistantMessage(session.id, 1, 2)
|
||||
const secondMessage = yield* insertLegacyAssistantMessage(session.id, 2, 1)
|
||||
|
||||
const sessionPage = yield* request(
|
||||
`/api/session?${new URLSearchParams({
|
||||
|
|
@ -480,8 +483,30 @@ describe("session HttpApi", () => {
|
|||
})
|
||||
|
||||
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
|
||||
const messageCursor = (yield* json<{ cursor: { next?: string } }>(messagePage)).cursor.next
|
||||
const messageBody = yield* json<{ items: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
|
||||
const messageCursor = messageBody.cursor.next
|
||||
expect(messageCursor).toBeTruthy()
|
||||
expect(messageBody.items.map((message) => message.id)).toEqual([secondMessage.id])
|
||||
expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({
|
||||
id: secondMessage.id,
|
||||
order: "desc",
|
||||
direction: "next",
|
||||
})
|
||||
|
||||
const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, { headers })
|
||||
expect((yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id)).toEqual([
|
||||
firstMessage.id,
|
||||
])
|
||||
|
||||
const legacyMessageCursor = Buffer.from(
|
||||
JSON.stringify({ id: secondMessage.id, time: 1, order: "desc", direction: "next" }),
|
||||
).toString("base64url")
|
||||
const legacyMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${legacyMessageCursor}`, {
|
||||
headers,
|
||||
})
|
||||
expect((yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id)).toEqual([
|
||||
firstMessage.id,
|
||||
])
|
||||
|
||||
const messageCursorWithOrder = yield* request(
|
||||
`/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`,
|
||||
|
|
@ -543,6 +568,64 @@ describe("session HttpApi", () => {
|
|||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"durably records one v2 prompt for exact message-ID retries",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const session = yield* createSession({ title: "v2 prompt recording" })
|
||||
|
||||
const recordPrompt = () =>
|
||||
request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "hello" } }),
|
||||
})
|
||||
const first = yield* recordPrompt()
|
||||
const retried = yield* recordPrompt()
|
||||
type PromptBody = { id: string; type: string; text: string }
|
||||
const firstBody = yield* json<PromptBody>(first)
|
||||
const retriedBody = yield* json<PromptBody>(retried)
|
||||
expect(first.status).toBe(200)
|
||||
expect(retried.status).toBe(200)
|
||||
expect(retriedBody).toEqual(firstBody)
|
||||
expect(firstBody).toMatchObject({ type: "user", text: "hello" })
|
||||
|
||||
const messages = yield* requestJson<{ items: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
||||
headers,
|
||||
})
|
||||
expect(messages.items).toHaveLength(0)
|
||||
const admitted = yield* Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionInputTable)
|
||||
.where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt")))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
expect(admitted).toMatchObject({
|
||||
id: "evt_http_prompt",
|
||||
session_id: session.id,
|
||||
delivery: "steer",
|
||||
promoted_seq: null,
|
||||
})
|
||||
|
||||
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "goodbye" } }),
|
||||
})
|
||||
expect(conflict.status).toBe(409)
|
||||
expect(yield* responseJson(conflict)).toEqual({
|
||||
_tag: "ConflictError",
|
||||
message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt",
|
||||
resource: "evt_http_prompt",
|
||||
})
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns v2 public unavailable errors for unfinished session mutations",
|
||||
() =>
|
||||
|
|
@ -551,18 +634,6 @@ describe("session HttpApi", () => {
|
|||
const headers = { "x-opencode-directory": test.directory }
|
||||
const session = yield* createSession({ title: "v2 unavailable" })
|
||||
|
||||
const prompt = yield* request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ prompt: { text: "hello" } }),
|
||||
})
|
||||
expect(prompt.status).toBe(503)
|
||||
expect(yield* responseJson(prompt)).toEqual({
|
||||
_tag: "ServiceUnavailableError",
|
||||
message: "V2 session prompt is not available yet",
|
||||
service: "v2.session.prompt",
|
||||
})
|
||||
|
||||
const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers })
|
||||
expect(compact.status).toBe(503)
|
||||
expect(yield* responseJson(compact)).toEqual({
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tool, type ModelMessage, type JSONValue } from "ai"
|
||||
import { Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import path from "node:path"
|
||||
import z from "zod"
|
||||
import { Auth } from "@/auth"
|
||||
|
|
@ -280,13 +277,10 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
|
|||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(ModelsDev.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
)
|
||||
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
|
||||
const recordedClient = LLMClient.layer.pipe(
|
||||
Layer.provide(Layer.mergeAll(RequestExecutor.layer, WebSocketExecutor.layer)),
|
||||
Layer.provide(
|
||||
HttpRecorder.recordingLayer(scenario.cassette, {
|
||||
const recordedHttp = HttpRecorder.cassetteLayer(scenario.cassette, {
|
||||
directory: FIXTURES_DIR,
|
||||
mode: shouldRecord ? "record" : "replay",
|
||||
metadata: {
|
||||
provider: scenario.providerID,
|
||||
|
|
@ -295,7 +289,10 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
|
|||
tags: scenario.tags,
|
||||
},
|
||||
redactor: recordingRedactor,
|
||||
}).pipe(Layer.provide(FetchHttpClient.layer)),
|
||||
})
|
||||
const recordedClient = LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -307,9 +304,6 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
|
|||
Layer.provide(provider),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(recordedClient),
|
||||
Layer.provide(
|
||||
HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(Layer.provide(NodeFileSystem.layer)),
|
||||
),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import { LLMEvent, ToolFailure } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/llm/route"
|
||||
import { jsonSchema, tool, type ModelMessage, type Tool } from "ai"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { LLMNative } from "@/session/llm/native-request"
|
||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
|
|
@ -535,6 +535,66 @@ describe("session.llm-native.request", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("emits native tool calls before overlapping local settlements complete", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[] = []
|
||||
const started: string[] = []
|
||||
let release: (() => void) | undefined
|
||||
let notifyStarted: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const bothStarted = new Promise<void>((resolve) => {
|
||||
notifyStarted = resolve
|
||||
})
|
||||
const lookup = {
|
||||
description: "Lookup data",
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
execute: async (_args: unknown, options: { toolCallId: string }) => {
|
||||
started.push(options.toolCallId)
|
||||
if (started.length === 2) notifyStarted?.()
|
||||
await gate
|
||||
return { output: options.toolCallId }
|
||||
},
|
||||
} satisfies Tool
|
||||
const llmClient = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: () =>
|
||||
Stream.fromIterable([
|
||||
LLMEvent.toolCall({ id: "call-1", name: "lookup", input: {} }),
|
||||
LLMEvent.toolCall({ id: "call-2", name: "lookup", input: {} }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]),
|
||||
generate: () => Effect.die("unused"),
|
||||
} as LLMClientShape
|
||||
const native = LLMNativeRuntime.stream({
|
||||
model: baseModel,
|
||||
provider: providerInfo,
|
||||
auth: undefined,
|
||||
llmClient,
|
||||
messages: [],
|
||||
tools: { lookup },
|
||||
headers: {},
|
||||
abort: new AbortController().signal,
|
||||
})
|
||||
expect(native.type).toBe("supported")
|
||||
if (native.type === "unsupported") throw new Error(native.reason)
|
||||
|
||||
const fiber = yield* native.stream.pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => observed.push(event.type))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.promise(() => bothStarted)
|
||||
|
||||
expect(started).toEqual(["call-1", "call-2"])
|
||||
expect(observed).toEqual(["tool-call", "tool-call", "finish"])
|
||||
|
||||
release?.()
|
||||
yield* Fiber.join(fiber)
|
||||
expect(observed).toEqual(["tool-call", "tool-call", "finish", "tool-result", "tool-result"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compiles through the native OpenAI Responses route", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [storedSession.user("hello")],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
|||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { expect } from "bun:test"
|
||||
import { tool } from "ai"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
|
|
@ -25,11 +25,13 @@ import { SessionSummary } from "../../src/session/summary"
|
|||
import { Snapshot } from "../../src/snapshot"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { raw, reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
|
|
@ -198,6 +200,58 @@ const env = Layer.mergeAll(
|
|||
|
||||
const it = testEffect(env)
|
||||
|
||||
const providerErrorLLM = Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
stream: () =>
|
||||
Stream.make(
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-1", name: "lookup" }),
|
||||
LLMEvent.toolInputEnd({ id: "call-1", name: "lookup" }),
|
||||
LLMEvent.toolCall({ id: "call-1", name: "lookup", input: {}, providerExecuted: true }),
|
||||
LLMEvent.toolResult({
|
||||
id: "call-1",
|
||||
name: "lookup",
|
||||
result: { type: "error", value: "provider boom" },
|
||||
providerExecuted: true,
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const providerErrorEnv = SessionProcessor.layer.pipe(
|
||||
Layer.provide(summary),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
Layer.provide(providerErrorLLM),
|
||||
Layer.provideMerge(deps),
|
||||
)
|
||||
const itProviderError = testEffect(providerErrorEnv)
|
||||
|
||||
const fragmentFailureLLM = Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
stream: () =>
|
||||
Stream.make(
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "reasoning-1" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textDelta({ id: "text-1", text: "partial" }),
|
||||
LLMEvent.providerError({ message: "provider boom" }),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const fragmentFailureEnv = SessionProcessor.layer.pipe(
|
||||
Layer.provide(summary),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
Layer.provide(fragmentFailureLLM),
|
||||
Layer.provideMerge(deps),
|
||||
)
|
||||
const itFragmentFailure = testEffect(fragmentFailureEnv)
|
||||
|
||||
const boot = Effect.fn("test.boot")(function* () {
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
|
|
@ -936,3 +990,109 @@ it.live("session.processor effect tests mark interruptions aborted without manua
|
|||
{ config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
itProviderError.live("session.processor effect tests fail provider-executed error results", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "provider tool error")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const settlements: Array<typeof SessionEvent.Tool.Failed.Type> = []
|
||||
const off = yield* events.listen((event) => {
|
||||
if (event.type === SessionEvent.Tool.Failed.type) settlements.push(event as typeof SessionEvent.Tool.Failed.Type)
|
||||
return Effect.void
|
||||
})
|
||||
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })
|
||||
|
||||
yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies SessionV1.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "provider tool error" }],
|
||||
tools: {},
|
||||
})
|
||||
yield* off
|
||||
|
||||
const parts = yield* MessageV2.parts(msg.id)
|
||||
const call = parts.find((part): part is SessionV1.ToolPart => part.type === "tool")
|
||||
expect(call?.state.status).toBe("error")
|
||||
if (call?.state.status === "error") expect(call.state.error).toBe("provider boom")
|
||||
expect(settlements).toHaveLength(1)
|
||||
expect(settlements[0]?.data).toMatchObject({
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "provider boom" },
|
||||
result: { type: "error", value: "provider boom" },
|
||||
provider: { executed: true },
|
||||
})
|
||||
}),
|
||||
{ config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
itFragmentFailure.live("session.processor effect tests flush partial v2 fragments before step failure", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "provider failure")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const seen: string[] = []
|
||||
let text: string | undefined
|
||||
let reasoning: string | undefined
|
||||
const off = yield* events.listen((event) => {
|
||||
seen.push(event.type)
|
||||
if (event.type === SessionEvent.Text.Ended.type) text = (event.data as typeof SessionEvent.Text.Ended.data.Type).text
|
||||
if (event.type === SessionEvent.Reasoning.Ended.type)
|
||||
reasoning = (event.data as typeof SessionEvent.Reasoning.Ended.data.Type).text
|
||||
return Effect.void
|
||||
})
|
||||
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })
|
||||
|
||||
expect(
|
||||
yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies SessionV1.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "provider failure" }],
|
||||
tools: {},
|
||||
}),
|
||||
).toBe("stop")
|
||||
yield* off
|
||||
|
||||
const failed = seen.indexOf(SessionEvent.Step.Failed.type)
|
||||
expect(failed).toBeGreaterThan(-1)
|
||||
expect(seen.indexOf(SessionEvent.Text.Ended.type)).toBeLessThan(failed)
|
||||
expect(seen.indexOf(SessionEvent.Reasoning.Ended.type)).toBeLessThan(failed)
|
||||
expect(text).toBe("partial")
|
||||
expect(reasoning).toBe("thinking")
|
||||
}),
|
||||
{ config: cfg },
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,16 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
|
||||
test.skip("step snapshots carry over to assistant messages", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const assistantMessageID = EventV2.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: EventV2.ID.create(),
|
||||
id: assistantMessageID,
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
|
|
@ -36,6 +38,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
|
|||
type: "session.next.step.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
|
|
@ -84,6 +87,7 @@ test.skip("text ended populates assistant text content", () => {
|
|||
data: {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
textID: "text-1",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
|
|
@ -95,6 +99,7 @@ test.skip("text ended populates assistant text content", () => {
|
|||
data: {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
textID: "text-1",
|
||||
text: "hello assistant",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
|
|
@ -102,17 +107,18 @@ test.skip("text ended populates assistant text content", () => {
|
|||
|
||||
expect(state.messages[0]?.type).toBe("assistant")
|
||||
if (state.messages[0]?.type !== "assistant") return
|
||||
expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }])
|
||||
expect(state.messages[0].content).toEqual([{ type: "text", id: "text-1", text: "hello assistant" }])
|
||||
})
|
||||
|
||||
test.skip("tool completion stores completed timestamp", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const callID = "call"
|
||||
const assistantMessageID = EventV2.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: EventV2.ID.create(),
|
||||
id: assistantMessageID,
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
|
|
@ -133,6 +139,7 @@ test.skip("tool completion stores completed timestamp", () => {
|
|||
type: "session.next.tool.input.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
callID,
|
||||
name: "bash",
|
||||
|
|
@ -146,11 +153,12 @@ test.skip("tool completion stores completed timestamp", () => {
|
|||
type: "session.next.tool.called",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
callID,
|
||||
tool: "bash",
|
||||
input: { command: "pwd" },
|
||||
provider: { executed: true, metadata: { source: "provider" } },
|
||||
provider: { executed: true, metadata: { fake: { source: "provider" } } },
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
|
|
@ -161,11 +169,12 @@ test.skip("tool completion stores completed timestamp", () => {
|
|||
type: "session.next.tool.success",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(4),
|
||||
callID,
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "/tmp" }],
|
||||
provider: { executed: true, metadata: { status: "done" } },
|
||||
content: [ToolOutput.text({ type: "text", text: "/tmp" })],
|
||||
provider: { executed: true, metadata: { fake: { status: "done" } } },
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
|
|
@ -175,7 +184,7 @@ test.skip("tool completion stores completed timestamp", () => {
|
|||
expect(state.messages[0].content[0]?.type).toBe("tool")
|
||||
if (state.messages[0].content[0]?.type !== "tool") return
|
||||
expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4))
|
||||
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } })
|
||||
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } })
|
||||
})
|
||||
|
||||
test.skip("compaction events reduce to compaction message", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue