Compare commits

..
4 changed files with 83 additions and 75 deletions
+11
View File
@@ -99,6 +99,17 @@ export const create = (
const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
// Agents assume JSON returned as text is already an object. mcp.ts folds text content into
// `output` as a string, so parse it for MCP tools without an output schema (registered as `{}`).
const noSchema = tool.output !== undefined && Object.keys(tool.output).length === 0
if (typeof executed.output === "string" && noSchema) {
const trimmed = executed.output.trimStart()
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return JSON.parse(executed.output)
} catch {}
}
}
if (executed.output !== undefined) return executed.output
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
+9 -19
View File
@@ -1,7 +1,6 @@
export * as Generate from "./generate.js"
import { LLM, LLMClient, AIError } from "@opencode/ai"
import { SessionID } from "@opencode/schema/session-id"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform.js"
@@ -59,24 +58,15 @@ export const layer = Layer.effect(
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm
.generate(
LLM.request({
model: resolved.model,
prompt: input.prompt,
// Gateways require session attribution even for a stateless call; no Session is stored.
http: { headers: { "x-opencode-session": SessionID.create() } },
}),
)
.pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
return response.text
})
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import { CodeModeTool } from "@opencode/core/codemode/tool"
import type { Context, Info, Result } from "@opencode/schema/tool"
import { Effect, Schema } from "effect"
// Runs `tools.probe({})` and returns what the program saw as [typeof value, value].
const run = async (output: Info["output"], result: Result) => {
const tool: Info = {
name: "probe",
description: "probe",
input: Schema.Struct({}),
execute: () => Effect.succeed(result),
...(output === undefined ? {} : { output }),
}
const execute = CodeModeTool.create({ tools: new Map([["probe", tool]]) }, () => Effect.succeed(result))
const context = { progress: () => Effect.void } as unknown as Context
const executed = await Effect.runPromise(
execute.execute({ code: "const r = await tools.probe({}); return [typeof r, r]" }, context),
)
return JSON.parse(executed.output.output)
}
// An MCP-shaped result: text content, output mirrors the text, and `{}` for a missing outputSchema.
const mcp = (text: string) => ({ output: text, content: [{ type: "text" as const, text }] })
describe("code mode parses JSON text results from MCP tools without an outputSchema", () => {
test("one JSON object or array text block becomes a value", async () => {
expect(await run({}, mcp('{"issues":[{"id":1}]}'))).toEqual(["object", { issues: [{ id: 1 }] }])
expect(await run({}, mcp(" [1, 2]"))).toEqual(["object", [1, 2]])
})
test("structuredContent is untouched", async () => {
expect(await run({}, { output: { a: 1 }, content: [{ type: "text", text: "ignored" }] })).toEqual([
"object",
{ a: 1 },
])
})
test("a declared output schema is never second-guessed", async () => {
expect(await run({ type: "string" }, mcp('{"a":1}'))).toEqual(["string", '{"a":1}'])
expect(await run(Schema.String, mcp('{"a":1}'))).toEqual(["string", '{"a":1}'])
})
test("tools without any output schema keep their advertised string result", async () => {
expect(await run(undefined, { content: [{ type: "text", text: '{"a":1}' }] })).toEqual(["string", '{"a":1}'])
})
test("primitives and prose stay strings", async () => {
expect(await run({}, mcp("42"))).toEqual(["string", "42"])
expect(await run({}, mcp("null"))).toEqual(["string", "null"])
expect(await run({}, mcp("[INFO] started"))).toEqual(["string", "[INFO] started"])
expect(await run({}, mcp("{not json"))).toEqual(["string", "{not json"])
})
test("multiple text blocks are joined, not parsed", async () => {
const content = [
{ type: "text" as const, text: "Result:" },
{ type: "text" as const, text: '{"a":1}' },
]
expect(await run({}, { output: 'Result:\n{"a":1}', content })).toEqual(["string", 'Result:\n{"a":1}'])
})
})
+1 -56
View File
@@ -1,6 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { TestLLM } from "@opencode/ai/testing"
import { AISDK } from "@opencode/core/aisdk"
@@ -12,7 +11,6 @@ import { ID, Info, Ref } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Npm } from "@opencode/util/npm"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -100,56 +98,3 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
})
}),
)
testEffect(Layer.empty).effect("attributes each stateless completion without creating a stored session", () =>
Effect.gen(function* () {
const sessions: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
const session = request.headers["x-opencode-session"]
if (!session)
return HttpClientResponse.fromWeb(
request,
Response.json(
{
error: { type: "MissingSessionID", message: "Session ID is required" },
},
{ status: 400 },
),
)
sessions.push(session)
return HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({
id: "completion",
object: "chat.completion.chunk",
created: 1,
model: "gemini",
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: "stop" }],
})}\n\ndata: [DONE]\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
}),
),
)
const native = LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(http))))
yield* Effect.gen(function* () {
const generate = yield* Generate.Service
for (let index = 0; index < 2; index++) {
expect(
yield* generate.text({
prompt: "Return exactly OK",
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
}),
).toBe("OK")
}
}).pipe(Effect.provide(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, native)))))
expect(sessions).toHaveLength(2)
expect(sessions[0]).toStartWith("ses_")
expect(sessions[1]).not.toBe(sessions[0])
}),
)