Skip to content

Commit 6e82d3d

Browse files
committed
test: add integration test proving reasoning not round-tripped in multi-turn
Uses TestLLMServer mock to simulate interleaved reasoning model. Second turn request has NO assistant message at all - the entire first turn (including reasoning_content) is missing from history.
1 parent 50ad5c0 commit 6e82d3d

1 file changed

Lines changed: 260 additions & 0 deletions

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { NodeFileSystem } from "@effect/platform-node"
2+
import { expect } from "bun:test"
3+
import { Cause, Effect, Layer } from "effect"
4+
import path from "path"
5+
import { Agent as AgentSvc } from "../../src/agent/agent"
6+
import { Bus } from "../../src/bus"
7+
import { Config } from "../../src/config/config"
8+
import { Permission } from "../../src/permission"
9+
import { Plugin } from "../../src/plugin"
10+
import { Provider } from "../../src/provider/provider"
11+
import { ModelID, ProviderID } from "../../src/provider/schema"
12+
import { Session } from "../../src/session"
13+
import { LLM } from "../../src/session/llm"
14+
import { MessageV2 } from "../../src/session/message-v2"
15+
import { SessionProcessor } from "../../src/session/processor"
16+
import { MessageID, PartID, SessionID } from "../../src/session/schema"
17+
import { SessionStatus } from "../../src/session/status"
18+
import { Snapshot } from "../../src/snapshot"
19+
import { Log } from "../../src/util/log"
20+
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
21+
import { provideTmpdirServer } from "../fixture/fixture"
22+
import { testEffect } from "../lib/effect"
23+
import { TestLLMServer } from "../lib/llm-server"
24+
25+
Log.init({ print: false })
26+
27+
const ref = {
28+
providerID: ProviderID.make("test"),
29+
modelID: ModelID.make("test-reasoner"),
30+
}
31+
32+
const cfg = {
33+
provider: {
34+
test: {
35+
name: "Test",
36+
id: "test",
37+
env: [],
38+
npm: "@ai-sdk/openai-compatible",
39+
models: {
40+
"test-reasoner": {
41+
id: "test-reasoner",
42+
name: "Test Reasoner",
43+
attachment: false,
44+
reasoning: true,
45+
temperature: false,
46+
tool_call: true,
47+
interleaved: { field: "reasoning_content" },
48+
release_date: "2025-01-01",
49+
limit: { context: 100000, output: 10000 },
50+
cost: { input: 0, output: 0 },
51+
options: {},
52+
},
53+
},
54+
options: {
55+
apiKey: "test-key",
56+
baseURL: "http://localhost:1/v1",
57+
},
58+
},
59+
},
60+
}
61+
62+
function providerCfg(url: string) {
63+
return {
64+
...cfg,
65+
provider: {
66+
...cfg.provider,
67+
test: {
68+
...cfg.provider.test,
69+
options: {
70+
...cfg.provider.test.options,
71+
baseURL: url,
72+
},
73+
},
74+
},
75+
}
76+
}
77+
78+
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
79+
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
80+
const deps = Layer.mergeAll(
81+
Session.defaultLayer,
82+
Snapshot.defaultLayer,
83+
AgentSvc.defaultLayer,
84+
Permission.defaultLayer,
85+
Plugin.defaultLayer,
86+
Config.defaultLayer,
87+
LLM.defaultLayer,
88+
Provider.defaultLayer,
89+
status,
90+
).pipe(Layer.provideMerge(infra))
91+
const env = Layer.mergeAll(TestLLMServer.layer, SessionProcessor.layer.pipe(Layer.provideMerge(deps)))
92+
93+
const it = testEffect(env)
94+
95+
const boot = Effect.fn("test.boot")(function* () {
96+
const processors = yield* SessionProcessor.Service
97+
const session = yield* Session.Service
98+
const provider = yield* Provider.Service
99+
return { processors, session, provider }
100+
})
101+
102+
const user = Effect.fn("TestReasoning.user")(function* (sessionID: SessionID, text: string) {
103+
const session = yield* Session.Service
104+
const msg = yield* session.updateMessage({
105+
id: MessageID.ascending(),
106+
role: "user",
107+
sessionID,
108+
agent: "build",
109+
model: ref,
110+
time: { created: Date.now() },
111+
})
112+
yield* session.updatePart({
113+
id: PartID.ascending(),
114+
messageID: msg.id,
115+
sessionID,
116+
type: "text",
117+
text,
118+
})
119+
return msg
120+
})
121+
122+
const assistant = Effect.fn("TestReasoning.assistant")(function* (
123+
sessionID: SessionID,
124+
parentID: MessageID,
125+
root: string,
126+
) {
127+
const session = yield* Session.Service
128+
const msg: MessageV2.Assistant = {
129+
id: MessageID.ascending(),
130+
role: "assistant",
131+
sessionID,
132+
mode: "build",
133+
agent: "build",
134+
path: { cwd: root, root },
135+
cost: 0,
136+
tokens: {
137+
total: 0,
138+
input: 0,
139+
output: 0,
140+
reasoning: 0,
141+
cache: { read: 0, write: 0 },
142+
},
143+
modelID: ref.modelID,
144+
providerID: ref.providerID,
145+
parentID,
146+
time: { created: Date.now() },
147+
finish: "end_turn",
148+
}
149+
yield* session.updateMessage(msg)
150+
return msg
151+
})
152+
153+
it.live(
154+
"BUG: reasoning_content not round-tripped in multi-turn conversation with interleaved model",
155+
() =>
156+
provideTmpdirServer(
157+
({ dir, llm }) =>
158+
Effect.gen(function* () {
159+
const { processors, session, provider } = yield* boot()
160+
161+
yield* llm.reason("thinking step 1", { text: "answer 1", usage: { input: 50, output: 20 } })
162+
yield* llm.text("answer 2")
163+
164+
const chat = yield* session.create({})
165+
166+
const parent1 = yield* user(chat.id, "turn 1")
167+
const msg1 = yield* assistant(chat.id, parent1.id, path.resolve(dir))
168+
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
169+
170+
const handle1 = yield* processors.create({
171+
assistantMessage: msg1,
172+
sessionID: chat.id,
173+
model: mdl,
174+
})
175+
176+
yield* handle1.process({
177+
user: {
178+
id: parent1.id,
179+
sessionID: chat.id,
180+
role: "user",
181+
time: parent1.time,
182+
agent: parent1.agent,
183+
model: { providerID: ref.providerID, modelID: ref.modelID },
184+
} satisfies MessageV2.User,
185+
sessionID: chat.id,
186+
model: mdl,
187+
agent: {
188+
name: "build",
189+
mode: "primary",
190+
options: {},
191+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
192+
},
193+
system: [],
194+
messages: [{ role: "user", content: "turn 1" }],
195+
tools: {},
196+
} satisfies LLM.StreamInput)
197+
198+
const calls1 = yield* llm.calls
199+
expect(calls1).toBe(1)
200+
201+
const parent2 = yield* user(chat.id, "turn 2")
202+
const msg2 = yield* assistant(chat.id, parent2.id, path.resolve(dir))
203+
204+
const handle2 = yield* processors.create({
205+
assistantMessage: msg2,
206+
sessionID: chat.id,
207+
model: mdl,
208+
})
209+
210+
yield* handle2.process({
211+
user: {
212+
id: parent2.id,
213+
sessionID: chat.id,
214+
role: "user",
215+
time: parent2.time,
216+
agent: parent2.agent,
217+
model: { providerID: ref.providerID, modelID: ref.modelID },
218+
} satisfies MessageV2.User,
219+
sessionID: chat.id,
220+
model: mdl,
221+
agent: {
222+
name: "build",
223+
mode: "primary",
224+
options: {},
225+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
226+
},
227+
system: [],
228+
messages: [{ role: "user", content: "turn 2" }],
229+
tools: {},
230+
} satisfies LLM.StreamInput)
231+
232+
const inputs = yield* llm.inputs
233+
expect(inputs.length).toBeGreaterThanOrEqual(2)
234+
235+
const chatInputs = inputs.filter((body: any) => body.messages && !JSON.stringify(body).includes("Generate a title"))
236+
expect(chatInputs.length).toBeGreaterThanOrEqual(2)
237+
238+
const secondRequest = chatInputs[1]
239+
const messages = secondRequest.messages as Array<any>
240+
241+
const assistantMsg = messages.find((m: any) => m.role === "assistant")
242+
243+
expect(assistantMsg).toBeDefined()
244+
245+
const hasReasoningContent =
246+
typeof assistantMsg.reasoning_content === "string" && assistantMsg.reasoning_content.length > 0
247+
248+
const hasReasoningInContent = Array.isArray(assistantMsg.content)
249+
? assistantMsg.content.some(
250+
(c: any) =>
251+
(typeof c === "string" && c.length > 0) ||
252+
(c && typeof c === "object" && c.type === "reasoning"),
253+
)
254+
: typeof assistantMsg.content === "string" && assistantMsg.content.length > 0
255+
256+
expect(hasReasoningContent || hasReasoningInContent).toBe(true)
257+
}),
258+
{ git: true, config: (url) => providerCfg(url) },
259+
),
260+
)

0 commit comments

Comments
 (0)