Merge remote-tracking branch 'origin/v2' into media-attachment
# Conflicts: # packages/core/test/session-compaction.test.ts
This commit is contained in:
commit
6cc8195572
93 changed files with 5662 additions and 3800 deletions
|
|
@ -15,6 +15,7 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
|
|||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
||||
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
|
|
@ -39,6 +40,29 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
|||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("resets incompatible V2 Session event history", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`)
|
||||
yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`)
|
||||
yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [resetSessionEventsMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("serializes concurrent embedded initialization for one database path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||
|
|
|
|||
|
|
@ -71,9 +71,9 @@ const it = testEffect(
|
|||
describe("MCP errors", () => {
|
||||
test("expose useful messages", () => {
|
||||
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
||||
expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe(
|
||||
"failed",
|
||||
)
|
||||
expect(
|
||||
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
|
||||
).toBe("failed")
|
||||
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
||||
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
})
|
||||
|
|
@ -231,7 +231,7 @@ 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.BlockedError({ rules: [] }))
|
||||
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -141,11 +141,30 @@ it.effect("does not count file attachments as text context", () =>
|
|||
}),
|
||||
)
|
||||
|
||||
test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
|
||||
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
|
||||
"## Objective",
|
||||
"## Important Details",
|
||||
"## Work State",
|
||||
"## Next Move",
|
||||
])
|
||||
expect(prompt).toContain("one or two brief sentences")
|
||||
expect(prompt).toContain("constraints/preferences, decisions and why")
|
||||
expect(prompt).toContain("Completed:")
|
||||
expect(prompt).toContain("Active:")
|
||||
expect(prompt).toContain("Blocked:")
|
||||
expect(prompt).toContain("immediate concrete action")
|
||||
expect(prompt).toContain("next action if known")
|
||||
expect(prompt).toContain("Keep every section, even when empty.")
|
||||
})
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const events = yield* EventV2.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = SessionV2.ID.make("ses_manual_compaction")
|
||||
const userMessage = {
|
||||
|
|
@ -181,7 +200,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
|||
),
|
||||
)
|
||||
|
||||
const delta = yield* events
|
||||
.subscribe(SessionEvent.Compaction.Delta)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
|
|
|
|||
88
packages/core/test/session-error.test.ts
Normal file
88
packages/core/test/session-error.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
NoRouteReason,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
ToolFailure,
|
||||
} from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
|
||||
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
||||
|
||||
describe("toSessionError", () => {
|
||||
test("maps every LLM reason to the open wire type", () => {
|
||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "rate",
|
||||
})
|
||||
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
||||
"provider.auth",
|
||||
)
|
||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||
"provider.internal",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
||||
"provider.invalid-output",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
||||
expect(
|
||||
toSessionError(
|
||||
llm(
|
||||
new NoRouteReason({
|
||||
route: "route",
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
),
|
||||
).type,
|
||||
).toBe("provider.no-route")
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||
})
|
||||
|
||||
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||
const blocked = new PermissionV2.BlockedError({ rules: [], permission: "external_directory", resources: [] })
|
||||
expect(toSessionError(blocked)).toEqual({
|
||||
type: "permission.rejected",
|
||||
message: "Permission denied: external_directory",
|
||||
})
|
||||
expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({
|
||||
type: "permission.rejected",
|
||||
message: "Permission denied: external_directory",
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||
llm(new TransportReason({ message: "transport" })),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||
llm(new QuotaExceededReason({ message: "quota" })),
|
||||
llm(new ContentPolicyReason({ message: "blocked" })),
|
||||
llm(new InvalidProviderOutputReason({ message: "output" })),
|
||||
llm(new InvalidRequestReason({ message: "request" })),
|
||||
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
||||
llm(new UnknownProviderReason({ message: "unknown" })),
|
||||
]
|
||||
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
})
|
||||
})
|
||||
36
packages/core/test/session-execution-local.test.ts
Normal file
36
packages/core/test/session-execution-local.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
||||
import { terminal } from "@opencode-ai/core/session/execution/local"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Effect, Exit } from "effect"
|
||||
|
||||
describe("SessionExecutionLocal lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||
expect(
|
||||
terminal(
|
||||
Exit.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
||||
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||
expect(terminal(Exit.fail(storage))).toEqual({
|
||||
type: "failed",
|
||||
error: { type: "unknown", message: storage.message },
|
||||
})
|
||||
})
|
||||
|
||||
test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => {
|
||||
const interrupted = Effect.runSyncExit(Effect.interrupt)
|
||||
expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" })
|
||||
expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" })
|
||||
expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" })
|
||||
expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" })
|
||||
})
|
||||
})
|
||||
|
|
@ -101,7 +101,7 @@ describe("SessionProjector", () => {
|
|||
})
|
||||
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID,
|
||||
messageID: boundary,
|
||||
to: boundary,
|
||||
})
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
|
|
@ -437,6 +437,73 @@ describe("SessionProjector", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const first = SessionMessage.ID.make("msg_retry_first")
|
||||
const second = SessionMessage.ID.make("msg_retry_second")
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: first,
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
})
|
||||
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
|
||||
const firstRow = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, first))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection")))
|
||||
expect(decode(projected)).toMatchObject({
|
||||
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
|
||||
})
|
||||
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: second,
|
||||
attempt: 3,
|
||||
at: 6_000,
|
||||
error: { type: "provider.internal", message: "Unavailable" },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
|
||||
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(decode(rows[0])).not.toHaveProperty("retry")
|
||||
expect(decode(rows[1])).not.toHaveProperty("retry")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates only the newest incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -530,7 +597,7 @@ describe("SessionProjector", () => {
|
|||
yield* service.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
textID: "text-stale",
|
||||
ordinal: 0,
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
|
|
@ -549,7 +616,7 @@ describe("SessionProjector", () => {
|
|||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })],
|
||||
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
SessionMessage.Assistant.make({
|
||||
|
|
|
|||
|
|
@ -275,9 +275,11 @@ describe("SessionV2.prompt", () => {
|
|||
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"',
|
||||
)
|
||||
expect(
|
||||
Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64")
|
||||
.toString("utf8")
|
||||
.replace(/\r$/, ""),
|
||||
).toBe('import { describe, expect } from "bun:test"')
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -565,7 +567,12 @@ describe("SessionV2.prompt", () => {
|
|||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Promote once" }), resume: false })
|
||||
yield* session.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: PromptInput.Prompt.make({ text: "Promote once" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* Effect.all(
|
||||
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
||||
|
|
@ -677,7 +684,12 @@ describe("SessionV2.prompt", () => {
|
|||
.pipe(Effect.orDie)
|
||||
|
||||
const failure = yield* session
|
||||
.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), resume: false })
|
||||
.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }),
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
|
||||
|
|
|
|||
|
|
@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => {
|
|||
Effect.gen(function* () {
|
||||
const failure = new Error("failed")
|
||||
const defect = new Error("defect")
|
||||
const settled: Exit.Exit<void, Error>[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
|
||||
settled: (_key, exit) => Effect.sync(() => void settled.push(exit)),
|
||||
})
|
||||
|
||||
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
|
||||
|
|
@ -115,6 +117,25 @@ describe("SessionRunCoordinator", () => {
|
|||
const died = yield* coordinator.run("defect").pipe(Effect.exit)
|
||||
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(settled).toHaveLength(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves settlement hook defects while releasing ownership", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const defect = new Error("terminal publication failed")
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Effect.void,
|
||||
settled: () => Effect.die(defect),
|
||||
})
|
||||
|
||||
const exit = yield* coordinator.run("session").pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(defect)
|
||||
expect(yield* coordinator.active).toEqual(new Set())
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -209,8 +230,41 @@ describe("SessionRunCoordinator", () => {
|
|||
it.effect("does nothing when interrupted while idle", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
|
||||
yield* coordinator.interrupt("session")
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () => Effect.void,
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* coordinator.run("session")
|
||||
expect(reasons).toEqual([undefined])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not attach a late interrupt reason after terminal settlement starts", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const settling = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () => Effect.void,
|
||||
settled: (_key, _exit, reason) =>
|
||||
Deferred.succeed(settling, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(Effect.sync(() => void reasons.push(reason))),
|
||||
),
|
||||
})
|
||||
|
||||
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(settling)
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(run)
|
||||
yield* coordinator.run("session")
|
||||
|
||||
expect(reasons).toEqual([undefined, undefined])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -221,25 +275,28 @@ describe("SessionRunCoordinator", () => {
|
|||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||
),
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
|
||||
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* coordinator.wake("session")
|
||||
yield* coordinator.interrupt("session")
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* Deferred.await(interrupted)
|
||||
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(runs).toBe(1)
|
||||
expect(reasons).toEqual(["user"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -252,6 +309,7 @@ describe("SessionRunCoordinator", () => {
|
|||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
let starts = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
|
|
@ -266,6 +324,7 @@ describe("SessionRunCoordinator", () => {
|
|||
: Deferred.succeed(secondStarted, undefined),
|
||||
),
|
||||
),
|
||||
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
|
|
@ -278,6 +337,7 @@ describe("SessionRunCoordinator", () => {
|
|||
yield* Deferred.await(secondStarted)
|
||||
|
||||
expect(runs).toBe(2)
|
||||
expect(starts).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -399,6 +459,7 @@ describe("SessionRunCoordinator", () => {
|
|||
const gate = yield* Deferred.make<void>()
|
||||
const idle = yield* Deferred.make<void>()
|
||||
let drains = 0
|
||||
let starts = 0
|
||||
const settled: Exit.Exit<void, never>[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never>({
|
||||
drain: () =>
|
||||
|
|
@ -410,6 +471,7 @@ describe("SessionRunCoordinator", () => {
|
|||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
|
||||
settled: (_key, exit) =>
|
||||
Effect.sync(() => void settled.push(exit)).pipe(
|
||||
Effect.andThen(Deferred.succeed(idle, undefined)),
|
||||
|
|
@ -424,6 +486,7 @@ describe("SessionRunCoordinator", () => {
|
|||
yield* Deferred.await(idle)
|
||||
|
||||
expect(drains).toBe(2)
|
||||
expect(starts).toBe(1)
|
||||
expect(settled).toHaveLength(1)
|
||||
expect(Exit.isSuccess(settled[0]!)).toBe(true)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -27,17 +27,14 @@ describe("toLLMMessages", () => {
|
|||
const messages = toLLMMessages(
|
||||
[
|
||||
assistant("empty", []),
|
||||
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
|
||||
assistant("empty-reasoning", [
|
||||
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
|
||||
]),
|
||||
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
|
||||
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]),
|
||||
assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]),
|
||||
assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]),
|
||||
assistant("reasoning", [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
state: { signature: "sig_1" },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
|
|
@ -258,12 +255,11 @@ Recent work
|
|||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
|
||||
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-1",
|
||||
text: "Think",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
state: { signature: "sig_1" },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
|
|
@ -308,11 +304,9 @@ Recent work
|
|||
type: "tool",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { fake: { continuation: "hosted-call" } },
|
||||
resultMetadata: { fake: { continuation: "hosted-result" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { continuation: "hosted-call" },
|
||||
providerResultState: { continuation: "hosted-result" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
|
|
@ -325,7 +319,8 @@ Recent work
|
|||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
||||
executed: true,
|
||||
providerState: { continuation: "failed" },
|
||||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { path: "README.md" },
|
||||
|
|
@ -345,7 +340,7 @@ Recent work
|
|||
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Checking" },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { provider: { signature: "sig_1" } } },
|
||||
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
||||
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
||||
{
|
||||
|
|
@ -360,14 +355,14 @@ Recent work
|
|||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-call" } },
|
||||
providerMetadata: { provider: { continuation: "hosted-call" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-result" } },
|
||||
providerMetadata: { provider: { continuation: "hosted-result" } },
|
||||
result: { type: "text", value: "Found it" },
|
||||
},
|
||||
{
|
||||
|
|
@ -376,14 +371,14 @@ Recent work
|
|||
name: "write",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
providerMetadata: { provider: { continuation: "failed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
providerMetadata: { provider: { continuation: "failed" } },
|
||||
result: {
|
||||
type: "error",
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||
|
|
@ -417,9 +412,8 @@ Recent work
|
|||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-openai",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
|
|
@ -432,7 +426,7 @@ Recent work
|
|||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
|
@ -448,19 +442,16 @@ Recent work
|
|||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-failed",
|
||||
text: "Partial thought",
|
||||
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
|
||||
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "call_failed" } },
|
||||
resultMetadata: { openai: { itemId: "result_failed" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { itemId: "call_failed" },
|
||||
providerResultState: { itemId: "result_failed" },
|
||||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { query: "Effect" },
|
||||
|
|
@ -520,19 +511,16 @@ Recent work
|
|||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-old-model",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { anthropic: { signature: "sig_old" } },
|
||||
state: { signature: "sig_old" },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "hosted-old-model" } },
|
||||
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { itemId: "hosted-old-model" },
|
||||
providerResultState: { itemId: "hosted-old-model" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
|
|
@ -546,11 +534,9 @@ Recent work
|
|||
type: "tool",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
provider: {
|
||||
executed: false,
|
||||
metadata: { fake: { call: "old" } },
|
||||
resultMetadata: { fake: { result: "old" } },
|
||||
},
|
||||
executed: false,
|
||||
providerState: { call: "old" },
|
||||
providerResultState: { result: "old" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
|
|
@ -620,9 +606,8 @@ Recent work
|
|||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-alias",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } },
|
||||
state: { reasoningEncryptedContent: "encrypted" },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
|
|
@ -635,7 +620,7 @@ Recent work
|
|||
{
|
||||
type: "reasoning",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } },
|
||||
providerMetadata: { provider: { reasoningEncryptedContent: "encrypted" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const capture = () => {
|
|||
id: ModelV2.ID.make("model"),
|
||||
providerID: ProviderV2.ID.make("provider"),
|
||||
},
|
||||
provider: "openai",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +89,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
|
|||
})
|
||||
})
|
||||
|
||||
test("provider-executed success retains its compatibility result", async () => {
|
||||
test("provider-executed success retains its raw provider result", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
|
||||
|
|
@ -96,6 +97,19 @@ test("provider-executed success retains its compatibility result", async () => {
|
|||
expect(success?.data).toHaveProperty("result")
|
||||
})
|
||||
|
||||
test("provider state uses the route provider instead of the catalog provider", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
|
||||
state: { itemId: "reasoning" },
|
||||
})
|
||||
})
|
||||
|
||||
test("binary failure emits no success event", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
|
|
@ -112,7 +126,7 @@ test("binary failure emits no success event", async () => {
|
|||
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("old success event data containing result still decodes", () => {
|
||||
test("success event data can carry a provider-executed result", () => {
|
||||
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
|
|
@ -120,7 +134,7 @@ test("old success event data containing result still decodes", () => {
|
|||
structured: { type: "media", mime: "image/png" },
|
||||
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
|
||||
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
|
||||
provider: { executed: false },
|
||||
executed: true,
|
||||
})
|
||||
expect(decoded.result).toMatchObject({ type: "content" })
|
||||
})
|
||||
|
|
@ -133,3 +147,40 @@ test("step finish records settlement without publishing step ended", async () =>
|
|||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
||||
test("content-filter finish retains failure evidence until step closeout", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" })))
|
||||
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
|
||||
await Effect.runPromise(publisher.publishStepFailure())
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
|
||||
expect(published.at(-1)?.data).toMatchObject({
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
})
|
||||
expect(publisher.stepSettlement()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
Effect.forEach(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text" }),
|
||||
LLMEvent.textDelta({ id: "text", text: "Partial" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||
],
|
||||
(event) => publisher.publish(event),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(publisher.publishStepFailure())
|
||||
|
||||
expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false)
|
||||
expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" })
|
||||
expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({
|
||||
error: { type: "provider.content-filter" },
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -76,9 +76,8 @@ describe("Tool.Progress", () => {
|
|||
sessionID,
|
||||
assistantMessageID,
|
||||
callID,
|
||||
tool: "bash",
|
||||
input: { command: "pwd" },
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -104,7 +103,7 @@ describe("Tool.Progress", () => {
|
|||
callID: "call-success",
|
||||
structured: { phase: "done" },
|
||||
content: content("complete"),
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
|
||||
|
|
@ -123,7 +122,7 @@ describe("Tool.Progress", () => {
|
|||
assistantMessageID,
|
||||
callID: "call-failed",
|
||||
error: { type: "unknown", message: "boom" },
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
expect((yield* readAssistant).content[1]).toMatchObject({
|
||||
state: {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[coreIntegration.Ref, Integration.Ref],
|
||||
[coreLocation.Ref, Location.Ref],
|
||||
[coreLLM.ProviderMetadata, LLM.ProviderMetadata],
|
||||
[coreLLM.FinishReason, LLM.FinishReason],
|
||||
[coreLLM.ToolTextContent, LLM.ToolTextContent],
|
||||
[coreLLM.ToolFileContent, LLM.ToolFileContent],
|
||||
[coreLLM.ToolContent, LLM.ToolContent],
|
||||
|
|
@ -137,7 +138,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[coreSessionInput.Delivery, SessionInput.Delivery],
|
||||
[coreSessionInput.Admitted, SessionInput.Admitted],
|
||||
[coreSessionMessage.ID, SessionMessage.ID],
|
||||
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
|
||||
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
|
||||
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
||||
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
|
||||
[coreSessionMessage.User, SessionMessage.User],
|
||||
|
|
@ -183,7 +184,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
test("shared record schemas construct and decode plain objects", () => {
|
||||
const made = Prompt.make({ text: "hello" })
|
||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
|
||||
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", text: "hi" })
|
||||
|
||||
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,15 @@ const permission = Layer.succeed(
|
|||
}).pipe(
|
||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -40,7 +40,15 @@ const permission = Layer.succeed(
|
|||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,17 @@ const permission = Layer.succeed(
|
|||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
|
@ -82,7 +92,13 @@ describe("QuestionTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
|
||||
).toEqual({
|
||||
result: { type: "error", value: "Permission denied: question" },
|
||||
error: {
|
||||
type: "permission.rejected",
|
||||
message: "Permission denied: question",
|
||||
},
|
||||
})
|
||||
expect(capturedInput()).toBeUndefined()
|
||||
deny = false
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,19 @@ const permission = Layer.succeed(
|
|||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))),
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -47,7 +47,15 @@ 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.BlockedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
@ -75,7 +83,6 @@ const executionNode = makeGlobalNode({
|
|||
const session = yield* store.get(id)
|
||||
if (!session) return
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const textID = "text_shell_test"
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
|
|
@ -85,12 +92,12 @@ const executionNode = makeGlobalNode({
|
|||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
ordinal: 0,
|
||||
text: "ok",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,17 @@ describe("SkillTool", () => {
|
|||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ const executionNode = makeGlobalNode({
|
|||
}
|
||||
completed.add(sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const textID = "text_subagent_test"
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
|
|
@ -59,12 +58,12 @@ const executionNode = makeGlobalNode({
|
|||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
ordinal: 0,
|
||||
text: childText,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,17 @@ const permission = Layer.succeed(
|
|||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -38,7 +38,15 @@ const permission = Layer.succeed(
|
|||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue