mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 09:37:47 +03:00
561 lines
23 KiB
TypeScript
561 lines
23 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Deferred, Effect, Layer, Option, Schedule, Schema, Stream } from "effect"
|
|
import { AIError, InvalidRequestError, LanguageModel, type LLMRequest } from "@opencode/ai/effect"
|
|
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
|
|
import { TestLLM } from "@opencode/ai/testing"
|
|
import { Agent } from "@opencode/core/agent"
|
|
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
|
import { LayerNodePlatform } from "@opencode/core/effect/app-node-platform"
|
|
import { Watcher } from "@opencode/core/filesystem/watcher"
|
|
import { Job } from "@opencode/core/job"
|
|
import { KV } from "@opencode/core/kv"
|
|
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
|
import { Model } from "@opencode/core/model"
|
|
import { GoalPlugin } from "@opencode/core/plugin/goal"
|
|
import { PluginHost } from "@opencode/core/plugin/host"
|
|
import { Provider } from "@opencode/core/provider"
|
|
import { AbsolutePath } from "@opencode/core/schema"
|
|
import { Session } from "@opencode/core/session"
|
|
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
|
import { Goal } from "@opencode/schema/goal"
|
|
import { LayerNode } from "@opencode/util/effect/layer-node"
|
|
import { Global } from "@opencode/util/global"
|
|
import { tempGlobalLayer } from "../fixture/global"
|
|
import { offlineModels } from "../fixture/models"
|
|
import { tmpdirScoped } from "../fixture/tmpdir"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Working on it", "work") })
|
|
const it = testEffect(
|
|
Layer.merge(
|
|
llmLayer,
|
|
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node, Job.node, KV.node]), [
|
|
Global.node.replace(tempGlobalLayer),
|
|
offlineModels,
|
|
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
|
LayerNodePlatform.llmClient.replace(llmLayer),
|
|
SessionRunnerModel.node.replace(
|
|
Layer.succeed(SessionRunnerModel.Service, {
|
|
resolve: () =>
|
|
Effect.succeed(
|
|
SessionRunnerModel.resolved(
|
|
LanguageModel.make({ id: "goal", provider: "test", route: OpenAIChat.route }),
|
|
{
|
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
|
cost: [],
|
|
limit: { context: 200_000, output: 32_000 },
|
|
},
|
|
),
|
|
),
|
|
}),
|
|
),
|
|
]),
|
|
),
|
|
)
|
|
|
|
const model = Model.Ref.make({ id: Model.ID.make("goal"), providerID: Provider.ID.make("test") })
|
|
|
|
describe("goal plugin", () => {
|
|
it.live("keeps working in one execution until the reviewer finds the goal met", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const verdicts = reviewer(
|
|
'{"verdict":"continue","reason":"The parser test still fails.","next_step":"Fix the date parser."}',
|
|
'```json\n{"verdict":"done","reason":"All 12 tests pass."}\n```',
|
|
)
|
|
yield* serve(verdicts.respond)
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: " Make the test suite pass " })
|
|
yield* sessions.wait(session.id)
|
|
|
|
expect(verdicts.asked()).toBe(2)
|
|
expect(yield* goal(session.id)).toMatchObject({
|
|
objective: "Make the test suite pass",
|
|
status: "complete",
|
|
reason: "All 12 tests pass.",
|
|
rounds: 1,
|
|
})
|
|
const messages = yield* sessions.messages({ sessionID: session.id, order: "asc" })
|
|
// The kickoff is a notice, so timelines show the objective rather than the whole template.
|
|
expect(messages.filter((message) => message.type === "user")).toEqual([])
|
|
const notices = messages.filter((message) => message.type === "synthetic")
|
|
expect(notices).toMatchObject([
|
|
{ metadata: { source: "goal", goalID: expect.any(String) } },
|
|
{ metadata: { source: "goal", goalID: expect.any(String) } },
|
|
])
|
|
// Only the kickoff has a description, so timelines show it and hide the continuation.
|
|
expect(notices.map((message) => message.type === "synthetic" && message.description)).toEqual([
|
|
"Goal: Make the test suite pass",
|
|
undefined,
|
|
])
|
|
expect(notices[0]?.type === "synthetic" && notices[0].text).toContain(
|
|
"<untrusted_objective>\nMake the test suite pass\n</untrusted_objective>",
|
|
)
|
|
expect(notices[1]?.type === "synthetic" && notices[1].text).toContain(
|
|
"The parser test still fails.\nNext step: Fix the date parser.",
|
|
)
|
|
// Continuations run inside the same execution, so the Session went idle once.
|
|
expect(messages.filter((message) => message.type === "idle")).toMatchObject([{ outcome: "succeeded" }])
|
|
}),
|
|
)
|
|
|
|
for (const scenario of [
|
|
{
|
|
// The first round with this reason made progress; the three repeats after it did not.
|
|
name: "the reviewer keeps giving the same reason, even with tool calls",
|
|
tools: true,
|
|
reasons: Array<string>(4).fill("The flaky test still fails."),
|
|
},
|
|
{
|
|
name: "rounds keep passing without tool calls",
|
|
tools: false,
|
|
reasons: ["Two tests fail.", "One test fails.", "The lint step fails."],
|
|
},
|
|
])
|
|
it.live(`pauses when ${scenario.name}`, () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const verdicts = reviewer(
|
|
...scenario.reasons.map((reason) => JSON.stringify({ verdict: "continue", reason })),
|
|
'{"verdict":"done","reason":"Unreachable."}',
|
|
)
|
|
yield* serve(scenario.tools ? withTools(verdicts) : verdicts.respond)
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Fix the flaky test" })
|
|
yield* sessions.wait(session.id)
|
|
|
|
// The third stalled round in a row pauses instead of sending another continuation.
|
|
expect(verdicts.asked()).toBe(scenario.reasons.length)
|
|
expect(yield* goal(session.id)).toMatchObject({
|
|
status: "paused",
|
|
reason: `Not making progress: ${scenario.reasons.at(-1)}`,
|
|
rounds: scenario.reasons.length - 1,
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.live("keeps going while each round calls tools and finds a new reason", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const verdicts = reviewer(
|
|
'{"verdict":"continue","reason":"Two tests fail."}',
|
|
'{"verdict":"continue","reason":"One test fails."}',
|
|
'{"verdict":"continue","reason":"The lint step fails."}',
|
|
'{"verdict":"done","reason":"Everything passes."}',
|
|
)
|
|
yield* serve(withTools(verdicts))
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Make CI green" })
|
|
yield* sessions.wait(session.id)
|
|
|
|
expect(verdicts.asked()).toBe(4)
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "complete", rounds: 3, stalls: 0 })
|
|
}),
|
|
)
|
|
|
|
it.live("pauses when the reviewer gives no usable verdict twice", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const verdicts = reviewer("Looks good to me.", "I think so.")
|
|
yield* serve(verdicts.respond)
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Ship it" })
|
|
yield* sessions.wait(session.id)
|
|
|
|
expect(verdicts.asked()).toBe(2)
|
|
expect(yield* goal(session.id)).toMatchObject({
|
|
status: "paused",
|
|
reason: "The goal reviewer gave no usable verdict",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.live("waits for a background subagent instead of judging", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const jobs = yield* Job.Service
|
|
const child = yield* sessions.create({ parentID: session.id, title: "Explore", agent: Agent.ID.make("build") })
|
|
const done = yield* Deferred.make<string>()
|
|
yield* jobs.start({
|
|
id: child.id,
|
|
type: "subagent",
|
|
recovery: {
|
|
kind: "subagent",
|
|
parentSessionID: session.id,
|
|
childSessionID: child.id,
|
|
agent: "explore",
|
|
description: "Explore",
|
|
},
|
|
run: Deferred.await(done),
|
|
})
|
|
yield* jobs.background(child.id)
|
|
const verdicts = reviewer('{"verdict":"done","reason":"The report is written."}')
|
|
yield* serve(verdicts.respond)
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Write the report" })
|
|
yield* sessions.wait(session.id)
|
|
expect(verdicts.asked()).toBe(0)
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "active", rounds: 0 })
|
|
|
|
// The subagent's notice wakes the Session, and the reviewer runs once nothing is outstanding.
|
|
yield* Deferred.succeed(done, "Found everything")
|
|
yield* jobs.wait({ id: child.id })
|
|
yield* sessions.synthetic({ sessionID: session.id, text: "Subagent finished" })
|
|
yield* sessions.wait(session.id)
|
|
expect(verdicts.asked()).toBe(1)
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "complete" })
|
|
}),
|
|
)
|
|
|
|
it.live("does not wait on work a descendant owns, since only the descendant is woken", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const jobs = yield* Job.Service
|
|
const child = yield* sessions.create({ parentID: session.id, title: "Explore", agent: Agent.ID.make("build") })
|
|
const grandchild = yield* sessions.create({ parentID: child.id, title: "Dig", agent: Agent.ID.make("build") })
|
|
yield* jobs.start({
|
|
id: grandchild.id,
|
|
type: "subagent",
|
|
recovery: {
|
|
kind: "subagent",
|
|
parentSessionID: child.id,
|
|
childSessionID: grandchild.id,
|
|
agent: "explore",
|
|
description: "Dig",
|
|
},
|
|
run: Effect.never,
|
|
})
|
|
yield* jobs.background(grandchild.id)
|
|
yield* jobs.start({
|
|
id: "child-shell",
|
|
type: "shell",
|
|
recovery: { kind: "shell", sessionID: child.id, shellID: "child-shell", command: "bun run build" },
|
|
run: Effect.never,
|
|
})
|
|
yield* jobs.background("child-shell")
|
|
const prompts: string[] = []
|
|
const verdicts = reviewer(
|
|
'{"verdict":"wait","reason":"bun run build is still running."}',
|
|
'{"verdict":"done","reason":"The report is written."}',
|
|
)
|
|
yield* serve((request) => {
|
|
if (isReview(request)) prompts.push(JSON.stringify(request.messages.at(-1)))
|
|
return verdicts.respond(request)
|
|
})
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Write the report" })
|
|
yield* sessions.wait(session.id)
|
|
|
|
// The grandchild reports to the child, so the reviewer runs. With none of the Session's own shells running,
|
|
// "wait" is not offered and counts as an unusable answer.
|
|
expect(verdicts.asked()).toBe(2)
|
|
expect(prompts[0]).toContain("No background shells are running.")
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "complete", reason: "The report is written." })
|
|
}),
|
|
)
|
|
|
|
it.live("drops the reviewer's verdict when the goal is paused while it runs", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const judging = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
yield* serve((request) =>
|
|
isReview(request)
|
|
? Stream.fromEffect(Deferred.succeed(judging, undefined).pipe(Effect.andThen(Deferred.await(release)))).pipe(
|
|
Stream.flatMap(() =>
|
|
Stream.fromIterable(TestLLM.text('{"verdict":"continue","reason":"More to do."}', "review")),
|
|
),
|
|
)
|
|
: TestLLM.text("Working on it", "work"),
|
|
)
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Rename the module" })
|
|
yield* Deferred.await(judging)
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "pause" })
|
|
yield* Deferred.succeed(release, undefined)
|
|
yield* sessions.wait(session.id)
|
|
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "paused", reason: "Paused by the user", rounds: 0 })
|
|
expect(
|
|
(yield* sessions.messages({ sessionID: session.id, order: "asc" })).flatMap((message) =>
|
|
message.type === "synthetic" ? [message.description] : [],
|
|
),
|
|
).toEqual(["Goal: Rename the module"])
|
|
}),
|
|
)
|
|
|
|
it.live("reminds the model of the goal after a compaction until a continuation restates it", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const llm = yield* TestLLM.Test
|
|
const summarize = (request: LLMRequest) => JSON.stringify(request.messages.at(-1)).includes("summar")
|
|
yield* serve((request) =>
|
|
summarize(request) ? TestLLM.text("## Objective\n- Make CI green", "summary") : TestLLM.text("On it.", "work"),
|
|
)
|
|
yield* sessions.prompt({ sessionID: session.id, text: "Look at the failing CI job" })
|
|
yield* sessions.wait(session.id)
|
|
yield* setGoal(session.id, {
|
|
id: "goal-compacted",
|
|
objective: "Make CI green",
|
|
status: "active",
|
|
rounds: 2,
|
|
maxRounds: 50,
|
|
boot: "an-earlier-process",
|
|
updatedAt: 0,
|
|
})
|
|
|
|
yield* sessions.compact({ sessionID: session.id })
|
|
yield* sessions.wait(session.id)
|
|
|
|
const before = (yield* llm.requests()).length
|
|
yield* sessions.prompt({ sessionID: session.id, text: "Carry on" })
|
|
yield* sessions.wait(session.id)
|
|
const next = (yield* llm.requests()).slice(before).find((request) => !summarize(request) && !isReview(request))
|
|
expect(JSON.stringify(next?.messages)).toContain("A goal is active for this session. Keep working toward it.")
|
|
// The reminder goes before the user's prompt and is not saved to the Session.
|
|
expect(JSON.stringify(next?.messages.at(-1))).toContain("Carry on")
|
|
expect(
|
|
(yield* sessions.messages({ sessionID: session.id, order: "asc" })).some(
|
|
(message) => message.type === "synthetic" && message.text.includes("Keep working toward it."),
|
|
),
|
|
).toBe(false)
|
|
}),
|
|
)
|
|
|
|
it.live("compacts and asks again when the reviewer's request is too large", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const reviews = { count: 0 }
|
|
yield* serve((request) => {
|
|
if (JSON.stringify(request.messages.at(-1)).includes("summar"))
|
|
return TestLLM.text("## Objective\n- Make CI green", "summary")
|
|
if (!isReview(request)) return TestLLM.text("Working on it", "work")
|
|
reviews.count++
|
|
return reviews.count === 1 ? overflow() : TestLLM.text('{"verdict":"done","reason":"CI is green."}', "review")
|
|
})
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Make CI green" })
|
|
yield* sessions.wait(session.id)
|
|
|
|
expect(reviews.count).toBe(2)
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "complete", reason: "CI is green." })
|
|
expect(
|
|
(yield* sessions.messages({ sessionID: session.id, order: "asc" })).filter(
|
|
(message) => message.type === "compaction",
|
|
),
|
|
).toMatchObject([{ status: "completed" }])
|
|
}),
|
|
)
|
|
|
|
it.live("pauses when the reviewer's request is too large even after compacting", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
yield* serve((request) => {
|
|
if (JSON.stringify(request.messages.at(-1)).includes("summar"))
|
|
return TestLLM.text("## Objective\n- Make CI green", "summary")
|
|
return isReview(request) ? overflow() : TestLLM.text("Working on it", "work")
|
|
})
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Make CI green" })
|
|
yield* sessions.wait(session.id)
|
|
|
|
expect(yield* goal(session.id)).toMatchObject({
|
|
status: "paused",
|
|
reason: "The goal reviewer's request is too large for the model",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.live("pauses an active goal when the user stops the session", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const llm = yield* TestLLM.Test
|
|
const gate = yield* llm.gate()
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Refactor the store" })
|
|
yield* gate.started
|
|
yield* sessions.interrupt(session.id)
|
|
yield* sessions.wait(session.id)
|
|
|
|
expect(yield* eventually(goal(session.id), (state) => state?.status === "paused")).toMatchObject({
|
|
reason: "Stopped by the user",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.live("pauses a goal set by an earlier server process instead of continuing it", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const verdicts = reviewer('{"verdict":"continue","reason":"Not yet."}')
|
|
yield* serve(verdicts.respond)
|
|
yield* setGoal(session.id, {
|
|
id: "goal-earlier",
|
|
objective: "Migrate the database",
|
|
status: "active",
|
|
rounds: 3,
|
|
maxRounds: 20,
|
|
boot: "an-earlier-process",
|
|
updatedAt: 0,
|
|
})
|
|
|
|
yield* sessions.prompt({ sessionID: session.id, text: "Server came back" })
|
|
yield* sessions.wait(session.id)
|
|
|
|
expect(verdicts.asked()).toBe(0)
|
|
expect(yield* goal(session.id)).toMatchObject({
|
|
status: "paused",
|
|
reason: "The server restarted while the goal was active",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.live("does not give a child session its parent's goal", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const llm = yield* TestLLM.Test
|
|
const verdicts = reviewer('{"verdict":"blocked","reason":"Waiting on the user."}')
|
|
yield* serve(verdicts.respond)
|
|
const gate = yield* llm.gate()
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Tidy the docs" })
|
|
yield* gate.started
|
|
// Created while the parent's goal is active.
|
|
const child = yield* sessions.create({ parentID: session.id, title: "Helper", agent: Agent.ID.make("build") })
|
|
yield* gate.release
|
|
yield* sessions.wait(session.id)
|
|
expect(verdicts.asked()).toBe(1)
|
|
|
|
expect(yield* goal(child.id)).toBeUndefined()
|
|
yield* sessions.prompt({ sessionID: child.id, text: "Help out" })
|
|
yield* sessions.wait(child.id)
|
|
|
|
expect(verdicts.asked()).toBe(1)
|
|
}),
|
|
)
|
|
|
|
it.live("pauses, resumes and clears a goal through its subcommands", () =>
|
|
Effect.gen(function* () {
|
|
const session = yield* project()
|
|
const sessions = yield* Session.Service
|
|
const verdicts = reviewer(
|
|
'{"verdict":"paused","reason":"The user asked to stop for now."}',
|
|
'{"verdict":"done","reason":"The changelog lists every PR."}',
|
|
)
|
|
yield* serve(verdicts.respond)
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Update the changelog" })
|
|
yield* sessions.wait(session.id)
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "paused", reason: "The user asked to stop for now." })
|
|
const paused = yield* sessions
|
|
.command({ sessionID: session.id, command: "goal", text: "pause" })
|
|
.pipe(Effect.exit)
|
|
expect(paused._tag).toBe("Failure")
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "Resume" })
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "active", rounds: 1 })
|
|
yield* sessions.wait(session.id)
|
|
expect(yield* goal(session.id)).toMatchObject({ status: "complete", rounds: 1 })
|
|
const resumed = (yield* sessions.messages({ sessionID: session.id, order: "asc" })).filter(
|
|
(message) => message.type === "synthetic",
|
|
)
|
|
expect(resumed.map((message) => message.type === "synthetic" && message.description)).toEqual([
|
|
"Goal: Update the changelog",
|
|
undefined,
|
|
])
|
|
|
|
yield* sessions.command({ sessionID: session.id, command: "goal", text: "clear" })
|
|
expect(yield* goal(session.id)).toBeUndefined()
|
|
}),
|
|
)
|
|
})
|
|
|
|
const REVIEW_MARKER = "You are now the reviewer for the session's goal"
|
|
|
|
// Answers reviewer requests from a script and every other request as ordinary assistant work.
|
|
function reviewer(...replies: ReadonlyArray<string>) {
|
|
const queue = [...replies]
|
|
const asked = { count: 0 }
|
|
return {
|
|
asked: () => asked.count,
|
|
respond: (request: LLMRequest) => {
|
|
if (!isReview(request)) return TestLLM.text("Working on it", "work")
|
|
asked.count++
|
|
return TestLLM.text(queue.shift() ?? '{"verdict":"done","reason":"Out of script."}', `review-${asked.count}`)
|
|
},
|
|
}
|
|
}
|
|
|
|
// Each round starts with a tool call and ends with text once the tool result is in.
|
|
function withTools(verdicts: ReturnType<typeof reviewer>) {
|
|
return (request: LLMRequest) => {
|
|
if (isReview(request)) return verdicts.respond(request)
|
|
const last = JSON.stringify(request.messages.at(-1))
|
|
return last.includes("tool") && !last.includes("untrusted_objective")
|
|
? TestLLM.text("Checked.", "work")
|
|
: TestLLM.toolCall(`call-${verdicts.asked()}`, "missing_tool", {})
|
|
}
|
|
}
|
|
|
|
function overflow() {
|
|
return TestLLM.failAfter(
|
|
new AIError({ reason: new InvalidRequestError({ message: "Too long", classification: "context-overflow" }) }),
|
|
)
|
|
}
|
|
|
|
function isReview(request: LLMRequest) {
|
|
return JSON.stringify(request.messages.at(-1)).includes(REVIEW_MARKER)
|
|
}
|
|
|
|
function serve(respond: (request: LLMRequest) => TestLLM.Response) {
|
|
return TestLLM.Test.use((llm) => llm.serve(respond))
|
|
}
|
|
|
|
// The goal plugin's own storage, as the plugin host hands it to the plugin.
|
|
const goals = KV.Service.use((kv) => Effect.succeed(PluginHost.storage(kv, GoalPlugin.Plugin.id)))
|
|
const decodeGoal = Schema.decodeUnknownOption(Goal.State)
|
|
|
|
function goal(sessionID: Session.ID) {
|
|
return goals.pipe(
|
|
Effect.flatMap((storage) => storage.get(`session:${sessionID}`)),
|
|
Effect.map((value) => Option.getOrUndefined(decodeGoal(value))),
|
|
)
|
|
}
|
|
|
|
function setGoal(sessionID: Session.ID, state: Goal.State) {
|
|
return goals.pipe(Effect.flatMap((storage) => storage.set(`session:${sessionID}`, state)))
|
|
}
|
|
|
|
// The interrupt watcher reacts to the bus stream, after the interrupted execution has settled.
|
|
function eventually<A, R>(effect: Effect.Effect<A, unknown, R>, done: (value: A) => boolean) {
|
|
return effect.pipe(
|
|
Effect.repeat({ until: done, schedule: Schedule.spaced("10 millis") }),
|
|
Effect.timeout("10 seconds"),
|
|
Effect.orDie,
|
|
)
|
|
}
|
|
|
|
function project() {
|
|
return Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const sessions = yield* Session.Service
|
|
return yield* sessions.create({
|
|
location: { directory: AbsolutePath.make(tmp.path) },
|
|
title: "Goal session",
|
|
agent: Agent.ID.make("build"),
|
|
model,
|
|
})
|
|
})
|
|
}
|