From 1d5d08f76f7ed028f17a2381514582f520cb2fdf Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 21:57:23 +0800 Subject: [PATCH 1/2] test(llm): cover midstream timeout gaps --- packages/llm/test/transport-timeout.test.ts | 46 ++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/llm/test/transport-timeout.test.ts b/packages/llm/test/transport-timeout.test.ts index b62d05dd99..94de6e2e20 100644 --- a/packages/llm/test/transport-timeout.test.ts +++ b/packages/llm/test/transport-timeout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Cause, Duration, Effect, Exit, Fiber, Option, Stream } from "effect" +import { Cause, Deferred, Duration, Effect, Exit, Fiber, Option, Stream } from "effect" import { FetchHttpClient } from "effect/unstable/http" import * as TestClock from "effect/testing/TestClock" import { LLM, LLMError, LLMEvent } from "../src" @@ -9,7 +9,7 @@ import { LLMClient } from "../src/route" import { testEffect } from "./lib/effect" import { dynamicResponse, fixedResponse, runtimeLayer } from "./lib/http" import { deltaChunk } from "./lib/openai-chunks" -import { sseEvents } from "./lib/sse" +import { sseEvents, sseRaw } from "./lib/sse" const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) @@ -35,6 +35,23 @@ const hangingBody = dynamicResponse((input) => ), ) +const stalledAfterFirstFrame = dynamicResponse((input) => + Effect.sync(() => + input.respond( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + sseRaw(`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`), + ), + ) + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + ), +) + const expectTimeoutExit = (exit: Exit.Exit) => { if (Exit.isSuccess(exit)) { throw new Error(`expected a Timeout failure, stream completed with ${exit.value.length} events`) @@ -118,6 +135,31 @@ describe("http transport timeout", () => { }), ) + testEffect(stalledAfterFirstFrame).effect( + "delivers the first frame before timing out the next inter-frame gap", + () => + Effect.gen(function* () { + const firstFrameDelivered = yield* Deferred.make() + const fiber = yield* LLMClient.stream(request(1000)).pipe( + Stream.tap((event) => + LLMEvent.is.textDelta(event) && event.text === "Hello" + ? Deferred.succeed(firstFrameDelivered, undefined) + : Effect.void, + ), + Stream.runCollect, + Effect.forkChild, + ) + + yield* Deferred.await(firstFrameDelivered) + yield* TestClock.adjust(2000) + yield* Effect.yieldNow + + const exit = fiber.pollUnsafe() + if (exit === undefined) throw new Error("expected the stalled stream to time out") + expectTimeoutExit(exit) + }), + ) + testEffect(fixedResponse(sseEvents(deltaChunk({ role: "assistant", content: "Hello" })))).effect( "completes normally when the stream finishes within the timeout", () => From eadf7d0d6ada9096a7dd164c17b5d6758adc73a9 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 21:59:27 +0800 Subject: [PATCH 2/2] docs: close midstream timeout ticket --- .../issues/03-transport-midstream-stall.md | 21 +++++++++++++------ .../issues/04-f3-subscription-readiness.md | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.scratch/batch-b/issues/03-transport-midstream-stall.md b/.scratch/batch-b/issues/03-transport-midstream-stall.md index 5303984f16..0e57d6a6c7 100644 --- a/.scratch/batch-b/issues/03-transport-midstream-stall.md +++ b/.scratch/batch-b/issues/03-transport-midstream-stall.md @@ -6,10 +6,19 @@ **Evidence:** `.scratch/batch-b/evidence.md#transport-mid-stream-stall` **Branch:** `test/midstream-timeout` **Blocked by:** None(02 已完成) -**Status:** ready-for-agent +**Status:** closed -- [ ] 用 fence 证明 timeout 前合法首帧已经交付给消费者 -- [ ] 用 TestClock 越过下一帧间隔,随后得到现有 Transport/Timeout -- [ ] 不引入真实墙钟 sleep,不改变 timeout 默认值与错误词汇 -- [ ] 完整 `transport-timeout.test.ts` 覆盖保持绿色 -- [ ] 在 `packages/llm` 运行目标测试与 `bun typecheck` +- [x] 用 fence 证明 timeout 前合法首帧已经交付给消费者 +- [x] 用 TestClock 越过下一帧间隔,随后得到现有 Transport/Timeout +- [x] 不引入真实墙钟 sleep,不改变 timeout 默认值与错误词汇 +- [x] 完整 `transport-timeout.test.ts` 覆盖保持绿色 +- [x] 在 `packages/llm` 运行目标测试与 `bun typecheck` + +## 验证证据 + +- 基线:`dev@1a8635400f3f4b7c4985b06f0776e13d6f2e5e05`;分支:`test/midstream-timeout`。 +- 实现提交:`1d5d08f76`;PR:[LeXwDeX/OpenCode-GraphAgent#194](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/194) → `dev`。 +- 公开 seam:`LLMClient.stream(...)` 消费合法 SSE text delta;`Deferred` fence 只在 `text-delta` 的 `text === "Hello"` 已交付时解除,随后 `TestClock` 将 1000ms 帧间 timeout 推进到 2000ms。 +- mutation 红灯:临时绕过 response stream 的 `Stream.timeoutOrElse` 后,首帧 fence 仍解除,但新增场景因 stream 未结束而 0 pass / 1 fail;mutation 已恢复,生产文件无 diff。 +- `cd packages/llm && bun test test/transport-timeout.test.ts`:连续 3 次均为 8 pass、0 fail、16 expect;`bun typecheck`:`tsgo --noEmit`,exit 0。 +- 现有生产实现满足 OpenSpec Requirement 3;无生产代码、timeout 默认值、错误词汇或 provider protocol 修改。 diff --git a/.scratch/batch-b/issues/04-f3-subscription-readiness.md b/.scratch/batch-b/issues/04-f3-subscription-readiness.md index 4ac2da220e..88375cef16 100644 --- a/.scratch/batch-b/issues/04-f3-subscription-readiness.md +++ b/.scratch/batch-b/issues/04-f3-subscription-readiness.md @@ -4,8 +4,8 @@ **Evidence:** `.scratch/batch-b/evidence.md#f3--固定订阅-settle-sleep` **Branch:** `test/goal-readiness` -**Blocked by:** 03(批次串行;代码写集独立) -**Status:** blocked +**Blocked by:** None(03 已完成;代码写集独立) +**Status:** ready-for-agent - [ ] 先证明每个 sleep 等待的具体事件/状态,不用另一个超时数值替换 200ms - [ ] 8 个固定 settle sleeps 全部删除或由同一确定性同步机制取代