Compare commits

...

10 Commits

Author SHA1 Message Date
Kit Langton 9609670544 fix(tui): prevent stale shell counts 2026-07-07 20:22:42 -04:00
Dax e1abf59c59 fix(core): provide PluginHooks in plugin test layer (#35811) 2026-07-07 20:03:41 -04:00
Dax 4f976bcf1a feat(plugin): add session request hook (#35794) 2026-07-07 19:56:47 -04:00
Kit Langton e25a724bc2 test(tui): cover patch diff display aliases (#35810) 2026-07-07 19:44:54 -04:00
Kit Langton 521ea87192 fix(core): retain reasoning delta state (#35758) 2026-07-07 19:44:40 -04:00
Aiden Cline 3100701488 fix(tui): restore prompt context usage (#35795) 2026-07-07 17:49:33 -05:00
Kit Langton 9c54291ae8 fix(tui): restore forms on reconnect
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-07 22:47:13 +00:00
Dax Raad 52ad916ba4 fix(tui): restore permissions on reconnect
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-07 22:44:26 +00:00
Dax Raad 5db320b02d refactor: rename apply_patch tool 2026-07-07 16:27:25 -04:00
Aiden Cline 910e37f6d8 fix: update v2 session usage metrics (#35468) 2026-07-07 14:31:54 -05:00
161 changed files with 8329 additions and 5079 deletions
@@ -20,7 +20,7 @@ const profiles = [
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
{ {
name: "multi patch", name: "multi patch",
tool: "apply_patch", tool: "patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
}, },
] as const ] as const
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
userMessage(), userMessage(),
assistantMessage( assistantMessage(
[ [
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"), textPart(followingID, "Following incremental patch"),
], ],
{ completed: false }, { completed: false },
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"running", "running",
{ files: [first.filePath, second.filePath] }, { files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } }, { metadata: { files: [first, second] } },
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"completed", "completed",
{ files: [first.filePath, second.filePath, third.filePath] }, { files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } }, { metadata: { files: [first, second, third] } },
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
messageID: assistantID, messageID: assistantID,
type: "tool", type: "tool",
callID: `call_0000_${suffix}_patch`, callID: `call_0000_${suffix}_patch`,
tool: "apply_patch", tool: "patch",
state: { state: {
status: "completed", status: "completed",
input: { patchText: realisticPatch(index) }, input: { patchText: realisticPatch(index) },
@@ -131,7 +131,7 @@ function toolPart(
): MessagePart { ): MessagePart {
const metadata = const metadata =
metadataOverride ?? metadataOverride ??
(tool === "apply_patch" (tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { ? {
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []), : []),
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([ assistantMessage([
toolPart( toolPart(
id, id,
"apply_patch", "patch",
"completed", "completed",
{ files: ["src/a.ts"] }, { files: ["src/a.ts"] },
{ {
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
assistantMessage([ assistantMessage([
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"completed", "completed",
{ files: files.map((file) => file.filePath) }, { files: files.map((file) => file.filePath) },
{ metadata: { files } }, { metadata: { files } },
@@ -246,7 +246,7 @@ function editPart(id: string) {
function patchPart(id: string) { function patchPart(id: string) {
return toolPart( return toolPart(
id, id,
"apply_patch", "patch",
"completed", "completed",
{ files: ["src/a.ts", "src/b.ts"] }, { files: ["src/a.ts", "src/b.ts"] },
{ {
@@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) => const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
) )
@@ -90,7 +90,7 @@ function questionInput() {
function errorInput(tool: string) { function errorInput(tool: string) {
if (tool === "bash") return { command: "exit 1" } if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] } if (tool === "patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" } if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" } if (tool === "websearch") return { query: "failure" }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" } if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
@@ -120,7 +120,7 @@ function toolPart(
outputLength = 160, outputLength = 160,
): MessagePart { ): MessagePart {
const metadata = const metadata =
tool === "apply_patch" tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { ? {
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []), : []),
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
@@ -1245,7 +1245,7 @@ export function MessageTimeline(props: {
const value = row() const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool) return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool)
} }
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined let contentMeasureFrame: number | undefined
+2 -2
View File
@@ -628,11 +628,11 @@ function emitEdit(state: State): void {
function emitPatch(state: State): void { function emitPatch(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts") const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "apply_patch", { const ref = make(state, "patch", {
patchText: "*** Begin Patch\n*** End Patch", patchText: "*** Begin Patch\n*** End Patch",
}) })
doneTool(state, ref, { doneTool(state, ref, {
title: "apply_patch", title: "patch",
output: "", output: "",
metadata: { metadata: {
files: [ files: [
+2 -2
View File
@@ -119,7 +119,7 @@ type ToolName =
| "bash" | "bash"
| "write" | "write"
| "edit" | "edit"
| "apply_patch" | "patch"
| "batch" | "batch"
| "task" | "task"
| "todowrite" | "todowrite"
@@ -1094,7 +1094,7 @@ const TOOL_RULES = {
}, },
permission: permEdit, permission: permEdit,
}, },
apply_patch: { patch: {
view: { view: {
output: false, output: false,
final: true, final: true,
+2 -3
View File
@@ -3,15 +3,14 @@ import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from
import { import {
ClientApi, ClientApi,
effectOmitEndpoints, effectOmitEndpoints,
endpointNames,
groupNames, groupNames,
promiseOmitEndpoints, promiseOmitEndpoints,
} from "@opencode-ai/protocol/client" } from "@opencode-ai/protocol/client"
import { Effect } from "effect" import { Effect } from "effect"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints }) const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
await Effect.runPromise( await Effect.runPromise(
Effect.all( Effect.all(
-1
View File
@@ -1,7 +1,6 @@
export { export {
ClientApi, ClientApi,
effectOmitEndpoints, effectOmitEndpoints,
endpointNames,
groupNames, groupNames,
promiseOmitEndpoints, promiseOmitEndpoints,
} from "@opencode-ai/protocol/client" } from "@opencode-ai/protocol/client"
+30 -24
View File
@@ -291,9 +291,11 @@ export interface SessionApi<E = never> {
readonly shell: SessionShellOperation<E> readonly shell: SessionShellOperation<E>
readonly compact: SessionCompactOperation<E> readonly compact: SessionCompactOperation<E>
readonly wait: SessionWaitOperation<E> readonly wait: SessionWaitOperation<E>
readonly revertStage: SessionRevertStageOperation<E> readonly revert: {
readonly revertClear: SessionRevertClearOperation<E> readonly stage: SessionRevertStageOperation<E>
readonly revertCommit: SessionRevertCommitOperation<E> readonly clear: SessionRevertClearOperation<E>
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E> readonly context: SessionContextOperation<E>
readonly instructions: { readonly instructions: {
readonly entry: { readonly entry: {
@@ -438,11 +440,15 @@ export type IntegrationAttemptCancelOperation<E = never> = (
export interface IntegrationApi<E = never> { export interface IntegrationApi<E = never> {
readonly list: IntegrationListOperation<E> readonly list: IntegrationListOperation<E>
readonly get: IntegrationGetOperation<E> readonly get: IntegrationGetOperation<E>
readonly connectKey: IntegrationConnectKeyOperation<E> readonly connect: {
readonly connectOauth: IntegrationConnectOauthOperation<E> readonly key: IntegrationConnectKeyOperation<E>
readonly attemptStatus: IntegrationAttemptStatusOperation<E> readonly oauth: IntegrationConnectOauthOperation<E>
readonly attemptComplete: IntegrationAttemptCompleteOperation<E> }
readonly attemptCancel: IntegrationAttemptCancelOperation<E> readonly attempt: {
readonly status: IntegrationAttemptStatusOperation<E>
readonly complete: IntegrationAttemptCompleteOperation<E>
readonly cancel: IntegrationAttemptCancelOperation<E>
}
} }
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0] type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
@@ -453,11 +459,13 @@ export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => E
type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0] type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>> export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type ServerMcpCatalogOperation<E = never> = (input?: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E> export type ServerMcpResourceCatalogOperation<E = never> = (
input?: Endpoint10_1Input,
) => Effect.Effect<Endpoint10_1Output, E>
export interface ServerMcpApi<E = never> { export interface ServerMcpApi<E = never> {
readonly list: ServerMcpListOperation<E> readonly list: ServerMcpListOperation<E>
readonly catalog: ServerMcpCatalogOperation<E> readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
} }
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
@@ -507,7 +515,7 @@ export interface ProjectApi<E = never> {
type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0] type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.form"]["form.request.list"]>> export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.form"]["form.request.list"]>>
export type FormListRequestsOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E> export type FormRequestListOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E>
type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0] type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] } export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] }
@@ -561,7 +569,7 @@ export type Endpoint13_6Output = EffectValue<ReturnType<RawClient["server.form"]
export type FormCancelOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E> export type FormCancelOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E>
export interface FormApi<E = never> { export interface FormApi<E = never> {
readonly listRequests: FormListRequestsOperation<E> readonly request: { readonly list: FormRequestListOperation<E> }
readonly list: FormListOperation<E> readonly list: FormListOperation<E>
readonly create: FormCreateOperation<E> readonly create: FormCreateOperation<E>
readonly get: FormGetOperation<E> readonly get: FormGetOperation<E>
@@ -573,7 +581,7 @@ export interface FormApi<E = never> {
type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0] type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>> export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>>
export type PermissionListRequestsOperation<E = never> = ( export type PermissionRequestListOperation<E = never> = (
input?: Endpoint14_0Input, input?: Endpoint14_0Input,
) => Effect.Effect<Endpoint14_0Output, E> ) => Effect.Effect<Endpoint14_0Output, E>
@@ -582,14 +590,14 @@ export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["quer
export type Endpoint14_1Output = EffectValue< export type Endpoint14_1Output = EffectValue<
ReturnType<RawClient["server.permission"]["permission.saved.list"]> ReturnType<RawClient["server.permission"]["permission.saved.list"]>
>["data"] >["data"]
export type PermissionListSavedOperation<E = never> = ( export type PermissionSavedListOperation<E = never> = (
input?: Endpoint14_1Input, input?: Endpoint14_1Input,
) => Effect.Effect<Endpoint14_1Output, E> ) => Effect.Effect<Endpoint14_1Output, E>
type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0] type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] } export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] }
export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>> export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>>
export type PermissionRemoveSavedOperation<E = never> = ( export type PermissionSavedRemoveOperation<E = never> = (
input: Endpoint14_2Input, input: Endpoint14_2Input,
) => Effect.Effect<Endpoint14_2Output, E> ) => Effect.Effect<Endpoint14_2Output, E>
@@ -637,9 +645,8 @@ export type Endpoint14_6Output = EffectValue<ReturnType<RawClient["server.permis
export type PermissionReplyOperation<E = never> = (input: Endpoint14_6Input) => Effect.Effect<Endpoint14_6Output, E> export type PermissionReplyOperation<E = never> = (input: Endpoint14_6Input) => Effect.Effect<Endpoint14_6Output, E>
export interface PermissionApi<E = never> { export interface PermissionApi<E = never> {
readonly listRequests: PermissionListRequestsOperation<E> readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly listSaved: PermissionListSavedOperation<E> readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
readonly removeSaved: PermissionRemoveSavedOperation<E>
readonly create: PermissionCreateOperation<E> readonly create: PermissionCreateOperation<E>
readonly list: PermissionListOperation<E> readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E> readonly get: PermissionGetOperation<E>
@@ -808,7 +815,7 @@ export interface ShellApi<E = never> {
type Endpoint21_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0] type Endpoint21_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] }
export type Endpoint21_0Output = EffectValue<ReturnType<RawClient["server.question"]["question.request.list"]>> export type Endpoint21_0Output = EffectValue<ReturnType<RawClient["server.question"]["question.request.list"]>>
export type QuestionListRequestsOperation<E = never> = ( export type QuestionRequestListOperation<E = never> = (
input?: Endpoint21_0Input, input?: Endpoint21_0Input,
) => Effect.Effect<Endpoint21_0Output, E> ) => Effect.Effect<Endpoint21_0Output, E>
@@ -835,7 +842,7 @@ export type Endpoint21_3Output = EffectValue<ReturnType<RawClient["server.questi
export type QuestionRejectOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E> export type QuestionRejectOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E>
export interface QuestionApi<E = never> { export interface QuestionApi<E = never> {
readonly listRequests: QuestionListRequestsOperation<E> readonly request: { readonly list: QuestionRequestListOperation<E> }
readonly list: QuestionListOperation<E> readonly list: QuestionListOperation<E>
readonly reply: QuestionReplyOperation<E> readonly reply: QuestionReplyOperation<E>
readonly reject: QuestionRejectOperation<E> readonly reject: QuestionRejectOperation<E>
@@ -905,16 +912,15 @@ export interface VcsApi<E = never> {
} }
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>> export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E> export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
type Endpoint25_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0] type Endpoint25_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] }
export type Endpoint25_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>> export type Endpoint25_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
export type DebugEvictLocationOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E> export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export interface DebugApi<E = never> { export interface DebugApi<E = never> {
readonly location: DebugLocationOperation<E> readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
readonly evictLocation: DebugEvictLocationOperation<E>
} }
export interface AppApi<E = never> { export interface AppApi<E = never> {
+12 -16
View File
@@ -381,9 +381,7 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
shell: Endpoint4_14(raw), shell: Endpoint4_14(raw),
compact: Endpoint4_15(raw), compact: Endpoint4_15(raw),
wait: Endpoint4_16(raw), wait: Endpoint4_16(raw),
revertStage: Endpoint4_17(raw), revert: { stage: Endpoint4_17(raw), clear: Endpoint4_18(raw), commit: Endpoint4_19(raw) },
revertClear: Endpoint4_18(raw),
revertCommit: Endpoint4_19(raw),
context: Endpoint4_20(raw), context: Endpoint4_20(raw),
instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } }, instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } },
log: Endpoint4_24(raw), log: Endpoint4_24(raw),
@@ -536,11 +534,8 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
const adaptGroup9 = (raw: RawClient["server.integration"]) => ({ const adaptGroup9 = (raw: RawClient["server.integration"]) => ({
list: Endpoint9_0(raw), list: Endpoint9_0(raw),
get: Endpoint9_1(raw), get: Endpoint9_1(raw),
connectKey: Endpoint9_2(raw), connect: { key: Endpoint9_2(raw), oauth: Endpoint9_3(raw) },
connectOauth: Endpoint9_3(raw), attempt: { status: Endpoint9_4(raw), complete: Endpoint9_5(raw), cancel: Endpoint9_6(raw) },
attemptStatus: Endpoint9_4(raw),
attemptComplete: Endpoint9_5(raw),
attemptCancel: Endpoint9_6(raw),
}) })
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0] type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
@@ -553,7 +548,10 @@ type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["loc
const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) => const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) =>
raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw), catalog: Endpoint10_1(raw) }) const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({
list: Endpoint10_0(raw),
resource: { catalog: Endpoint10_1(raw) },
})
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint11_0Input = { type Endpoint11_0Input = {
@@ -690,7 +688,7 @@ const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Inpu
) )
const adaptGroup13 = (raw: RawClient["server.form"]) => ({ const adaptGroup13 = (raw: RawClient["server.form"]) => ({
listRequests: Endpoint13_0(raw), request: { list: Endpoint13_0(raw) },
list: Endpoint13_1(raw), list: Endpoint13_1(raw),
create: Endpoint13_2(raw), create: Endpoint13_2(raw),
get: Endpoint13_3(raw), get: Endpoint13_3(raw),
@@ -778,9 +776,8 @@ const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
const adaptGroup14 = (raw: RawClient["server.permission"]) => ({ const adaptGroup14 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint14_0(raw), request: { list: Endpoint14_0(raw) },
listSaved: Endpoint14_1(raw), saved: { list: Endpoint14_1(raw), remove: Endpoint14_2(raw) },
removeSaved: Endpoint14_2(raw),
create: Endpoint14_3(raw), create: Endpoint14_3(raw),
list: Endpoint14_4(raw), list: Endpoint14_4(raw),
get: Endpoint14_5(raw), get: Endpoint14_5(raw),
@@ -1013,7 +1010,7 @@ const Endpoint21_3 = (raw: RawClient["server.question"]) => (input: Endpoint21_3
) )
const adaptGroup21 = (raw: RawClient["server.question"]) => ({ const adaptGroup21 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint21_0(raw), request: { list: Endpoint21_0(raw) },
list: Endpoint21_1(raw), list: Endpoint21_1(raw),
reply: Endpoint21_2(raw), reply: Endpoint21_2(raw),
reject: Endpoint21_3(raw), reject: Endpoint21_3(raw),
@@ -1099,8 +1096,7 @@ const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1In
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
location: Endpoint25_0(raw), location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
evictLocation: Endpoint25_1(raw),
}) })
const adaptClient = (raw: RawClient) => ({ const adaptClient = (raw: RawClient) => ({
+224 -206
View File
@@ -89,8 +89,8 @@ import type {
IntegrationAttemptCancelOutput, IntegrationAttemptCancelOutput,
ServerMcpListInput, ServerMcpListInput,
ServerMcpListOutput, ServerMcpListOutput,
ServerMcpCatalogInput, ServerMcpResourceCatalogInput,
ServerMcpCatalogOutput, ServerMcpResourceCatalogOutput,
CredentialUpdateInput, CredentialUpdateInput,
CredentialUpdateOutput, CredentialUpdateOutput,
CredentialRemoveInput, CredentialRemoveInput,
@@ -100,8 +100,8 @@ import type {
ProjectCurrentOutput, ProjectCurrentOutput,
ProjectDirectoriesInput, ProjectDirectoriesInput,
ProjectDirectoriesOutput, ProjectDirectoriesOutput,
FormListRequestsInput, FormRequestListInput,
FormListRequestsOutput, FormRequestListOutput,
FormListInput, FormListInput,
FormListOutput, FormListOutput,
FormCreateInput, FormCreateInput,
@@ -114,12 +114,12 @@ import type {
FormReplyOutput, FormReplyOutput,
FormCancelInput, FormCancelInput,
FormCancelOutput, FormCancelOutput,
PermissionListRequestsInput, PermissionRequestListInput,
PermissionListRequestsOutput, PermissionRequestListOutput,
PermissionListSavedInput, PermissionSavedListInput,
PermissionListSavedOutput, PermissionSavedListOutput,
PermissionRemoveSavedInput, PermissionSavedRemoveInput,
PermissionRemoveSavedOutput, PermissionSavedRemoveOutput,
PermissionCreateInput, PermissionCreateInput,
PermissionCreateOutput, PermissionCreateOutput,
PermissionListInput, PermissionListInput,
@@ -161,8 +161,8 @@ import type {
ShellOutputOutput, ShellOutputOutput,
ShellRemoveInput, ShellRemoveInput,
ShellRemoveOutput, ShellRemoveOutput,
QuestionListRequestsInput, QuestionRequestListInput,
QuestionListRequestsOutput, QuestionRequestListOutput,
QuestionListInput, QuestionListInput,
QuestionListOutput, QuestionListOutput,
QuestionReplyInput, QuestionReplyInput,
@@ -181,9 +181,9 @@ import type {
VcsStatusOutput, VcsStatusOutput,
VcsDiffInput, VcsDiffInput,
VcsDiffOutput, VcsDiffOutput,
DebugLocationOutput, DebugLocationListOutput,
DebugEvictLocationInput, DebugLocationEvictInput,
DebugEvictLocationOutput, DebugLocationEvictOutput,
} from "./types" } from "./types"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
@@ -601,40 +601,42 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => revert: {
request<{ readonly data: SessionRevertStageOutput }>( stage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) =>
{ request<{ readonly data: SessionRevertStageOutput }>(
method: "POST", {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, method: "POST",
body: { messageID: input["messageID"], files: input["files"] }, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
successStatus: 200, body: { messageID: input["messageID"], files: input["files"] },
declaredStatuses: [404, 409, 500, 400, 401], successStatus: 200,
empty: false, declaredStatuses: [404, 409, 500, 400, 401],
}, empty: false,
requestOptions, },
).then((value) => value.data), requestOptions,
revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => ).then((value) => value.data),
request<SessionRevertClearOutput>( clear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) =>
{ request<SessionRevertClearOutput>(
method: "POST", {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, method: "POST",
successStatus: 204, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
declaredStatuses: [404, 409, 500, 400, 401], successStatus: 204,
empty: true, declaredStatuses: [404, 409, 500, 400, 401],
}, empty: true,
requestOptions, },
), requestOptions,
revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => ),
request<SessionRevertCommitOutput>( commit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) =>
{ request<SessionRevertCommitOutput>(
method: "POST", {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, method: "POST",
successStatus: 204, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
declaredStatuses: [404, 409, 400, 401], successStatus: 204,
empty: true, declaredStatuses: [404, 409, 400, 401],
}, empty: true,
requestOptions, },
), requestOptions,
),
},
context: (input: SessionContextInput, requestOptions?: RequestOptions) => context: (input: SessionContextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionContextOutput }>( request<{ readonly data: SessionContextOutput }>(
{ {
@@ -836,69 +838,73 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => connect: {
request<IntegrationConnectKeyOutput>( key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
{ request<IntegrationConnectKeyOutput>(
method: "POST", {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, method: "POST",
query: { location: input["location"] }, path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
body: { key: input["key"], label: input["label"] }, query: { location: input["location"] },
successStatus: 204, body: { key: input["key"], label: input["label"] },
declaredStatuses: [400, 401], successStatus: 204,
empty: true, declaredStatuses: [400, 401],
}, empty: true,
requestOptions, },
), requestOptions,
connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => ),
request<IntegrationConnectOauthOutput>( oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) =>
{ request<IntegrationConnectOauthOutput>(
method: "POST", {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, method: "POST",
query: { location: input["location"] }, path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, query: { location: input["location"] },
successStatus: 200, body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
declaredStatuses: [400, 401], successStatus: 200,
empty: false, declaredStatuses: [400, 401],
}, empty: false,
requestOptions, },
), requestOptions,
attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => ),
request<IntegrationAttemptStatusOutput>( },
{ attempt: {
method: "GET", status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) =>
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, request<IntegrationAttemptStatusOutput>(
query: { location: input["location"] }, {
successStatus: 200, method: "GET",
declaredStatuses: [401, 400], path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
empty: false, query: { location: input["location"] },
}, successStatus: 200,
requestOptions, declaredStatuses: [401, 400],
), empty: false,
attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => },
request<IntegrationAttemptCompleteOutput>( requestOptions,
{ ),
method: "POST", complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) =>
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, request<IntegrationAttemptCompleteOutput>(
query: { location: input["location"] }, {
body: { code: input["code"] }, method: "POST",
successStatus: 204, path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
declaredStatuses: [400, 401], query: { location: input["location"] },
empty: true, body: { code: input["code"] },
}, successStatus: 204,
requestOptions, declaredStatuses: [400, 401],
), empty: true,
attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => },
request<IntegrationAttemptCancelOutput>( requestOptions,
{ ),
method: "DELETE", cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) =>
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, request<IntegrationAttemptCancelOutput>(
query: { location: input["location"] }, {
successStatus: 204, method: "DELETE",
declaredStatuses: [401, 400], path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
empty: true, query: { location: input["location"] },
}, successStatus: 204,
requestOptions, declaredStatuses: [401, 400],
), empty: true,
},
requestOptions,
),
},
}, },
"server.mcp": { "server.mcp": {
list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) => list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
@@ -913,18 +919,20 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
catalog: (input?: ServerMcpCatalogInput, requestOptions?: RequestOptions) => resource: {
request<ServerMcpCatalogOutput>( catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
{ request<ServerMcpResourceCatalogOutput>(
method: "GET", {
path: `/api/mcp/resource`, method: "GET",
query: { location: input?.["location"] }, path: `/api/mcp/resource`,
successStatus: 200, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
),
},
}, },
credential: { credential: {
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
@@ -985,18 +993,20 @@ export function make(options: ClientOptions) {
), ),
}, },
form: { form: {
listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) => request: {
request<FormListRequestsOutput>( list: (input?: FormRequestListInput, requestOptions?: RequestOptions) =>
{ request<FormRequestListOutput>(
method: "GET", {
path: `/api/form/request`, method: "GET",
query: { location: input?.["location"] }, path: `/api/form/request`,
successStatus: 200, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
),
},
list: (input: FormListInput, requestOptions?: RequestOptions) => list: (input: FormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormListOutput }>( request<{ readonly data: FormListOutput }>(
{ {
@@ -1074,41 +1084,45 @@ export function make(options: ClientOptions) {
), ),
}, },
permission: { permission: {
listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) => request: {
request<PermissionListRequestsOutput>( list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) =>
{ request<PermissionRequestListOutput>(
method: "GET", {
path: `/api/permission/request`, method: "GET",
query: { location: input?.["location"] }, path: `/api/permission/request`,
successStatus: 200, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) => ),
request<{ readonly data: PermissionListSavedOutput }>( },
{ saved: {
method: "GET", list: (input?: PermissionSavedListInput, requestOptions?: RequestOptions) =>
path: `/api/permission/saved`, request<{ readonly data: PermissionSavedListOutput }>(
query: { projectID: input?.["projectID"] }, {
successStatus: 200, method: "GET",
declaredStatuses: [401, 400], path: `/api/permission/saved`,
empty: false, query: { projectID: input?.["projectID"] },
}, successStatus: 200,
requestOptions, declaredStatuses: [401, 400],
).then((value) => value.data), empty: false,
removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) => },
request<PermissionRemoveSavedOutput>( requestOptions,
{ ).then((value) => value.data),
method: "DELETE", remove: (input: PermissionSavedRemoveInput, requestOptions?: RequestOptions) =>
path: `/api/permission/saved/${encodeURIComponent(input.id)}`, request<PermissionSavedRemoveOutput>(
successStatus: 204, {
declaredStatuses: [401, 400], method: "DELETE",
empty: true, path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
}, successStatus: 204,
requestOptions, declaredStatuses: [401, 400],
), empty: true,
},
requestOptions,
),
},
create: (input: PermissionCreateInput, requestOptions?: RequestOptions) => create: (input: PermissionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionCreateOutput }>( request<{ readonly data: PermissionCreateOutput }>(
{ {
@@ -1390,18 +1404,20 @@ export function make(options: ClientOptions) {
), ),
}, },
question: { question: {
listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) => request: {
request<QuestionListRequestsOutput>( list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
{ request<QuestionRequestListOutput>(
method: "GET", {
path: `/api/question/request`, method: "GET",
query: { location: input?.["location"] }, path: `/api/question/request`,
successStatus: 200, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
),
},
list: (input: QuestionListInput, requestOptions?: RequestOptions) => list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionListOutput }>( request<{ readonly data: QuestionListOutput }>(
{ {
@@ -1518,29 +1534,31 @@ export function make(options: ClientOptions) {
), ),
}, },
debug: { debug: {
location: (requestOptions?: RequestOptions) => location: {
request<DebugLocationOutput>( list: (requestOptions?: RequestOptions) =>
{ request<DebugLocationListOutput>(
method: "GET", {
path: `/api/debug/location`, method: "GET",
successStatus: 200, path: `/api/debug/location`,
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
evictLocation: (input?: DebugEvictLocationInput, requestOptions?: RequestOptions) => ),
request<DebugEvictLocationOutput>( evict: (input?: DebugLocationEvictInput, requestOptions?: RequestOptions) =>
{ request<DebugLocationEvictOutput>(
method: "DELETE", {
path: `/api/debug/location`, method: "DELETE",
query: { location: input?.["location"] }, path: `/api/debug/location`,
successStatus: 204, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 204,
empty: true, declaredStatuses: [401, 400],
}, empty: true,
requestOptions, },
), requestOptions,
),
},
}, },
} }
} }
+46 -15
View File
@@ -1439,6 +1439,13 @@ export type SessionLogOutput =
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string }
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
} }
} }
| { | {
@@ -2501,13 +2508,13 @@ export type ServerMcpListOutput = {
}> }>
} }
export type ServerMcpCatalogInput = { export type ServerMcpResourceCatalogInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type ServerMcpCatalogOutput = { export type ServerMcpResourceCatalogOutput = {
readonly location: { readonly location: {
readonly directory: string readonly directory: string
readonly workspaceID?: string readonly workspaceID?: string
@@ -2578,13 +2585,13 @@ export type ProjectDirectoriesInput = {
export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }> export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }>
export type FormListRequestsInput = { export type FormRequestListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type FormListRequestsOutput = { export type FormRequestListOutput = {
readonly location: { readonly location: {
readonly directory: string readonly directory: string
readonly workspaceID?: string readonly workspaceID?: string
@@ -3652,13 +3659,13 @@ export type FormCancelInput = {
export type FormCancelOutput = void export type FormCancelOutput = void
export type PermissionListRequestsInput = { export type PermissionRequestListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type PermissionListRequestsOutput = { export type PermissionRequestListOutput = {
readonly location: { readonly location: {
readonly directory: string readonly directory: string
readonly workspaceID?: string readonly workspaceID?: string
@@ -3675,9 +3682,9 @@ export type PermissionListRequestsOutput = {
}> }>
} }
export type PermissionListSavedInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
export type PermissionListSavedOutput = { export type PermissionSavedListOutput = {
readonly data: ReadonlyArray<{ readonly data: ReadonlyArray<{
readonly id: string readonly id: string
readonly projectID: string readonly projectID: string
@@ -3686,9 +3693,9 @@ export type PermissionListSavedOutput = {
}> }>
}["data"] }["data"]
export type PermissionRemoveSavedInput = { readonly id: { readonly id: string }["id"] } export type PermissionSavedRemoveInput = { readonly id: { readonly id: string }["id"] }
export type PermissionRemoveSavedOutput = void export type PermissionSavedRemoveOutput = void
export type PermissionCreateInput = { export type PermissionCreateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -4542,6 +4549,23 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string } readonly data: { readonly sessionID: string; readonly title: string }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.usage.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@@ -4765,6 +4789,13 @@ export type EventSubscribeOutput =
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string }
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
} }
} }
| { | {
@@ -6018,13 +6049,13 @@ export type ShellRemoveInput = {
export type ShellRemoveOutput = void export type ShellRemoveOutput = void
export type QuestionListRequestsInput = { export type QuestionRequestListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type QuestionListRequestsOutput = { export type QuestionRequestListOutput = {
readonly location: { readonly location: {
readonly directory: string readonly directory: string
readonly workspaceID?: string readonly workspaceID?: string
@@ -6190,12 +6221,12 @@ export type VcsDiffOutput = {
}> }>
} }
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> export type DebugLocationListOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
export type DebugEvictLocationInput = { export type DebugLocationEvictInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type DebugEvictLocationOutput = void export type DebugLocationEvictOutput = void
@@ -19,7 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace" import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { ClientApi, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract" import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract"
const Client = await import("../src/effect") const Client = await import("../src/effect")
@@ -49,8 +49,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
}) })
test("client and Server contracts generate identically", () => { test("client and Server contracts generate identically", () => {
const server = compile(Api, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
expect(emitPromise(client)).toEqual(emitPromise(server)) expect(emitPromise(client)).toEqual(emitPromise(server))
}) })
+5 -10
View File
@@ -33,16 +33,11 @@ test("exposes every standard HTTP API group", () => {
"debug", "debug",
]) ])
expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
expect(Object.keys(client.message)).toEqual(["list"]) expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual([ expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"])
"list", expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"])
"get", expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"])
"connectKey",
"connectOauth",
"attemptStatus",
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
@@ -66,7 +61,7 @@ test("MCP resource catalog uses the public HTTP contract", async () => {
}, },
}) })
const result = await client["server.mcp"].catalog({ location: { directory: "/tmp/project" } }) const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } })
expect(result.data.resources[0]?.uri).toBe("docs://readme") expect(result.data.resources[0]?.uri).toBe("docs://readme")
expect(request?.method).toBe("GET") expect(request?.method).toBe("GET")
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -177,13 +177,13 @@ describe("OpenAPI.fromSpec", () => {
const spec = await opencodeSpec() const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl }) const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(result.skipped).toHaveLength(5) expect(result.skipped).toHaveLength(4)
expect(result.skipped).toContainEqual({ expect(result.skipped).toContainEqual({
method: "GET", method: "GET",
path: "/api/pty/{ptyID}/connect", path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported", reason: "WebSocket operations are not supported",
}) })
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3) expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
expect(result.skipped).toContainEqual({ expect(result.skipped).toContainEqual({
method: "GET", method: "GET",
path: "/api/fs/read/*", path: "/api/fs/read/*",
@@ -210,11 +210,11 @@ describe("OpenAPI.fromSpec", () => {
if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated") if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }") expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined() expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined() expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined() expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined()
}) })
test("preserves operation path sanitization and collision handling", () => { test("preserves operation path sanitization and collision handling", () => {
+3 -2
View File
@@ -6,6 +6,7 @@ import type {
JSONValue, JSONValue,
LanguageModelV3, LanguageModelV3,
LanguageModelV3CallOptions, LanguageModelV3CallOptions,
LanguageModelV3FinishReason,
LanguageModelV3FunctionTool, LanguageModelV3FunctionTool,
LanguageModelV3Message, LanguageModelV3Message,
LanguageModelV3Prompt, LanguageModelV3Prompt,
@@ -624,8 +625,8 @@ function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["us
return Object.values(output).some((value) => value !== undefined) ? output : undefined return Object.values(output).some((value) => value !== undefined) ? output : undefined
} }
function finishReason(value: unknown): FinishReason { function finishReason(value: LanguageModelV3FinishReason): FinishReason {
return Schema.is(FinishReason)(value) ? value : "unknown" return value.unified === "other" ? "unknown" : value.unified
} }
function providerMetadata(value: unknown) { function providerMetadata(value: unknown) {
+1 -1
View File
@@ -200,6 +200,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.no
// TODO: Publish watcher/file-edit events after V2 watcher integration exists. // TODO: Publish watcher/file-edit events after V2 watcher integration exists.
// TODO: Add snapshots / undo after V2 snapshot design exists. // TODO: Add snapshots / undo after V2 snapshot design exists.
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists. // TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits. // TODO: Design multi-file transactions / rollback if patch needs atomic edits.
// Until then, edits are sequential and report partial application. // Until then, edits are sequential and report partial application.
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement. // TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.
+13 -6
View File
@@ -1,6 +1,6 @@
export * as PluginV2 from "./plugin" export * as PluginV2 from "./plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import { Event, ID, type Info } from "@opencode-ai/schema/plugin" import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
@@ -18,6 +18,7 @@ import { SkillV2 } from "./skill"
import { State } from "./state" import { State } from "./state"
import { ToolRegistry } from "./tool/registry" import { ToolRegistry } from "./tool/registry"
import { ToolHooks } from "./tool/hooks" import { ToolHooks } from "./tool/hooks"
import { PluginHooks } from "./plugin/hooks"
export interface Interface { export interface Interface {
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void> readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
@@ -57,12 +58,13 @@ const layer = Layer.effect(
generation.length === definitions.length && generation.length === definitions.length &&
generation.every( generation.every(
(plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version, (plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
) ) &&
definitions.every((definition) => active.has(definition.id))
) { ) {
return return
} }
generation = undefined generation = undefined
const exit = yield* State.batch( yield* State.batch(
Effect.gen(function* () { Effect.gen(function* () {
const scopes = Array.from(active.values()).toReversed() const scopes = Array.from(active.values()).toReversed()
active.clear() active.clear()
@@ -81,13 +83,17 @@ const layer = Layer.effect(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit, Effect.exit,
) )
if (Exit.isFailure(loaded)) return loaded if (Exit.isFailure(loaded)) {
yield* Effect.logWarning("failed to load plugin", {
"plugin.id": definition.id,
cause: loaded.cause,
})
continue
}
active.set(definition.id, child) active.set(definition.id, child)
} }
return Exit.void
}), }),
) )
if (Exit.isFailure(exit)) return yield* exit
generation = definitions.map((definition) => ({ generation = definitions.map((definition) => ({
id: definition.id, id: definition.id,
...(definition.version === undefined ? {} : { version: definition.version }), ...(definition.version === undefined ? {} : { version: definition.version }),
@@ -131,6 +137,7 @@ export const node = makeLocationNode({
SkillV2.node, SkillV2.node,
ToolRegistry.toolsNode, ToolRegistry.toolsNode,
ToolHooks.node, ToolHooks.node,
PluginHooks.node,
PluginRuntime.node, PluginRuntime.node,
], ],
}) })
+67
View File
@@ -0,0 +1,67 @@
export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly tool: ToolHooks
}
type Callback<Event> = (event: Event) => Effect.Effect<void>
export interface Interface {
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
callback: Callback<Domains[Domain][Name]>,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
event: Domains[Domain][Name],
) => Effect.Effect<Domains[Domain][Name]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginHooks") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const callbacks = new Map<string, Function[]>()
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
const scope = yield* Scope.Scope
const id = key(domain, name)
let active = true
callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
if (next.length === 0) callbacks.delete(id)
else callbacks.set(id, next)
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
yield* result
}
return event
})
return Service.of({ register, trigger })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+74 -68
View File
@@ -1,6 +1,6 @@
export * as PluginHost from "./host" export * as PluginHost from "./host"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { EventManifest } from "@opencode-ai/schema/event-manifest" import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
@@ -22,6 +22,7 @@ import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools" import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks" import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T> const mutable = <T>(value: T) => value as DeepMutable<T>
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
@@ -36,6 +37,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const skill = yield* SkillV2.Service const skill = yield* SkillV2.Service
const tools = yield* Tools.Service const tools = yield* Tools.Service
const toolHooks = yield* ToolHooks.Service const toolHooks = yield* ToolHooks.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const locationInfo = () => const locationInfo = () =>
new Location.Info({ new Location.Info({
@@ -43,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
workspaceID: location.workspaceID, workspaceID: location.workspaceID,
project: location.project, project: location.project,
}) })
const locationRef = (input?: Parameters<PluginContext["agent"]["list"]>[0]) => const locationRef = (input?: Parameters<Plugin.Context["agent"]["list"]>[0]) =>
input?.location === undefined input?.location === undefined
? undefined ? undefined
: Location.Ref.make({ : Location.Ref.make({
@@ -79,32 +81,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}), }),
}, },
aisdk: { aisdk: {
sdk: (callback) => hook: (name, callback) => {
aisdk.hook.sdk((event) => { if (name === "sdk") {
return aisdk.hook.sdk((event) => {
const output = {
model: mutable(event.model),
package: event.package,
options: event.options,
sdk: event.sdk,
}
return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
)
})
}
return aisdk.hook.language((event) => {
const output = { const output = {
model: mutable(event.model), model: mutable(event.model),
package: event.package,
options: event.options, options: event.options,
sdk: event.sdk, sdk: event.sdk,
}
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
)
}),
language: (callback) =>
aisdk.hook.language((event) => {
const output = {
model: mutable(event.model),
sdk: event.sdk,
options: event.options,
language: event.language, language: event.language,
} }
const result = callback(output) return Reflect.apply(callback, undefined, [output]).pipe(
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.language = output.language))), Effect.tap(() => Effect.sync(() => (event.language = output.language))),
) )
}), })
},
}, },
catalog: { catalog: {
provider: { provider: {
@@ -164,25 +166,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
integration: { integration: {
list: () => response(integration.list()), list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
connectKey: (input) => connect: {
integration.connection.key({ key: (input) =>
integrationID: Integration.ID.make(input.integrationID), integration.connection.key({
key: input.key,
label: input.label,
}),
connectOauth: (input) =>
response(
integration.connection.oauth({
integrationID: Integration.ID.make(input.integrationID), integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID), key: input.key,
inputs: input.inputs,
label: input.label, label: input.label,
}), }),
), oauth: (input) =>
attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), response(
attemptComplete: (input) => integration.connection.oauth({
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), integrationID: Integration.ID.make(input.integrationID),
attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), methodID: Integration.MethodID.make(input.methodID),
inputs: input.inputs,
label: input.label,
}),
),
},
attempt: {
status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
complete: (input) =>
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
},
reload: integration.reload, reload: integration.reload,
connection: { connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)), active: (id) => integration.connection.active(Integration.ID.make(id)),
@@ -317,11 +323,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
registrations, registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options), (registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true }, { discard: true },
) ).pipe(Effect.orDie)
return { dispose: Effect.void }
}), }),
execute: { hook: (name, callback) => {
before: (callback) => if (name === "execute.before") {
toolHooks.hook.before((event) => { return toolHooks.hook.before((event) => {
const output = { const output = {
tool: event.tool, tool: event.tool,
sessionID: event.sessionID, sessionID: event.sessionID,
@@ -330,38 +337,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
toolCallID: event.toolCallID, toolCallID: event.toolCallID,
input: event.input, input: event.input,
} }
const result = callback(output) return Reflect.apply(callback, undefined, [output]).pipe(
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.input = output.input))), Effect.tap(() => Effect.sync(() => (event.input = output.input))),
) )
}), })
after: (callback) => }
toolHooks.hook.after((event) => { return toolHooks.hook.after((event) => {
const output = { const output = {
tool: event.tool, tool: event.tool,
sessionID: event.sessionID, sessionID: event.sessionID,
agent: event.agent, agent: event.agent,
assistantMessageID: event.assistantMessageID, assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID, toolCallID: event.toolCallID,
input: event.input, input: event.input,
result: event.result, result: event.result,
output: event.output, output: event.output,
outputPaths: event.outputPaths, outputPaths: event.outputPaths,
} }
const result = callback(output) return Reflect.apply(callback, undefined, [output]).pipe(
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( Effect.tap(() =>
Effect.tap(() => Effect.sync(() => {
Effect.sync(() => { event.result = output.result
event.result = output.result event.output = output.output
event.output = output.output event.outputPaths = output.outputPaths
event.outputPaths = output.outputPaths }),
}), ),
), )
) })
}),
}, },
}, },
session: { session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input?.id, id: input?.id,
@@ -375,5 +381,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
command: runtime.session.command, command: runtime.session.command,
interrupt: (input) => runtime.session.interrupt(input.sessionID), interrupt: (input) => runtime.session.interrupt(input.sessionID),
}, },
} satisfies PluginContext } satisfies Plugin.Context
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginInternal from "./internal" export * as PluginInternal from "./internal"
import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import { Context, Effect, Scope } from "effect" import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
+24 -14
View File
@@ -1,11 +1,12 @@
export * as PluginPromise from "./promise" export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope, Stream } from "effect" import { Effect, Scope, Stream } from "effect"
type HostRegistration = { readonly dispose: Effect.Effect<void> } type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> } type Registration = { readonly dispose: () => Promise<void> }
type PromisePlugin = import("@opencode-ai/plugin/v2/plugin").Plugin
type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context
/** /**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only * Adapts a Promise plugin into an Effect plugin so the existing Effect-only
@@ -16,8 +17,8 @@ type Registration = { readonly dispose: () => Promise<void> }
* preserves boot-time batching, so Promise-plugin transforms still coalesce * preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain. * into one reload per domain.
*/ */
export function fromPromise(plugin: Plugin) { export function fromPromise(plugin: PromisePlugin) {
return define({ return Plugin.define({
id: plugin.id, id: plugin.id,
effect: (host) => effect: (host) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -43,7 +44,7 @@ export function fromPromise(plugin: Plugin) {
}), }),
) )
const context2: PluginContext = { const context2: PromisePluginContext = {
options: host.options, options: host.options,
agent: { agent: {
list: (input) => run(host.agent.list(input)), list: (input) => run(host.agent.list(input)),
@@ -51,10 +52,8 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.agent.reload()), reload: () => run(host.agent.reload()),
}, },
aisdk: { aisdk: {
sdk: (callback) => hook: (name, callback) =>
register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
language: (callback) =>
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
}, },
catalog: { catalog: {
provider: { provider: {
@@ -79,11 +78,15 @@ export function fromPromise(plugin: Plugin) {
integration: { integration: {
list: (input) => run(host.integration.list(input)), list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)), get: (input) => run(host.integration.get(input)),
connectKey: (input) => run(host.integration.connectKey(input)), connect: {
connectOauth: (input) => run(host.integration.connectOauth(input)), key: (input) => run(host.integration.connect.key(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)), oauth: (input) => run(host.integration.connect.oauth(input)),
attemptComplete: (input) => run(host.integration.attemptComplete(input)), },
attemptCancel: (input) => run(host.integration.attemptCancel(input)), attempt: {
status: (input) => run(host.integration.attempt.status(input)),
complete: (input) => run(host.integration.attempt.complete(input)),
cancel: (input) => run(host.integration.attempt.cancel(input)),
},
transform: transform(host.integration), transform: transform(host.integration),
reload: () => run(host.integration.reload()), reload: () => run(host.integration.reload()),
connection: { connection: {
@@ -104,12 +107,19 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.skill), transform: transform(host.skill),
reload: () => run(host.skill.reload()), reload: () => run(host.skill.reload()),
}, },
tool: {
transform: transform(host.tool),
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
session: { session: {
create: (input) => run(host.session.create(input)), create: (input) => run(host.session.create(input)),
get: (input) => run(host.session.get(input)), get: (input) => run(host.session.get(input)),
prompt: (input) => run(host.session.prompt(input)), prompt: (input) => run(host.session.prompt(input)),
command: (input) => run(host.session.command(input)), command: (input) => run(host.session.command(input)),
interrupt: (input) => run(host.session.interrupt(input)), interrupt: (input) => run(host.session.interrupt(input)),
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
}, },
} }
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AlibabaPlugin = define({ export const AlibabaPlugin = define({
id: "opencode.provider.alibaba", id: "opencode.provider.alibaba",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/alibaba") return if (evt.package !== "@ai-sdk/alibaba") return
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
@@ -75,7 +75,8 @@ export const AmazonBedrockPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
const options = { ...evt.options } const options = { ...evt.options }
@@ -108,7 +109,8 @@ export const AmazonBedrockPlugin = define({
evt.sdk = mod.createAmazonBedrock(options) evt.sdk = mod.createAmazonBedrock(options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
if ( if (
@@ -17,7 +17,8 @@ export const AnthropicPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return if (evt.package !== "@ai-sdk/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
+6 -3
View File
@@ -26,7 +26,8 @@ export const AzurePlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return if (evt.package !== "@ai-sdk/azure") return
if (evt.model.providerID === ProviderV2.ID.azure) { if (evt.model.providerID === ProviderV2.ID.azure) {
@@ -44,7 +45,8 @@ export const AzurePlugin = define({
evt.sdk = mod.createAzure(evt.options) evt.sdk = mod.createAzure(evt.options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.azure) return if (evt.model.providerID !== ProviderV2.ID.azure) return
evt.language = selectLanguage( evt.language = selectLanguage(
@@ -75,7 +77,8 @@ export const AzureCognitiveServicesPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
evt.language = selectLanguage( evt.language = selectLanguage(
@@ -14,7 +14,8 @@ export const CerebrasPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return if (evt.package !== "@ai-sdk/cerebras") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
@@ -6,7 +6,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CloudflareAIGatewayPlugin = define({ export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway", id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "ai-gateway-provider") return if (evt.package !== "ai-gateway-provider") return
if (evt.options.baseURL) return if (evt.options.baseURL) return
@@ -19,7 +19,8 @@ export const CloudflareWorkersAIPlugin = define({
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
}) })
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return if (evt.package !== "@ai-sdk/openai-compatible") return
@@ -35,7 +36,8 @@ export const CloudflareWorkersAIPlugin = define({
) )
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CoherePlugin = define({ export const CoherePlugin = define({
id: "opencode.provider.cohere", id: "opencode.provider.cohere",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cohere") return if (evt.package !== "@ai-sdk/cohere") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const DeepInfraPlugin = define({ export const DeepInfraPlugin = define({
id: "opencode.provider.deepinfra", id: "opencode.provider.deepinfra",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/deepinfra") return if (evt.package !== "@ai-sdk/deepinfra") return
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
+2 -1
View File
@@ -7,7 +7,8 @@ export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic", id: "opencode.provider.dynamic",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.sdk) return if (evt.sdk) return
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GatewayPlugin = define({ export const GatewayPlugin = define({
id: "opencode.provider.gateway", id: "opencode.provider.gateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/gateway") return if (evt.package !== "@ai-sdk/gateway") return
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
@@ -23,14 +23,16 @@ export const GithubCopilotPlugin = define({
model.enabled = false model.enabled = false
}) })
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
evt.sdk = mod.createOpenaiCompatible(evt.options) evt.sdk = mod.createOpenaiCompatible(evt.options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
+4 -2
View File
@@ -7,7 +7,8 @@ import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({ export const GitLabPlugin = define({
id: "opencode.provider.gitlab", id: "opencode.provider.gitlab",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "gitlab-ai-provider") return if (evt.package !== "gitlab-ai-provider") return
const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
@@ -31,7 +32,8 @@ export const GitLabPlugin = define({
}) })
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.gitlab) return if (evt.model.providerID !== ProviderV2.ID.gitlab) return
const featureFlags = const featureFlags =
@@ -85,7 +85,8 @@ export const GoogleVertexPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
evt.options.fetch = authFetch(evt.options.fetch) evt.options.fetch = authFetch(evt.options.fetch)
@@ -104,7 +105,8 @@ export const GoogleVertexPlugin = define({
}) })
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
@@ -135,7 +137,8 @@ export const GoogleVertexAnthropicPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
@@ -161,7 +164,8 @@ export const GoogleVertexAnthropicPlugin = define({
}) })
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GooglePlugin = define({ export const GooglePlugin = define({
id: "opencode.provider.google", id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google")) const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GroqPlugin = define({ export const GroqPlugin = define({
id: "opencode.provider.groq", id: "opencode.provider.groq",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/groq") return if (evt.package !== "@ai-sdk/groq") return
const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const MistralPlugin = define({ export const MistralPlugin = define({
id: "opencode.provider.mistral", id: "opencode.provider.mistral",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/mistral") return if (evt.package !== "@ai-sdk/mistral") return
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenAICompatiblePlugin = define({ export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai-compatible", id: "opencode.provider.openai-compatible",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.sdk) return if (evt.sdk) return
if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return
+4 -2
View File
@@ -210,14 +210,16 @@ export const OpenAIPlugin = define({
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
yield* refresh().pipe(Effect.forkScoped) yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options) evt.sdk = mod.createOpenAI(evt.options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
@@ -23,7 +23,8 @@ export const OpenRouterPlugin = define({
} }
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const PerplexityPlugin = define({ export const PerplexityPlugin = define({
id: "opencode.provider.perplexity", id: "opencode.provider.perplexity",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/perplexity") return if (evt.package !== "@ai-sdk/perplexity") return
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
@@ -8,7 +8,8 @@ export const SapAICorePlugin = define({
id: "opencode.provider.sap-ai-core", id: "opencode.provider.sap-ai-core",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
const serviceKey = const serviceKey =
@@ -37,7 +38,8 @@ export const SapAICorePlugin = define({
) )
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
evt.language = evt.sdk(evt.model.modelID ?? evt.model.id) evt.language = evt.sdk(evt.model.modelID ?? evt.model.id)
@@ -67,7 +67,8 @@ export function cortexFetch(upstream: FetchLike = fetch) {
export const SnowflakeCortexPlugin = define({ export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake-cortex", id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const token = const token =
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const TogetherAIPlugin = define({ export const TogetherAIPlugin = define({
id: "opencode.provider.togetherai", id: "opencode.provider.togetherai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/togetherai") return if (evt.package !== "@ai-sdk/togetherai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VenicePlugin = define({ export const VenicePlugin = define({
id: "opencode.provider.venice", id: "opencode.provider.venice",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "venice-ai-sdk-provider") return if (evt.package !== "venice-ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
+2 -1
View File
@@ -14,7 +14,8 @@ export const VercelPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return if (evt.package !== "@ai-sdk/vercel") return
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
+4 -2
View File
@@ -5,14 +5,16 @@ import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({ export const XAIPlugin = define({
id: "opencode.provider.xai", id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/xai") return if (evt.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
evt.sdk = mod.createXai(evt.options) evt.sdk = mod.createXai(evt.options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SdkPlugins from "./sdk" export * as SdkPlugins from "./sdk"
import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node" import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event" import { EventV2 } from "../event"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginSupervisor from "./supervisor" export * as PluginSupervisor from "./supervisor"
import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import { Event } from "@opencode-ai/schema/config" import { Event } from "@opencode-ai/schema/config"
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect" import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
import path from "path" import path from "path"
@@ -143,6 +143,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () { return Effect.gen(function* () {
yield* SessionEvent.All.match(event, { yield* SessionEvent.All.match(event, {
"session.usage.updated": () => Effect.void,
"session.agent.selected": (event) => { "session.agent.selected": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.AgentSelected.make({ SessionMessage.AgentSelected.make({
@@ -296,6 +297,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
draft.finish = "error" draft.finish = "error"
draft.error = castDraft(event.data.error) draft.error = castDraft(event.data.error)
draft.retry = undefined draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
draft.cost = event.data.cost
draft.tokens = castDraft(event.data.tokens)
}
}) })
}, },
"session.text.started": (event) => { "session.text.started": (event) => {
+60 -47
View File
@@ -1,7 +1,7 @@
export * as SessionProjector from "./projector" export * as SessionProjector from "./projector"
import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm" import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect" import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { makeGlobalNode } from "../effect/app-node" import { makeGlobalNode } from "../effect/app-node"
@@ -48,11 +48,6 @@ type Usage = {
const ForkBatchSize = 500 const ForkBatchSize = 500
const emptyUsage = (): Usage => ({
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
const forkTitle = (value: string) => { const forkTitle = (value: string) => {
const match = value.match(/^(.+) \(fork #(\d+)\)$/) const match = value.match(/^(.+) \(fork #(\d+)\)$/)
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})` if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
@@ -67,22 +62,6 @@ function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] |
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] } return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
} }
function addUsage(target: Usage, value: Usage) {
target.cost += value.cost
target.tokens.input += value.tokens.input
target.tokens.output += value.tokens.output
target.tokens.reasoning += value.tokens.reasoning
target.tokens.cache.read += value.tokens.cache.read
target.tokens.cache.write += value.tokens.cache.write
}
function messageUsage(row: typeof SessionMessageTable.$inferSelect): Usage | undefined {
if (row.type !== "assistant") return undefined
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
if (message.type !== "assistant" || message.cost === undefined || message.tokens === undefined) return undefined
return { cost: message.cost, tokens: message.tokens }
}
function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert { function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert {
return { return {
id: info.id, id: info.id,
@@ -151,6 +130,37 @@ function applyUsage(
.pipe(Effect.orDie) .pipe(Effect.orDie)
} }
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
) {
const row = yield* db
.select({
cost: SessionTable.cost,
input: SessionTable.tokens_input,
output: SessionTable.tokens_output,
reasoning: SessionTable.tokens_reasoning,
cacheRead: SessionTable.tokens_cache_read,
cacheWrite: SessionTable.tokens_cache_write,
})
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return
yield* events.publish(SessionEvent.UsageUpdated, {
sessionID,
cost: row.cost,
tokens: {
input: row.input,
output: row.output,
reasoning: row.reasoning,
cache: { read: row.cacheRead, write: row.cacheWrite },
},
})
})
const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
db: DatabaseService, db: DatabaseService,
event: typeof SessionEvent.Forked.Type, event: typeof SessionEvent.Forked.Type,
@@ -187,7 +197,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.limit(1) .limit(1)
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const copiedSeq = copied?.seq ?? 0 const copiedSeq = copied?.seq
const stored = yield* db const stored = yield* db
.insert(SessionTable) .insert(SessionTable)
@@ -237,9 +247,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie) .pipe(Effect.orDie)
} }
const usage = emptyUsage()
let cursor = -1 let cursor = -1
while (true) { while (copiedSeq !== undefined) {
const rows = yield* db const rows = yield* db
.select() .select()
.from(SessionMessageTable) .from(SessionMessageTable)
@@ -247,7 +256,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
and( and(
eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor), gt(SessionMessageTable.seq, cursor),
copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1), lt(SessionMessageTable.seq, copiedSeq + 1),
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`, sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
), ),
) )
@@ -318,27 +327,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie) .pipe(Effect.orDie)
} }
for (const row of rows) {
const value = messageUsage(row)
if (value) addUsage(usage, value)
}
cursor = rows.at(-1)!.seq cursor = rows.at(-1)!.seq
} }
if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
yield* db
.update(SessionTable)
.set({
cost: usage.cost,
tokens_input: usage.tokens.input,
tokens_output: usage.tokens.output,
tokens_reasoning: usage.tokens.reasoning,
tokens_cache_read: usage.tokens.cache.read,
tokens_cache_write: usage.tokens.cache.write,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
}) })
function run(db: DatabaseService, event: MessageEvent) { function run(db: DatabaseService, event: MessageEvent) {
@@ -697,8 +688,19 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Ended, (event) =>
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event)) Effect.gen(function* () {
yield* run(db, event)
yield* applyUsage(db, event.data.sessionID, event.data)
}),
)
yield* events.project(SessionEvent.Step.Failed, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.data.cost !== undefined && event.data.tokens !== undefined)
yield* applyUsage(db, event.data.sessionID, { cost: event.data.cost, tokens: event.data.tokens })
}),
)
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
@@ -790,6 +792,17 @@ const layer = Layer.effectDiscard(
yield* InstructionCheckpoint.reset(db, event.data.sessionID) yield* InstructionCheckpoint.reset(db, event.data.sessionID)
}), }),
) )
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
Stream.runForEach((event) => {
if (
event.type === SessionEvent.Step.Failed.type &&
(event.data.cost === undefined || event.data.tokens === undefined)
)
return Effect.void
return publishSessionUsage(db, events, event.data.sessionID)
}),
Effect.forkScoped({ startImmediately: true }),
)
}), }),
) )
+65 -7
View File
@@ -17,6 +17,7 @@ import { Config } from "../../config"
import { Database } from "../../database/database" import { Database } from "../../database/database"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { Location } from "../../location" import { Location } from "../../location"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission" import { PermissionV2 } from "../../permission"
import { Instructions } from "../../instructions/index" import { Instructions } from "../../instructions/index"
import { InstructionBuiltIns } from "../../instructions/builtins" import { InstructionBuiltIns } from "../../instructions/builtins"
@@ -49,6 +50,32 @@ import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError, UserInterruptedError } from "../error" import { StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error" import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { PluginHooks } from "../../plugin/hooks"
type StepTokens = {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
const context = tokens.input + tokens.cache.read + tokens.cache.write
const tier = costs
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return 0
return (
(tokens.input * cost.input +
(tokens.output + tokens.reasoning) * cost.output +
tokens.cache.read * cost.cache.read +
tokens.cache.write * cost.cache.write) /
1_000_000
)
}
/** /**
* Runs one durable coding-agent Session until it settles. * Runs one durable coding-agent Session until it settles.
@@ -107,6 +134,7 @@ const layer = Layer.effect(
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const location = yield* Location.Service const location = yield* Location.Service
@@ -227,10 +255,34 @@ const layer = Layer.effect(
tools: toolMaterialization?.definitions ?? [], tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined, toolChoice: isLastStep ? "none" : undefined,
}) })
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
const requestEvent: SessionHooks["request"] = {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system: [...request.system],
messages: [...request.messages],
tools: Object.fromEntries(
request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
}
// Plugins may reshape the draft, but cannot advertise tools excluded earlier
// by permissions or registration state.
yield* hooks.trigger("session", "request", requestEvent)
const hookedRequest = LLM.updateRequest(request, {
system: requestEvent.system,
messages: requestEvent.messages,
tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => {
const registered = availableTools.get(name)
if (!registered) return []
return [{ ...registered, description: tool.description, inputSchema: tool.input }]
}),
})
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
// Automatic compaction completed; rebuild the request from compacted history. // Automatic compaction completed; rebuild the request from compacted history.
if ( if (
!(yield* SessionInput.pendingCompaction(db, session.id)) && !(yield* SessionInput.pendingCompaction(db, session.id)) &&
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request })) (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
) )
return { _tag: "RestartAfterCompaction", step: currentStep } as const return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture() const startSnapshot = yield* snapshots.capture()
@@ -251,7 +303,7 @@ const layer = Layer.effect(
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) => const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths, error)) serialized(publisher.publish(event, outputPaths, error))
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe( const providerStream = llm.stream(hookedRequest).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return if (overflowFailure || publisher.hasProviderError()) return
@@ -263,11 +315,11 @@ const layer = Layer.effect(
} }
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) { if (!toolMaterialization || !advertisedTools.has(event.name)) {
yield* serialized( yield* serialized(
publisher.failUnsettledTools({ publisher.failUnsettledTools({
type: "tool.execution", type: "tool.execution",
message: "Tools are disabled after the maximum agent steps", message: `Tool is not available for this request: ${event.name}`,
}), }),
) )
return return
@@ -312,6 +364,11 @@ const layer = Layer.effect(
Effect.ensuring(serialized(publisher.flush())), Effect.ensuring(serialized(publisher.flush())),
) )
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens,
})
// Captures the end snapshot, diffs it against the step's start, and durably ends the // Captures the end snapshot, diffs it against the step's start, and durably ends the
// assistant step. // assistant step.
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
@@ -328,8 +385,7 @@ const layer = Layer.effect(
sessionID: session.id, sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(), assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish, finish: settlement.finish,
cost: 0, ...stepUsage(settlement),
tokens: settlement.tokens,
snapshot: endSnapshot, snapshot: endSnapshot,
files, files,
}), }),
@@ -452,7 +508,8 @@ const layer = Layer.effect(
const stepEndedCleanly = const stepEndedCleanly =
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure !streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement) if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
if (stepFailure) yield* serialized(publisher.publishStepFailure()) if (stepFailure)
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (userDeclined) return yield* Effect.interrupt if (userDeclined) return yield* Effect.interrupt
@@ -595,6 +652,7 @@ export const node = makeLocationNode({
llmClient, llmClient,
AgentV2.node, AgentV2.node,
ToolRegistry.node, ToolRegistry.node,
PluginHooks.node,
SessionRunnerModel.node, SessionRunnerModel.node,
SessionStore.node, SessionStore.node,
Location.node, Location.node,
+5 -1
View File
@@ -82,6 +82,8 @@ export interface Resolved {
readonly model: Model readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */ /** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref readonly ref: ModelV2.Ref
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
} }
export interface Interface { export interface Interface {
@@ -94,13 +96,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({ export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
model, model,
ref: ModelV2.Ref.make({ ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id), id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider), providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }), ...(variant === undefined ? {} : { variant }),
}), }),
cost,
}) })
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
@@ -341,6 +344,7 @@ const layer = Layer.effect(
providerID: selected.providerID, providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}), }),
cost: selected.cost,
} }
}), }),
}) })
@@ -101,27 +101,36 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>, ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false, single = false,
) => { ) => {
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>() const chunks = new Map<
string,
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> }
>()
let nextOrdinal = 0 let nextOrdinal = 0
const start = (id: string) => const start = (id: string, state?: Record<string, unknown>) =>
Effect.suspend(() => { Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`)) if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
const ordinal = nextOrdinal++ const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [] }) chunks.set(id, { ordinal, values: [], state })
return Effect.succeed(ordinal) return Effect.succeed(ordinal)
}) })
const append = (id: string, value: string) => const append = (id: string, value: string, state?: Record<string, unknown>) =>
Effect.suspend(() => { Effect.suspend(() => {
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value) current.values.push(value)
if (state !== undefined) current.state = { ...current.state, ...state }
return Effect.succeed(current.ordinal) return Effect.succeed(current.ordinal)
}) })
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) { const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* ended(id, current.values.join(""), current.ordinal, state) yield* ended(
id,
current.values.join(""),
current.ordinal,
state === undefined ? current.state : { ...current.state, ...state },
)
chunks.delete(id) chunks.delete(id)
}) })
const flush = Effect.fnUntraced(function* () { const flush = Effect.fnUntraced(function* () {
@@ -216,7 +225,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (replace || stepFailure === undefined) stepFailure = error if (replace || stepFailure === undefined) stepFailure = error
}) })
const publishStepFailure = Effect.fnUntraced(function* () { const publishStepFailure = Effect.fnUntraced(function* (usage?: {
readonly cost: number
readonly tokens: ReturnType<typeof tokens>
}) {
if (stepFailed || stepFailure === undefined) return if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant() const assistantMessageID = yield* startAssistant()
stepFailed = true stepFailed = true
@@ -224,6 +236,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID, assistantMessageID,
error: stepFailure, error: stepFailure,
...usage,
}) })
}) })
@@ -284,7 +297,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return return
case "reasoning-start": case "reasoning-start":
retryEvidence = true retryEvidence = true
const startedReasoningOrdinal = yield* reasoning.start(event.id) const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Reasoning.Started, { yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(), assistantMessageID: yield* startAssistant(),
@@ -293,7 +306,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}) })
return return
case "reasoning-delta": case "reasoning-delta":
const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text) const deltaReasoningOrdinal = yield* reasoning.append(
event.id,
event.text,
providerState(event.providerMetadata),
)
yield* events.publish(SessionEvent.Reasoning.Delta, { yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(), assistantMessageID: yield* currentAssistantMessageID(),
@@ -409,12 +426,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "step-finish": case "step-finish":
yield* flush() yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
if (event.reason === "content-filter") { if (event.reason === "content-filter") {
providerFailed = true providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true) yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
return return
} }
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
return return
case "finish": case "finish":
return return
+1 -1
View File
@@ -41,7 +41,7 @@ Registrations are scoped:
## Permissions ## Permissions
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action. The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
+17 -4
View File
@@ -1,6 +1,6 @@
export * as ApplyPatchTool from "./apply-patch" export * as ApplyPatchTool from "./apply-patch"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
@@ -12,7 +12,7 @@ import { Patch } from "../patch"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
export const name = "apply_patch" export const name = "patch"
export const Input = Schema.Struct({ export const Input = Schema.Struct({
patchText: Schema.String.annotate({ patchText: Schema.String.annotate({
@@ -91,11 +91,11 @@ export const Plugin = {
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({ const hunks = yield* Effect.try({
try: () => Patch.parse(input.patchText), try: () => Patch.parse(input.patchText),
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }),
}) })
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" })
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks) for (const hunk of hunks)
@@ -194,6 +194,19 @@ export const Plugin = {
), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.session.hook("request", (event) =>
Effect.sync(() => {
const usePatch =
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
if (usePatch) {
delete event.tools.edit
delete event.tools.write
return
}
delete event.tools.patch
}),
)
}), }),
} }
+1 -1
View File
@@ -6,7 +6,7 @@
*/ */
export * as EditTool from "./edit" export * as EditTool from "./edit"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as GlobTool from "./glob" export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as GrepTool from "./grep" export * as GrepTool from "./grep"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as QuestionTool from "./question" export * as QuestionTool from "./question"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Form } from "../form" import { Form } from "../form"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ReadTool from "./read" export * as ReadTool from "./read"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { dirname } from "path" import { dirname } from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
-4
View File
@@ -191,12 +191,8 @@ const registryLayer = Layer.effect(
const registration = entries.at(-1)?.registration const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, registration) if (registration) registrations.set(name, registration)
} }
// OpenAI/GPT models use apply_patch; every other model uses edit and write.
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
for (const [name, registration] of registrations) { for (const [name, registration] of registrations) {
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if ( if (
wrongEditTool ||
(registration.deferred && !Flag.CODEMODE_ENABLED) || (registration.deferred && !Flag.CODEMODE_ENABLED) ||
whollyDisabled(permission(registration.tool, name), input.permissions ?? []) whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
) )
+1 -1
View File
@@ -2,7 +2,7 @@ export * as ShellTool from "./shell"
import path from "path" import path from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect" import { Effect, Schema, Scope } from "effect"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SkillTool from "./skill" export * as SkillTool from "./skill"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path" import path from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
+44 -1
View File
@@ -1,10 +1,11 @@
export * as SubagentTool from "./subagent" export * as SubagentTool from "./subagent"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect" import { Effect, Schema, Scope } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { PluginRuntime } from "../plugin/runtime" import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { Tool } from "./tool" import { Tool } from "./tool"
@@ -42,6 +43,7 @@ export const Plugin = {
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const permission = yield* PermissionV2.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
// Concatenate the child's final completed assistant text. Distinguishes "completed with no // Concatenate the child's final completed assistant text. Distinguishes "completed with no
@@ -114,6 +116,20 @@ export const Plugin = {
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
if (agent.mode === "primary") if (agent.mode === "primary")
return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
yield* permission
.assert({
action: name,
resources: [agent.id],
save: [agent.id],
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.assistantMessageID,
callID: context.toolCallID,
},
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
// Model selection is policy/config/session state, not an LLM-facing tool argument. // Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model const model = agent.model ?? parent.model
@@ -176,5 +192,32 @@ export const Plugin = {
), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.session.hook("request", (event) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
const selected = yield* agents.resolve(event.agent)
if (!selected) return
const available = (yield* agents.list())
.filter(
(agent) =>
agent.mode !== "primary" &&
!agent.hidden &&
PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny",
)
.toSorted((a, b) => a.id.localeCompare(b.id))
if (available.length === 0) return
tool.description = [
tool.description,
"",
"Available subagents:",
...available.map(
(agent) =>
`- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
),
].join("\n")
}),
)
}), }),
} }
+1 -1
View File
@@ -1,6 +1,6 @@
export * as TodoWriteTool from "./todowrite" export * as TodoWriteTool from "./todowrite"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WebFetchTool from "./webfetch" export * as WebFetchTool from "./webfetch"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Schema } from "effect" import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WebSearchTool from "./websearch" export * as WebSearchTool from "./websearch"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect" import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpClient, HttpClientRequest } from "effect/unstable/http"
+1 -1
View File
@@ -6,7 +6,7 @@
*/ */
export * as WriteTool from "./write" export * as WriteTool from "./write"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
+5 -7
View File
@@ -51,13 +51,11 @@ it.effect("projects request settings, headers, and body overlays", () =>
apiKey: "secret", apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 }, thinkingConfig: { thinkingBudget: 1024 },
}) })
const resolved = yield* aisdk.model( const resolved = yield* aisdk.model({
{ ...input,
...input, headers: { "x-test": "header" },
headers: { "x-test": "header" }, body: { safety_setting: "strict" },
body: { safety_setting: "strict" }, })
},
)
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>( const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: resolved, prompt: "Hello" }), LLM.request({ model: resolved, prompt: "Hello" }),
) )
@@ -1,6 +1,6 @@
import { define } from "@opencode-ai/plugin/v2/promise" import { Plugin } from "@opencode-ai/plugin/v2"
export default define({ export default Plugin.define({
id: "directory-plugin", id: "directory-plugin",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
@@ -1,6 +1,6 @@
import { define } from "@opencode-ai/plugin/v2/promise" import { Plugin } from "@opencode-ai/plugin/v2"
export default define({ export default Plugin.define({
id: "folder-plugin", id: "folder-plugin",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
+3 -3
View File
@@ -2,7 +2,7 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
@@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => {
it.live("loads user plugins before internal post plugins", () => it.live("loads user plugins before internal post plugins", () =>
Effect.gen(function* () { Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void })) yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
yield* withLocation( yield* withLocation(
{ {
plugins: [ plugins: [
@@ -275,7 +275,7 @@ function mutablePlugin(description: string) {
return ` return `
import { define } from ${JSON.stringify(plugin)} import { define } from ${JSON.stringify(plugin)}
export default define({ export default EffectPlugin.define({
id: "mutable-plugin", id: "mutable-plugin",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
+4 -6
View File
@@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tool } from "@opencode-ai/core/tool/tool" import { Tool } from "@opencode-ai/core/tool/tool"
import { Tools } from "@opencode-ai/core/tool/tools" import { Tools } from "@opencode-ai/core/tool/tools"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, type Scope } from "effect" import { Effect, type Scope } from "effect"
export const toolIdentity = { export const toolIdentity = {
@@ -66,12 +66,10 @@ export const registerToolPlugin = <R>(plugin: {
registrations, registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options), (registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true }, { discard: true },
) ).pipe(Effect.orDie)
return { dispose: Effect.void }
}), }),
execute: { hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
}, },
} }
yield* plugin.effect(context as PluginContext) yield* plugin.effect(context as PluginContext)
+3 -3
View File
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config" import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -44,7 +44,7 @@ describe("LocationServiceMap", () => {
const sdk = yield* SdkPlugins.Service const sdk = yield* SdkPlugins.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const id = AgentV2.ID.make("persistent-sdk-agent") const id = AgentV2.ID.make("persistent-sdk-agent")
const plugin = define({ const plugin = EffectPlugin.define({
id: "persistent-sdk-plugin", id: "persistent-sdk-plugin",
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})), effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
}) })
@@ -432,7 +432,7 @@ describe("LocationServiceMap", () => {
Effect.flatMap((dir) => Effect.flatMap((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const reviewer = define({ const reviewer = EffectPlugin.define({
id: "reviewer", id: "reviewer",
effect: (ctx) => effect: (ctx) =>
ctx.agent ctx.agent
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/llm"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Layer } from "effect"
import { PluginHooks } from "../src/plugin/hooks"
describe("PluginHooks", () => {
it("registers scoped domain hooks and triggers them sequentially", async () => {
const seen: string[] = []
const program = Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
seen.push("first")
event.system.push(SystemPart.make("second"))
}),
)
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
seen.push(event.system[1]?.text ?? "missing")
event.messages = [Message.user("changed")]
}),
)
const event = {
sessionID: Session.ID.make("ses_hooks"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("first")],
messages: [Message.user("original")],
tools: {},
}
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
expect(seen).toEqual(["first", "second"])
expect(event.messages).toEqual([Message.user("changed")])
})
await Effect.runPromise(
Effect.scoped(program).pipe(
Effect.provide(PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>),
),
)
})
})
+46 -27
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
@@ -49,7 +49,7 @@ describe("PluginV2", () => {
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
const managed = () => const managed = () =>
define({ EffectPlugin.define({
id: "managed", id: "managed",
effect: (ctx) => effect: (ctx) =>
ctx.agent ctx.agent
@@ -97,25 +97,40 @@ describe("PluginV2", () => {
}), }),
) )
it.effect("retries the same generation after materialization fails", () => it.effect("skips failed plugins and loads the rest", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
let fail = true let fail = true
const plugin = define({ const good = EffectPlugin.define({
id: "retry", id: "good",
effect: (ctx) => effect: (ctx) =>
ctx.agent ctx.agent
.transform(() => { .transform((agents) =>
if (fail) throw new Error("materialization failed") agents.update("configured", (agent) => {
}) agent.description = "loaded"
}),
)
.pipe(Effect.asVoid), .pipe(Effect.asVoid),
}) })
const bad = EffectPlugin.define({
id: "bad",
effect: () => {
if (fail) return Effect.die(new Error("materialization failed"))
return Effect.void
},
})
yield* plugins.activate([{ plugin: good }, { plugin: bad }])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true)
fail = false fail = false
yield* plugins.activate([{ plugin }]) yield* plugins.activate([{ plugin: good }, { plugin: bad }])
expect(yield* plugins.list()).toEqual([
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }]) { id: Plugin.ID.make("good") },
{ id: Plugin.ID.make("bad") },
])
}), }),
) )
@@ -142,7 +157,7 @@ describe("PluginV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
let visible = true let visible = true
const plugin = define({ const plugin = EffectPlugin.define({
id: "isolated", id: "isolated",
effect: () => effect: () =>
Effect.serviceOption(Secret).pipe( Effect.serviceOption(Secret).pipe(
@@ -161,7 +176,7 @@ describe("PluginV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const plugin = define({ const plugin = EffectPlugin.define({
id: "tool-plugin", id: "tool-plugin",
effect: (ctx) => effect: (ctx) =>
ctx.tool ctx.tool
@@ -202,7 +217,7 @@ describe("PluginV2", () => {
output: Schema.Struct({ ok: Schema.Boolean }), output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }), execute: () => Effect.succeed({ ok: true }),
}) })
const plugin = define({ const plugin = EffectPlugin.define({
id: "grouped-tools", id: "grouped-tools",
effect: (ctx) => effect: (ctx) =>
ctx.tool ctx.tool
@@ -234,7 +249,7 @@ describe("PluginV2", () => {
after?: { input: unknown; result: unknown; output: unknown } after?: { input: unknown; result: unknown; output: unknown }
} = {} } = {}
const plugin = define({ const plugin = EffectPlugin.define({
id: "tool-hooks", id: "tool-hooks",
effect: (ctx) => effect: (ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -252,19 +267,23 @@ describe("PluginV2", () => {
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.tool.execute yield* ctx.tool
.before((event) => { .hook("execute.before", (event) =>
seen.before = event.input Effect.sync(() => {
event.input = { text: "before-mutated" } seen.before = event.input
}) event.input = { text: "before-mutated" }
}),
)
.pipe(Effect.asVoid) .pipe(Effect.asVoid)
yield* ctx.tool.execute yield* ctx.tool
.after((event) => { .hook("execute.after", (event) =>
seen.after = { input: event.input, result: event.result, output: event.output } Effect.sync(() => {
event.result = { type: "text", value: "after-mutated" } seen.after = { input: event.input, result: event.result, output: event.output }
event.output = { structured: { rewritten: true }, content: [] } event.result = { type: "text", value: "after-mutated" }
}) event.output = { structured: { rewritten: true }, content: [] }
}),
)
.pipe(Effect.asVoid) .pipe(Effect.asVoid)
}), }),
}) })
+2
View File
@@ -13,6 +13,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Reference } from "@opencode-ai/core/reference" import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill" import { SkillV2 } from "@opencode-ai/core/skill"
@@ -46,6 +47,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
CommandV2.node, CommandV2.node,
Integration.node, Integration.node,
PluginRuntime.node, PluginRuntime.node,
PluginHooks.node,
Reference.node, Reference.node,
SkillV2.node, SkillV2.node,
ToolHooks.node, ToolHooks.node,
@@ -1,7 +1,7 @@
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect" import { Effect } from "effect"
export default define({ export default Plugin.define({
id: "config-effect-plugin", id: "config-effect-plugin",
effect: (ctx) => effect: (ctx) =>
ctx.agent ctx.agent
@@ -1,6 +1,6 @@
import { define } from "@opencode-ai/plugin/v2/promise" import { Plugin } from "@opencode-ai/plugin/v2"
export default define({ export default Plugin.define({
id: "config-promise-plugin", id: "config-promise-plugin",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
@@ -1,7 +1,7 @@
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect" import { Effect } from "effect"
export default define({ export default Plugin.define({
id: "failing-plugin", id: "failing-plugin",
effect: () => Effect.die("plugin failed"), effect: () => Effect.die("plugin failed"),
}) })
@@ -1,8 +1,8 @@
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect } from "effect" import { Effect } from "effect"
export default define({ export default Plugin.define({
id: "variant-source", id: "variant-source",
effect: (ctx) => effect: (ctx) =>
ctx.catalog ctx.catalog
+22 -17
View File
@@ -1,4 +1,4 @@
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
@@ -19,8 +19,7 @@ export function host(overrides: Overrides = {}): PluginContext {
reload: () => Effect.die("unused agent.reload"), reload: () => Effect.die("unused agent.reload"),
}, },
aisdk: overrides.aisdk ?? { aisdk: overrides.aisdk ?? {
sdk: () => Effect.die("unused aisdk.sdk"), hook: () => Effect.die("unused aisdk.hook"),
language: () => Effect.die("unused aisdk.language"),
}, },
catalog: overrides.catalog ?? { catalog: overrides.catalog ?? {
provider: { provider: {
@@ -45,11 +44,15 @@ export function host(overrides: Overrides = {}): PluginContext {
integration: overrides.integration ?? { integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"), list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"), get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"), connect: {
connectOauth: () => Effect.die("unused integration.connectOauth"), key: () => Effect.die("unused integration.connect.key"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"), oauth: () => Effect.die("unused integration.connect.oauth"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"), },
attemptCancel: () => Effect.die("unused integration.attemptCancel"), attempt: {
status: () => Effect.die("unused integration.attempt.status"),
complete: () => Effect.die("unused integration.attempt.complete"),
cancel: () => Effect.die("unused integration.attempt.cancel"),
},
transform: () => Effect.die("unused integration.transform"), transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"), reload: () => Effect.die("unused integration.reload"),
connection: { connection: {
@@ -72,10 +75,7 @@ export function host(overrides: Overrides = {}): PluginContext {
}, },
tool: overrides.tool ?? { tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"), transform: () => Effect.die("unused tool.transform"),
execute: { hook: () => Effect.die("unused tool.hook"),
before: () => Effect.die("unused tool.execute.before"),
after: () => Effect.die("unused tool.execute.after"),
},
}, },
session: overrides.session ?? { session: overrides.session ?? {
create: () => Effect.die("unused session.create"), create: () => Effect.die("unused session.create"),
@@ -83,6 +83,7 @@ export function host(overrides: Overrides = {}): PluginContext {
prompt: () => Effect.die("unused session.prompt"), prompt: () => Effect.die("unused session.prompt"),
command: () => Effect.die("unused session.command"), command: () => Effect.die("unused session.command"),
interrupt: () => Effect.die("unused session.interrupt"), interrupt: () => Effect.die("unused session.interrupt"),
hook: () => Effect.die("unused session.hook"),
}, },
} }
} }
@@ -188,11 +189,15 @@ export function integrationHost(integration: Integration.Interface): PluginConte
return { return {
list: () => Effect.die("unused integration.list"), list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"), get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"), connect: {
connectOauth: () => Effect.die("unused integration.connectOauth"), key: () => Effect.die("unused integration.connect.key"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"), oauth: () => Effect.die("unused integration.connect.oauth"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"), },
attemptCancel: () => Effect.die("unused integration.attemptCancel"), attempt: {
status: () => Effect.die("unused integration.attempt.status"),
complete: () => Effect.die("unused integration.attempt.complete"),
cancel: () => Effect.die("unused integration.attempt.cancel"),
},
reload: integration.reload, reload: integration.reload,
connection: { connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)), active: (id) => integration.connection.active(Integration.ID.make(id)),
+4 -4
View File
@@ -4,7 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise" import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { define } from "@opencode-ai/plugin/v2/promise" import { Plugin } from "@opencode-ai/plugin/v2"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@@ -16,7 +16,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const seen: string[] = [] const seen: string[] = []
const promisePlugin = define({ const promisePlugin = Plugin.define({
id: "promise-client-reads", id: "promise-client-reads",
setup: async (ctx) => { setup: async (ctx) => {
const results = await Promise.all([ const results = await Promise.all([
@@ -46,7 +46,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const promisePlugin = define({ const promisePlugin = Plugin.define({
id: "promise-example", id: "promise-example",
setup: async (ctx) => { setup: async (ctx) => {
expect(ctx.options.mode).toBe("strict") expect(ctx.options.mode).toBe("strict")
@@ -75,7 +75,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const promisePlugin = define({ const promisePlugin = Plugin.define({
id: "promise-dispose", id: "promise-dispose",
setup: async (ctx) => { setup: async (ctx) => {
const registration = await ctx.agent.transform((draft) => { const registration = await ctx.agent.transform((draft) => {
+39 -3
View File
@@ -225,9 +225,17 @@ describe("SessionV2.create", () => {
promotedSeq: 2, promotedSeq: 2,
}) })
yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false }) yield* session.prompt({
sessionID: parent.id,
prompt: PromptInput.Prompt.make({ text: "Parent changed" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id) yield* SessionInput.promoteSteers(db, events, parent.id)
yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false }) yield* session.prompt({
sessionID: forked.id,
prompt: PromptInput.Prompt.make({ text: "Child continues" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, forked.id) yield* SessionInput.promoteSteers(db, events, forked.id)
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@@ -260,8 +268,25 @@ describe("SessionV2.create", () => {
resume: false, resume: false,
}) })
yield* SessionInput.promoteSteers(db, events, parent.id) yield* SessionInput.promoteSteers(db, events, parent.id)
const assistantMessageID = SessionMessage.ID.create()
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
yield* events.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID,
agent: "build",
model,
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: parent.id,
assistantMessageID,
finish: "stop",
cost: 0.75,
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
})
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id }) const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id })
const complete = yield* session.fork({ sessionID: parent.id })
const context = yield* session.context(forked.id) const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
@@ -269,6 +294,13 @@ describe("SessionV2.create", () => {
expect(context).toMatchObject([{ text: "First" }]) expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id) expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { from: second.id } }) expect(history[0]).toMatchObject({ data: { from: second.id } })
expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
expect(yield* session.context(beforeFirst.id)).toEqual([])
expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
expect(complete).toMatchObject({
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}), }),
) )
@@ -375,7 +407,11 @@ describe("SessionV2.create", () => {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const { db } = yield* Database.Service const { db } = yield* Database.Service
const created = yield* session.create({ location }) const created = yield* session.create({ location })
yield* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false }) yield* session.prompt({
sessionID: created.id,
prompt: PromptInput.Prompt.make({ text: "Hello" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, created.id) yield* SessionInput.promoteSteers(db, events, created.id)
expect( expect(
+59 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { DateTime, Effect, Schema } from "effect" import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -41,12 +41,15 @@ const assistantRow = (
id: SessionMessage.ID, id: SessionMessage.ID,
seq: number, seq: number,
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created }, time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
) => { ) => {
const { const {
id: _, id: _,
type, type,
...data ...data
} = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time })) } = encodeMessage(
SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time, ...usage }),
)
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
} }
@@ -67,6 +70,12 @@ describe("SessionProjector", () => {
directory: "/project", directory: "/project",
title: "test", title: "test",
version: "test", version: "test",
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
}) })
.run() .run()
const boundary = SessionMessage.ID.make("msg_boundary") const boundary = SessionMessage.ID.make("msg_boundary")
@@ -75,8 +84,24 @@ describe("SessionProjector", () => {
.insert(SessionMessageTable) .insert(SessionMessageTable)
.values([ .values([
assistantRow(earlier, 0), assistantRow(earlier, 0),
assistantRow(boundary, 1), assistantRow(
assistantRow(SessionMessage.ID.make("msg_later"), 2), boundary,
1,
{ created },
{
cost: 0.5,
tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
},
),
assistantRow(
SessionMessage.ID.make("msg_later"),
2,
{ created },
{
cost: 0.75,
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
},
),
]) ])
.run() .run()
yield* db yield* db
@@ -106,6 +131,14 @@ describe("SessionProjector", () => {
expect( expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
).toEqual([earlier]) ).toEqual([earlier])
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
// A committed revert resets the context checkpoint so the next turn re-initializes. // A committed revert resets the context checkpoint so the next turn re-initializes.
expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined() expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
}), }),
@@ -534,12 +567,15 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie) .pipe(Effect.orDie)
const service = yield* EventV2.Service const service = yield* EventV2.Service
const usageUpdated = yield* service
.subscribe(SessionEvent.UsageUpdated)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* service.publish(SessionEvent.Step.Ended, { yield* service.publish(SessionEvent.Step.Ended, {
sessionID, sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop", finish: "stop",
cost: 0, cost: 1.25,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
}) })
const rows = yield* db const rows = yield* db
@@ -556,8 +592,25 @@ describe("SessionProjector", () => {
expect(messages[1]).toMatchObject({ expect(messages[1]).toMatchObject({
type: "assistant", type: "assistant",
finish: "stop", finish: "stop",
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) }, time: { completed: DateTime.makeUnsafe(0) },
}) })
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
).toMatchObject({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({
sessionID,
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
}), }),
) )
@@ -110,6 +110,25 @@ test("provider state uses the route provider instead of the catalog provider", a
}) })
}) })
test("reasoning state from an empty delta is retained at reasoning end", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.reasoningStart({ id: "reasoning" })))
await Effect.runPromise(
publisher.publish(
LLMEvent.reasoningDelta({
id: "reasoning",
text: "",
providerMetadata: { openai: { signature: "signed" } },
}),
),
)
await Effect.runPromise(publisher.publish(LLMEvent.reasoningEnd({ id: "reasoning" })))
expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
state: { signature: "signed" },
})
})
test("binary failure emits no success event", async () => { test("binary failure emits no success event", async () => {
const { published, publisher } = capture() const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call)) await Effect.runPromise(publisher.publish(call))
@@ -151,15 +170,39 @@ test("step finish records settlement without publishing step ended", async () =>
test("content-filter finish retains failure evidence until step closeout", async () => { test("content-filter finish retains failure evidence until step closeout", async () => {
const { published, publisher } = capture() const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" }))) await Effect.runPromise(
publisher.publish(
LLMEvent.stepFinish({
index: 0,
reason: "content-filter",
usage: {
nonCachedInputTokens: 8,
outputTokens: 3,
reasoningTokens: 1,
},
}),
),
)
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"]) expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
await Effect.runPromise(publisher.publishStepFailure()) const settlement = publisher.stepSettlement()
expect(settlement).toMatchObject({
finish: "content-filter",
tokens: { input: 8, output: 2, reasoning: 1 },
})
if (!settlement) throw new Error("Expected content-filter settlement")
await Effect.runPromise(
publisher.publishStepFailure({
cost: 1.25,
tokens: settlement.tokens,
}),
)
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"]) expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
expect(published.at(-1)?.data).toMatchObject({ expect(published.at(-1)?.data).toMatchObject({
error: { type: "provider.content-filter", message: "Provider blocked the response" }, error: { type: "provider.content-filter", message: "Provider blocked the response" },
cost: 1.25,
tokens: { input: 8, output: 2, reasoning: 1 },
}) })
expect(publisher.stepSettlement()).toBeUndefined()
}) })
test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => { test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
@@ -82,24 +82,27 @@ describe("ToolRegistry", () => {
}), }),
) )
it.effect("selects one edit tool family for each model", () => it.effect("materializes all permission-eligible edit tools before request policy", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
yield* service.register({ yield* service.register({
read: make(), read: make(),
edit: make("edit"), edit: make("edit"),
write: make("edit"), write: make("edit"),
apply_patch: make("edit"), patch: make("edit"),
}) })
const names = (model: ToolRegistry.MaterializeInput["model"]) => const names = (model: ToolRegistry.MaterializeInput["model"]) =>
service service
.materialize({ model }) .materialize({ model })
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name))) .pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "apply_patch"]) expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "apply_patch"]) expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([
expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "apply_patch"]) "read",
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"]) "edit",
"write",
"patch",
])
}), }),
) )
+35 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
LLMClient, LLMClient,
LLMError, LLMError,
@@ -116,6 +116,28 @@ const recoveryModel = Model.make({
provider: "fake", provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }), route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
}) })
test("calculates step cost using the matching context tier", () => {
expect(
SessionRunnerLLM.calculateCost(
[
{ input: 1, output: 2, cache: { read: 0.1, write: 0.5 } },
{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } },
],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } },
),
).toBeCloseTo(0.0002926)
})
test("does not apply an ineligible tier without base pricing", () => {
expect(
SessionRunnerLLM.calculateCost(
[{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } }],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } },
),
).toBe(0)
})
const authorizations: Tool.Context[] = [] const authorizations: Tool.Context[] = []
const executions: string[] = [] const executions: string[] = []
const permission = Layer.succeed( const permission = Layer.succeed(
@@ -1704,6 +1726,7 @@ describe("SessionRunnerLLM", () => {
{ {
type: "assistant", type: "assistant",
finish: "tool-calls", finish: "tool-calls",
cost: 0,
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } }, tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } },
content: [ content: [
{ type: "reasoning", text: "Think" }, { type: "reasoning", text: "Think" },
@@ -3635,7 +3658,11 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepStart({ index: 0 }), LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "partial" }), LLMEvent.textStart({ id: "partial" }),
LLMEvent.textDelta({ id: "partial", text: "Partial" }), LLMEvent.textDelta({ id: "partial", text: "Partial" }),
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), LLMEvent.stepFinish({
index: 0,
reason: "content-filter",
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
}),
LLMEvent.finish({ reason: "content-filter" }), LLMEvent.finish({ reason: "content-filter" }),
] ]
@@ -3646,9 +3673,15 @@ describe("SessionRunnerLLM", () => {
type: "assistant", type: "assistant",
finish: "error", finish: "error",
error: { type: "provider.content-filter" }, error: { type: "provider.content-filter" },
cost: 0,
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
content: [{ type: "text", text: "Partial" }], content: [{ type: "text", text: "Partial" }],
}, },
]) ])
expect(yield* session.get(sessionID)).toMatchObject({
cost: 0,
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
})
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1") expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1")
}), }),
) )
+5 -5
View File
@@ -26,7 +26,7 @@ const applyPatchToolNode = makeLocationNode({
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
}) })
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test") const sessionID = SessionV2.ID.make("ses_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined let denyAction: string | undefined
let failRemoveTarget: string | undefined let failRemoveTarget: string | undefined
@@ -132,10 +132,10 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (patchText: string, id = "call-apply-patch") => ({ const call = (patchText: string, id = "call-apply-patch") => ({
sessionID, sessionID,
...toolIdentity, ...toolIdentity,
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } }, call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
}) })
// apply_patch is only materialized for OpenAI/GPT models. // patch is only materialized for OpenAI/GPT models.
const model = { id: "gpt-5", provider: "openai" } const model = { id: "gpt-5", provider: "openai" }
const exists = (target: string) => const exists = (target: string) =>
@@ -162,7 +162,7 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([ expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([
"apply_patch", "patch",
]) ])
const settled = yield* settleTool( const settled = yield* settleTool(
registry, registry,
@@ -241,7 +241,7 @@ describe("ApplyPatchTool", () => {
), ),
model, model,
), ),
).toEqual({ type: "error", value: "apply_patch moves are not supported yet" }) ).toEqual({ type: "error", value: "patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
expect(assertions).toEqual([]) expect(assertions).toEqual([])
}), }),
+1865 -689
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More