chore: merge dev into v2 (#35591)

Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
This commit is contained in:
Aiden Cline
2026-07-06 16:05:29 -05:00
committed by GitHub
co-authored by Frank Aarav Sareen Brendan Allan opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Jack Brendan Allan Shoubhit Dash opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> James Long Dustin Deus starptech Luke Parker 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 Dax usrnk1 Jay runvip opencode Julian Coy Vladimir Glafirov Adam Kit Langton Simon Klee Jay David Hill
parent f87998f37f
commit 9e0d3976e1
332 changed files with 24650 additions and 4497 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.13",
"version": "1.17.14",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+10 -9
View File
@@ -59,13 +59,13 @@ export type AskResult = typeof AskResult.Type
export const Event = Permission.Event
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export class DeclinedError extends Schema.TaggedErrorClass<DeclinedError>()("PermissionV2.DeclinedError", {}) {}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
}) {}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
export class BlockedError extends Schema.TaggedErrorClass<BlockedError>()("PermissionV2.BlockedError", {
rules: Permission.Ruleset,
}) {}
@@ -73,7 +73,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Per
requestID: ID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError
export type Error = BlockedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return (
@@ -105,7 +105,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
interface Pending {
readonly request: Request
readonly agent?: AgentV2.ID
readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
}
const layer = Layer.effect(
@@ -119,7 +119,7 @@ const layer = Layer.effect(
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
discard: true,
}).pipe(
Effect.ensuring(
@@ -175,7 +175,7 @@ const layer = Layer.effect(
const create = (request: Request, agent?: AgentV2.ID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
@@ -199,13 +199,14 @@ const layer = Layer.effect(
Effect.gen(function* () {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new DeniedError({
return yield* new BlockedError({
rules: relevant(input, result.rules),
})
}
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
Effect.catchTag("PermissionV2.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
@@ -230,7 +231,7 @@ const layer = Layer.effect(
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
@@ -240,7 +241,7 @@ const layer = Layer.effect(
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.fail(item.deferred, new DeclinedError())
pending.delete(id)
}
return
@@ -37,6 +37,14 @@ export const GithubCopilotPlugin = define({
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
return
}
if (evt.options.endpoint === "responses" && evt.sdk.responses) {
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
return
}
if (evt.options.endpoint === "chat" && evt.sdk.chat) {
evt.language = evt.sdk.chat(evt.model.modelID ?? evt.model.id)
return
}
const id = evt.model.modelID ?? evt.model.id
evt.language = shouldUseResponses(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
}),
+13 -25
View File
@@ -18,39 +18,27 @@ const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_OUTPUT_TOKENS = 4_096
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Goal
- [single-sentence task summary]
## Objective
- [one or two brief sentences describing what the user is trying to accomplish]
## Constraints & Preferences
- [user constraints, preferences, specs, or "(none)"]
## Important Details
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
## Progress
### Done
- [completed work or "(none)"]
## Work State
- Completed: [finished work, verified facts, or changes made; otherwise "(none)"]
- Active: [current work, partial changes, or investigation state; otherwise "(none)"]
- Blocked: [blockers, failing commands, or unknowns; otherwise "(none)"]
### In Progress
- [current work or "(none)"]
### Blocked
- [blockers or "(none)"]
## Key Decisions
- [decision and why, or "(none)"]
## Next Steps
- [ordered next actions or "(none)"]
## Critical Context
- [important technical facts, errors, open questions, or "(none)"]
## Relevant Files
- [file or directory path: why it matters, or "(none)"]
## Next Move
1. [immediate concrete action, or "(none)"]
2. [next action if known, or "(none)"]
</template>
Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, commands, error strings, and identifiers when known.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Put relevant files and symbols inside the section where they matter; do not add extra sections.
- Do not mention the summary process or that context was compacted.`
type Settings = {
+11 -5
View File
@@ -16,6 +16,7 @@ import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { PermissionV2 } from "../../permission"
import { Instructions } from "../../instructions/index"
import { InstructionBuiltIns } from "../../instructions/builtins"
import { InstructionDiscovery } from "../../instruction-discovery"
@@ -149,8 +150,13 @@ const layer = Layer.effect(
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
const isQuestionCancelled = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionTool.CancelledError)
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
cause.reasons.some(
(reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError),
)
const loadInstructions = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
Effect.all(
@@ -343,13 +349,13 @@ const layer = Layer.effect(
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
const questionCancelled = settled._tag === "Failure" && isQuestionCancelled(settled.cause)
const userDeclined = settled._tag === "Failure" && isUserDeclined(settled.cause)
if (questionCancelled || streamInterrupted || toolsInterrupted) {
if (userDeclined || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
if (questionCancelled) return yield* Effect.interrupt
if (userDeclined) return yield* Effect.interrupt
}
// A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the step still
+11 -25
View File
@@ -9,7 +9,6 @@ import { Effect } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
ModelV2.Info.make({
@@ -31,9 +30,7 @@ it.effect("keys language models by package and flattened overlays", () =>
const first = yield* aisdk.language(model("first", { region: "us-east-1" }))
const second = yield* aisdk.language(model("second", { region: "us-east-1" }))
const third = yield* aisdk.language(
model("second", { region: "us-east-1", fetch: async () => new Response("ok") }),
)
const third = yield* aisdk.language(model("second", { region: "us-west-2" }))
expect(first).not.toBe(second)
expect(second).not.toBe(third)
@@ -41,30 +38,25 @@ it.effect("keys language models by package and flattened overlays", () =>
}),
)
it.effect("projects request settings, headers, and raw body overlays", () =>
it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let wrappedFetch: Fetch | undefined
let body: unknown
const customFetch: Fetch = async (_input, init) => {
body = init?.body
return new Response("ok")
}
yield* aisdk.hook.sdk((event) => {
wrappedFetch = event.options.fetch
body = event.options.body
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const input = model("@ai-sdk/google", {
apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 },
})
const resolved = yield* aisdk.model(
ModelV2.Info.make({
...model("@ai-sdk/google", {
apiKey: "secret",
fetch: customFetch,
thinkingConfig: { thinkingBudget: 1024 },
}),
{
...input,
headers: { "x-test": "header" },
body: { safety_setting: "strict" },
}),
},
)
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: resolved, prompt: "Hello" }),
@@ -74,12 +66,6 @@ it.effect("projects request settings, headers, and raw body overlays", () =>
google: { thinkingConfig: { thinkingBudget: 1024 } },
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(wrappedFetch).toBeFunction()
if (wrappedFetch === undefined) return yield* Effect.die("Expected wrapped fetch")
const fetchRequest = wrappedFetch
yield* Effect.promise(() =>
fetchRequest("https://provider.example", { method: "POST", body: JSON.stringify({ model: "api-model" }) }),
)
expect(JSON.parse(String(body))).toEqual({ model: "api-model", safety_setting: "strict" })
expect(body).toEqual({ safety_setting: "strict" })
}),
)
-1
View File
@@ -76,5 +76,4 @@ describe("CommandV2", () => {
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
}),
)
})
+4 -4
View File
@@ -227,20 +227,20 @@ it.effect("waits for permission before calling an MCP tool", () =>
}),
)
it.effect("does not call MCP when permission is rejected", () =>
it.effect("does not call MCP when permission is blocked", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.RejectedError())
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [] }))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const settlement = yield* settleTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_rejected"),
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_rejected",
id: "call_mcp_blocked",
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
+21 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -148,8 +148,8 @@ describe("PermissionV2", () => {
const service = yield* PermissionV2.Service
yield* service.assert(assertion())
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
const denied = yield* service.assert(assertion()).pipe(Effect.flip)
expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
const blocked = yield* service.assert(assertion()).pipe(Effect.flip)
expect(blocked).toBeInstanceOf(PermissionV2.BlockedError)
expect(yield* service.list()).toEqual([])
}),
)
@@ -266,6 +266,24 @@ describe("PermissionV2", () => {
}),
)
it.effect("defects when an asked permission is declined", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
yield* service.reply({ requestID: request.id, reply: "reject" })
const exit = yield* Fiber.await(fiber)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure")
expect(
exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof PermissionV2.DeclinedError,
),
).toBe(true)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
@@ -166,6 +166,36 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
modelID: ModelV2.ID.make("mai-code-1-flash-picker"),
package: "aisdk:test-provider",
settings: { endpoint: "responses" },
}),
sdk: fakeSelectorSdk(calls),
options: { endpoint: "responses" },
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: "aisdk:test-provider",
settings: { endpoint: "chat" },
}),
sdk: fakeSelectorSdk(calls),
options: { endpoint: "chat" },
})
expect(calls).toEqual(["responses:mai-code-1-flash-picker", "chat:gpt-5"])
}),
)
it.effect("uses the API model ID when selecting responses or chat", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
+9 -5
View File
@@ -12,9 +12,7 @@ import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)),
)
const it = testEffect(Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)))
describe("ProjectV2.list", () => {
it.effect("returns complete projects ordered by recent update", () =>
@@ -262,8 +260,14 @@ describe("ProjectV2.resolve", () => {
yield* Effect.promise(async () => {
await $`hg init`.cwd(tmp.path).quiet()
await Bun.write(path.join(tmp.path, "file.txt"), "one\n")
await $`hg addremove -q`.cwd(tmp.path).env({ ...process.env, HGPLAIN: "1" }).quiet()
await $`hg commit -q -m initial -u test`.cwd(tmp.path).env({ ...process.env, HGPLAIN: "1" }).quiet()
await $`hg addremove -q`
.cwd(tmp.path)
.env({ ...process.env, HGPLAIN: "1" })
.quiet()
await $`hg commit -q -m initial -u test`
.cwd(tmp.path)
.env({ ...process.env, HGPLAIN: "1" })
.quiet()
await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
})
const project = yield* ProjectV2.Service
+9 -8
View File
@@ -269,14 +269,15 @@ describe("SessionV2.prompt", () => {
resume: false,
})
expect(message.prompt.files).toEqual([
{
data: Buffer.from('import { describe, expect } from "bun:test"').toString("base64"),
mime: "text/plain",
source: { type: "uri", uri: sourceUri.href },
name: "main.ts",
},
])
expect(message.prompt.files).toHaveLength(1)
expect(message.prompt.files?.[0]).toMatchObject({
mime: "text/plain",
source: { type: "uri", uri: sourceUri.href },
name: "main.ts",
})
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8").replace(/\r$/, "")).toBe(
'import { describe, expect } from "bun:test"',
)
}),
)
+157 -15
View File
@@ -1280,7 +1280,7 @@ describe("SessionRunnerLLM", () => {
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Goal\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({
@@ -1291,22 +1291,22 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain("## Goal")
expect(userTexts(requests[0])[0]).toContain("## Objective")
expect(userTexts(requests[1])).toHaveLength(1)
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Goal\n- Preserve the task\n</summary>")
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
const context = yield* (yield* SessionStore.Service).context(sessionID)
expect(context.map((message) => message.type)).toEqual(["compaction", "assistant"])
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Goal\n- Preserve the task",
summary: "## Objective\n- Preserve the task",
})
requests.length = 0
executions.length = 0
responses = [
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-summary-2", ["## Objective\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
]
yield* session.prompt({
@@ -1318,12 +1318,12 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
"<previous-summary>\n## Goal\n- Preserve the task\n</previous-summary>",
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
)
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
type: "compaction",
summary: "## Goal\n- Preserve the updated task",
summary: "## Objective\n- Preserve the updated task",
})
}),
)
@@ -1336,17 +1336,17 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
],
fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Objective\n- Recover overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("## Goal")
expect(userTexts(requests[2])[0]).toContain("<summary>\n## Goal\n- Recover overflow\n</summary>")
expect(userTexts(requests[1])[0]).toContain("## Objective")
expect(userTexts(requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Goal\n- Recover overflow" },
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
{ type: "assistant", finish: "stop" },
])
yield* replaySessionProjection(sessionID)
@@ -1366,7 +1366,7 @@ describe("SessionRunnerLLM", () => {
]
responses = [
overflow(),
fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Objective\n- Recover once"]).completeEvents,
overflow(),
]
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
@@ -1394,7 +1394,7 @@ describe("SessionRunnerLLM", () => {
}),
)
responses = [
fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Objective\n- Recover raw overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
@@ -1402,7 +1402,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Goal\n- Recover raw overflow" },
{ type: "compaction", summary: "## Objective\n- Recover raw overflow" },
{ type: "assistant", finish: "stop" },
])
}),
@@ -1433,7 +1433,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setupOverflowRecovery
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
fragmentFixture("text", "text-summary", ["## Goal\n- Interrupted"]).completeEvents,
fragmentFixture("text", "text-summary", ["## Objective\n- Interrupted"]).completeEvents,
]
const firstGate = yield* Deferred.make<void>()
const summaryGate = yield* Deferred.make<void>()
@@ -2780,6 +2780,148 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("returns policy-blocked tools to the model and continues", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
yield* registry.register({
blocked: Tool.make({
description: "Fail because policy blocked execution",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.BlockedError({ rules: [] })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
),
}),
})
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call blocked" }), resume: false })
requests.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-blocked", name: "blocked", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call blocked" },
{
type: "assistant",
content: [
{ type: "tool", id: "call-blocked", state: { status: "error", error: { message: "Permission blocked" } } },
],
},
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("interrupts runner continuation when permission approval is declined", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
yield* registry.register({
declined: Tool.make({
description: "Fail because the user declined approval",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new PermissionV2.DeclinedError()),
}),
})
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call declined" }), resume: false })
requests.length = 0
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
]
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call declined" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-declined",
state: { status: "error", error: { message: "Tool execution interrupted" } },
},
],
},
])
}),
)
it.effect("returns permission corrections to the model and continues", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
yield* registry.register({
corrected: Tool.make({
description: "Fail with user correction feedback",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
),
}),
})
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call corrected" }), resume: false })
requests.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-corrected", name: "corrected", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call corrected" },
{
type: "assistant",
content: [
{ type: "tool", id: "call-corrected", state: { status: "error", error: { message: "Use another tool" } } },
],
},
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("fails the drain when tool output persistence fails", () =>
Effect.gen(function* () {
yield* setup
+9 -12
View File
@@ -30,7 +30,8 @@ async function pull(skills: unknown[], files: Record<string, string> = {}, fixtu
fetch(request) {
state.requests.push(request.url)
const pathname = new URL(request.url).pathname
const body = pathname === "/catalog/index.json" ? JSON.stringify({ skills: state.skills }) : state.files[pathname]
const body =
pathname === "/catalog/index.json" ? JSON.stringify({ skills: state.skills }) : state.files[pathname]
return new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 })
},
})
@@ -119,10 +120,9 @@ describe("SkillDiscovery.pull", () => {
})
test("refreshes cached files when the version changes", async () => {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
{ "/catalog/deploy/SKILL.md": "# Old" },
)
const first = await pull([{ name: "deploy", version: "1", files: ["SKILL.md"] }], {
"/catalog/deploy/SKILL.md": "# Old",
})
try {
const second = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
@@ -144,13 +144,10 @@ describe("SkillDiscovery.pull", () => {
})
test("publishes complete updates and removes stale files", async () => {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
{
"/catalog/deploy/SKILL.md": "# Old",
"/catalog/deploy/old.md": "old reference",
},
)
const first = await pull([{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }], {
"/catalog/deploy/SKILL.md": "# Old",
"/catalog/deploy/old.md": "old reference",
})
try {
const root = first.directories[0]
+1 -3
View File
@@ -26,9 +26,7 @@ const discovery = Layer.succeed(
}),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node, EventV2.node]), [
[SkillDiscovery.node, discovery],
]),
AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node, EventV2.node]), [[SkillDiscovery.node, discovery]]),
)
function write(directory: string, name: string, description: string) {
+1 -1
View File
@@ -47,7 +47,7 @@ const permission = Layer.succeed(
}).pipe(
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
+1 -1
View File
@@ -40,7 +40,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
+1 -1
View File
@@ -23,7 +23,7 @@ const permission = Layer.succeed(
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
+1 -1
View File
@@ -81,7 +81,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
+1 -1
View File
@@ -47,7 +47,7 @@ const permission = Layer.succeed(
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
+1 -1
View File
@@ -55,7 +55,7 @@ describe("SkillTool", () => {
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
+1 -1
View File
@@ -33,7 +33,7 @@ const permission = Layer.succeed(
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
+1 -1
View File
@@ -38,7 +38,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),