mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 18:03:32 +00:00
test(ai): record responses websocket flows (#43660)
This commit is contained in:
parent
6f629c2a9d
commit
a71884dfdf
7 changed files with 612 additions and 5 deletions
|
|
@ -0,0 +1,100 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "openai",
|
||||||
|
"protocol": "openai-responses",
|
||||||
|
"transport": "websocket",
|
||||||
|
"model": "gpt-5.5",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-responses-websocket",
|
||||||
|
"provider:openai",
|
||||||
|
"protocol:openai-responses",
|
||||||
|
"transport:websocket",
|
||||||
|
"tool",
|
||||||
|
"continuation"
|
||||||
|
],
|
||||||
|
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
|
||||||
|
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 0,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "openai",
|
||||||
|
"protocol": "openai-responses",
|
||||||
|
"transport": "websocket",
|
||||||
|
"model": "gpt-5.5",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-responses-websocket",
|
||||||
|
"provider:openai",
|
||||||
|
"protocol:openai-responses",
|
||||||
|
"transport:websocket",
|
||||||
|
"reconnect",
|
||||||
|
"full-context"
|
||||||
|
],
|
||||||
|
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
|
||||||
|
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 0,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 1,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "openai",
|
||||||
|
"protocol": "openai-responses",
|
||||||
|
"transport": "websocket",
|
||||||
|
"model": "gpt-5.5",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-responses-websocket",
|
||||||
|
"provider:openai",
|
||||||
|
"protocol:openai-responses",
|
||||||
|
"transport:websocket",
|
||||||
|
"continuation",
|
||||||
|
"recovery"
|
||||||
|
],
|
||||||
|
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
|
||||||
|
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 0,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 1,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { Socket } from "effect/unstable/socket"
|
||||||
|
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
|
||||||
|
import {
|
||||||
|
LLMClient,
|
||||||
|
WebSocketTransport,
|
||||||
|
type ChannelCheckpoint,
|
||||||
|
type ChannelObservation,
|
||||||
|
type WebSocketChannelExchange,
|
||||||
|
type WebSocketChannelExecutor,
|
||||||
|
type WebSocketConnection,
|
||||||
|
} from "../../src/route.js"
|
||||||
|
import { configure } from "../../src/providers/openai.js"
|
||||||
|
import { decodeJson } from "../../src/protocols/shared.js"
|
||||||
|
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
|
||||||
|
import { recordedTests } from "../recorded-test.js"
|
||||||
|
|
||||||
|
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "openai-responses-websocket",
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
requires: ["OPENAI_API_KEY"],
|
||||||
|
tags: ["transport:websocket"],
|
||||||
|
metadata: { transport: "websocket", model: model.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const observationFrame = (observation: ChannelObservation) => {
|
||||||
|
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
|
||||||
|
return Effect.succeed(observation.frame)
|
||||||
|
return Effect.fail(observation.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||||
|
|
||||||
|
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
|
||||||
|
const makeChannel = Effect.gen(function* () {
|
||||||
|
const constructor = yield* Socket.WebSocketConstructor
|
||||||
|
let connection: WebSocketConnection | undefined
|
||||||
|
let checkpoint: ChannelCheckpoint | undefined
|
||||||
|
let pending: ChannelCheckpoint | undefined
|
||||||
|
let opens = 0
|
||||||
|
const sent: unknown[] = []
|
||||||
|
|
||||||
|
const close = Effect.suspend(() => {
|
||||||
|
const current = connection
|
||||||
|
connection = undefined
|
||||||
|
return current ? current.close : Effect.void
|
||||||
|
})
|
||||||
|
yield* Effect.addFinalizer(() => close)
|
||||||
|
|
||||||
|
const executor: WebSocketChannelExecutor = {
|
||||||
|
execute: (exchange: WebSocketChannelExchange) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!connection) {
|
||||||
|
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
|
||||||
|
Effect.provideService(Socket.WebSocketConstructor, constructor),
|
||||||
|
)
|
||||||
|
opens += 1
|
||||||
|
}
|
||||||
|
const current = connection
|
||||||
|
const create = yield* exchange.driver.create(checkpoint)
|
||||||
|
if (create.mode === "full") checkpoint = undefined
|
||||||
|
pending = undefined
|
||||||
|
sent.push(decodeJson(create.message))
|
||||||
|
yield* current.sendText(create.message)
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
return {
|
||||||
|
frames: current.messages.pipe(
|
||||||
|
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
|
||||||
|
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||||
|
Stream.tap((observation) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (!terminal(observation)) return
|
||||||
|
pending = observation.type === "completed" ? observation.checkpoint : undefined
|
||||||
|
if (observation.type !== "completed") checkpoint = undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Stream.takeUntil(terminal),
|
||||||
|
Stream.mapEffect(observationFrame),
|
||||||
|
),
|
||||||
|
complete: Effect.sync(() => {
|
||||||
|
checkpoint = pending
|
||||||
|
pending = undefined
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
executor,
|
||||||
|
sent,
|
||||||
|
opens: () => opens,
|
||||||
|
reconnect: (preserveCheckpoint = false) =>
|
||||||
|
close.pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending = undefined
|
||||||
|
if (!preserveCheckpoint) checkpoint = undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("OpenAI Responses WebSocket recorded", () => {
|
||||||
|
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const channel = yield* makeChannel
|
||||||
|
const request = LLM.request({
|
||||||
|
id: "recorded_openai_responses_websocket_tool",
|
||||||
|
model,
|
||||||
|
system: "Call get_weather once, then reply exactly: Paris is sunny.",
|
||||||
|
prompt: "What is the weather in Paris?",
|
||||||
|
tools: [weatherTool],
|
||||||
|
generation: { maxTokens: 50 },
|
||||||
|
cache: "none",
|
||||||
|
})
|
||||||
|
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||||
|
const call = first.toolCalls[0]
|
||||||
|
if (!call) yield* Effect.die("Expected get_weather tool call")
|
||||||
|
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
|
||||||
|
const second = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(request, {
|
||||||
|
messages: [
|
||||||
|
...request.messages,
|
||||||
|
first.message,
|
||||||
|
Message.tool({ id: call.id, name: call.name, result: result.result }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ webSocket: channel.executor },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(second.text).toBe("Paris is sunny.")
|
||||||
|
expect(channel.opens()).toBe(1)
|
||||||
|
expect(channel.sent).toHaveLength(2)
|
||||||
|
expect(channel.sent[1]).toMatchObject({
|
||||||
|
previous_response_id: expect.any(String),
|
||||||
|
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const channel = yield* makeChannel
|
||||||
|
const request = LLM.request({
|
||||||
|
id: "recorded_openai_responses_websocket_reconnect",
|
||||||
|
model,
|
||||||
|
system: "Follow the user's exact reply instruction.",
|
||||||
|
prompt: "Reply exactly: Alpha.",
|
||||||
|
generation: { maxTokens: 30 },
|
||||||
|
cache: "none",
|
||||||
|
})
|
||||||
|
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||||
|
yield* channel.reconnect()
|
||||||
|
const second = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(request, {
|
||||||
|
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
|
||||||
|
}),
|
||||||
|
{ webSocket: channel.executor },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(first.text).toBe("Alpha.")
|
||||||
|
expect(second.text).toBe("Beta.")
|
||||||
|
expect(channel.opens()).toBe(2)
|
||||||
|
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
|
||||||
|
expect(channel.sent[1]).toMatchObject({
|
||||||
|
input: [
|
||||||
|
{ role: "system", content: "Follow the user's exact reply instruction." },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const channel = yield* makeChannel
|
||||||
|
const request = LLM.request({
|
||||||
|
id: "recorded_openai_responses_websocket_rejection",
|
||||||
|
model,
|
||||||
|
system: "Follow the user's exact reply instruction.",
|
||||||
|
prompt: "Reply exactly: Ready.",
|
||||||
|
generation: { maxTokens: 30 },
|
||||||
|
cache: "none",
|
||||||
|
})
|
||||||
|
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||||
|
const continuation = LLMRequest.update(request, {
|
||||||
|
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
|
||||||
|
})
|
||||||
|
yield* channel.reconnect(true)
|
||||||
|
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
|
||||||
|
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
|
||||||
|
|
||||||
|
expect(rejected).toMatchObject({
|
||||||
|
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
|
||||||
|
})
|
||||||
|
expect(recovered.text).toBe("Recovered.")
|
||||||
|
expect(channel.opens()).toBe(2)
|
||||||
|
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
|
||||||
|
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
|
||||||
|
expect(channel.sent[2]).toMatchObject({
|
||||||
|
input: [
|
||||||
|
{ role: "system", content: "Follow the user's exact reply instruction." },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||||
|
import { NodeSocket } from "@effect/platform-node"
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
|
import { Socket } from "effect/unstable/socket"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||||
|
|
@ -16,7 +18,7 @@ import {
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||||
|
|
||||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
|
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
|
||||||
|
|
||||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||||
readonly options?: HttpRecorder.RecorderOptions
|
readonly options?: HttpRecorder.RecorderOptions
|
||||||
|
|
@ -69,7 +71,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||||
...metadata,
|
...metadata,
|
||||||
}
|
}
|
||||||
if (recording) {
|
if (recording) {
|
||||||
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
|
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
|
||||||
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
|
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
|
||||||
}
|
}
|
||||||
const requestExecutor = RequestExecutor.layer.pipe(
|
const requestExecutor = RequestExecutor.layer.pipe(
|
||||||
|
|
@ -81,10 +83,16 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
|
||||||
|
...recorderOptions,
|
||||||
|
directory: FIXTURES_DIR,
|
||||||
|
metadata: recorderMetadata,
|
||||||
|
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
requestExecutor,
|
requestExecutor,
|
||||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||||
|
webSocket,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,11 @@ interface PendingRecordings {
|
||||||
}
|
}
|
||||||
type Frame = string | Uint8Array
|
type Frame = string | Uint8Array
|
||||||
|
|
||||||
const normalizeProtocols = (protocols?: string | Array<string>): Array<string> =>
|
const normalizeProtocols = (protocols: unknown): Array<string> => {
|
||||||
protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols]
|
if (typeof protocols === "string") return [protocols]
|
||||||
|
if (Array.isArray(protocols)) return protocols.filter((protocol): protocol is string => typeof protocol === "string")
|
||||||
|
return []
|
||||||
|
}
|
||||||
const frameFromWebSocketData = async (data: unknown): Promise<Frame> => {
|
const frameFromWebSocketData = async (data: unknown): Promise<Frame> => {
|
||||||
if (typeof data === "string") return data
|
if (typeof data === "string") return data
|
||||||
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer())
|
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer())
|
||||||
|
|
@ -371,7 +374,7 @@ const makeRecordingWebSocketConstructor = (
|
||||||
return (url, protocols) => {
|
return (url, protocols) => {
|
||||||
const sequence = nextSequence++
|
const sequence = nextSequence++
|
||||||
const requestedProtocols = normalizeProtocols(protocols)
|
const requestedProtocols = normalizeProtocols(protocols)
|
||||||
const native = upstream(url, requestedProtocols)
|
const native = Reflect.apply(upstream, undefined, [url, protocols])
|
||||||
const events: WebSocketEvent[] = []
|
const events: WebSocketEvent[] = []
|
||||||
let opened = false
|
let opened = false
|
||||||
let failed = false
|
let failed = false
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,38 @@ describe("WebSocket", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("constructor recording forwards handshake options", async () => {
|
||||||
|
using directory = tempDirectory("http-recorder-websocket-constructor-")
|
||||||
|
let received: unknown
|
||||||
|
const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-options", {
|
||||||
|
directory: directory.path,
|
||||||
|
}).pipe(
|
||||||
|
Layer.provide(
|
||||||
|
Layer.succeed(Socket.WebSocketConstructor, (url, options) => {
|
||||||
|
received = options
|
||||||
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the fixture implements the WebSocket surface used by the recorder.
|
||||||
|
return new EchoWebSocket(url) as unknown as globalThis.WebSocket
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const constructor = yield* Socket.WebSocketConstructor
|
||||||
|
const options = { headers: { authorization: "Bearer fixture" } }
|
||||||
|
const socket = Reflect.apply(constructor, undefined, ["wss://echo.example.test/options", options])
|
||||||
|
yield* Effect.callback<void>((resume) => {
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
socket.close()
|
||||||
|
resume(Effect.void)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).pipe(Effect.scoped, Effect.provide(recorder)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(received).toEqual({ headers: { authorization: "Bearer fixture" } })
|
||||||
|
})
|
||||||
|
|
||||||
test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => {
|
test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => {
|
||||||
using directory = tempDirectory("http-recorder-websocket-constructor-")
|
using directory = tempDirectory("http-recorder-websocket-constructor-")
|
||||||
await seedCassetteDirectory(directory.path, "websocket/constructor", [
|
await seedCassetteDirectory(directory.path, "websocket/constructor", [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue