test(core): cover permission denial continuation

This commit is contained in:
Kit Langton 2026-06-05 19:25:48 -04:00
commit 8bb211ebfd

View file

@ -5,6 +5,7 @@ import {
LLMEvent, LLMEvent,
Model, Model,
Tool, Tool,
ToolFailure,
TransportReason, TransportReason,
InvalidRequestReason, InvalidRequestReason,
type LLMClientShape, type LLMClientShape,
@ -112,6 +113,7 @@ const recoveryModel = Model.make({
}) })
const authorizations: ToolRegistry.AuthorizeInput[] = [] const authorizations: ToolRegistry.AuthorizeInput[] = []
const executions: string[] = [] const executions: string[] = []
let denyEcho = false
const permission = Layer.succeed( const permission = Layer.succeed(
PermissionV2.Service, PermissionV2.Service,
PermissionV2.Service.of({ PermissionV2.Service.of({
@ -135,9 +137,18 @@ const echo = Layer.effectDiscard(
registry.contribute((editor) => { registry.contribute((editor) => {
;(editor.set("echo", { ;(editor.set("echo", {
authorize: (input) => authorize: (input) =>
Effect.sync(() => { denyEcho
authorizations.push(input) ? Effect.fail(
}), new ToolFailure({
message: "Permission denied",
error: new PermissionV2.DeniedError({
rules: [{ action: "echo", resource: "*", effect: "deny" }],
}),
}),
)
: Effect.sync(() => {
authorizations.push(input)
}),
tool: Tool.make({ tool: Tool.make({
description: "Echo text", description: "Echo text",
parameters: Schema.Struct({ text: Schema.String }), parameters: Schema.Struct({ text: Schema.String }),
@ -1809,6 +1820,53 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("continues after a permission is denied without awaiting a responder", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Try the denied tool" }), resume: false })
requests.length = 0
executions.length = 0
denyEcho = true
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-denied", name: "echo", input: { text: "blocked" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-after-denial" }),
LLMEvent.textDelta({ id: "text-after-denial", text: "Permission denied" }),
LLMEvent.textEnd({ id: "text-after-denial" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID).pipe(Effect.ensuring(Effect.sync(() => (denyEcho = false))))
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(executions).toEqual([])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Try the denied tool" },
{
type: "assistant",
finish: "tool-calls",
content: [{ type: "tool", id: "call-denied", name: "echo", state: { status: "error" } }],
},
{
type: "assistant",
finish: "stop",
content: [{ type: "text", id: "text-after-denial", text: "Permission denied" }],
},
])
}),
)
it.effect("reloads a model switch before a tool-driven continuation turn", () => it.effect("reloads a model switch before a tool-driven continuation turn", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup