chore: initialize qiming workspace repository
This commit is contained in:
2181
qimingcode/packages/opencode/test/session/compaction.test.ts
Normal file
2181
qimingcode/packages/opencode/test/session/compaction.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
387
qimingcode/packages/opencode/test/session/instruction.test.ts
Normal file
387
qimingcode/packages/opencode/test/session/instruction.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
|
||||
|
||||
function loaded(filepath: string): MessageV2.WithParts[] {
|
||||
const sessionID = SessionID.make("session-loaded-1")
|
||||
const messageID = MessageID.make("message-loaded-1")
|
||||
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 0 },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make("anthropic"),
|
||||
modelID: ModelID.make("claude-sonnet-4-20250514"),
|
||||
},
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: PartID.make("part-loaded-1"),
|
||||
messageID,
|
||||
sessionID,
|
||||
type: "tool",
|
||||
callID: "call-loaded-1",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
output: "done",
|
||||
title: "Read",
|
||||
metadata: { loaded: [filepath] },
|
||||
time: { start: 0, end: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe("Instruction.resolve", () => {
|
||||
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
|
||||
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(tmp.path, "src", "file.ts"),
|
||||
MessageID.make("message-test-1"),
|
||||
)
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(tmp.path, "subdir", "nested", "file.ts"),
|
||||
MessageID.make("message-test-2"),
|
||||
)
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test("doesn't reload AGENTS.md when reading it directly", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(filepath)).toBe(false)
|
||||
|
||||
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test("does not reattach the same nearby instructions twice for one message", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-1")
|
||||
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
expect(second).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test("clear allows nearby instructions to be attached again for the same message", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-2")
|
||||
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
yield* svc.clear(id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(1)
|
||||
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test("skips instructions already reported by prior read metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-3")
|
||||
|
||||
const results = yield* svc.resolve(loaded(agents), filepath, id)
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
|
||||
})
|
||||
|
||||
describe("Instruction.system", () => {
|
||||
test("loads both project and global AGENTS.md when both exist", async () => {
|
||||
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions")
|
||||
},
|
||||
})
|
||||
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const rules = yield* svc.system()
|
||||
expect(rules).toHaveLength(2)
|
||||
expect(rules).toContain(
|
||||
`Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`,
|
||||
)
|
||||
expect(rules).toContain(
|
||||
`Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`,
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
} else {
|
||||
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
} else {
|
||||
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
|
||||
}
|
||||
})
|
||||
|
||||
test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => {
|
||||
await using profileTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions")
|
||||
},
|
||||
})
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
|
||||
test("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", async () => {
|
||||
await using profileTmp = await tmpdir()
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
|
||||
test("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", async () => {
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
})
|
||||
1272
qimingcode/packages/opencode/test/session/llm.test.ts
Normal file
1272
qimingcode/packages/opencode/test/session/llm.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1283
qimingcode/packages/opencode/test/session/message-v2.test.ts
Normal file
1283
qimingcode/packages/opencode/test/session/message-v2.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,842 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider } from "../../src/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Session } from "../../src/session"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionProcessor } from "../../src/session/processor"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Log } from "../../src/util"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { raw, reply, TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const summary = Layer.succeed(
|
||||
SessionSummary.Service,
|
||||
SessionSummary.Service.of({
|
||||
summarize: () => Effect.void,
|
||||
diff: () => Effect.succeed([]),
|
||||
computeDiff: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: "http://localhost:1/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function providerCfg(url: string) {
|
||||
return {
|
||||
...cfg,
|
||||
provider: {
|
||||
...cfg.provider,
|
||||
test: {
|
||||
...cfg.provider.test,
|
||||
options: {
|
||||
...cfg.provider.test.options,
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function agent(): Agent.Info {
|
||||
return {
|
||||
name: "build",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
}
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const user = Effect.fn("TestSession.user")(function* (sessionID: SessionID, text: string) {
|
||||
const session = yield* Session.Service
|
||||
const msg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
return msg
|
||||
})
|
||||
|
||||
const assistant = Effect.fn("TestSession.assistant")(function* (
|
||||
sessionID: SessionID,
|
||||
parentID: MessageID,
|
||||
root: string,
|
||||
) {
|
||||
const session = yield* Session.Service
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: root, root },
|
||||
cost: 0,
|
||||
tokens: {
|
||||
total: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
parentID,
|
||||
time: { created: Date.now() },
|
||||
finish: "end_turn",
|
||||
}
|
||||
yield* session.updateMessage(msg)
|
||||
return msg
|
||||
})
|
||||
|
||||
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||
const deps = Layer.mergeAll(
|
||||
Session.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
AgentSvc.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const env = Layer.mergeAll(
|
||||
TestLLMServer.layer,
|
||||
SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)),
|
||||
)
|
||||
|
||||
const it = testEffect(env)
|
||||
|
||||
const boot = Effect.fn("test.boot")(function* () {
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const provider = yield* Provider.Service
|
||||
return { processors, session, provider }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it.live("session.processor effect tests capture llm input cleanly", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.text("hello")
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "hi")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const input = {
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: {},
|
||||
} satisfies LLM.StreamInput
|
||||
|
||||
const value = yield* handle.process(input)
|
||||
const parts = MessageV2.parts(msg.id)
|
||||
const calls = yield* llm.calls
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(calls).toBe(1)
|
||||
expect(parts.some((part) => part.type === "text" && part.text === "hello")).toBe(true)
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests preserve text start time", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const gate = defer<void>()
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.push(
|
||||
raw({
|
||||
head: [
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: { role: "assistant" } }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: { content: "hello" } }],
|
||||
},
|
||||
],
|
||||
wait: gate.promise,
|
||||
tail: [
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "hi")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const run = yield* handle
|
||||
.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: {},
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
const stop = Date.now() + 500
|
||||
while (Date.now() < stop) {
|
||||
const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")
|
||||
if (text?.time?.start) return
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
throw new Error("timed out waiting for text part")
|
||||
})
|
||||
yield* Effect.sleep("20 millis")
|
||||
gate.resolve()
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")
|
||||
|
||||
expect(Exit.isSuccess(exit)).toBe(true)
|
||||
expect(text?.text).toBe("hello")
|
||||
expect(text?.time?.start).toBeDefined()
|
||||
expect(text?.time?.end).toBeDefined()
|
||||
if (!text?.time?.start || !text.time.end) return
|
||||
expect(text.time.start).toBeLessThan(text.time.end)
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests stop after token overflow requests compaction", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.text("after", { usage: { input: 100, output: 0 } })
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "compact")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const base = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const mdl = { ...base, limit: { context: 20, output: 10 } }
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "compact" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const parts = MessageV2.parts(msg.id)
|
||||
|
||||
expect(value).toBe("compact")
|
||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||
expect(parts.some((part) => part.type === "step-finish")).toBe(true)
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests capture reasoning from http mock", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.push(reply().reason("think").text("done").stop())
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "reason")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "reason" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const parts = MessageV2.parts(msg.id)
|
||||
const reasoning = parts.find((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
||||
const text = parts.find((part): part is MessageV2.TextPart => part.type === "text")
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(reasoning?.text).toBe("think")
|
||||
expect(text?.text).toBe("done")
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests reset reasoning state across retries", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.push(reply().reason("one").reset(), reply().reason("two").stop())
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "reason")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "reason" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const parts = MessageV2.parts(msg.id)
|
||||
const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(reasoning.some((part) => part.text === "two")).toBe(true)
|
||||
expect(reasoning.some((part) => part.text === "onetwo")).toBe(false)
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests do not retry unknown json errors", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.error(400, { error: { message: "no_kv_space" } })
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "json")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "json" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
expect(value).toBe("stop")
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(handle.message.error?.name).toBe("APIError")
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests retry recognized structured json errors", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.error(429, { type: "error", error: { type: "too_many_requests" } })
|
||||
yield* llm.text("after")
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "retry json")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "retry json" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const parts = MessageV2.parts(msg.id)
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||
expect(handle.message.error).toBeUndefined()
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests publish retry status updates", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
yield* llm.error(503, { error: "boom" })
|
||||
yield* llm.text("")
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "retry")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const states: number[] = []
|
||||
const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
|
||||
if (evt.properties.sessionID !== chat.id) return
|
||||
if (evt.properties.status.type === "retry") states.push(evt.properties.status.attempt)
|
||||
})
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "retry" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
off()
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(states).toStrictEqual([1])
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests compact on structured context overflow", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.error(400, { type: "error", error: { code: "context_length_exceeded" } })
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "compact json")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "compact json" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
expect(value).toBe("compact")
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(handle.message.error).toBeUndefined()
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.toolHang("bash", { cmd: "pwd" })
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "tool abort")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const run = yield* handle
|
||||
.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "tool abort" }],
|
||||
tools: {},
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
|
||||
yield* llm.wait(1)
|
||||
yield* Effect.promise(async () => {
|
||||
const end = Date.now() + 500
|
||||
while (Date.now() < end) {
|
||||
const parts = await MessageV2.parts(msg.id)
|
||||
if (parts.some((part) => part.type === "tool")) return
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
})
|
||||
yield* Fiber.interrupt(run)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
const parts = MessageV2.parts(msg.id)
|
||||
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
}
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(call?.state.status).toBe("error")
|
||||
if (call?.state.status === "error") {
|
||||
expect(call.state.error).toBe("Tool execution aborted")
|
||||
expect(call.state.metadata?.interrupted).toBe(true)
|
||||
expect(call.state.time.end).toBeDefined()
|
||||
}
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests record aborted errors and idle state", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const seen = defer<void>()
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const bus = yield* Bus.Service
|
||||
const sts = yield* SessionStatus.Service
|
||||
|
||||
yield* llm.hang
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "abort")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const errs: string[] = []
|
||||
const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => {
|
||||
if (evt.properties.sessionID !== chat.id) return
|
||||
if (!evt.properties.error) return
|
||||
errs.push(evt.properties.error.name)
|
||||
seen.resolve()
|
||||
})
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const run = yield* handle
|
||||
.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "abort" }],
|
||||
tools: {},
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
|
||||
yield* llm.wait(1)
|
||||
yield* Fiber.interrupt(run)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
yield* Effect.promise(() => seen.promise)
|
||||
const stored = MessageV2.get({ sessionID: chat.id, messageID: msg.id })
|
||||
const state = yield* sts.get(chat.id)
|
||||
off()
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
}
|
||||
expect(handle.message.error?.name).toBe("MessageAbortedError")
|
||||
expect(stored.info.role).toBe("assistant")
|
||||
if (stored.info.role === "assistant") {
|
||||
expect(stored.info.error?.name).toBe("MessageAbortedError")
|
||||
}
|
||||
expect(state).toMatchObject({ type: "idle" })
|
||||
expect(errs).toContain("MessageAbortedError")
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests mark interruptions aborted without manual abort", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const sts = yield* SessionStatus.Service
|
||||
|
||||
yield* llm.hang
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "interrupt")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const run = yield* handle
|
||||
.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "interrupt" }],
|
||||
tools: {},
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
|
||||
yield* llm.wait(1)
|
||||
yield* Fiber.interrupt(run)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
const stored = MessageV2.get({ sessionID: chat.id, messageID: msg.id })
|
||||
const state = yield* sts.get(chat.id)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(handle.message.error?.name).toBe("MessageAbortedError")
|
||||
expect(stored.info.role).toBe("assistant")
|
||||
if (stored.info.role === "assistant") {
|
||||
expect(stored.info.error?.name).toBe("MessageAbortedError")
|
||||
}
|
||||
expect(state).toMatchObject({ type: "idle" })
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
1976
qimingcode/packages/opencode/test/session/prompt.test.ts
Normal file
1976
qimingcode/packages/opencode/test/session/prompt.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
319
qimingcode/packages/opencode/test/session/retry.test.ts
Normal file
319
qimingcode/packages/opencode/test/session/retry.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { APICallError } from "ai"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Effect, Schedule } from "effect"
|
||||
import { SessionRetry } from "../../src/session/retry"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const providerID = ProviderID.make("test")
|
||||
|
||||
function apiError(headers?: Record<string, string>): MessageV2.APIError {
|
||||
return new MessageV2.APIError({
|
||||
message: "boom",
|
||||
isRetryable: true,
|
||||
responseHeaders: headers,
|
||||
}).toObject() as MessageV2.APIError
|
||||
}
|
||||
|
||||
function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
|
||||
return { data: { message } } as ReturnType<NamedError["toObject"]>
|
||||
}
|
||||
|
||||
describe("session.retry.delay", () => {
|
||||
test("caps delay at 30 seconds when headers missing", () => {
|
||||
const error = apiError()
|
||||
const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error))
|
||||
expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
|
||||
})
|
||||
|
||||
test("prefers retry-after-ms when shorter than exponential", () => {
|
||||
const error = apiError({ "retry-after-ms": "1500" })
|
||||
expect(SessionRetry.delay(4, error)).toBe(1500)
|
||||
})
|
||||
|
||||
test("uses retry-after seconds when reasonable", () => {
|
||||
const error = apiError({ "retry-after": "30" })
|
||||
expect(SessionRetry.delay(3, error)).toBe(30000)
|
||||
})
|
||||
|
||||
test("accepts http-date retry-after values", () => {
|
||||
const date = new Date(Date.now() + 20000).toUTCString()
|
||||
const error = apiError({ "retry-after": date })
|
||||
const d = SessionRetry.delay(1, error)
|
||||
expect(d).toBeGreaterThanOrEqual(19000)
|
||||
expect(d).toBeLessThanOrEqual(20000)
|
||||
})
|
||||
|
||||
test("ignores invalid retry hints", () => {
|
||||
const error = apiError({ "retry-after": "not-a-number" })
|
||||
expect(SessionRetry.delay(1, error)).toBe(2000)
|
||||
})
|
||||
|
||||
test("ignores malformed date retry hints", () => {
|
||||
const error = apiError({ "retry-after": "Invalid Date String" })
|
||||
expect(SessionRetry.delay(1, error)).toBe(2000)
|
||||
})
|
||||
|
||||
test("ignores past date retry hints", () => {
|
||||
const pastDate = new Date(Date.now() - 5000).toUTCString()
|
||||
const error = apiError({ "retry-after": pastDate })
|
||||
expect(SessionRetry.delay(1, error)).toBe(2000)
|
||||
})
|
||||
|
||||
test("uses retry-after values even when exceeding 10 minutes with headers", () => {
|
||||
const error = apiError({ "retry-after": "50" })
|
||||
expect(SessionRetry.delay(1, error)).toBe(50000)
|
||||
|
||||
const longError = apiError({ "retry-after-ms": "700000" })
|
||||
expect(SessionRetry.delay(1, longError)).toBe(700000)
|
||||
})
|
||||
|
||||
test("caps oversized header delays to the runtime timer limit", () => {
|
||||
const error = apiError({ "retry-after-ms": "999999999999" })
|
||||
expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY)
|
||||
})
|
||||
|
||||
test("policy updates retry status and increments attempts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("session-retry-test")
|
||||
const error = apiError({ "retry-after-ms": "0" })
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const step = yield* Schedule.toStepWithMetadata(
|
||||
SessionRetry.policy({
|
||||
parse: (err) => err as MessageV2.APIError,
|
||||
set: (info) =>
|
||||
Effect.promise(() =>
|
||||
AppRuntime.runPromise(
|
||||
SessionStatus.Service.use((svc) =>
|
||||
svc.set(sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* step(error)
|
||||
yield* step(error)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "boom",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.retry.retryable", () => {
|
||||
test("maps too_many_requests json messages", () => {
|
||||
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
|
||||
expect(SessionRetry.retryable(error)).toBe("Too Many Requests")
|
||||
})
|
||||
|
||||
test("maps overloaded provider codes", () => {
|
||||
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
|
||||
expect(SessionRetry.retryable(error)).toBe("Provider is overloaded")
|
||||
})
|
||||
|
||||
test("does not retry unknown json messages", () => {
|
||||
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
|
||||
expect(SessionRetry.retryable(error)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not throw on numeric error codes", () => {
|
||||
const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
|
||||
const result = SessionRetry.retryable(error)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for non-json message", () => {
|
||||
const error = wrap("not-json")
|
||||
expect(SessionRetry.retryable(error)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("retries plain text rate limit errors from Alibaba", () => {
|
||||
const msg =
|
||||
"Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries plain text rate limit errors", () => {
|
||||
const msg = "Rate limit exceeded, please try again later"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries too many requests in plain text", () => {
|
||||
const msg = "Too many requests, please slow down"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("does not retry context overflow errors", () => {
|
||||
const error = new MessageV2.ContextOverflowError({
|
||||
message: "Input exceeds context window of this model",
|
||||
responseBody: '{"error":{"code":"context_length_exceeded"}}',
|
||||
}).toObject() as ReturnType<NamedError["toObject"]>
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("retries 500 errors even when isRetryable is false", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Internal server error",
|
||||
isRetryable: false,
|
||||
statusCode: 500,
|
||||
responseBody: '{"type":"api_error","message":"Internal server error"}',
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBe("Internal server error")
|
||||
})
|
||||
|
||||
test("retries 502 bad gateway errors", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Bad gateway",
|
||||
isRetryable: false,
|
||||
statusCode: 502,
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBe("Bad gateway")
|
||||
})
|
||||
|
||||
test("retries 503 service unavailable errors", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Service unavailable",
|
||||
isRetryable: false,
|
||||
statusCode: 503,
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBe("Service unavailable")
|
||||
})
|
||||
|
||||
test("does not retry 4xx errors when isRetryable is false", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Bad request",
|
||||
isRetryable: false,
|
||||
statusCode: 400,
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("retries ZlibError decompression failures", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Response decompression failed",
|
||||
isRetryable: true,
|
||||
metadata: { code: "ZlibError" },
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
const retryable = SessionRetry.retryable(error)
|
||||
expect(retryable).toBeDefined()
|
||||
expect(retryable).toBe("Response decompression failed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.message-v2.fromError", () => {
|
||||
test.concurrent(
|
||||
"converts ECONNRESET socket errors to retryable APIError",
|
||||
async () => {
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
idleTimeout: 8,
|
||||
async fetch(_req) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async pull(controller) {
|
||||
controller.enqueue("Hello,")
|
||||
await sleep(10000)
|
||||
controller.enqueue(" World!")
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "Content-Type": "text/plain" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const error = await fetch(new URL("/", server.url.origin))
|
||||
.then((res) => res.text())
|
||||
.catch((e) => e)
|
||||
|
||||
const result = MessageV2.fromError(error, { providerID })
|
||||
|
||||
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
|
||||
expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
|
||||
expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
|
||||
expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
test("ECONNRESET socket error is retryable", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Connection reset by server",
|
||||
isRetryable: true,
|
||||
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
const retryable = SessionRetry.retryable(error)
|
||||
expect(retryable).toBeDefined()
|
||||
expect(retryable).toBe("Connection reset by server")
|
||||
})
|
||||
|
||||
test("marks OpenAI 404 status codes as retryable", () => {
|
||||
const error = new APICallError({
|
||||
message: "boom",
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
requestBodyValues: {},
|
||||
statusCode: 404,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: '{"error":"boom"}',
|
||||
isRetryable: false,
|
||||
})
|
||||
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError
|
||||
expect(result.data.isRetryable).toBe(true)
|
||||
})
|
||||
|
||||
test("converts OpenAI server_error stream chunks to retryable APIError", () => {
|
||||
const result = MessageV2.fromError(
|
||||
{
|
||||
message: JSON.stringify({
|
||||
type: "error",
|
||||
sequence_number: 2,
|
||||
error: {
|
||||
type: "server_error",
|
||||
code: "server_error",
|
||||
message: "An error occurred while processing your request.",
|
||||
param: null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ providerID: ProviderID.make("openai") },
|
||||
)
|
||||
|
||||
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
|
||||
expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.")
|
||||
})
|
||||
})
|
||||
639
qimingcode/packages/opencode/test/session/revert-compact.test.ts
Normal file
639
qimingcode/packages/opencode/test/session/revert-compact.test.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session } from "../../src/session"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { SessionRevert } from "../../src/session/revert"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Log } from "../../src/util"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const env = Layer.mergeAll(
|
||||
Session.defaultLayer,
|
||||
SessionRevert.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
)
|
||||
|
||||
const it = testEffect(env)
|
||||
|
||||
const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") {
|
||||
const session = yield* Session.Service
|
||||
return yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user" as const,
|
||||
sessionID,
|
||||
agent,
|
||||
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
})
|
||||
|
||||
const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) {
|
||||
const session = yield* Session.Service
|
||||
return yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant" as const,
|
||||
sessionID,
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: { cwd: dir, root: dir },
|
||||
cost: 0,
|
||||
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ModelID.make("gpt-4"),
|
||||
providerID: ProviderID.make("openai"),
|
||||
parentID,
|
||||
time: { created: Date.now() },
|
||||
finish: "end_turn",
|
||||
})
|
||||
})
|
||||
|
||||
const text = Effect.fn("test.text")(function* (sessionID: SessionID, messageID: MessageID, content: string) {
|
||||
const session = yield* Session.Service
|
||||
return yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID,
|
||||
type: "text" as const,
|
||||
text: content,
|
||||
})
|
||||
})
|
||||
|
||||
const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: MessageID) {
|
||||
const session = yield* Session.Service
|
||||
return yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID,
|
||||
type: "tool" as const,
|
||||
tool: "bash",
|
||||
callID: "call-1",
|
||||
state: {
|
||||
status: "completed" as const,
|
||||
input: {},
|
||||
output: "done",
|
||||
title: "",
|
||||
metadata: {},
|
||||
time: { start: 0, end: 1 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const read = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
|
||||
const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text))
|
||||
|
||||
const tokens = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
}
|
||||
|
||||
describe("revert + compact workflow", () => {
|
||||
it.live(
|
||||
"should properly handle compact command after revert",
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
|
||||
const info = yield* session.create({})
|
||||
const sessionID = info.id
|
||||
|
||||
const userMsg1 = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "default",
|
||||
model: {
|
||||
providerID: ProviderID.make("openai"),
|
||||
modelID: ModelID.make("gpt-4"),
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
})
|
||||
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg1.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "Hello, please help me",
|
||||
})
|
||||
|
||||
const assistantMsg1: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: {
|
||||
cwd: dir,
|
||||
root: dir,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ModelID.make("gpt-4"),
|
||||
providerID: ProviderID.make("openai"),
|
||||
parentID: userMsg1.id,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
finish: "end_turn",
|
||||
}
|
||||
yield* session.updateMessage(assistantMsg1)
|
||||
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: assistantMsg1.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "Sure, I'll help you!",
|
||||
})
|
||||
|
||||
const userMsg2 = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "default",
|
||||
model: {
|
||||
providerID: ProviderID.make("openai"),
|
||||
modelID: ModelID.make("gpt-4"),
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
})
|
||||
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg2.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "What's the capital of France?",
|
||||
})
|
||||
|
||||
const assistantMsg2: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: {
|
||||
cwd: dir,
|
||||
root: dir,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ModelID.make("gpt-4"),
|
||||
providerID: ProviderID.make("openai"),
|
||||
parentID: userMsg2.id,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
finish: "end_turn",
|
||||
}
|
||||
yield* session.updateMessage(assistantMsg2)
|
||||
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: assistantMsg2.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "The capital of France is Paris.",
|
||||
})
|
||||
|
||||
let messages = yield* session.messages({ sessionID })
|
||||
expect(messages.length).toBe(4)
|
||||
const messageIds = messages.map((m) => m.info.id)
|
||||
expect(messageIds).toContain(userMsg1.id)
|
||||
expect(messageIds).toContain(userMsg2.id)
|
||||
expect(messageIds).toContain(assistantMsg1.id)
|
||||
expect(messageIds).toContain(assistantMsg2.id)
|
||||
|
||||
yield* revert.revert({
|
||||
sessionID,
|
||||
messageID: userMsg2.id,
|
||||
})
|
||||
|
||||
let sessionInfo = yield* session.get(sessionID)
|
||||
expect(sessionInfo.revert).toBeDefined()
|
||||
expect(sessionInfo.revert?.messageID).toBeDefined()
|
||||
|
||||
messages = yield* session.messages({ sessionID })
|
||||
expect(messages.length).toBe(4)
|
||||
|
||||
yield* revert.cleanup(sessionInfo)
|
||||
|
||||
messages = yield* session.messages({ sessionID })
|
||||
const remainingIds = messages.map((m) => m.info.id)
|
||||
expect(messages.length).toBeLessThan(4)
|
||||
expect(remainingIds).not.toContain(userMsg2.id)
|
||||
expect(remainingIds).not.toContain(assistantMsg2.id)
|
||||
|
||||
sessionInfo = yield* session.get(sessionID)
|
||||
expect(sessionInfo.revert).toBeUndefined()
|
||||
|
||||
yield* session.remove(sessionID)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"should properly clean up revert state before creating compaction message",
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
|
||||
const info = yield* session.create({})
|
||||
const sessionID = info.id
|
||||
|
||||
const userMsg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "default",
|
||||
model: {
|
||||
providerID: ProviderID.make("openai"),
|
||||
modelID: ModelID.make("gpt-4"),
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
})
|
||||
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
})
|
||||
|
||||
const assistantMsg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: {
|
||||
cwd: dir,
|
||||
root: dir,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ModelID.make("gpt-4"),
|
||||
providerID: ProviderID.make("openai"),
|
||||
parentID: userMsg.id,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
finish: "end_turn",
|
||||
}
|
||||
yield* session.updateMessage(assistantMsg)
|
||||
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "Hi there!",
|
||||
})
|
||||
|
||||
yield* revert.revert({
|
||||
sessionID,
|
||||
messageID: userMsg.id,
|
||||
})
|
||||
|
||||
let sessionInfo = yield* session.get(sessionID)
|
||||
expect(sessionInfo.revert).toBeDefined()
|
||||
|
||||
yield* revert.cleanup(sessionInfo)
|
||||
|
||||
sessionInfo = yield* session.get(sessionID)
|
||||
expect(sessionInfo.revert).toBeUndefined()
|
||||
|
||||
const messages = yield* session.messages({ sessionID })
|
||||
expect(messages.length).toBe(0)
|
||||
|
||||
yield* session.remove(sessionID)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"cleanup with partID removes parts from the revert point onward",
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
|
||||
const info = yield* session.create({})
|
||||
const sid = info.id
|
||||
|
||||
const u1 = yield* user(sid)
|
||||
const p1 = yield* text(sid, u1.id, "first part")
|
||||
const p2 = yield* tool(sid, u1.id)
|
||||
yield* text(sid, u1.id, "third part")
|
||||
|
||||
yield* session.setRevert({
|
||||
sessionID: sid,
|
||||
revert: { messageID: u1.id, partID: p2.id },
|
||||
summary: { additions: 0, deletions: 0, files: 0 },
|
||||
})
|
||||
|
||||
const state = yield* session.get(sid)
|
||||
yield* revert.cleanup(state)
|
||||
|
||||
const msgs = yield* session.messages({ sessionID: sid })
|
||||
expect(msgs.length).toBe(1)
|
||||
expect(msgs[0].parts.length).toBe(1)
|
||||
expect(msgs[0].parts[0].id).toBe(p1.id)
|
||||
|
||||
const cleared = yield* session.get(sid)
|
||||
expect(cleared.revert).toBeUndefined()
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"cleanup removes messages after revert point but keeps earlier ones",
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
|
||||
const info = yield* session.create({})
|
||||
const sid = info.id
|
||||
|
||||
const u1 = yield* user(sid)
|
||||
yield* text(sid, u1.id, "hello")
|
||||
const a1 = yield* assistant(sid, u1.id, dir)
|
||||
yield* text(sid, a1.id, "hi back")
|
||||
|
||||
const u2 = yield* user(sid)
|
||||
yield* text(sid, u2.id, "second question")
|
||||
const a2 = yield* assistant(sid, u2.id, dir)
|
||||
yield* text(sid, a2.id, "second answer")
|
||||
|
||||
yield* session.setRevert({
|
||||
sessionID: sid,
|
||||
revert: { messageID: u2.id },
|
||||
summary: { additions: 0, deletions: 0, files: 0 },
|
||||
})
|
||||
|
||||
const state = yield* session.get(sid)
|
||||
yield* revert.cleanup(state)
|
||||
|
||||
const msgs = yield* session.messages({ sessionID: sid })
|
||||
const ids = msgs.map((m) => m.info.id)
|
||||
expect(ids).toContain(u1.id)
|
||||
expect(ids).toContain(a1.id)
|
||||
expect(ids).not.toContain(u2.id)
|
||||
expect(ids).not.toContain(a2.id)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"cleanup is a no-op when session has no revert state",
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
|
||||
const info = yield* session.create({})
|
||||
const sid = info.id
|
||||
|
||||
const u1 = yield* user(sid)
|
||||
yield* text(sid, u1.id, "hello")
|
||||
|
||||
const state = yield* session.get(sid)
|
||||
expect(state.revert).toBeUndefined()
|
||||
yield* revert.cleanup(state)
|
||||
|
||||
const msgs = yield* session.messages({ sessionID: sid })
|
||||
expect(msgs.length).toBe(1)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"restore messages in sequential order",
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
|
||||
yield* write(path.join(dir, "a.txt"), "a0")
|
||||
yield* write(path.join(dir, "b.txt"), "b0")
|
||||
yield* write(path.join(dir, "c.txt"), "c0")
|
||||
|
||||
const info = yield* session.create({})
|
||||
const sid = info.id
|
||||
|
||||
const turn = Effect.fn("test.turn")(function* (file: string, next: string) {
|
||||
const u = yield* user(sid)
|
||||
yield* text(sid, u.id, `${file}:${next}`)
|
||||
const a = yield* assistant(sid, u.id, dir)
|
||||
const before = yield* snapshot.track()
|
||||
if (!before) throw new Error("expected snapshot")
|
||||
yield* write(path.join(dir, file), next)
|
||||
const after = yield* snapshot.track()
|
||||
if (!after) throw new Error("expected snapshot")
|
||||
const patch = yield* snapshot.patch(before)
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: a.id,
|
||||
sessionID: sid,
|
||||
type: "step-start",
|
||||
snapshot: before,
|
||||
})
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: a.id,
|
||||
sessionID: sid,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
snapshot: after,
|
||||
cost: 0,
|
||||
tokens,
|
||||
})
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: a.id,
|
||||
sessionID: sid,
|
||||
type: "patch",
|
||||
hash: patch.hash,
|
||||
files: patch.files,
|
||||
})
|
||||
return u.id
|
||||
})
|
||||
|
||||
const first = yield* turn("a.txt", "a1")
|
||||
const second = yield* turn("b.txt", "b2")
|
||||
const third = yield* turn("c.txt", "c3")
|
||||
|
||||
yield* revert.revert({
|
||||
sessionID: sid,
|
||||
messageID: first,
|
||||
})
|
||||
expect((yield* session.get(sid)).revert?.messageID).toBe(first)
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
|
||||
expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
|
||||
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
|
||||
|
||||
yield* revert.revert({
|
||||
sessionID: sid,
|
||||
messageID: second,
|
||||
})
|
||||
expect((yield* session.get(sid)).revert?.messageID).toBe(second)
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
|
||||
expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
|
||||
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
|
||||
|
||||
yield* revert.revert({
|
||||
sessionID: sid,
|
||||
messageID: third,
|
||||
})
|
||||
expect((yield* session.get(sid)).revert?.messageID).toBe(third)
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
|
||||
expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
|
||||
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
|
||||
|
||||
yield* revert.unrevert({
|
||||
sessionID: sid,
|
||||
})
|
||||
expect((yield* session.get(sid)).revert).toBeUndefined()
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
|
||||
expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
|
||||
expect(yield* read(path.join(dir, "c.txt"))).toBe("c3")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"restore same file in sequential order",
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
|
||||
yield* write(path.join(dir, "a.txt"), "a0")
|
||||
|
||||
const info = yield* session.create({})
|
||||
const sid = info.id
|
||||
|
||||
const turn = Effect.fn("test.turnSame")(function* (next: string) {
|
||||
const u = yield* user(sid)
|
||||
yield* text(sid, u.id, `a.txt:${next}`)
|
||||
const a = yield* assistant(sid, u.id, dir)
|
||||
const before = yield* snapshot.track()
|
||||
if (!before) throw new Error("expected snapshot")
|
||||
yield* write(path.join(dir, "a.txt"), next)
|
||||
const after = yield* snapshot.track()
|
||||
if (!after) throw new Error("expected snapshot")
|
||||
const patch = yield* snapshot.patch(before)
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: a.id,
|
||||
sessionID: sid,
|
||||
type: "step-start",
|
||||
snapshot: before,
|
||||
})
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: a.id,
|
||||
sessionID: sid,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
snapshot: after,
|
||||
cost: 0,
|
||||
tokens,
|
||||
})
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: a.id,
|
||||
sessionID: sid,
|
||||
type: "patch",
|
||||
hash: patch.hash,
|
||||
files: patch.files,
|
||||
})
|
||||
return u.id
|
||||
})
|
||||
|
||||
const first = yield* turn("a1")
|
||||
const second = yield* turn("a2")
|
||||
const third = yield* turn("a3")
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
|
||||
|
||||
yield* revert.revert({
|
||||
sessionID: sid,
|
||||
messageID: first,
|
||||
})
|
||||
expect((yield* session.get(sid)).revert?.messageID).toBe(first)
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
|
||||
|
||||
yield* revert.revert({
|
||||
sessionID: sid,
|
||||
messageID: second,
|
||||
})
|
||||
expect((yield* session.get(sid)).revert?.messageID).toBe(second)
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
|
||||
|
||||
yield* revert.revert({
|
||||
sessionID: sid,
|
||||
messageID: third,
|
||||
})
|
||||
expect((yield* session.get(sid)).revert?.messageID).toBe(third)
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a2")
|
||||
|
||||
yield* revert.unrevert({
|
||||
sessionID: sid,
|
||||
})
|
||||
expect((yield* session.get(sid)).revert).toBeUndefined()
|
||||
expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionRevert } from "../../src/session/revert"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
|
||||
// Covers the session-domain Effect Schema migration. For each migrated
|
||||
// schema we assert:
|
||||
// 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input.
|
||||
// 2. The derived Zod (`X.zod.parse`) accepts the same input and returns the
|
||||
// same shape.
|
||||
// 3. Clearly-invalid input is rejected by both paths.
|
||||
//
|
||||
// The point is to lock down the Schema <-> Zod bridge so a future edit to
|
||||
// any input schema can't silently drop or widen a field on one side.
|
||||
|
||||
// Representative valid IDs — the branded schemas require the right prefix
|
||||
// (see src/id/id.ts).
|
||||
const sessionID = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K")
|
||||
const sessionIDChild = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L")
|
||||
const messageID = MessageID.zod.parse("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M")
|
||||
const partID = PartID.zod.parse("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N")
|
||||
const projectID = ProjectID.zod.parse("proj-alpha")
|
||||
const workspaceID = WorkspaceID.zod.parse("wrk-primary")
|
||||
|
||||
function decodeUnknown<S extends Schema.Top>(schema: S) {
|
||||
const decode = Schema.decodeUnknownSync(schema as any)
|
||||
return (input: unknown): Schema.Schema.Type<S> => decode(input) as Schema.Schema.Type<S>
|
||||
}
|
||||
|
||||
describe("Session.Info", () => {
|
||||
const decode = decodeUnknown(Session.Info)
|
||||
|
||||
test("accepts minimal session", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "hello",
|
||||
projectID,
|
||||
directory: "/tmp/proj",
|
||||
title: "First session",
|
||||
version: "0.1.0",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("round-trips every optional field", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "fullshape",
|
||||
projectID,
|
||||
workspaceID,
|
||||
directory: "/tmp/proj",
|
||||
parentID: sessionIDChild,
|
||||
summary: {
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
files: 2,
|
||||
diffs: [{ additions: 1, deletions: 0, file: "a.ts", patch: "--- a/a.ts" }],
|
||||
},
|
||||
share: { url: "https://share.example.com/s/1" },
|
||||
title: "Full session",
|
||||
version: "1.0.0",
|
||||
time: { created: 100, updated: 200, compacting: 150, archived: 300 },
|
||||
permission: [{ action: "allow" as const, pattern: "*", permission: "read" }],
|
||||
revert: {
|
||||
messageID,
|
||||
partID,
|
||||
snapshot: "snap-1",
|
||||
diff: "diff-1",
|
||||
},
|
||||
}
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("rejects unbranded session id", () => {
|
||||
const bad = { id: "not-a-session-id" } as unknown
|
||||
expect(() => decode(bad)).toThrow()
|
||||
expect(() => Session.Info.zod.parse(bad)).toThrow()
|
||||
})
|
||||
|
||||
test("rejects missing required fields", () => {
|
||||
const bad = { id: sessionID } as unknown
|
||||
expect(() => decode(bad)).toThrow()
|
||||
expect(() => Session.Info.zod.parse(bad)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Session.ProjectInfo", () => {
|
||||
const decode = decodeUnknown(Session.ProjectInfo)
|
||||
|
||||
test("accepts with and without optional name", () => {
|
||||
const noName = { id: projectID, worktree: "/tmp/wt" }
|
||||
const withName = { ...noName, name: "alpha" }
|
||||
expect(decode(noName)).toEqual(noName)
|
||||
expect(decode(withName)).toEqual(withName)
|
||||
expect(Session.ProjectInfo.zod.parse(noName)).toEqual(noName)
|
||||
expect(Session.ProjectInfo.zod.parse(withName)).toEqual(withName)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Session.GlobalInfo", () => {
|
||||
const decode = decodeUnknown(Session.GlobalInfo)
|
||||
|
||||
test("accepts null project", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "global",
|
||||
projectID,
|
||||
directory: "/tmp/proj",
|
||||
title: "global",
|
||||
version: "0",
|
||||
time: { created: 0, updated: 0 },
|
||||
project: null,
|
||||
}
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.GlobalInfo.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("accepts populated project", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "global",
|
||||
projectID,
|
||||
directory: "/tmp/proj",
|
||||
title: "global",
|
||||
version: "0",
|
||||
time: { created: 0, updated: 0 },
|
||||
project: { id: projectID, worktree: "/tmp/wt", name: "alpha" },
|
||||
}
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.GlobalInfo.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Session input schemas", () => {
|
||||
test("CreateInput accepts undefined and populated forms", () => {
|
||||
const decode = decodeUnknown(Session.CreateInput)
|
||||
expect(decode(undefined)).toBeUndefined()
|
||||
expect(Session.CreateInput.zod.parse(undefined)).toBeUndefined()
|
||||
|
||||
const populated = {
|
||||
parentID: sessionID,
|
||||
title: "child",
|
||||
permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }],
|
||||
workspaceID,
|
||||
}
|
||||
expect(decode(populated)).toEqual(populated)
|
||||
expect(Session.CreateInput.zod.parse(populated)).toEqual(populated)
|
||||
})
|
||||
|
||||
test("ForkInput round-trips", () => {
|
||||
const decode = decodeUnknown(Session.ForkInput)
|
||||
const input = { sessionID, messageID }
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.ForkInput.zod.parse(input)).toEqual(input)
|
||||
// messageID is optional
|
||||
const bare = { sessionID }
|
||||
expect(decode(bare)).toEqual(bare)
|
||||
expect(Session.ForkInput.zod.parse(bare)).toEqual(bare)
|
||||
})
|
||||
|
||||
test("SetTitleInput rejects missing title", () => {
|
||||
expect(() => decodeUnknown(Session.SetTitleInput)({ sessionID })).toThrow()
|
||||
expect(() => Session.SetTitleInput.zod.parse({ sessionID })).toThrow()
|
||||
})
|
||||
|
||||
test("SetArchivedInput accepts both with and without time", () => {
|
||||
const decode = decodeUnknown(Session.SetArchivedInput)
|
||||
expect(decode({ sessionID })).toEqual({ sessionID })
|
||||
expect(decode({ sessionID, time: 123 })).toEqual({ sessionID, time: 123 })
|
||||
})
|
||||
|
||||
test("SetPermissionInput requires a ruleset", () => {
|
||||
const decode = decodeUnknown(Session.SetPermissionInput)
|
||||
const input = { sessionID, permission: [{ action: "deny" as const, pattern: "*", permission: "write" }] }
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(() => decode({ sessionID })).toThrow()
|
||||
})
|
||||
|
||||
test("MessagesInput accepts optional limit", () => {
|
||||
const decode = decodeUnknown(Session.MessagesInput)
|
||||
expect(decode({ sessionID })).toEqual({ sessionID })
|
||||
expect(decode({ sessionID, limit: 50 })).toEqual({ sessionID, limit: 50 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionRevert.RevertInput", () => {
|
||||
const decode = decodeUnknown(SessionRevert.RevertInput)
|
||||
|
||||
test("messageID is required, partID is optional", () => {
|
||||
const withPart = { sessionID, messageID, partID }
|
||||
expect(decode(withPart)).toEqual(withPart)
|
||||
expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart)
|
||||
|
||||
const noPart = { sessionID, messageID }
|
||||
expect(decode(noPart)).toEqual(noPart)
|
||||
expect(SessionRevert.RevertInput.zod.parse(noPart)).toEqual(noPart)
|
||||
|
||||
expect(() => decode({ sessionID })).toThrow()
|
||||
expect(() => SessionRevert.RevertInput.zod.parse({ sessionID })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionSummary.DiffInput", () => {
|
||||
const decode = decodeUnknown(SessionSummary.DiffInput)
|
||||
|
||||
test("messageID optional", () => {
|
||||
expect(decode({ sessionID })).toEqual({ sessionID })
|
||||
expect(decode({ sessionID, messageID })).toEqual({ sessionID, messageID })
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionStatus.Info", () => {
|
||||
const decode = decodeUnknown(SessionStatus.Info)
|
||||
|
||||
test("idle / busy discriminators", () => {
|
||||
expect(decode({ type: "idle" })).toEqual({ type: "idle" })
|
||||
expect(decode({ type: "busy" })).toEqual({ type: "busy" })
|
||||
expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" })
|
||||
})
|
||||
|
||||
test("retry carries attempt/message/next", () => {
|
||||
const input = { type: "retry" as const, attempt: 1, message: "transient", next: 500 }
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(SessionStatus.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("rejects unknown type", () => {
|
||||
expect(() => decode({ type: "bogus" })).toThrow()
|
||||
expect(() => SessionStatus.Info.zod.parse({ type: "bogus" })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Todo.Info", () => {
|
||||
const decode = decodeUnknown(Todo.Info)
|
||||
|
||||
test("three-field round-trip", () => {
|
||||
const input = { content: "do a thing", status: "pending", priority: "high" }
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Todo.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionPrompt input schemas", () => {
|
||||
test("LoopInput is just sessionID", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.LoopInput)
|
||||
expect(decode({ sessionID })).toEqual({ sessionID })
|
||||
expect(SessionPrompt.LoopInput.zod.parse({ sessionID } as unknown)).toEqual({ sessionID })
|
||||
})
|
||||
|
||||
test("ShellInput requires agent + command", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.ShellInput)
|
||||
const expected = { sessionID, agent: "build", command: "echo hi" }
|
||||
const input: unknown = expected
|
||||
expect(decode(input)).toEqual(expected)
|
||||
expect(SessionPrompt.ShellInput.zod.parse(input as unknown)).toEqual(expected)
|
||||
expect(() => decode({ sessionID })).toThrow()
|
||||
})
|
||||
|
||||
test("PromptInput accepts a text part and a file part", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.PromptInput)
|
||||
const expected = {
|
||||
sessionID,
|
||||
parts: [
|
||||
{ type: "text" as const, text: "hello" },
|
||||
{ type: "file" as const, mime: "image/png", url: "data:image/png;base64,AAAA" },
|
||||
],
|
||||
}
|
||||
const input: unknown = expected
|
||||
const decoded = decode(input)
|
||||
expect(decoded.parts).toHaveLength(2)
|
||||
expect(decoded.parts[0]).toMatchObject({ type: "text", text: "hello" })
|
||||
expect(decoded.parts[1]).toMatchObject({ type: "file", mime: "image/png" })
|
||||
|
||||
const viaZod = SessionPrompt.PromptInput.zod.parse(input)
|
||||
expect(viaZod.parts).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("PromptInput rejects unknown part type", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.PromptInput)
|
||||
const bad = {
|
||||
sessionID,
|
||||
parts: [{ type: "nonsense", payload: 42 }],
|
||||
}
|
||||
expect(() => decode(bad)).toThrow()
|
||||
expect(() => SessionPrompt.PromptInput.zod.parse(bad)).toThrow()
|
||||
})
|
||||
|
||||
test("CommandInput round-trips core fields", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.CommandInput)
|
||||
const expected = {
|
||||
sessionID,
|
||||
arguments: "--flag",
|
||||
command: "deploy",
|
||||
}
|
||||
const input: unknown = expected
|
||||
expect(decode(input)).toEqual(expected)
|
||||
expect(SessionPrompt.CommandInput.zod.parse(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,916 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import * as FastCheck from "effect/testing/FastCheck"
|
||||
import { SessionEntry } from "../../src/v2/session-entry"
|
||||
import { SessionEntryStepper } from "../../src/v2/session-entry-stepper"
|
||||
import { SessionEvent } from "../../src/v2/session-event"
|
||||
|
||||
const time = (n: number) => DateTime.makeUnsafe(n)
|
||||
|
||||
const word = FastCheck.string({ minLength: 1, maxLength: 8 })
|
||||
const text = FastCheck.string({ maxLength: 16 })
|
||||
const texts = FastCheck.array(text, { maxLength: 8 })
|
||||
const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
|
||||
const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
|
||||
const files = FastCheck.array(
|
||||
word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
|
||||
{ maxLength: 2 },
|
||||
)
|
||||
|
||||
function maybe<A>(arb: FastCheck.Arbitrary<A>) {
|
||||
return FastCheck.oneof(FastCheck.constant(undefined), arb)
|
||||
}
|
||||
|
||||
function assistant() {
|
||||
return new SessionEntry.Assistant({
|
||||
id: SessionEvent.ID.create(),
|
||||
type: "assistant",
|
||||
time: { created: time(0) },
|
||||
content: [],
|
||||
retries: [],
|
||||
})
|
||||
}
|
||||
|
||||
function retryError(message: string) {
|
||||
return new SessionEvent.RetryError({
|
||||
message,
|
||||
isRetryable: true,
|
||||
})
|
||||
}
|
||||
|
||||
function retry(attempt: number, message: string, created: number) {
|
||||
return new SessionEntry.AssistantRetry({
|
||||
attempt,
|
||||
error: retryError(message),
|
||||
time: {
|
||||
created: time(created),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function memoryState() {
|
||||
const state: SessionEntryStepper.MemoryState = {
|
||||
entries: [],
|
||||
pending: [],
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function active() {
|
||||
const state: SessionEntryStepper.MemoryState = {
|
||||
entries: [assistant()],
|
||||
pending: [],
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function run(events: SessionEvent.Event[], state = memoryState()) {
|
||||
return events.reduce<SessionEntryStepper.MemoryState>((state, event) => SessionEntryStepper.step(state, event), state)
|
||||
}
|
||||
|
||||
function last(state: SessionEntryStepper.MemoryState) {
|
||||
const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
|
||||
expect(entry?.type).toBe("assistant")
|
||||
return entry?.type === "assistant" ? entry : undefined
|
||||
}
|
||||
|
||||
function texts_of(state: SessionEntryStepper.MemoryState) {
|
||||
const entry = last(state)
|
||||
if (!entry) return []
|
||||
return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
|
||||
}
|
||||
|
||||
function reasons(state: SessionEntryStepper.MemoryState) {
|
||||
const entry = last(state)
|
||||
if (!entry) return []
|
||||
return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
|
||||
}
|
||||
|
||||
function tools(state: SessionEntryStepper.MemoryState) {
|
||||
const entry = last(state)
|
||||
if (!entry) return []
|
||||
return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
|
||||
}
|
||||
|
||||
function tool(state: SessionEntryStepper.MemoryState, callID: string) {
|
||||
return tools(state).find((x) => x.callID === callID)
|
||||
}
|
||||
|
||||
function retriesOf(state: SessionEntryStepper.MemoryState) {
|
||||
const entry = last(state)
|
||||
if (!entry) return []
|
||||
return entry.retries ?? []
|
||||
}
|
||||
|
||||
function adapterStore() {
|
||||
return {
|
||||
committed: [] as SessionEntry.Entry[],
|
||||
deferred: [] as SessionEntry.Entry[],
|
||||
}
|
||||
}
|
||||
|
||||
function adapterFor(store: ReturnType<typeof adapterStore>): SessionEntryStepper.Adapter<typeof store> {
|
||||
const activeAssistantIndex = () =>
|
||||
store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
|
||||
|
||||
const getCurrentAssistant = () => {
|
||||
const index = activeAssistantIndex()
|
||||
if (index < 0) return
|
||||
const assistant = store.committed[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
getCurrentAssistant,
|
||||
updateAssistant(assistant) {
|
||||
const index = activeAssistantIndex()
|
||||
if (index < 0) return
|
||||
const current = store.committed[index]
|
||||
if (current?.type !== "assistant") return
|
||||
store.committed[index] = assistant
|
||||
},
|
||||
appendEntry(entry) {
|
||||
store.committed.push(entry)
|
||||
},
|
||||
appendPending(entry) {
|
||||
store.deferred.push(entry)
|
||||
},
|
||||
finish() {
|
||||
return store
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("session-entry-stepper", () => {
|
||||
describe("stepWith", () => {
|
||||
test("reduces through a custom adapter", () => {
|
||||
const store = adapterStore()
|
||||
store.committed.push(assistant())
|
||||
|
||||
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) }))
|
||||
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) }))
|
||||
SessionEntryStepper.stepWith(
|
||||
adapterFor(store),
|
||||
SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }),
|
||||
)
|
||||
SessionEntryStepper.stepWith(
|
||||
adapterFor(store),
|
||||
SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }),
|
||||
)
|
||||
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) }))
|
||||
SessionEntryStepper.stepWith(
|
||||
adapterFor(store),
|
||||
SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }),
|
||||
)
|
||||
SessionEntryStepper.stepWith(
|
||||
adapterFor(store),
|
||||
SessionEvent.Step.Ended.create({
|
||||
reason: "stop",
|
||||
cost: 1,
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
reasoning: 3,
|
||||
cache: {
|
||||
read: 4,
|
||||
write: 5,
|
||||
},
|
||||
},
|
||||
timestamp: time(7),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(store.deferred).toHaveLength(1)
|
||||
expect(store.deferred[0]?.type).toBe("user")
|
||||
expect(store.committed).toHaveLength(1)
|
||||
expect(store.committed[0]?.type).toBe("assistant")
|
||||
if (store.committed[0]?.type !== "assistant") return
|
||||
|
||||
expect(store.committed[0].content).toEqual([
|
||||
{ type: "reasoning", text: "thought" },
|
||||
{ type: "text", text: "world" },
|
||||
])
|
||||
expect(store.committed[0].time.completed).toEqual(time(7))
|
||||
})
|
||||
|
||||
test("aggregates retry events onto the current assistant", () => {
|
||||
const store = adapterStore()
|
||||
store.committed.push(assistant())
|
||||
|
||||
SessionEntryStepper.stepWith(
|
||||
adapterFor(store),
|
||||
SessionEvent.Retried.create({
|
||||
attempt: 1,
|
||||
error: retryError("rate limited"),
|
||||
timestamp: time(1),
|
||||
}),
|
||||
)
|
||||
SessionEntryStepper.stepWith(
|
||||
adapterFor(store),
|
||||
SessionEvent.Retried.create({
|
||||
attempt: 2,
|
||||
error: retryError("provider overloaded"),
|
||||
timestamp: time(2),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(store.committed[0]?.type).toBe("assistant")
|
||||
if (store.committed[0]?.type !== "assistant") return
|
||||
|
||||
expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
|
||||
})
|
||||
})
|
||||
|
||||
describe("memory", () => {
|
||||
test("tracks and replaces the current assistant", () => {
|
||||
const state = active()
|
||||
const adapter = SessionEntryStepper.memory(state)
|
||||
const current = adapter.getCurrentAssistant()
|
||||
|
||||
expect(current?.type).toBe("assistant")
|
||||
if (!current) return
|
||||
|
||||
adapter.updateAssistant(
|
||||
new SessionEntry.Assistant({
|
||||
...current,
|
||||
content: [new SessionEntry.AssistantText({ type: "text", text: "done" })],
|
||||
time: {
|
||||
...current.time,
|
||||
completed: time(1),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(adapter.getCurrentAssistant()).toBeUndefined()
|
||||
expect(state.entries[0]?.type).toBe("assistant")
|
||||
if (state.entries[0]?.type !== "assistant") return
|
||||
|
||||
expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }])
|
||||
expect(state.entries[0].time.completed).toEqual(time(1))
|
||||
})
|
||||
|
||||
test("appends committed and pending entries", () => {
|
||||
const state = memoryState()
|
||||
const adapter = SessionEntryStepper.memory(state)
|
||||
const committed = SessionEntry.User.fromEvent(
|
||||
SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }),
|
||||
)
|
||||
const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) }))
|
||||
|
||||
adapter.appendEntry(committed)
|
||||
adapter.appendPending(pending)
|
||||
|
||||
expect(state.entries).toEqual([committed])
|
||||
expect(state.pending).toEqual([pending])
|
||||
})
|
||||
|
||||
test("stepWith through memory records reasoning", () => {
|
||||
const state = active()
|
||||
|
||||
SessionEntryStepper.stepWith(
|
||||
SessionEntryStepper.memory(state),
|
||||
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
|
||||
)
|
||||
SessionEntryStepper.stepWith(
|
||||
SessionEntryStepper.memory(state),
|
||||
SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }),
|
||||
)
|
||||
SessionEntryStepper.stepWith(
|
||||
SessionEntryStepper.memory(state),
|
||||
SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }),
|
||||
)
|
||||
|
||||
expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }])
|
||||
})
|
||||
|
||||
test("stepWith through memory records retries", () => {
|
||||
const state = active()
|
||||
|
||||
SessionEntryStepper.stepWith(
|
||||
SessionEntryStepper.memory(state),
|
||||
SessionEvent.Retried.create({
|
||||
attempt: 1,
|
||||
error: retryError("rate limited"),
|
||||
timestamp: time(1),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)])
|
||||
})
|
||||
})
|
||||
|
||||
describe("step", () => {
|
||||
describe("seeded pending assistant", () => {
|
||||
test("stores prompts in entries when no assistant is pending", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(word, (body) => {
|
||||
const next = SessionEntryStepper.step(
|
||||
memoryState(),
|
||||
SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
|
||||
)
|
||||
expect(next.entries).toHaveLength(1)
|
||||
expect(next.entries[0]?.type).toBe("user")
|
||||
if (next.entries[0]?.type !== "user") return
|
||||
expect(next.entries[0].text).toBe(body)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("stores prompts in pending when an assistant is pending", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(word, (body) => {
|
||||
const next = SessionEntryStepper.step(
|
||||
active(),
|
||||
SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
|
||||
)
|
||||
expect(next.pending).toHaveLength(1)
|
||||
expect(next.pending[0]?.type).toBe("user")
|
||||
if (next.pending[0]?.type !== "user") return
|
||||
expect(next.pending[0].text).toBe(body)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("accumulates text deltas on the latest text part", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(texts, (parts) => {
|
||||
const next = parts.reduce(
|
||||
(state, part, i) =>
|
||||
SessionEntryStepper.step(
|
||||
state,
|
||||
SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }),
|
||||
),
|
||||
SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
|
||||
)
|
||||
|
||||
expect(texts_of(next)).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: parts.join(""),
|
||||
},
|
||||
])
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
})
|
||||
|
||||
test("routes later text deltas to the latest text segment", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(texts, texts, (a, b) => {
|
||||
const next = run(
|
||||
[
|
||||
SessionEvent.Text.Started.create({ timestamp: time(1) }),
|
||||
...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
|
||||
SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
|
||||
...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
|
||||
],
|
||||
active(),
|
||||
)
|
||||
|
||||
expect(texts_of(next)).toEqual([
|
||||
{ type: "text", text: a.join("") },
|
||||
{ type: "text", text: b.join("") },
|
||||
])
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("reasoning.ended replaces buffered reasoning text", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(texts, text, (parts, end) => {
|
||||
const next = run(
|
||||
[
|
||||
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
|
||||
...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
|
||||
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
|
||||
],
|
||||
active(),
|
||||
)
|
||||
|
||||
expect(reasons(next)).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: end,
|
||||
},
|
||||
])
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
})
|
||||
|
||||
test("tool.success completes the latest running tool", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(
|
||||
word,
|
||||
word,
|
||||
dict,
|
||||
maybe(text),
|
||||
maybe(dict),
|
||||
maybe(files),
|
||||
texts,
|
||||
(callID, title, input, output, metadata, attachments, parts) => {
|
||||
const next = run(
|
||||
[
|
||||
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
||||
...parts.map((x, i) =>
|
||||
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
|
||||
),
|
||||
SessionEvent.Tool.Called.create({
|
||||
callID,
|
||||
tool: "bash",
|
||||
input,
|
||||
provider: { executed: true },
|
||||
timestamp: time(parts.length + 2),
|
||||
}),
|
||||
SessionEvent.Tool.Success.create({
|
||||
callID,
|
||||
title,
|
||||
output,
|
||||
metadata,
|
||||
attachments,
|
||||
provider: { executed: true },
|
||||
timestamp: time(parts.length + 3),
|
||||
}),
|
||||
],
|
||||
active(),
|
||||
)
|
||||
|
||||
const match = tool(next, callID)
|
||||
expect(match?.state.status).toBe("completed")
|
||||
if (match?.state.status !== "completed") return
|
||||
|
||||
expect(match.time.ran).toEqual(time(parts.length + 2))
|
||||
expect(match.state.input).toEqual(input)
|
||||
expect(match.state.output).toBe(output ?? "")
|
||||
expect(match.state.title).toBe(title)
|
||||
expect(match.state.metadata).toEqual(metadata ?? {})
|
||||
expect(match.state.attachments).toEqual(attachments ?? [])
|
||||
},
|
||||
),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("tool.error completes the latest running tool with an error", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
|
||||
const next = run(
|
||||
[
|
||||
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
||||
SessionEvent.Tool.Called.create({
|
||||
callID,
|
||||
tool: "bash",
|
||||
input,
|
||||
provider: { executed: true },
|
||||
timestamp: time(2),
|
||||
}),
|
||||
SessionEvent.Tool.Error.create({
|
||||
callID,
|
||||
error,
|
||||
metadata,
|
||||
provider: { executed: true },
|
||||
timestamp: time(3),
|
||||
}),
|
||||
],
|
||||
active(),
|
||||
)
|
||||
|
||||
const match = tool(next, callID)
|
||||
expect(match?.state.status).toBe("error")
|
||||
if (match?.state.status !== "error") return
|
||||
|
||||
expect(match.time.ran).toEqual(time(2))
|
||||
expect(match.state.input).toEqual(input)
|
||||
expect(match.state.error).toBe(error)
|
||||
expect(match.state.metadata).toEqual(metadata ?? {})
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("tool.success is ignored before tool.called promotes the tool to running", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(word, word, (callID, title) => {
|
||||
const next = run(
|
||||
[
|
||||
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
||||
SessionEvent.Tool.Success.create({
|
||||
callID,
|
||||
title,
|
||||
provider: { executed: true },
|
||||
timestamp: time(2),
|
||||
}),
|
||||
],
|
||||
active(),
|
||||
)
|
||||
const match = tool(next, callID)
|
||||
expect(match?.state).toEqual({
|
||||
status: "pending",
|
||||
input: "",
|
||||
})
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("step.ended copies completion fields onto the pending assistant", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
|
||||
const event = SessionEvent.Step.Ended.create({
|
||||
reason: "stop",
|
||||
cost: 1,
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
reasoning: 3,
|
||||
cache: {
|
||||
read: 4,
|
||||
write: 5,
|
||||
},
|
||||
},
|
||||
timestamp: time(n),
|
||||
})
|
||||
const next = SessionEntryStepper.step(active(), event)
|
||||
const entry = last(next)
|
||||
expect(entry).toBeDefined()
|
||||
if (!entry) return
|
||||
|
||||
expect(entry.time.completed).toEqual(event.timestamp)
|
||||
expect(entry.cost).toBe(event.cost)
|
||||
expect(entry.tokens).toEqual(event.tokens)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("known reducer gaps", () => {
|
||||
test("prompt appends immutably when no assistant is pending", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(word, (body) => {
|
||||
const old = memoryState()
|
||||
const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
||||
expect(old).not.toBe(next)
|
||||
expect(old.entries).toHaveLength(0)
|
||||
expect(next.entries).toHaveLength(1)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("prompt appends immutably when an assistant is pending", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(word, (body) => {
|
||||
const old = active()
|
||||
const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
||||
expect(old).not.toBe(next)
|
||||
expect(old.pending).toHaveLength(0)
|
||||
expect(next.pending).toHaveLength(1)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("step.started creates an assistant consumed by follow-up events", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(texts, (parts) => {
|
||||
const next = run([
|
||||
SessionEvent.Step.Started.create({
|
||||
model: {
|
||||
id: "model",
|
||||
providerID: "provider",
|
||||
},
|
||||
timestamp: time(1),
|
||||
}),
|
||||
SessionEvent.Text.Started.create({ timestamp: time(2) }),
|
||||
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
||||
SessionEvent.Step.Ended.create({
|
||||
reason: "stop",
|
||||
cost: 1,
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
reasoning: 3,
|
||||
cache: {
|
||||
read: 4,
|
||||
write: 5,
|
||||
},
|
||||
},
|
||||
timestamp: time(parts.length + 3),
|
||||
}),
|
||||
])
|
||||
const entry = last(next)
|
||||
|
||||
expect(entry).toBeDefined()
|
||||
if (!entry) return
|
||||
|
||||
expect(entry.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: parts.join(""),
|
||||
},
|
||||
])
|
||||
expect(entry.time.completed).toEqual(time(parts.length + 3))
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
})
|
||||
|
||||
test("replays prompt -> step -> text -> step.ended", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(word, texts, (body, parts) => {
|
||||
const next = run([
|
||||
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
|
||||
SessionEvent.Step.Started.create({
|
||||
model: {
|
||||
id: "model",
|
||||
providerID: "provider",
|
||||
},
|
||||
timestamp: time(1),
|
||||
}),
|
||||
SessionEvent.Text.Started.create({ timestamp: time(2) }),
|
||||
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
||||
SessionEvent.Step.Ended.create({
|
||||
reason: "stop",
|
||||
cost: 1,
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
reasoning: 3,
|
||||
cache: {
|
||||
read: 4,
|
||||
write: 5,
|
||||
},
|
||||
},
|
||||
timestamp: time(parts.length + 3),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(next.entries).toHaveLength(2)
|
||||
expect(next.entries[0]?.type).toBe("user")
|
||||
expect(next.entries[1]?.type).toBe("assistant")
|
||||
if (next.entries[1]?.type !== "assistant") return
|
||||
|
||||
expect(next.entries[1].content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: parts.join(""),
|
||||
},
|
||||
])
|
||||
expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(
|
||||
word,
|
||||
texts,
|
||||
text,
|
||||
dict,
|
||||
word,
|
||||
maybe(text),
|
||||
maybe(dict),
|
||||
maybe(files),
|
||||
(body, reason, end, input, title, output, metadata, attachments) => {
|
||||
const callID = "call"
|
||||
const next = run([
|
||||
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
|
||||
SessionEvent.Step.Started.create({
|
||||
model: {
|
||||
id: "model",
|
||||
providerID: "provider",
|
||||
},
|
||||
timestamp: time(1),
|
||||
}),
|
||||
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
|
||||
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
||||
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
|
||||
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
|
||||
SessionEvent.Tool.Called.create({
|
||||
callID,
|
||||
tool: "bash",
|
||||
input,
|
||||
provider: { executed: true },
|
||||
timestamp: time(reason.length + 5),
|
||||
}),
|
||||
SessionEvent.Tool.Success.create({
|
||||
callID,
|
||||
title,
|
||||
output,
|
||||
metadata,
|
||||
attachments,
|
||||
provider: { executed: true },
|
||||
timestamp: time(reason.length + 6),
|
||||
}),
|
||||
SessionEvent.Step.Ended.create({
|
||||
reason: "stop",
|
||||
cost: 1,
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
reasoning: 3,
|
||||
cache: {
|
||||
read: 4,
|
||||
write: 5,
|
||||
},
|
||||
},
|
||||
timestamp: time(reason.length + 7),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(next.entries.at(-1)?.type).toBe("assistant")
|
||||
const entry = next.entries.at(-1)
|
||||
if (entry?.type !== "assistant") return
|
||||
|
||||
expect(entry.content).toHaveLength(2)
|
||||
expect(entry.content[0]).toEqual({
|
||||
type: "reasoning",
|
||||
text: end,
|
||||
})
|
||||
expect(entry.content[1]?.type).toBe("tool")
|
||||
if (entry.content[1]?.type !== "tool") return
|
||||
expect(entry.content[1].state.status).toBe("completed")
|
||||
expect(entry.time.completed).toEqual(time(reason.length + 7))
|
||||
},
|
||||
),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("starting a new step completes the old assistant and appends a new active assistant", () => {
|
||||
const next = run(
|
||||
[
|
||||
SessionEvent.Step.Started.create({
|
||||
model: {
|
||||
id: "model",
|
||||
providerID: "provider",
|
||||
},
|
||||
timestamp: time(1),
|
||||
}),
|
||||
],
|
||||
active(),
|
||||
)
|
||||
expect(next.entries).toHaveLength(2)
|
||||
expect(next.entries[0]?.type).toBe("assistant")
|
||||
expect(next.entries[1]?.type).toBe("assistant")
|
||||
if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
|
||||
|
||||
expect(next.entries[0].time.completed).toEqual(time(1))
|
||||
expect(next.entries[1].time.created).toEqual(time(1))
|
||||
expect(next.entries[1].time.completed).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles sequential tools independently", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
|
||||
const next = run(
|
||||
[
|
||||
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
|
||||
SessionEvent.Tool.Called.create({
|
||||
callID: "a",
|
||||
tool: "bash",
|
||||
input: a,
|
||||
provider: { executed: true },
|
||||
timestamp: time(2),
|
||||
}),
|
||||
SessionEvent.Tool.Success.create({
|
||||
callID: "a",
|
||||
title,
|
||||
output: "done",
|
||||
provider: { executed: true },
|
||||
timestamp: time(3),
|
||||
}),
|
||||
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
|
||||
SessionEvent.Tool.Called.create({
|
||||
callID: "b",
|
||||
tool: "bash",
|
||||
input: b,
|
||||
provider: { executed: true },
|
||||
timestamp: time(5),
|
||||
}),
|
||||
SessionEvent.Tool.Error.create({
|
||||
callID: "b",
|
||||
error,
|
||||
provider: { executed: true },
|
||||
timestamp: time(6),
|
||||
}),
|
||||
],
|
||||
active(),
|
||||
)
|
||||
|
||||
const first = tool(next, "a")
|
||||
const second = tool(next, "b")
|
||||
|
||||
expect(first?.state.status).toBe("completed")
|
||||
if (first?.state.status !== "completed") return
|
||||
expect(first.state.input).toEqual(a)
|
||||
expect(first.state.output).toBe("done")
|
||||
expect(first.state.title).toBe(title)
|
||||
|
||||
expect(second?.state.status).toBe("error")
|
||||
if (second?.state.status !== "error") return
|
||||
expect(second.state.input).toEqual(b)
|
||||
expect(second.state.error).toBe(error)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("routes tool events by callID when tool streams interleave", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
|
||||
const next = run(
|
||||
[
|
||||
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
|
||||
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
|
||||
SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
|
||||
SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
|
||||
SessionEvent.Tool.Called.create({
|
||||
callID: "a",
|
||||
tool: "bash",
|
||||
input: a,
|
||||
provider: { executed: true },
|
||||
timestamp: time(5),
|
||||
}),
|
||||
SessionEvent.Tool.Called.create({
|
||||
callID: "b",
|
||||
tool: "grep",
|
||||
input: b,
|
||||
provider: { executed: true },
|
||||
timestamp: time(6),
|
||||
}),
|
||||
SessionEvent.Tool.Success.create({
|
||||
callID: "a",
|
||||
title: titleA,
|
||||
output: "done-a",
|
||||
provider: { executed: true },
|
||||
timestamp: time(7),
|
||||
}),
|
||||
SessionEvent.Tool.Success.create({
|
||||
callID: "b",
|
||||
title: titleB,
|
||||
output: "done-b",
|
||||
provider: { executed: true },
|
||||
timestamp: time(8),
|
||||
}),
|
||||
],
|
||||
active(),
|
||||
)
|
||||
|
||||
const first = tool(next, "a")
|
||||
const second = tool(next, "b")
|
||||
|
||||
expect(first?.state.status).toBe("completed")
|
||||
expect(second?.state.status).toBe("completed")
|
||||
if (first?.state.status !== "completed" || second?.state.status !== "completed") return
|
||||
|
||||
expect(first.state.input).toEqual(a)
|
||||
expect(second.state.input).toEqual(b)
|
||||
expect(first.state.title).toBe(titleA)
|
||||
expect(second.state.title).toBe(titleB)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("records synthetic events", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(word, (body) => {
|
||||
const next = SessionEntryStepper.step(
|
||||
memoryState(),
|
||||
SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }),
|
||||
)
|
||||
expect(next.entries).toHaveLength(1)
|
||||
expect(next.entries[0]?.type).toBe("synthetic")
|
||||
if (next.entries[0]?.type !== "synthetic") return
|
||||
expect(next.entries[0].text).toBe(body)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
|
||||
test("records compaction events", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
|
||||
const next = SessionEntryStepper.step(
|
||||
memoryState(),
|
||||
SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
|
||||
)
|
||||
expect(next.entries).toHaveLength(1)
|
||||
expect(next.entries[0]?.type).toBe("compaction")
|
||||
if (next.entries[0]?.type !== "compaction") return
|
||||
expect(next.entries[0].auto).toBe(auto)
|
||||
expect(next.entries[0].overflow).toBe(overflow)
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
184
qimingcode/packages/opencode/test/session/session.test.ts
Normal file
184
qimingcode/packages/opencode/test/session/session.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Log } from "../../src/util"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
void Log.init({ print: false })
|
||||
|
||||
function create(input?: SessionNs.CreateInput) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
}
|
||||
|
||||
function get(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
|
||||
function remove(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
}
|
||||
|
||||
function updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
}
|
||||
|
||||
function updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
}
|
||||
|
||||
describe("session.created event", () => {
|
||||
test("should emit session.created event when session is created", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
let eventReceived = false
|
||||
let receivedInfo: SessionNs.Info | undefined
|
||||
|
||||
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
|
||||
eventReceived = true
|
||||
receivedInfo = event.properties.info as SessionNs.Info
|
||||
})
|
||||
|
||||
const info = await create({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
unsub()
|
||||
|
||||
expect(eventReceived).toBe(true)
|
||||
expect(receivedInfo).toBeDefined()
|
||||
expect(receivedInfo?.id).toBe(info.id)
|
||||
expect(receivedInfo?.projectID).toBe(info.projectID)
|
||||
expect(receivedInfo?.directory).toBe(info.directory)
|
||||
expect(receivedInfo?.title).toBe(info.title)
|
||||
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("session.created event should be emitted before session.updated", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const events: string[] = []
|
||||
|
||||
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
|
||||
events.push("created")
|
||||
})
|
||||
|
||||
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
|
||||
events.push("updated")
|
||||
})
|
||||
|
||||
const info = await create({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
unsubCreated()
|
||||
unsubUpdated()
|
||||
|
||||
expect(events).toContain("created")
|
||||
expect(events).toContain("updated")
|
||||
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
|
||||
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("step-finish token propagation via Bus event", () => {
|
||||
test(
|
||||
"non-zero tokens propagate through PartUpdated event",
|
||||
async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const info = await create({})
|
||||
|
||||
const messageID = MessageID.ascending()
|
||||
await updateMessage({
|
||||
id: messageID,
|
||||
sessionID: info.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "user",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
|
||||
// Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part`
|
||||
// is the mutable domain type. Cast bridges the two — safe because the
|
||||
// test only reads the value afterwards.
|
||||
let received: MessageV2.Part | undefined
|
||||
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
|
||||
received = event.properties.part as MessageV2.Part
|
||||
})
|
||||
|
||||
const tokens = {
|
||||
total: 1500,
|
||||
input: 500,
|
||||
output: 800,
|
||||
reasoning: 200,
|
||||
cache: { read: 100, write: 50 },
|
||||
}
|
||||
|
||||
const partInput = {
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID: info.id,
|
||||
type: "step-finish" as const,
|
||||
reason: "stop",
|
||||
cost: 0.005,
|
||||
tokens,
|
||||
}
|
||||
|
||||
await updatePart(partInput)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toBeDefined()
|
||||
expect(received!.type).toBe("step-finish")
|
||||
const finish = received as MessageV2.StepFinishPart
|
||||
expect(finish.tokens.input).toBe(500)
|
||||
expect(finish.tokens.output).toBe(800)
|
||||
expect(finish.tokens.reasoning).toBe(200)
|
||||
expect(finish.tokens.total).toBe(1500)
|
||||
expect(finish.tokens.cache.read).toBe(100)
|
||||
expect(finish.tokens.cache.write).toBe(50)
|
||||
expect(finish.cost).toBe(0.005)
|
||||
expect(received).not.toBe(partInput)
|
||||
|
||||
unsub()
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session", () => {
|
||||
test("remove works without an instance", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const info = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => create({ title: "remove-without-instance" }),
|
||||
})
|
||||
|
||||
await expect(async () => {
|
||||
await remove(info.id)
|
||||
}).not.toThrow()
|
||||
|
||||
let missing = false
|
||||
await get(info.id).catch(() => {
|
||||
missing = true
|
||||
})
|
||||
|
||||
expect(missing).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Reproducer for snapshot race condition with instant tool execution.
|
||||
*
|
||||
* When the mock LLM returns a tool call response instantly, the AI SDK
|
||||
* processes the tool call and executes the tool (e.g. apply_patch) before
|
||||
* the processor's start-step handler can capture a pre-tool snapshot.
|
||||
* Both the "before" and "after" snapshots end up with the same git tree
|
||||
* hash, so computeDiff returns empty and the session summary shows 0 files.
|
||||
*
|
||||
* This is a real bug: the snapshot system assumes it can capture state
|
||||
* before tools run by hooking into start-step, but the AI SDK executes
|
||||
* tools internally during multi-step processing before emitting events.
|
||||
*/
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Session } from "../../src/session"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionRevert } from "../../src/session/revert"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Log } from "../../src/util"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
// Same layer setup as prompt-effect.test.ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Config } from "../../src/config"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Question } from "../../src/question"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { SystemPrompt } from "../../src/session/system"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import { SessionProcessor } from "../../src/session/processor"
|
||||
import { SessionRunState } from "../../src/session/run-state"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "../../src/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const mcp = Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
status: () => Effect.succeed({}),
|
||||
clients: () => Effect.succeed({}),
|
||||
tools: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
||||
connect: () => Effect.void,
|
||||
disconnect: () => Effect.void,
|
||||
getPrompt: () => Effect.succeed(undefined),
|
||||
readResource: () => Effect.succeed(undefined),
|
||||
startAuth: () => Effect.die("unexpected MCP auth"),
|
||||
authenticate: () => Effect.die("unexpected MCP auth"),
|
||||
finishAuth: () => Effect.die("unexpected MCP auth"),
|
||||
removeAuth: () => Effect.void,
|
||||
supportsOAuth: () => Effect.succeed(false),
|
||||
hasStoredTokens: () => Effect.succeed(false),
|
||||
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
|
||||
}),
|
||||
)
|
||||
|
||||
const lsp = Layer.succeed(
|
||||
LSP.Service,
|
||||
LSP.Service.of({
|
||||
init: () => Effect.void,
|
||||
status: () => Effect.succeed([]),
|
||||
hasClients: () => Effect.succeed(false),
|
||||
touchFile: () => Effect.void,
|
||||
diagnostics: () => Effect.succeed({}),
|
||||
hover: () => Effect.succeed(undefined),
|
||||
definition: () => Effect.succeed([]),
|
||||
references: () => Effect.succeed([]),
|
||||
implementation: () => Effect.succeed([]),
|
||||
documentSymbol: () => Effect.succeed([]),
|
||||
workspaceSymbol: () => Effect.succeed([]),
|
||||
prepareCallHierarchy: () => Effect.succeed([]),
|
||||
incomingCalls: () => Effect.succeed([]),
|
||||
outgoingCalls: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||
const run = SessionRunState.layer.pipe(Layer.provide(status))
|
||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||
|
||||
function makeHttp() {
|
||||
const deps = Layer.mergeAll(
|
||||
Session.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Env.defaultLayer,
|
||||
AgentSvc.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
ProviderSvc.defaultLayer,
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
)
|
||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||
const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps))
|
||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||
return Layer.mergeAll(
|
||||
TestLLMServer.layer,
|
||||
SessionSummary.defaultLayer,
|
||||
SessionPrompt.layer.pipe(
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provideMerge(run),
|
||||
Layer.provideMerge(compact),
|
||||
Layer.provideMerge(proc),
|
||||
Layer.provideMerge(registry),
|
||||
Layer.provideMerge(trunc),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(SystemPrompt.defaultLayer),
|
||||
Layer.provideMerge(deps),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const it = testEffect(makeHttp())
|
||||
|
||||
const providerCfg = (url: string) => ({
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
it.live("tool execution produces non-empty session diff (snapshot race)", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ dir, llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
|
||||
const session = yield* sessions.create({
|
||||
title: "snapshot race test",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
// Use bash tool (always registered) to create a file
|
||||
const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}`
|
||||
yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", {
|
||||
command,
|
||||
description: "create test file",
|
||||
})
|
||||
yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done")
|
||||
|
||||
// Seed user message
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "create the file" }],
|
||||
})
|
||||
|
||||
// Run the agent loop
|
||||
const result = yield* prompt.loop({ sessionID: session.id })
|
||||
expect(result.info.role).toBe("assistant")
|
||||
|
||||
// Verify the file was created
|
||||
const filePath = path.join(dir, "race-test.txt")
|
||||
const fileExists = yield* Effect.promise(() =>
|
||||
fs
|
||||
.access(filePath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
)
|
||||
expect(fileExists).toBe(true)
|
||||
|
||||
// Verify the tool call completed (in the first assistant message)
|
||||
const allMsgs = yield* MessageV2.filterCompactedEffect(session.id)
|
||||
const tool = allMsgs
|
||||
.flatMap((m) => m.parts)
|
||||
.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === "bash")
|
||||
expect(tool?.state.status).toBe("completed")
|
||||
|
||||
// Poll for diff — summarize() is fire-and-forget
|
||||
let diff: Array<{ file: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
diff = yield* summary.diff({ sessionID: session.id })
|
||||
if (diff.length > 0) break
|
||||
yield* Effect.sleep("100 millis")
|
||||
}
|
||||
expect(diff.length).toBeGreaterThan(0)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { Log } from "../../src/util"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
void Log.init({ print: false })
|
||||
|
||||
// Skip tests if no API key is available
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
|
||||
|
||||
// Helper to run test within Instance context
|
||||
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn,
|
||||
})
|
||||
}
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
|
||||
return Effect.runPromise(
|
||||
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
|
||||
)
|
||||
}
|
||||
|
||||
describe("StructuredOutput Integration", () => {
|
||||
test.skipIf(!hasApiKey)(
|
||||
"produces structured output with simple schema",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Structured Output Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 2 + 2? Provide a simple answer.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
answer: { type: "number", description: "The numerical answer" },
|
||||
explanation: { type: "string", description: "Brief explanation" },
|
||||
},
|
||||
required: ["answer"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
expect(typeof result.info.structured).toBe("object")
|
||||
|
||||
const output = result.info.structured as any
|
||||
expect(output.answer).toBe(4)
|
||||
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
"produces structured output with nested objects",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Nested Schema Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Tell me about Anthropic company in a structured format.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
company: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
founded: { type: "number" },
|
||||
},
|
||||
required: ["name", "founded"],
|
||||
},
|
||||
products: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["company"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
const output = result.info.structured as any
|
||||
|
||||
expect(output.company).toBeDefined()
|
||||
expect(output.company.name).toBe("Anthropic")
|
||||
expect(typeof output.company.founded).toBe("number")
|
||||
|
||||
if (output.products) {
|
||||
expect(Array.isArray(output.products)).toBe(true)
|
||||
}
|
||||
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
"works with text outputFormat (default)",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Text Output Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Say hello.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "text",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify no structured output (text mode) and no error
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeUndefined()
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Verify we got a response with parts
|
||||
expect(result.parts.length).toBeGreaterThan(0)
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
"stores outputFormat on user message",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
|
||||
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 1 + 1?",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { type: "number" },
|
||||
},
|
||||
required: ["result"],
|
||||
},
|
||||
retryCount: 3,
|
||||
},
|
||||
})
|
||||
|
||||
// Get all messages from session
|
||||
const messages = yield* sessions.messages({ sessionID: session.id })
|
||||
const userMessage = messages.find((m) => m.info.role === "user")
|
||||
|
||||
// Verify outputFormat was stored on user message
|
||||
expect(userMessage).toBeDefined()
|
||||
if (userMessage?.info.role === "user") {
|
||||
expect(userMessage.info.format).toBeDefined()
|
||||
expect(userMessage.info.format?.type).toBe("json_schema")
|
||||
if (userMessage.info.format?.type === "json_schema") {
|
||||
expect(userMessage.info.format.retryCount).toBe(3)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
60000,
|
||||
)
|
||||
|
||||
test("unit test: StructuredOutputError is properly structured", () => {
|
||||
const error = new MessageV2.StructuredOutputError({
|
||||
message: "Failed to produce valid structured output after 3 attempts",
|
||||
retries: 3,
|
||||
})
|
||||
|
||||
expect(error.name).toBe("StructuredOutputError")
|
||||
expect(error.data.message).toContain("3 attempts")
|
||||
expect(error.data.retries).toBe(3)
|
||||
|
||||
const obj = error.toObject()
|
||||
expect(obj.name).toBe("StructuredOutputError")
|
||||
expect(obj.data.retries).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
describe("structured-output.OutputFormat", () => {
|
||||
test("parses text format", () => {
|
||||
const result = MessageV2.Format.zod.safeParse({ type: "text" })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("text")
|
||||
}
|
||||
})
|
||||
|
||||
test("parses json_schema format with defaults", () => {
|
||||
const result = MessageV2.Format.zod.safeParse({
|
||||
type: "json_schema",
|
||||
schema: { type: "object", properties: { name: { type: "string" } } },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("json_schema")
|
||||
if (result.data.type === "json_schema") {
|
||||
expect(result.data.retryCount).toBe(2) // default value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("parses json_schema format with custom retryCount", () => {
|
||||
const result = MessageV2.Format.zod.safeParse({
|
||||
type: "json_schema",
|
||||
schema: { type: "object" },
|
||||
retryCount: 5,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success && result.data.type === "json_schema") {
|
||||
expect(result.data.retryCount).toBe(5)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects invalid type", () => {
|
||||
const result = MessageV2.Format.zod.safeParse({ type: "invalid" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects json_schema without schema", () => {
|
||||
const result = MessageV2.Format.zod.safeParse({ type: "json_schema" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects negative retryCount", () => {
|
||||
const result = MessageV2.Format.zod.safeParse({
|
||||
type: "json_schema",
|
||||
schema: { type: "object" },
|
||||
retryCount: -1,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("structured-output.StructuredOutputError", () => {
|
||||
test("creates error with message and retries", () => {
|
||||
const error = new MessageV2.StructuredOutputError({
|
||||
message: "Failed to validate",
|
||||
retries: 3,
|
||||
})
|
||||
|
||||
expect(error.name).toBe("StructuredOutputError")
|
||||
expect(error.data.message).toBe("Failed to validate")
|
||||
expect(error.data.retries).toBe(3)
|
||||
})
|
||||
|
||||
test("converts to object correctly", () => {
|
||||
const error = new MessageV2.StructuredOutputError({
|
||||
message: "Test error",
|
||||
retries: 2,
|
||||
})
|
||||
|
||||
const obj = error.toObject()
|
||||
expect(obj.name).toBe("StructuredOutputError")
|
||||
expect(obj.data.message).toBe("Test error")
|
||||
expect(obj.data.retries).toBe(2)
|
||||
})
|
||||
|
||||
test("isInstance correctly identifies error", () => {
|
||||
const error = new MessageV2.StructuredOutputError({
|
||||
message: "Test",
|
||||
retries: 1,
|
||||
})
|
||||
|
||||
expect(MessageV2.StructuredOutputError.isInstance(error)).toBe(true)
|
||||
expect(MessageV2.StructuredOutputError.isInstance({ name: "other" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("structured-output.UserMessage", () => {
|
||||
test("user message accepts outputFormat", () => {
|
||||
const result = MessageV2.User.zod.safeParse({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: SessionID.descending(),
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "default",
|
||||
model: { providerID: "anthropic", modelID: "claude-3" },
|
||||
outputFormat: {
|
||||
type: "json_schema",
|
||||
schema: { type: "object" },
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("user message works without outputFormat (optional)", () => {
|
||||
const result = MessageV2.User.zod.safeParse({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: SessionID.descending(),
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "default",
|
||||
model: { providerID: "anthropic", modelID: "claude-3" },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("structured-output.AssistantMessage", () => {
|
||||
const baseAssistantMessage = {
|
||||
id: MessageID.ascending(),
|
||||
sessionID: SessionID.descending(),
|
||||
role: "assistant" as const,
|
||||
parentID: MessageID.ascending(),
|
||||
modelID: "claude-3",
|
||||
providerID: "anthropic",
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
|
||||
test("assistant message accepts structured", () => {
|
||||
const result = MessageV2.Assistant.zod.safeParse({
|
||||
...baseAssistantMessage,
|
||||
structured: { company: "Anthropic", founded: 2021 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.structured).toEqual({ company: "Anthropic", founded: 2021 })
|
||||
}
|
||||
})
|
||||
|
||||
test("assistant message works without structured_output (optional)", () => {
|
||||
const result = MessageV2.Assistant.zod.safeParse(baseAssistantMessage)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("structured-output.createStructuredOutputTool", () => {
|
||||
test("creates tool with description", () => {
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: { type: "object" },
|
||||
onSuccess: () => {},
|
||||
})
|
||||
|
||||
expect(tool.description).toContain("structured format")
|
||||
})
|
||||
|
||||
test("creates tool with schema as inputSchema", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
company: { type: "string" },
|
||||
founded: { type: "number" },
|
||||
},
|
||||
required: ["company"],
|
||||
}
|
||||
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema,
|
||||
onSuccess: () => {},
|
||||
})
|
||||
|
||||
// AI SDK wraps schema in { jsonSchema: {...} }
|
||||
expect(tool.inputSchema).toBeDefined()
|
||||
const inputSchema = tool.inputSchema as any
|
||||
expect(inputSchema.jsonSchema?.properties?.company).toBeDefined()
|
||||
expect(inputSchema.jsonSchema?.properties?.founded).toBeDefined()
|
||||
})
|
||||
|
||||
test("strips $schema property from inputSchema", () => {
|
||||
const schema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
}
|
||||
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema,
|
||||
onSuccess: () => {},
|
||||
})
|
||||
|
||||
// AI SDK wraps schema in { jsonSchema: {...} }
|
||||
const inputSchema = tool.inputSchema as any
|
||||
expect(inputSchema.jsonSchema?.$schema).toBeUndefined()
|
||||
})
|
||||
|
||||
test("execute calls onSuccess with valid args", async () => {
|
||||
let capturedOutput: unknown
|
||||
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: { type: "object", properties: { name: { type: "string" } } },
|
||||
onSuccess: (output) => {
|
||||
capturedOutput = output
|
||||
},
|
||||
})
|
||||
|
||||
expect(tool.execute).toBeDefined()
|
||||
const testArgs = { name: "Test Company" }
|
||||
const result = await tool.execute!(testArgs, {
|
||||
toolCallId: "test-call-id",
|
||||
messages: [],
|
||||
abortSignal: undefined as any,
|
||||
})
|
||||
|
||||
expect(capturedOutput).toEqual(testArgs)
|
||||
expect(result.output).toBe("Structured output captured successfully.")
|
||||
expect(result.metadata.valid).toBe(true)
|
||||
})
|
||||
|
||||
test("AI SDK validates schema before execute - missing required field", async () => {
|
||||
// Note: The AI SDK validates the input against the schema BEFORE calling execute()
|
||||
// So invalid inputs never reach the tool's execute function
|
||||
// This test documents the expected schema behavior
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name", "age"],
|
||||
},
|
||||
onSuccess: () => {},
|
||||
})
|
||||
|
||||
// The schema requires both 'name' and 'age'
|
||||
expect(tool.inputSchema).toBeDefined()
|
||||
const inputSchema = tool.inputSchema as any
|
||||
expect(inputSchema.jsonSchema?.required).toContain("name")
|
||||
expect(inputSchema.jsonSchema?.required).toContain("age")
|
||||
})
|
||||
|
||||
test("AI SDK validates schema types before execute - wrong type", async () => {
|
||||
// Note: The AI SDK validates the input against the schema BEFORE calling execute()
|
||||
// So invalid inputs never reach the tool's execute function
|
||||
// This test documents the expected schema behavior
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "number" },
|
||||
},
|
||||
required: ["count"],
|
||||
},
|
||||
onSuccess: () => {},
|
||||
})
|
||||
|
||||
// The schema defines 'count' as a number
|
||||
expect(tool.inputSchema).toBeDefined()
|
||||
const inputSchema = tool.inputSchema as any
|
||||
expect(inputSchema.jsonSchema?.properties?.count?.type).toBe("number")
|
||||
})
|
||||
|
||||
test("execute handles nested objects", async () => {
|
||||
let capturedOutput: unknown
|
||||
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
email: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
required: ["user"],
|
||||
},
|
||||
onSuccess: (output) => {
|
||||
capturedOutput = output
|
||||
},
|
||||
})
|
||||
|
||||
// Valid nested object - AI SDK validates before calling execute()
|
||||
const validResult = await tool.execute!(
|
||||
{ user: { name: "John", email: "john@test.com" } },
|
||||
{
|
||||
toolCallId: "test-call-id",
|
||||
messages: [],
|
||||
abortSignal: undefined as any,
|
||||
},
|
||||
)
|
||||
|
||||
expect(capturedOutput).toEqual({ user: { name: "John", email: "john@test.com" } })
|
||||
expect(validResult.metadata.valid).toBe(true)
|
||||
|
||||
// Verify schema has correct nested structure
|
||||
const inputSchema = tool.inputSchema as any
|
||||
expect(inputSchema.jsonSchema?.properties?.user?.type).toBe("object")
|
||||
expect(inputSchema.jsonSchema?.properties?.user?.properties?.name?.type).toBe("string")
|
||||
expect(inputSchema.jsonSchema?.properties?.user?.required).toContain("name")
|
||||
})
|
||||
|
||||
test("execute handles arrays", async () => {
|
||||
let capturedOutput: unknown
|
||||
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["tags"],
|
||||
},
|
||||
onSuccess: (output) => {
|
||||
capturedOutput = output
|
||||
},
|
||||
})
|
||||
|
||||
// Valid array - AI SDK validates before calling execute()
|
||||
const validResult = await tool.execute!(
|
||||
{ tags: ["a", "b", "c"] },
|
||||
{
|
||||
toolCallId: "test-call-id",
|
||||
messages: [],
|
||||
abortSignal: undefined as any,
|
||||
},
|
||||
)
|
||||
|
||||
expect(capturedOutput).toEqual({ tags: ["a", "b", "c"] })
|
||||
expect(validResult.metadata.valid).toBe(true)
|
||||
|
||||
// Verify schema has correct array structure
|
||||
const inputSchema = tool.inputSchema as any
|
||||
expect(inputSchema.jsonSchema?.properties?.tags?.type).toBe("array")
|
||||
expect(inputSchema.jsonSchema?.properties?.tags?.items?.type).toBe("string")
|
||||
})
|
||||
|
||||
test("toModelOutput returns text value", async () => {
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: { type: "object" },
|
||||
onSuccess: () => {},
|
||||
})
|
||||
|
||||
expect(tool.toModelOutput).toBeDefined()
|
||||
const modelOutput = await Promise.resolve(
|
||||
tool.toModelOutput!({
|
||||
toolCallId: "test-call-id",
|
||||
input: {},
|
||||
output: {
|
||||
output: "Test output",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(modelOutput.type).toBe("text")
|
||||
if (modelOutput.type !== "text") throw new Error("expected text model output")
|
||||
expect(modelOutput.value).toBe("Test output")
|
||||
})
|
||||
|
||||
// Note: Retry behavior is handled by the AI SDK and the prompt loop, not the tool itself
|
||||
// The tool simply calls onSuccess when execute() is called with valid args
|
||||
// See prompt.ts loop() for actual retry logic
|
||||
})
|
||||
69
qimingcode/packages/opencode/test/session/system.test.ts
Normal file
69
qimingcode/packages/opencode/test/session/system.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SystemPrompt } from "../../src/session/system"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
|
||||
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
|
||||
}
|
||||
|
||||
describe("session.system", () => {
|
||||
test("skills output is sorted by name and stable across calls", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
for (const [name, description] of [
|
||||
["zeta-skill", "Zeta skill."],
|
||||
["alpha-skill", "Alpha skill."],
|
||||
["middle-skill", "Middle skill."],
|
||||
]) {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", name)
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
|
||||
# ${name}
|
||||
`,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
const runSkills = Effect.gen(function* () {
|
||||
const svc = yield* SystemPrompt.Service
|
||||
return yield* svc.skills(build!)
|
||||
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
|
||||
|
||||
const first = await Effect.runPromise(runSkills)
|
||||
const second = await Effect.runPromise(runSkills)
|
||||
|
||||
expect(first).toBe(second)
|
||||
|
||||
const alpha = first!.indexOf("<name>alpha-skill</name>")
|
||||
const middle = first!.indexOf("<name>middle-skill</name>")
|
||||
const zeta = first!.indexOf("<name>zeta-skill</name>")
|
||||
|
||||
expect(alpha).toBeGreaterThan(-1)
|
||||
expect(middle).toBeGreaterThan(alpha)
|
||||
expect(zeta).toBeGreaterThan(middle)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user