|
|
@@ -2609,6 +2609,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("interrupts runner continuation when a question is dismissed", () =>
|
|
|
Effect.gen(function* () {
|
|
|
yield* setup
|