|
|
@@ -433,317 +433,290 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
|
|
|
|
|
|
// Loop semantics
|
|
|
|
|
|
-it.instance(
|
|
|
- "loop exits immediately when last assistant has stop finish",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { llm } = yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const chat = yield* sessions.create({ title: "Pinned" })
|
|
|
- yield* seed(chat.id, { finish: "stop" })
|
|
|
-
|
|
|
- const result = yield* prompt.loop({ sessionID: chat.id })
|
|
|
- expect(result.info.role).toBe("assistant")
|
|
|
- if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
|
|
|
- expect(yield* llm.calls).toBe(0)
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+it.instance("loop exits immediately when last assistant has stop finish", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { llm } = yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const chat = yield* sessions.create({ title: "Pinned" })
|
|
|
+ yield* seed(chat.id, { finish: "stop" })
|
|
|
+
|
|
|
+ const result = yield* prompt.loop({ sessionID: chat.id })
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+ if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
|
|
|
+ expect(yield* llm.calls).toBe(0)
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "loop calls LLM and returns assistant message",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { llm } = yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const chat = yield* sessions.create({
|
|
|
- title: "Pinned",
|
|
|
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
- })
|
|
|
- yield* prompt.prompt({
|
|
|
- sessionID: chat.id,
|
|
|
- agent: "build",
|
|
|
- noReply: true,
|
|
|
- parts: [{ type: "text", text: "hello" }],
|
|
|
- })
|
|
|
- yield* llm.text("world")
|
|
|
+it.instance("loop calls LLM and returns assistant message", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { llm } = yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const chat = yield* sessions.create({
|
|
|
+ title: "Pinned",
|
|
|
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
+ })
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: chat.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [{ type: "text", text: "hello" }],
|
|
|
+ })
|
|
|
+ yield* llm.text("world")
|
|
|
|
|
|
- const result = yield* prompt.loop({ sessionID: chat.id })
|
|
|
- expect(result.info.role).toBe("assistant")
|
|
|
- const parts = result.parts.filter((p) => p.type === "text")
|
|
|
- expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
|
|
|
- expect(yield* llm.hits).toHaveLength(1)
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ const result = yield* prompt.loop({ sessionID: chat.id })
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+ const parts = result.parts.filter((p) => p.type === "text")
|
|
|
+ expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
|
|
|
+ expect(yield* llm.hits).toHaveLength(1)
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "prompt emits v2 prompted and synthetic events",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const chat = yield* sessions.create({ title: "Pinned" })
|
|
|
-
|
|
|
- yield* prompt.prompt({
|
|
|
- sessionID: chat.id,
|
|
|
- agent: "build",
|
|
|
- noReply: true,
|
|
|
- parts: [
|
|
|
- { type: "text", text: "hello v2" },
|
|
|
- {
|
|
|
- type: "file",
|
|
|
- mime: "text/plain",
|
|
|
- filename: "note.txt",
|
|
|
- url: "data:text/plain;base64,bm90ZSBjb250ZW50",
|
|
|
- },
|
|
|
- ],
|
|
|
- })
|
|
|
+it.instance("prompt emits v2 prompted and synthetic events", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const chat = yield* sessions.create({ title: "Pinned" })
|
|
|
+
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: chat.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [
|
|
|
+ { type: "text", text: "hello v2" },
|
|
|
+ {
|
|
|
+ type: "file",
|
|
|
+ mime: "text/plain",
|
|
|
+ filename: "note.txt",
|
|
|
+ url: "data:text/plain;base64,bm90ZSBjb250ZW50",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ })
|
|
|
|
|
|
- const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
|
|
|
- Effect.provide(SessionV2.layer),
|
|
|
- )
|
|
|
- const row = Database.use((db) =>
|
|
|
- db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),
|
|
|
- )
|
|
|
- expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
|
|
|
- expect(typeof row?.data.time.created).toBe("number")
|
|
|
- expect(messages).toEqual(
|
|
|
- expect.arrayContaining([
|
|
|
- expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }),
|
|
|
- expect.objectContaining({ type: "synthetic", text: "note content" }),
|
|
|
- ]),
|
|
|
- )
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
|
|
|
+ Effect.provide(SessionV2.layer),
|
|
|
+ )
|
|
|
+ const row = Database.use((db) =>
|
|
|
+ db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),
|
|
|
+ )
|
|
|
+ expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
|
|
|
+ expect(typeof row?.data.time.created).toBe("number")
|
|
|
+ expect(messages).toEqual(
|
|
|
+ expect.arrayContaining([
|
|
|
+ expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }),
|
|
|
+ expect.objectContaining({ type: "synthetic", text: "note content" }),
|
|
|
+ ]),
|
|
|
+ )
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "static loop returns assistant text through local provider",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { llm } = yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const session = yield* sessions.create({
|
|
|
- title: "Prompt provider",
|
|
|
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
- })
|
|
|
+it.instance("static loop returns assistant text through local provider", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { llm } = yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const session = yield* sessions.create({
|
|
|
+ title: "Prompt provider",
|
|
|
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
+ })
|
|
|
|
|
|
- yield* prompt.prompt({
|
|
|
- sessionID: session.id,
|
|
|
- agent: "build",
|
|
|
- noReply: true,
|
|
|
- parts: [{ type: "text", text: "hello" }],
|
|
|
- })
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [{ type: "text", text: "hello" }],
|
|
|
+ })
|
|
|
|
|
|
- yield* llm.text("world")
|
|
|
+ yield* llm.text("world")
|
|
|
|
|
|
- const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
- expect(result.info.role).toBe("assistant")
|
|
|
- expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
|
|
|
- expect(yield* llm.hits).toHaveLength(1)
|
|
|
- expect(yield* llm.pending).toBe(0)
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+ expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
|
|
|
+ expect(yield* llm.hits).toHaveLength(1)
|
|
|
+ expect(yield* llm.pending).toBe(0)
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "static loop consumes queued replies across turns",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { llm } = yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const session = yield* sessions.create({
|
|
|
- title: "Prompt provider turns",
|
|
|
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
- })
|
|
|
+it.instance("static loop consumes queued replies across turns", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { llm } = yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const session = yield* sessions.create({
|
|
|
+ title: "Prompt provider turns",
|
|
|
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
+ })
|
|
|
|
|
|
- yield* prompt.prompt({
|
|
|
- sessionID: session.id,
|
|
|
- agent: "build",
|
|
|
- noReply: true,
|
|
|
- parts: [{ type: "text", text: "hello one" }],
|
|
|
- })
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [{ type: "text", text: "hello one" }],
|
|
|
+ })
|
|
|
|
|
|
- yield* llm.text("world one")
|
|
|
+ yield* llm.text("world one")
|
|
|
|
|
|
- const first = yield* prompt.loop({ sessionID: session.id })
|
|
|
- expect(first.info.role).toBe("assistant")
|
|
|
- expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
|
|
|
+ const first = yield* prompt.loop({ sessionID: session.id })
|
|
|
+ expect(first.info.role).toBe("assistant")
|
|
|
+ expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
|
|
|
|
|
|
- yield* prompt.prompt({
|
|
|
- sessionID: session.id,
|
|
|
- agent: "build",
|
|
|
- noReply: true,
|
|
|
- parts: [{ type: "text", text: "hello two" }],
|
|
|
- })
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [{ type: "text", text: "hello two" }],
|
|
|
+ })
|
|
|
|
|
|
- yield* llm.text("world two")
|
|
|
+ yield* llm.text("world two")
|
|
|
|
|
|
- const second = yield* prompt.loop({ sessionID: session.id })
|
|
|
- expect(second.info.role).toBe("assistant")
|
|
|
- expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
|
|
|
+ const second = yield* prompt.loop({ sessionID: session.id })
|
|
|
+ expect(second.info.role).toBe("assistant")
|
|
|
+ expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
|
|
|
|
|
|
- expect(yield* llm.hits).toHaveLength(2)
|
|
|
- expect(yield* llm.pending).toBe(0)
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ expect(yield* llm.hits).toHaveLength(2)
|
|
|
+ expect(yield* llm.pending).toBe(0)
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "loop continues when finish is tool-calls",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { llm } = yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const session = yield* sessions.create({
|
|
|
- title: "Pinned",
|
|
|
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
- })
|
|
|
- yield* prompt.prompt({
|
|
|
- sessionID: session.id,
|
|
|
- agent: "build",
|
|
|
- noReply: true,
|
|
|
- parts: [{ type: "text", text: "hello" }],
|
|
|
- })
|
|
|
- yield* llm.tool("first", { value: "first" })
|
|
|
- yield* llm.text("second")
|
|
|
-
|
|
|
- const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
- expect(yield* llm.calls).toBe(2)
|
|
|
- expect(result.info.role).toBe("assistant")
|
|
|
- if (result.info.role === "assistant") {
|
|
|
- expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
|
|
|
- expect(result.info.finish).toBe("stop")
|
|
|
- }
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+it.instance("loop continues when finish is tool-calls", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { llm } = yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const session = yield* sessions.create({
|
|
|
+ title: "Pinned",
|
|
|
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
+ })
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [{ type: "text", text: "hello" }],
|
|
|
+ })
|
|
|
+ yield* llm.tool("first", { value: "first" })
|
|
|
+ yield* llm.text("second")
|
|
|
+
|
|
|
+ const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
+ expect(yield* llm.calls).toBe(2)
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+ if (result.info.role === "assistant") {
|
|
|
+ expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
|
|
|
+ expect(result.info.finish).toBe("stop")
|
|
|
+ }
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "glob tool keeps instance context during prompt runs",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { dir, llm } = yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const session = yield* sessions.create({
|
|
|
- title: "Glob context",
|
|
|
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
- })
|
|
|
- const file = path.join(dir, "probe.txt")
|
|
|
- yield* writeText(file, "probe")
|
|
|
-
|
|
|
- yield* prompt.prompt({
|
|
|
- sessionID: session.id,
|
|
|
- agent: "build",
|
|
|
- noReply: true,
|
|
|
- parts: [{ type: "text", text: "find text files" }],
|
|
|
- })
|
|
|
- yield* llm.tool("glob", { pattern: "**/*.txt" })
|
|
|
- yield* llm.text("done")
|
|
|
-
|
|
|
- const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
- expect(result.info.role).toBe("assistant")
|
|
|
-
|
|
|
- const msgs = yield* MessageV2.filterCompactedEffect(session.id)
|
|
|
- const tool = msgs
|
|
|
- .flatMap((msg) => msg.parts)
|
|
|
- .find(
|
|
|
- (part): part is CompletedToolPart =>
|
|
|
- part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
|
|
|
- )
|
|
|
- if (!tool) return
|
|
|
+it.instance("glob tool keeps instance context during prompt runs", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { dir, llm } = yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const session = yield* sessions.create({
|
|
|
+ title: "Glob context",
|
|
|
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
+ })
|
|
|
+ const file = path.join(dir, "probe.txt")
|
|
|
+ yield* writeText(file, "probe")
|
|
|
+
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [{ type: "text", text: "find text files" }],
|
|
|
+ })
|
|
|
+ yield* llm.tool("glob", { pattern: "**/*.txt" })
|
|
|
+ yield* llm.text("done")
|
|
|
+
|
|
|
+ const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+
|
|
|
+ const msgs = yield* MessageV2.filterCompactedEffect(session.id)
|
|
|
+ const tool = msgs
|
|
|
+ .flatMap((msg) => msg.parts)
|
|
|
+ .find(
|
|
|
+ (part): part is CompletedToolPart =>
|
|
|
+ part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
|
|
|
+ )
|
|
|
+ if (!tool) return
|
|
|
|
|
|
- expect(tool.state.output).toContain(file)
|
|
|
- expect(tool.state.output).not.toContain("No context found for instance")
|
|
|
- expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ expect(tool.state.output).toContain(file)
|
|
|
+ expect(tool.state.output).not.toContain("No context found for instance")
|
|
|
+ expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "loop continues when finish is stop but assistant has tool parts",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { llm } = yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const session = yield* sessions.create({
|
|
|
- title: "Pinned",
|
|
|
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
- })
|
|
|
- yield* prompt.prompt({
|
|
|
- sessionID: session.id,
|
|
|
- agent: "build",
|
|
|
- noReply: true,
|
|
|
- parts: [{ type: "text", text: "hello" }],
|
|
|
- })
|
|
|
- yield* llm.push(reply().tool("first", { value: "first" }).stop())
|
|
|
- yield* llm.text("second")
|
|
|
-
|
|
|
- const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
- expect(yield* llm.calls).toBe(2)
|
|
|
- expect(result.info.role).toBe("assistant")
|
|
|
- if (result.info.role === "assistant") {
|
|
|
- expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
|
|
|
- expect(result.info.finish).toBe("stop")
|
|
|
- }
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+it.instance("loop continues when finish is stop but assistant has tool parts", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { llm } = yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const session = yield* sessions.create({
|
|
|
+ title: "Pinned",
|
|
|
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
|
|
+ })
|
|
|
+ yield* prompt.prompt({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent: "build",
|
|
|
+ noReply: true,
|
|
|
+ parts: [{ type: "text", text: "hello" }],
|
|
|
+ })
|
|
|
+ yield* llm.push(reply().tool("first", { value: "first" }).stop())
|
|
|
+ yield* llm.text("second")
|
|
|
+
|
|
|
+ const result = yield* prompt.loop({ sessionID: session.id })
|
|
|
+ expect(yield* llm.calls).toBe(2)
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+ if (result.info.role === "assistant") {
|
|
|
+ expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
|
|
|
+ expect(result.info.finish).toBe("stop")
|
|
|
+ }
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "failed subtask preserves metadata on error tool state",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { llm } = yield* useServerConfig((url) => ({
|
|
|
- ...providerCfg(url),
|
|
|
- agent: {
|
|
|
- general: {
|
|
|
- model: "test/missing-model",
|
|
|
- },
|
|
|
+it.instance("failed subtask preserves metadata on error tool state", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { llm } = yield* useServerConfig((url) => ({
|
|
|
+ ...providerCfg(url),
|
|
|
+ agent: {
|
|
|
+ general: {
|
|
|
+ model: "test/missing-model",
|
|
|
},
|
|
|
- }))
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const chat = yield* sessions.create({ title: "Pinned" })
|
|
|
- yield* llm.tool("task", {
|
|
|
- description: "inspect bug",
|
|
|
- prompt: "look into the cache key path",
|
|
|
- subagent_type: "general",
|
|
|
- })
|
|
|
- yield* llm.text("done")
|
|
|
- const msg = yield* user(chat.id, "hello")
|
|
|
- yield* addSubtask(chat.id, msg.id)
|
|
|
-
|
|
|
- const result = yield* prompt.loop({ sessionID: chat.id })
|
|
|
- expect(result.info.role).toBe("assistant")
|
|
|
- expect(yield* llm.calls).toBe(2)
|
|
|
-
|
|
|
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
|
|
|
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
|
|
|
- expect(taskMsg?.info.role).toBe("assistant")
|
|
|
- if (!taskMsg || taskMsg.info.role !== "assistant") return
|
|
|
-
|
|
|
- const tool = errorTool(taskMsg.parts)
|
|
|
- if (!tool) return
|
|
|
-
|
|
|
- expect(tool.state.error).toContain("Tool execution failed")
|
|
|
- expect(tool.state.metadata).toBeDefined()
|
|
|
- expect(tool.state.metadata?.sessionId).toBeDefined()
|
|
|
- expect(tool.state.metadata?.model).toEqual({
|
|
|
- providerID: ProviderID.make("test"),
|
|
|
- modelID: ModelID.make("missing-model"),
|
|
|
- })
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ },
|
|
|
+ }))
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const chat = yield* sessions.create({ title: "Pinned" })
|
|
|
+ yield* llm.tool("task", {
|
|
|
+ description: "inspect bug",
|
|
|
+ prompt: "look into the cache key path",
|
|
|
+ subagent_type: "general",
|
|
|
+ })
|
|
|
+ yield* llm.text("done")
|
|
|
+ const msg = yield* user(chat.id, "hello")
|
|
|
+ yield* addSubtask(chat.id, msg.id)
|
|
|
+
|
|
|
+ const result = yield* prompt.loop({ sessionID: chat.id })
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+ expect(yield* llm.calls).toBe(2)
|
|
|
+
|
|
|
+ const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
|
|
|
+ const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
|
|
|
+ expect(taskMsg?.info.role).toBe("assistant")
|
|
|
+ if (!taskMsg || taskMsg.info.role !== "assistant") return
|
|
|
+
|
|
|
+ const tool = errorTool(taskMsg.parts)
|
|
|
+ if (!tool) return
|
|
|
+
|
|
|
+ expect(tool.state.error).toContain("Tool execution failed")
|
|
|
+ expect(tool.state.metadata).toBeDefined()
|
|
|
+ expect(tool.state.metadata?.sessionId).toBeDefined()
|
|
|
+ expect(tool.state.metadata?.model).toEqual({
|
|
|
+ providerID: ProviderID.make("test"),
|
|
|
+ modelID: ModelID.make("missing-model"),
|
|
|
+ })
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
it.instance(
|
|
|
@@ -778,7 +751,6 @@ it.instance(
|
|
|
yield* prompt.cancel(chat.id)
|
|
|
yield* Fiber.await(fiber)
|
|
|
}),
|
|
|
- { git: true },
|
|
|
5_000,
|
|
|
)
|
|
|
|
|
|
@@ -823,7 +795,6 @@ it.instance(
|
|
|
yield* prompt.cancel(chat.id)
|
|
|
yield* Fiber.await(fiber)
|
|
|
}),
|
|
|
- { git: true },
|
|
|
10_000,
|
|
|
)
|
|
|
|
|
|
@@ -848,7 +819,6 @@ it.instance(
|
|
|
yield* Fiber.await(fiber)
|
|
|
expect((yield* status.get(chat.id)).type).toBe("idle")
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
@@ -877,7 +847,6 @@ it.instance(
|
|
|
expect(exit.value.info.role).toBe("assistant")
|
|
|
}
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
@@ -904,7 +873,6 @@ it.instance(
|
|
|
}
|
|
|
}
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
@@ -994,7 +962,6 @@ race.instance(
|
|
|
expect(lastAssistant.info.parentID).toBe(lastUser?.info.id)
|
|
|
}
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
@@ -1041,7 +1008,7 @@ it.instance(
|
|
|
expect(taskMsg.info.time.completed).toBeDefined()
|
|
|
expect(taskMsg.info.finish).toBeDefined()
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
30_000,
|
|
|
)
|
|
|
|
|
|
@@ -1077,7 +1044,6 @@ it.instance(
|
|
|
expect((yield* status.get(chat.id)).type).toBe("idle")
|
|
|
expect((yield* status.get(childID)).type).toBe("idle")
|
|
|
}),
|
|
|
- { git: true },
|
|
|
10_000,
|
|
|
)
|
|
|
|
|
|
@@ -1111,22 +1077,19 @@ it.instance(
|
|
|
|
|
|
// Queue semantics
|
|
|
|
|
|
-it.instance(
|
|
|
- "concurrent loop callers get same result",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { prompt, run, chat } = yield* boot()
|
|
|
- yield* seed(chat.id, { finish: "stop" })
|
|
|
+it.instance("concurrent loop callers get same result", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { prompt, run, chat } = yield* boot()
|
|
|
+ yield* seed(chat.id, { finish: "stop" })
|
|
|
|
|
|
- const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
|
|
|
- concurrency: "unbounded",
|
|
|
- })
|
|
|
+ const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
|
|
|
+ concurrency: "unbounded",
|
|
|
+ })
|
|
|
|
|
|
- expect(a.info.id).toBe(b.info.id)
|
|
|
- expect(a.info.role).toBe("assistant")
|
|
|
- yield* run.assertNotBusy(chat.id)
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ expect(a.info.id).toBe(b.info.id)
|
|
|
+ expect(a.info.role).toBe("assistant")
|
|
|
+ yield* run.assertNotBusy(chat.id)
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
it.instance(
|
|
|
@@ -1147,7 +1110,6 @@ it.instance(
|
|
|
expect(a.info.id).toBe(b.info.id)
|
|
|
expect(a.info.role).toBe("assistant")
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
@@ -1216,7 +1178,6 @@ it.instance(
|
|
|
expect(inputs).toHaveLength(2)
|
|
|
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
@@ -1246,22 +1207,18 @@ it.instance(
|
|
|
yield* prompt.cancel(chat.id)
|
|
|
yield* Fiber.await(fiber)
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
-it.instance(
|
|
|
- "assertNotBusy succeeds when idle",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const run = yield* SessionRunState.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
+it.instance("assertNotBusy succeeds when idle", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const run = yield* SessionRunState.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
|
|
|
- const chat = yield* sessions.create({})
|
|
|
- const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
|
|
|
- expect(Exit.isSuccess(exit)).toBe(true)
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ const chat = yield* sessions.create({})
|
|
|
+ const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
|
|
|
+ expect(Exit.isSuccess(exit)).toBe(true)
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
// Shell semantics
|
|
|
@@ -1290,7 +1247,6 @@ it.instance(
|
|
|
yield* prompt.cancel(chat.id)
|
|
|
yield* Fiber.await(fiber)
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
@@ -1315,7 +1271,7 @@ unix(
|
|
|
expect(tool.state.metadata.output).toContain("err")
|
|
|
yield* run.assertNotBusy(chat.id)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
)
|
|
|
|
|
|
unix(
|
|
|
@@ -1339,7 +1295,7 @@ unix(
|
|
|
expect(tool.state.metadata.output).toContain(dir)
|
|
|
yield* run.assertNotBusy(chat.id)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
)
|
|
|
|
|
|
unix(
|
|
|
@@ -1361,7 +1317,7 @@ unix(
|
|
|
expect(tool.state.output).toContain("configured")
|
|
|
}),
|
|
|
),
|
|
|
- { git: true, config: { ...cfg, shell: "bash" } },
|
|
|
+ { config: { ...cfg, shell: "bash" } },
|
|
|
30_000,
|
|
|
)
|
|
|
|
|
|
@@ -1386,7 +1342,7 @@ unix(
|
|
|
expect(tool.state.metadata.output).toContain(parent)
|
|
|
yield* run.assertNotBusy(chat.id)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
)
|
|
|
|
|
|
unix(
|
|
|
@@ -1412,7 +1368,7 @@ unix(
|
|
|
expect(tool.state.metadata.output).toContain("README.md")
|
|
|
yield* run.assertNotBusy(chat.id)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
)
|
|
|
|
|
|
unix(
|
|
|
@@ -1434,7 +1390,7 @@ unix(
|
|
|
expect(tool.state.metadata.output).toContain("not found")
|
|
|
yield* run.assertNotBusy(chat.id)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
)
|
|
|
|
|
|
unix(
|
|
|
@@ -1462,7 +1418,7 @@ unix(
|
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
|
}),
|
|
|
),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
30_000,
|
|
|
)
|
|
|
|
|
|
@@ -1572,7 +1528,6 @@ unix(
|
|
|
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
|
|
|
}),
|
|
|
),
|
|
|
- { git: true },
|
|
|
30_000,
|
|
|
)
|
|
|
|
|
|
@@ -1808,7 +1763,7 @@ it.instance(
|
|
|
const exit = yield* Fiber.await(fiber)
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
30_000,
|
|
|
)
|
|
|
|
|
|
@@ -1843,7 +1798,7 @@ it.instance(
|
|
|
const exit = yield* Fiber.await(fiber)
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
30_000,
|
|
|
)
|
|
|
|
|
|
@@ -1882,7 +1837,7 @@ it.instance(
|
|
|
|
|
|
yield* sessions.remove(session.id)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
)
|
|
|
|
|
|
it.instance(
|
|
|
@@ -1924,7 +1879,7 @@ it.instance(
|
|
|
|
|
|
yield* sessions.remove(session.id)
|
|
|
}),
|
|
|
- { git: true, config: cfg },
|
|
|
+ { config: cfg },
|
|
|
)
|
|
|
|
|
|
it.instance(
|
|
|
@@ -1973,7 +1928,6 @@ it.instance(
|
|
|
expect(agents.map((agent) => agent.name)).toEqual(["build"])
|
|
|
}),
|
|
|
{
|
|
|
- git: true,
|
|
|
config: {
|
|
|
...cfg,
|
|
|
reference: {
|
|
|
@@ -2013,7 +1967,6 @@ it.instance(
|
|
|
yield* sessions.remove(session.id)
|
|
|
}),
|
|
|
{
|
|
|
- git: true,
|
|
|
config: {
|
|
|
...cfg,
|
|
|
reference: {
|
|
|
@@ -2079,7 +2032,6 @@ it.instance(
|
|
|
yield* sessions.remove(session.id)
|
|
|
}),
|
|
|
{
|
|
|
- git: true,
|
|
|
config: {
|
|
|
...cfg,
|
|
|
reference: {
|
|
|
@@ -2128,31 +2080,28 @@ it.instance(
|
|
|
|
|
|
// Regression: empty assistant turn loop
|
|
|
|
|
|
-it.instance(
|
|
|
- "does not loop empty assistant turns for a simple reply",
|
|
|
- () =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const { llm } = yield* useServerConfig(providerCfg)
|
|
|
- const prompt = yield* SessionPrompt.Service
|
|
|
- const sessions = yield* Session.Service
|
|
|
- const session = yield* sessions.create({ title: "Prompt regression" })
|
|
|
+it.instance("does not loop empty assistant turns for a simple reply", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const { llm } = yield* useServerConfig(providerCfg)
|
|
|
+ const prompt = yield* SessionPrompt.Service
|
|
|
+ const sessions = yield* Session.Service
|
|
|
+ const session = yield* sessions.create({ title: "Prompt regression" })
|
|
|
|
|
|
- yield* llm.text("packages/opencode/src/session/processor.ts")
|
|
|
+ yield* llm.text("packages/opencode/src/session/processor.ts")
|
|
|
|
|
|
- const result = yield* prompt.prompt({
|
|
|
- sessionID: session.id,
|
|
|
- agent: "build",
|
|
|
- parts: [{ type: "text", text: "Where is SessionProcessor?" }],
|
|
|
- })
|
|
|
+ const result = yield* prompt.prompt({
|
|
|
+ sessionID: session.id,
|
|
|
+ agent: "build",
|
|
|
+ parts: [{ type: "text", text: "Where is SessionProcessor?" }],
|
|
|
+ })
|
|
|
|
|
|
- expect(result.info.role).toBe("assistant")
|
|
|
- expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
|
|
|
+ expect(result.info.role).toBe("assistant")
|
|
|
+ expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
|
|
|
|
|
|
- const msgs = yield* sessions.messages({ sessionID: session.id })
|
|
|
- expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
|
|
|
- expect(yield* llm.calls).toBe(1)
|
|
|
- }),
|
|
|
- { git: true },
|
|
|
+ const msgs = yield* sessions.messages({ sessionID: session.id })
|
|
|
+ expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
|
|
|
+ expect(yield* llm.calls).toBe(1)
|
|
|
+ }),
|
|
|
)
|
|
|
|
|
|
it.instance(
|
|
|
@@ -2193,7 +2142,6 @@ it.instance(
|
|
|
expect(last.info.error?.name).toBe("MessageAbortedError")
|
|
|
}
|
|
|
}),
|
|
|
- { git: true },
|
|
|
3_000,
|
|
|
)
|
|
|
|
|
|
@@ -2244,7 +2192,6 @@ it.instance(
|
|
|
yield* sessions.remove(session.id)
|
|
|
}),
|
|
|
{
|
|
|
- git: true,
|
|
|
config: {
|
|
|
...cfg,
|
|
|
provider: {
|
|
|
@@ -2297,7 +2244,6 @@ it.instance(
|
|
|
}
|
|
|
}
|
|
|
}),
|
|
|
- { git: true },
|
|
|
30_000,
|
|
|
)
|
|
|
|
|
|
@@ -2326,7 +2272,6 @@ it.instance(
|
|
|
}
|
|
|
}
|
|
|
}),
|
|
|
- { git: true },
|
|
|
30_000,
|
|
|
)
|
|
|
|
|
|
@@ -2356,6 +2301,5 @@ it.instance(
|
|
|
}
|
|
|
}
|
|
|
}),
|
|
|
- { git: true },
|
|
|
30_000,
|
|
|
)
|