Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 41c0ed4fb7 fix(ai): support streams without finish reasons 2026-08-06 20:36:28 -05:00
18 changed files with 274 additions and 112 deletions
+26 -6
View File
@@ -241,6 +241,8 @@ export interface ParserState {
readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
readonly nextToolIndex: number
readonly outputStarted: boolean
readonly requireFinishReason: boolean
}
// =============================================================================
@@ -707,9 +709,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
Boolean(delta?.content) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
)
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
@@ -749,8 +749,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
const index =
tool.index ?? matched ??
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
const current = tools[index]
const pending = pendingTools[index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
@@ -806,6 +805,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
reasoningEmitted,
latestToolIndex,
nextToolIndex,
outputStarted: state.outputStarted || hasLateContent,
requireFinishReason: state.requireFinishReason,
},
events,
] as const
@@ -836,6 +837,23 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
return events
}
const onHalt = (state: ParserState) =>
Effect.gen(function* () {
if (state.finishReason !== undefined || state.requireFinishReason) return finishEvents(state)
if (!state.outputStarted) return []
if (Object.keys(state.pendingTools).length > 0)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
// Chat has no per-call stop event, so an accepted EOF must finalize every
// accumulated tool input before publishing the synthetic terminal reason.
const finished = yield* ToolStream.finishAll(ADAPTER, state.tools)
return finishEvents({
...state,
tools: finished.tools,
toolCallEvents: finished.events,
finishReason: { normalized: "unknown" },
})
})
// =============================================================================
// Protocol And OpenAI Route
// =============================================================================
@@ -863,9 +881,11 @@ export const protocol = Protocol.make({
reasoningDetailsObserved: false,
reasoningEmitted: false,
nextToolIndex: 0,
outputStarted: false,
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
}),
step,
onHalt: finishEvents,
onHalt,
},
})
+39 -16
View File
@@ -7,7 +7,7 @@ import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import type { Protocol, ProtocolStream } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { ProtocolID, ProviderOptions } from "../schema"
@@ -243,28 +243,56 @@ const incompleteStreamError = (route: string) =>
}),
})
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
const ensureTerminalEvent = (route: string, required: boolean) => (events: Stream.Stream<LLMEvent, AIError>) =>
Stream.suspend(() => {
let terminal = false
let output = false
const fallback = Stream.suspend(() => {
if (terminal) return Stream.empty
if (required || !output) return Stream.fail(incompleteStreamError(route))
// The compatibility override trusts a clean stream end, but it cannot
// recover the provider's omitted reason.
const reason = { normalized: "unknown" as const }
return Stream.make(LLMEvent.stepFinish({ index: 0, reason }), LLMEvent.finish({ reason }))
})
return events.pipe(
Stream.mapEffect((event) => {
if (terminal)
return Effect.fail(
ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal event`),
)
output = true
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
return Effect.succeed(event)
}),
Stream.onEnd(
Effect.suspend(() =>
terminal
? Effect.void
: Effect.fail(incompleteStreamError(route)),
),
),
Stream.concat(fallback),
)
})
type ProtocolEvent<Event> = { readonly type: "event"; readonly event: Event } | { readonly type: "halt" }
const parseProtocolEvents = <Event, State>(
events: Stream.Stream<Event, AIError>,
request: LLMRequest,
protocol: { readonly stream: ProtocolStream<unknown, Event, State> },
) =>
events.pipe(
Stream.map((event): ProtocolEvent<Event> => ({ type: "event", event })),
// A normal halt becomes an in-band parser input so finalization may fail.
Stream.concat(Stream.succeed({ type: "halt" } as const)),
Stream.mapAccumEffect(
() => protocol.stream.initial(request),
(state, event) => {
if (event.type === "event") return protocol.stream.step(state, event.event)
if (!protocol.stream.onHalt) return Effect.succeed([state, []] as const)
const events = protocol.stream.onHalt(state)
return Effect.isEffect(events)
? events.pipe(Effect.map((events) => [state, events] as const))
: Effect.succeed([state, events] as const)
},
),
)
function makeFromTransport<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared> {
@@ -329,14 +357,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
)
return events.pipe(
Stream.mapAccumEffect(
() => protocol.stream.initial(request),
protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
),
return parseProtocolEvents(events, request, protocol).pipe(
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route),
ensureTerminalEvent(route, request.model.compatibility?.requireFinishReason ?? true),
)
},
} satisfies Route<Body, Prepared>
+2 -2
View File
@@ -59,8 +59,8 @@ export interface ProtocolStream<Frame, Event, State> {
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
/** Optional request-completion signal for transports that do not end naturally. */
readonly terminal?: (event: Event) => boolean
/** Optional flush emitted when the framed stream ends. */
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
/** Optional flush emitted when the framed stream ends successfully. */
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent> | Effect.Effect<ReadonlyArray<LLMEvent>, AIError>
}
/**
+23
View File
@@ -138,6 +138,29 @@ describe("llm route", () => {
}),
)
unterminated.effect("synthesizes an unknown finish when a terminal event is not required", () =>
Effect.gen(function* () {
const response = yield* (yield* LLMClient.Service).generate(
LLMRequest.update(request, {
model: updateModel(request.model, { compatibility: { requireFinishReason: false } }),
}),
)
expect(response.text).toBe("partial")
expect(response.finishReason).toEqual({ normalized: "unknown" })
expect(response.events.slice(-2)).toEqual([
{
type: "step-finish",
index: 0,
reason: { normalized: "unknown" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "unknown" }, usage: undefined },
])
}),
)
it.effect("selects routes by model route value", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+90 -4
View File
@@ -40,6 +40,10 @@ const request = LLM.request({
generation: { maxTokens: 20, temperature: 0 },
})
const optionalFinishRequest = LLMRequest.update(request, {
model: LanguageModel.update(model, { compatibility: { requireFinishReason: false } }),
})
describe("OpenAI Chat route", () => {
it.effect("prepares OpenAI Chat payload", () =>
Effect.gen(function* () {
@@ -596,6 +600,20 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("accepts text and usage without a finish reason when configured", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({ role: "assistant", content: "Hello" }),
usageChunk({ prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }),
)
const response = yield* LLMClient.generate(optionalFinishRequest).pipe(Effect.provide(fixedResponse(body)))
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "unknown" })
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 1, totalTokens: 6 })
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
@@ -1145,21 +1163,89 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("fails on malformed stream events", () =>
it.effect("finalizes a streamed tool call without a finish reason when configured", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
role: "assistant",
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
}),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
)
const response = yield* LLMClient.generate(
LLMRequest.update(optionalFinishRequest, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
expect(response.finishReason).toEqual({ normalized: "unknown" })
}),
)
it.effect("settles malformed tool input without a finish reason when configured", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
role: "assistant",
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
}),
)
const response = yield* LLMClient.generate(
LLMRequest.update(optionalFinishRequest, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter(LLMEvent.is.toolInputError)).toMatchObject([
{ id: "call_1", name: "lookup", raw: '{"query"' },
])
expect(response.toolCalls).toEqual([])
expect(response.finishReason).toEqual({ normalized: "unknown" })
}),
)
it.effect("rejects incomplete tool identity without a finish reason when configured", () =>
Effect.gen(function* () {
const body = sseEvents(deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }] }))
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
Effect.provide(fixedResponse(body)),
Effect.flip,
)
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
}),
)
it.effect("rejects an empty stream when a finish reason is not required", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
Effect.provide(fixedResponse(sseEvents())),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
}),
)
it.effect("fails on malformed stream events when a finish reason is not required", () =>
Effect.gen(function* () {
const body = sseEvents(deltaChunk({ content: 123 }))
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
Effect.provide(fixedResponse(body)),
Effect.flip,
)
expect(error.message).toContain("Invalid openai/openai-chat stream event")
}),
)
it.effect("surfaces transport errors that occur mid-stream", () =>
it.effect("surfaces transport errors when a finish reason is not required", () =>
Effect.gen(function* () {
const layer = truncatedStream([
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
])
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(Effect.provide(layer), Effect.flip)
expect(error.message).toContain("Failed to read openai/openai-chat stream")
}),
+6 -9
View File
@@ -94,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
prepare: async (next) => {
const selected =
next.model ??
(await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data))
(options.variant
? await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data)
: undefined)
const model = selected
? {
providerID: selected.providerID,
@@ -106,12 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined
if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
const agent =
next.agent ??
(await client.agent
.list({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data.find((item) => item.mode !== "subagent" && !item.hidden)?.id))
return { model, agent }
return { model, agent: next.agent }
},
}).catch((error) => {
if (!(error instanceof RunTargetError)) throw error
+2 -5
View File
@@ -56,16 +56,13 @@ export async function resolveSessionTarget(input: {
agent: input.agent ?? selected?.agent,
signal: input.signal,
})
if (!selected && (!prepared.agent || !prepared.model)) {
throw new SessionTargetMutationError(new Error("Creating a session requires an agent and model"))
}
const session =
selected ??
(await input.client.session
.create(
{
agent: prepared.agent!,
model: prepared.model!,
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory, workspaceID: location.workspaceID },
},
...requestOptions(input.signal),
+6 -12
View File
@@ -61,11 +61,7 @@ describe("session target resolver", () => {
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
const create = spyOn(client.session, "create").mockImplementation(async (input) => {
order.push("create")
expect(input).toMatchObject({
agent: "prepared",
model: { providerID: "openai", id: "gpt-5" },
location: { directory: "/server", workspaceID: "work_1" },
})
expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } })
return session("ses_fresh", "/server", "work_1")
})
@@ -75,22 +71,20 @@ describe("session target resolver", () => {
prepare: async (input) => {
order.push("prepare")
expect(input.location.workspaceID).toBe("work_1")
return { model: { providerID: "openai", id: "gpt-5" }, agent: "prepared" }
return { model: input.model, agent: "prepared" }
},
})
expect(create).toHaveBeenCalledTimes(1)
expect(order).toEqual(["prepare", "create"])
})
test("requires an explicit agent and model for a fresh Session", async () => {
test("uses the agent resolved by the server for a fresh Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
const create = spyOn(client.session, "create")
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
await expect(resolveSessionTarget({ client, prepare })).rejects.toThrow(
"Creating a session requires an agent and model",
)
expect(create).not.toHaveBeenCalled()
const target = await resolveSessionTarget({ client, prepare })
expect(target.agent).toBe("review")
})
test("does not retry an ambiguous Session creation", async () => {
+3 -3
View File
@@ -120,12 +120,12 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined
readonly title?: string | undefined
readonly agent: Agent.ID
readonly model: Model.Ref
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
}
export type Endpoint5_1Output = Session.Info
export type SessionCreateOperation<E = never> = (input: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type Endpoint5_2Input = {
readonly info: Session.Info
@@ -305,15 +305,15 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Input) =>
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({
payload: {
id: input["id"],
title: input["title"],
agent: input["agent"],
model: input["model"],
location: input["location"],
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -464,17 +464,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
create: (input: SessionCreateInput, requestOptions?: RequestOptions) =>
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>(
{
method: "POST",
path: `/api/session`,
body: {
id: input["id"],
title: input["title"],
agent: input["agent"],
model: input["model"],
location: input["location"],
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
successStatus: 200,
declaredStatuses: [401, 400],
+12 -12
View File
@@ -2436,36 +2436,36 @@ export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null
readonly title?: string | null
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"]
readonly title?: {
readonly id?: string | null
readonly title?: string | null
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"]
readonly agent: {
readonly agent?: {
readonly id?: string | null
readonly title?: string | null
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"]
readonly model: {
readonly model?: {
readonly id?: string | null
readonly title?: string | null
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"]
readonly location?: {
readonly id?: string | null
readonly title?: string | null
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
}
-2
View File
@@ -181,8 +181,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const page = yield* client.session.list({ limit: 10 })
const active = yield* client.session.active()
const created = yield* client.session.create({
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
+2 -6
View File
@@ -454,11 +454,7 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active()
const created = await client.session.create({
agent: "build",
model: { id: "claude", providerID: "anthropic" },
location: { directory: "/tmp/project" },
})
const created = await client.session.create({ location: { directory: "/tmp/project" } })
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({
sessionID: "ses_test",
@@ -532,7 +528,7 @@ test("middleware errors remain declared client errors", async () => {
})
try {
await client.session.create({ agent: "build", model: { id: "claude", providerID: "anthropic" } })
await client.session.create({})
throw new Error("Expected request to fail")
} catch (error) {
expect(isUnauthorizedError(error)).toBe(true)
+5 -5
View File
@@ -340,12 +340,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
runtime.session.create({
id: input.id,
title: input.title,
agent: input.agent,
model: input.model,
id: input?.id,
title: input?.title,
agent: input?.agent,
model: input?.model,
location:
input.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt,
+19 -15
View File
@@ -269,21 +269,25 @@ export function fromPromise(plugin: Plugin) {
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) =>
run(
host.session.create({
id: input.id == null ? undefined : Session.ID.make(input.id),
agent: Agent.ID.make(input.agent),
model: model(input.model),
location:
input.location == null
? undefined
: Location.Ref.make({
directory: AbsolutePath.make(input.location.directory),
workspaceID:
input.location.workspaceID === undefined
? undefined
: Workspace.ID.make(input.location.workspaceID),
}),
}),
host.session.create(
input === undefined
? undefined
: {
id: input.id == null ? undefined : Session.ID.make(input.id),
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
model: input.model == null ? undefined : model(input.model),
location:
input.location == null
? undefined
: Location.Ref.make({
directory: AbsolutePath.make(input.location.directory),
workspaceID:
input.location.workspaceID === undefined
? undefined
: Workspace.ID.make(input.location.workspaceID),
}),
},
),
),
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
prompt: (input) =>
+24
View File
@@ -2440,6 +2440,30 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("continues after an unknown finish containing a local tool call", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Echo this")
yield* TestLLM.push(
TestLLM.complete(
{ reason: { normalized: "unknown" } },
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
),
TestLLM.text("Done", "text-final"),
)
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(executions).toEqual(["hello"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Echo this" },
{ type: "assistant", finish: "unknown", content: [{ type: "tool", state: { status: "completed" } }] },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
])
}),
)
it.effect("reloads a model switch before a tool-driven continuation step", () =>
Effect.gen(function* () {
const session = yield* setup
+3 -3
View File
@@ -151,8 +151,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional),
agent: Agent.ID,
model: Model.Ref,
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
@@ -160,7 +160,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({
identifier: "v2.session.create",
summary: "Create session",
description: "Create a session with an explicit agent and model at the requested location.",
description: "Create a session at the requested location.",
}),
),
)