import { assert, describe, it } from "@effect/vitest" import { Effect, flow, Schema, Stream } from "effect" import { LanguageModel, Prompt, type Response, ResponseIdTracker, Tool, Toolkit } from "effect/unstable/ai" const finishPart: Response.FinishPartEncoded = { type: "finish", reason: "stop", usage: { inputTokens: { uncached: 5, total: 5, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: 5, text: undefined, reasoning: undefined } }, response: undefined } const responseMetadataPart = (id: string): Response.ResponseMetadataPartEncoded => ({ type: "response-metadata", id, modelId: undefined, timestamp: undefined, request: undefined }) const textMessage = (text: string) => Prompt.textPart({ text }) const userMessage = (text: string) => Prompt.userMessage({ content: [textMessage(text)] }) const assistantMessage = (text: string) => Prompt.assistantMessage({ content: [textMessage(text)] }) const ApprovalTool = Tool.make("ApprovalTool", { parameters: Schema.Struct({ action: Schema.String }), success: Schema.Struct({ result: Schema.String }), needsApproval: true }) const ApprovalToolkit = Toolkit.make(ApprovalTool) const ApprovalToolkitLayer = ApprovalToolkit.toLayer({ ApprovalTool: () => Effect.succeed({ result: "approved-result" }) }) describe("LanguageModel tracker lifecycle integration", () => { it.effect("tracks generateText across turns and recovers after session drop", () => Effect.gen(function*() { const tracker = yield* ResponseIdTracker.make const providerOptions: Array = [] const responseIds = ["resp_1", "resp_2", "resp_3", "resp_4"] as const let responseIndex = 0 const languageModel = yield* LanguageModel.make({ generateText: (options) => Effect.sync(() => { const responseId = responseIds[responseIndex]! responseIndex += 1 providerOptions.push(options) return [ responseMetadataPart(responseId), { type: "text", text: `assistant-${responseId}` }, finishPart ] }), streamText: () => Stream.empty }) const provideModel = Effect.provideService(LanguageModel.LanguageModel, languageModel) const system = Prompt.systemMessage({ content: "system" }) const user1 = userMessage("user1") const assistant1 = assistantMessage("assistant1") const user2 = userMessage("user2") const assistant2 = assistantMessage("assistant2") const user3 = userMessage("user3") const assistant3 = assistantMessage("assistant3") const user4 = userMessage("user4") yield* LanguageModel.generateText({ prompt: Prompt.fromMessages([system, user1]) }).pipe(provideModel, Effect.provideService(ResponseIdTracker.ResponseIdTracker, tracker)) const firstSentPrompt = providerOptions[0]!.prompt yield* LanguageModel.generateText({ prompt: Prompt.fromMessages([...firstSentPrompt.content, assistant1, user2]) }).pipe(provideModel, Effect.provideService(ResponseIdTracker.ResponseIdTracker, tracker)) tracker.clearUnsafe() const secondSentPrompt = providerOptions[1]!.prompt yield* LanguageModel.generateText({ prompt: Prompt.fromMessages([...secondSentPrompt.content, assistant2, user3]) }).pipe(provideModel, Effect.provideService(ResponseIdTracker.ResponseIdTracker, tracker)) const thirdSentPrompt = providerOptions[2]!.prompt const expectedPostDropFullPrompt = Prompt.fromMessages([...secondSentPrompt.content, assistant2, user3]) yield* LanguageModel.generateText({ prompt: Prompt.fromMessages([...thirdSentPrompt.content, assistant3, user4]) }).pipe(provideModel, Effect.provideService(ResponseIdTracker.ResponseIdTracker, tracker)) assert.strictEqual(providerOptions.length, 4) assert.strictEqual(providerOptions[0]!.previousResponseId, undefined) assert.strictEqual(providerOptions[0]!.incrementalPrompt, undefined) assert.strictEqual(providerOptions[1]!.previousResponseId, "resp_1") assert.deepStrictEqual(providerOptions[1]!.incrementalPrompt, Prompt.fromMessages([user2])) assert.strictEqual(providerOptions[2]!.previousResponseId, undefined) assert.strictEqual(providerOptions[2]!.incrementalPrompt, undefined) assert.deepStrictEqual(providerOptions[2]!.prompt, expectedPostDropFullPrompt) assert.strictEqual(providerOptions[3]!.previousResponseId, "resp_3") assert.deepStrictEqual(providerOptions[3]!.incrementalPrompt, Prompt.fromMessages([user4])) })) it.effect("falls back to full prompt after stale previous response id and keeps tracking", () => Effect.gen(function*() { const tracker = yield* ResponseIdTracker.make const providerOptions: Array = [] const responseIds = ["resp_object_1", "resp_object_2"] as const let responseIndex = 0 const languageModel = yield* LanguageModel.make({ generateText: (options) => Effect.sync(() => { const responseId = responseIds[responseIndex]! responseIndex += 1 providerOptions.push(options) return [ responseMetadataPart(responseId), { type: "text", text: "{\"count\":1}" }, finishPart ] }), streamText: () => Stream.empty }) const provideModel = flow( Effect.provideService(LanguageModel.LanguageModel, languageModel), Effect.provideService(ResponseIdTracker.ResponseIdTracker, tracker) ) const schema = Schema.Struct({ count: Schema.Number }) const system = Prompt.systemMessage({ content: "system" }) const user1 = userMessage("user1") const assistant1 = assistantMessage("assistant1") const user2 = userMessage("user2") yield* LanguageModel.generateObject({ prompt: Prompt.fromMessages([system, user1]), schema }).pipe(provideModel) const firstSentPrompt = providerOptions[0]!.prompt yield* LanguageModel.generateObject({ prompt: Prompt.fromMessages([...firstSentPrompt.content, assistant1, user2]), schema }).pipe(provideModel) assert.strictEqual(providerOptions.length, 2) assert.strictEqual(providerOptions[0]!.previousResponseId, undefined) assert.strictEqual(providerOptions[0]!.incrementalPrompt, undefined) assert.strictEqual(providerOptions[1]!.previousResponseId, "resp_object_1") assert.deepStrictEqual(providerOptions[1]!.incrementalPrompt, Prompt.fromMessages([user2])) })) it.effect("tracks streamText with metadata parts across turns", () => Effect.gen(function*() { const tracker = yield* ResponseIdTracker.make const providerOptions: Array = [] const responseIds = ["resp_stream_1", "resp_stream_2"] as const let responseIndex = 0 const languageModel = yield* LanguageModel.make({ generateText: () => Effect.succeed([]), streamText: (options) => { const responseId = responseIds[responseIndex]! responseIndex += 1 providerOptions.push(options) return Stream.fromIterable([ responseMetadataPart(responseId), finishPart ]) } }) const provideModel = flow( Effect.provideService(LanguageModel.LanguageModel, languageModel), Effect.provideService(ResponseIdTracker.ResponseIdTracker, tracker) ) const system = Prompt.systemMessage({ content: "system" }) const user1 = userMessage("user1") const assistant1 = assistantMessage("assistant1") const user2 = userMessage("user2") yield* LanguageModel.streamText({ prompt: Prompt.fromMessages([system, user1]) }).pipe( Stream.runDrain, provideModel ) const firstSentPrompt = providerOptions[0]!.prompt yield* LanguageModel.streamText({ prompt: Prompt.fromMessages([...firstSentPrompt.content, assistant1, user2]) }).pipe( Stream.runDrain, provideModel ) assert.strictEqual(providerOptions.length, 2) assert.strictEqual(providerOptions[0]!.previousResponseId, undefined) assert.strictEqual(providerOptions[0]!.incrementalPrompt, undefined) assert.strictEqual(providerOptions[1]!.previousResponseId, "resp_stream_1") assert.deepStrictEqual(providerOptions[1]!.incrementalPrompt, Prompt.fromMessages([user2])) })) it.effect("emits finish parts in streamText when toolkit is provided and no tool calls are returned", () => Effect.gen(function*() { const languageModel = yield* LanguageModel.make({ generateText: () => Effect.succeed([]), streamText: () => Stream.fromIterable([finishPart]) }) const parts = yield* LanguageModel.streamText({ prompt: [userMessage("hello")], toolkit: ApprovalToolkit }).pipe( Stream.runCollect, Effect.map((parts) => Array.from(parts)), Effect.provideService(LanguageModel.LanguageModel, languageModel), Effect.provide(ApprovalToolkitLayer) ) assert.strictEqual(parts.length, 1) assert.strictEqual(parts[0]?.type, "finish") })) it.effect("keeps incremental provider fields undefined when tracker is omitted", () => Effect.gen(function*() { const generateTextOptions: Array = [] const streamTextOptions: Array = [] const languageModel = yield* LanguageModel.make({ generateText: (options) => Effect.sync(() => { generateTextOptions.push(options) return [ responseMetadataPart("resp_none"), { type: "text", text: "{\"count\":1}" }, finishPart ] }), streamText: (options) => { streamTextOptions.push(options) return Stream.fromIterable([ responseMetadataPart("resp_none_stream"), finishPart ]) } }) const provideModel = Effect.provideService(LanguageModel.LanguageModel, languageModel) const schema = Schema.Struct({ count: Schema.Number }) const system = Prompt.systemMessage({ content: "system" }) const user1 = userMessage("user1") const assistant1 = assistantMessage("assistant1") const user2 = userMessage("user2") yield* LanguageModel.generateText({ prompt: [system, user1] }).pipe(provideModel) yield* LanguageModel.generateText({ prompt: [system, user1, assistant1, user2] }).pipe(provideModel) yield* LanguageModel.generateObject({ prompt: [system, user1], schema }).pipe(provideModel) yield* LanguageModel.generateObject({ prompt: [system, user1, assistant1, user2], schema }).pipe(provideModel) yield* LanguageModel.streamText({ prompt: [system, user1] }).pipe( Stream.runDrain, provideModel ) yield* LanguageModel.streamText({ prompt: [system, user1, assistant1, user2] }).pipe( Stream.runDrain, provideModel ) for (const options of [...generateTextOptions, ...streamTextOptions]) { assert.strictEqual(options.previousResponseId, undefined) assert.strictEqual(options.incrementalPrompt, undefined) } })) it.effect("prepares after approval pre-resolution using stripped prompt", () => Effect.gen(function*() { const tracker = yield* ResponseIdTracker.make const providerOptions: Array = [] const responseIds = ["resp_tool_1", "resp_tool_2"] as const let responseIndex = 0 const languageModel = yield* LanguageModel.make({ generateText: (options) => Effect.sync(() => { const responseId = responseIds[responseIndex]! responseIndex += 1 providerOptions.push(options) return [ responseMetadataPart(responseId), finishPart ] }), streamText: () => Stream.empty }) const provideModel = flow( Effect.provideService(LanguageModel.LanguageModel, languageModel), Effect.provideService(ResponseIdTracker.ResponseIdTracker, tracker) ) const system = Prompt.systemMessage({ content: "system" }) const user1 = userMessage("user1") const toolCallId = "call-approval" const approvalId = "approval-1" yield* LanguageModel.generateText({ prompt: Prompt.fromMessages([system, user1]) }).pipe(provideModel) const firstSentPrompt = providerOptions[0]!.prompt const promptWithPendingApproval: Array = [ ...firstSentPrompt.content, Prompt.assistantMessage({ content: [ Prompt.makePart("tool-call", { id: toolCallId, name: "ApprovalTool", params: { action: "delete" }, providerExecuted: false }), Prompt.makePart("tool-approval-request", { approvalId, toolCallId }) ] }), Prompt.toolMessage({ content: [ Prompt.toolApprovalResponsePart({ approvalId, approved: true }) ] }) ] yield* LanguageModel.generateText({ prompt: Prompt.fromMessages(promptWithPendingApproval), toolkit: ApprovalToolkit }).pipe( provideModel, Effect.provide(ApprovalToolkitLayer) ) assert.strictEqual(providerOptions.length, 2) const secondCall = providerOptions[1]! assert.strictEqual(secondCall.previousResponseId, "resp_tool_1") const expectedIncremental = Prompt.fromMessages([ Prompt.toolMessage({ content: [ Prompt.toolResultPart({ id: toolCallId, name: "ApprovalTool", result: { result: "approved-result" }, isFailure: false }) ] }) ]) assert.deepStrictEqual(secondCall.incrementalPrompt, expectedIncremental) for (const message of secondCall.prompt.content) { if (message.role === "assistant") { assert.strictEqual(message.content.filter((part) => part.type === "tool-approval-request").length, 0) } if (message.role === "tool") { assert.strictEqual(message.content.filter((part) => part.type === "tool-approval-response").length, 0) } } })) })