chore: merge dev into v2
This commit is contained in:
commit
6cd5812ca1
304 changed files with 24678 additions and 4507 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,14 +3,6 @@ import { ModelV2 } from "../../model"
|
|||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "opencode.provider.github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
|
|
@ -37,9 +29,21 @@ export const GithubCopilotPlugin = define({
|
|||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
evt.language = shouldUseResponses(evt.model.api.id)
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
if (evt.options.endpoint === "responses" && evt.sdk.responses) {
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
if (evt.options.endpoint === "chat" && evt.sdk.chat) {
|
||||
evt.language = evt.sdk.chat(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
const match = /^gpt-(\d+)/.exec(evt.model.api.id)
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
evt.language =
|
||||
match && Number(match[1]) >= 5 && !evt.model.api.id.startsWith("gpt-5-mini") && evt.sdk.responses
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -340,13 +346,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
|
||||
|
|
|
|||
|
|
@ -76,5 +76,4 @@ describe("CommandV2", () => {
|
|||
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
|
||||
}),
|
||||
)
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({})" },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -244,7 +244,10 @@ describe("ModelsDevPlugin", () => {
|
|||
body: {},
|
||||
})
|
||||
|
||||
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-effort"))
|
||||
const anthropicEffortModel = yield* catalog.model.get(
|
||||
ProviderV2.ID.anthropic,
|
||||
ModelV2.ID.make("claude-effort"),
|
||||
)
|
||||
expect(anthropicEffortModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("low"),
|
||||
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
|
|
|
|||
|
|
@ -157,6 +157,42 @@ 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")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("mai-code-1-flash-picker"),
|
||||
type: "aisdk",
|
||||
package: "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")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("gpt-5"),
|
||||
type: "aisdk",
|
||||
package: "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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: 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: 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: 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: 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: 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: 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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue