Compare commits

..
Author SHA1 Message Date
Kit Langton 7bdb4e6280 test(webfetch): keep tool execution in scope
Execute the webfetch tool inside the same provided Effect scope used for initialization so the HTTP client layer stays alive for the assertions instead of interrupting the test fibers.
2026-04-12 08:17:46 -04:00
Kit Langton a550ebf682 refactor(tool): yield Truncate and Agent in Tool.define, close over in wrap
Services are resolved at define-time (in registry context), passed into
wrap as closed-over values. Info.init() and Tool.init() stay R=never.
Tests updated to provide Truncate.defaultLayer and Agent.defaultLayer.
2026-04-12 08:17:46 -04:00
12 changed files with 41 additions and 209 deletions
+2 -1
View File
@@ -114,7 +114,7 @@ jobs:
- build-cli - build-cli
- version - version
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -591,6 +591,7 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
@@ -31,16 +31,6 @@ import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
type SessionDiffSummary = Pick<Snapshot.FileDiff, "file" | "additions" | "deletions">
function summarizeDiff(diff?: Snapshot.FileDiff[]): SessionDiffSummary[] {
return (diff ?? []).map((item) => ({
file: item.file,
additions: item.additions,
deletions: item.deletions,
}))
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync", name: "Sync",
init: () => { init: () => {
@@ -65,7 +55,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
} }
session_diff: { session_diff: {
[sessionID: string]: SessionDiffSummary[] [sessionID: string]: Snapshot.FileDiff[]
} }
todo: { todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
@@ -203,7 +193,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break break
case "session.diff": case "session.diff":
setStore("session_diff", event.properties.sessionID, summarizeDiff(event.properties.diff)) setStore("session_diff", event.properties.sessionID, event.properties.diff)
break break
case "session.deleted": { case "session.deleted": {
@@ -513,7 +503,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
for (const message of messages.data!) { for (const message of messages.data!) {
draft.part[message.info.id] = message.parts draft.part[message.info.id] = message.parts
} }
draft.session_diff[sessionID] = summarizeDiff(diff.data) draft.session_diff[sessionID] = diff.data ?? []
}), }),
) )
fullSyncedSessions.add(sessionID) fullSyncedSessions.add(sessionID)
@@ -5,7 +5,6 @@ import { iife } from "@/util/iife"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models" import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2"
const log = Log.create({ service: "plugin.copilot" }) const log = Log.create({ service: "plugin.copilot" })
@@ -28,21 +27,6 @@ function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com" return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
} }
// Check if a message is a synthetic user msg used to attach an image from a tool call
function imgMsg(msg: any): boolean {
if (msg?.role !== "user") return false
// Handle the 3 api formats
const content = msg.content
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
if (!Array.isArray(content)) return false
return content.some(
(part: any) =>
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
)
}
function fix(model: Model, url: string): Model { function fix(model: Model, url: string): Model {
return { return {
...model, ...model,
@@ -106,7 +90,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(msg: any) => (msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
), ),
isAgent: last?.role !== "user" || imgMsg(last), isAgent: last?.role !== "user",
} }
} }
@@ -118,7 +102,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(item: any) => (item: any) =>
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
), ),
isAgent: last?.role !== "user" || imgMsg(last), isAgent: last?.role !== "user",
} }
} }
@@ -140,7 +124,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
part.content.some((nested: any) => nested?.type === "image")), part.content.some((nested: any) => nested?.type === "image")),
), ),
), ),
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last), isAgent: !(last?.role === "user" && hasNonToolCalls),
} }
} }
} catch {} } catch {}
+1 -3
View File
@@ -25,8 +25,6 @@ interface FetchDecompressionError extends Error {
} }
export namespace MessageV2 { export namespace MessageV2 {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export function isMedia(mime: string) { export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf" return mime.startsWith("image/") || mime === "application/pdf"
} }
@@ -810,7 +808,7 @@ export namespace MessageV2 {
parts: [ parts: [
{ {
type: "text" as const, type: "text" as const,
text: SYNTHETIC_ATTACHMENT_PROMPT, text: "Attached image(s) from tool result:",
}, },
...media.map((attachment) => ({ ...media.map((attachment) => ({
type: "file" as const, type: "file" as const,
+27 -74
View File
@@ -10,106 +10,59 @@ export namespace Message {
})), })),
) )
export class Source extends Schema.Class<Source>("Message.Source")({ export class File extends Schema.Class<File>("Message.File")({
start: Schema.Number, url: Schema.String,
end: Schema.Number,
text: Schema.String,
}) {}
export class FileAttachment extends Schema.Class<FileAttachment>("Message.File.Attachment")({
uri: Schema.String,
mime: Schema.String, mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) { }) {
static create(url: string) { static create(url: string) {
return new FileAttachment({ return new File({
uri: url, url,
mime: "text/plain", mime: "text/plain",
}) })
} }
} }
export class AgentAttachment extends Schema.Class<AgentAttachment>("Message.Agent.Attachment")({ export class UserContent extends Schema.Class<UserContent>("Message.User.Content")({
name: Schema.String, text: Schema.String,
source: Source.pipe(Schema.optional), synthetic: Schema.Boolean.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
files: Schema.Array(File).pipe(Schema.optional),
}) {} }) {}
export class User extends Schema.Class<User>("Message.User")({ export class User extends Schema.Class<User>("Message.User")({
id: ID, id: ID,
type: Schema.Literal("user"), type: Schema.Literal("user"),
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
time: Schema.Struct({ time: Schema.Struct({
created: Schema.DateTimeUtc, created: Schema.DateTimeUtc,
}), }),
content: UserContent,
}) { }) {
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) { static create(content: Schema.Schema.Type<typeof UserContent>) {
const msg = new User({ const msg = new User({
id: ID.create(), id: ID.create(),
type: "user", type: "user",
...input,
time: { time: {
created: Effect.runSync(DateTime.now), created: Effect.runSync(DateTime.now),
}, },
content,
}) })
return msg return msg
} }
static file(url: string) {
return new File({
url,
mime: "text/plain",
})
}
} }
export class Synthetic extends Schema.Class<Synthetic>("Message.Synthetic")({ export namespace User {}
id: ID,
type: Schema.Literal("synthetic"),
text: Schema.String,
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}) {}
export class Request extends Schema.Class<Request>("Message.Request")({
id: ID,
type: Schema.Literal("start"),
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}) {}
export class Text extends Schema.Class<Text>("Message.Text")({
id: ID,
type: Schema.Literal("text"),
text: Schema.String,
time: Schema.Struct({
created: Schema.DateTimeUtc,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class Complete extends Schema.Class<Complete>("Message.Complete")({
id: ID,
type: Schema.Literal("complete"),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
cost: Schema.Number,
tokens: Schema.Struct({
total: Schema.Number,
input: Schema.Number,
output: Schema.Number,
reasoning: Schema.Number,
cache: Schema.Struct({
read: Schema.Number,
write: Schema.Number,
}),
}),
}) {}
export const Info = Schema.Union([User, Text])
export type Info = Schema.Schema.Type<typeof Info>
} }
const msg = Message.User.create({
text: "Hello world",
files: [Message.File.create("file://example.com/file.txt")],
})
console.log(JSON.stringify(msg, null, 2))
-71
View File
@@ -1,71 +0,0 @@
import { Context, Layer, Schema, Effect } from "effect"
import { Message } from "./message"
import { Struct } from "effect"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
export namespace SessionV2 {
export const ID = SessionID
export type ID = Schema.Schema.Type<typeof ID>
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
...Struct.omit(Message.User.fields, ["time", "type"]),
id: Schema.optionalKey(Message.ID),
sessionID: SessionV2.ID,
}) {}
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
id: Schema.optionalKey(SessionV2.ID),
}) {}
export class Info extends Schema.Class<Info>("Session.Info")({
id: SessionV2.ID,
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
modelID: Schema.String,
}).pipe(Schema.optional),
}) {}
export interface Interface {
fromID: (id: SessionV2.ID) => Effect.Effect<Info>
create: (input: CreateInput) => Effect.Effect<Info>
prompt: (input: PromptInput) => Effect.Effect<Message.User>
}
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
export const layer = Layer.effect(Service)(
Effect.gen(function* () {
const session = yield* Session.Service
const create: Interface["create"] = Effect.fn("Session.create")(function* (input) {
throw new Error("Not implemented")
})
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (input) {
throw new Error("Not implemented")
})
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
const match = yield* session.get(id)
return fromV1(match)
})
return Service.of({
create,
prompt,
fromID,
})
}),
)
function fromV1(input: Session.Info): Info {
return new Info({
id: SessionV2.ID.make(input.id),
})
}
}
@@ -270,7 +270,6 @@ describe("SyncProvider", () => {
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" }) expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts") expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
expect(sync.data.session_diff.ses_1[0]).not.toHaveProperty("patch")
log.length = 0 log.length = 0
project.workspace.set("ws_b") project.workspace.set("ws_b")
@@ -286,7 +285,6 @@ describe("SyncProvider", () => {
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" }) expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts") expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
expect(sync.data.session_diff.ses_1[0]).not.toHaveProperty("patch")
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
@@ -14,14 +14,7 @@ import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll( Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, Truncate.defaultLayer, Agent.defaultLayer),
LSP.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
) )
const baseCtx = { const baseCtx = {
+1 -9
View File
@@ -36,15 +36,7 @@ async function touch(file: string, time: number) {
} }
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll( Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, Truncate.defaultLayer, Agent.defaultLayer),
LSP.defaultLayer,
FileTime.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
) )
afterAll(async () => { afterAll(async () => {
+1 -3
View File
@@ -9,9 +9,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
function initGrep() { function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init()))) return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
+1 -3
View File
@@ -21,9 +21,7 @@ const ctx = {
ask: () => Effect.void, ask: () => Effect.void,
} }
const it = testEffect( const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
for (;;) { for (;;) {
+1 -3
View File
@@ -152,9 +152,7 @@ Use this skill.
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const info = await runtime.runPromise(SkillTool) const info = await runtime.runPromise(SkillTool)
const tool = await runtime.runPromise(info.init()) const tool = await runtime.runPromise(info.init())
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []