Skip to content

[Bug]: fallback freeform wrappers stream raw JSON deltas then finish with unwrapped input #5047

Closed

Description

@Ingwannu

Client or integration

Streaming Responses clients using freeform custom tools.

Area

SSE custom-tool input projection.

Summary

The fallback wrappers accepted by #4983 are repaired only at completion. Partial input handling in src/bridge/sse.ts recognizes and holds only the compact {"input":" prefix.

For a newly accepted wrapper such as {"code":"const x = 1"}, the bridge streams raw JSON through response.custom_tool_call_input.delta, then publishes only const x = 1 in the done/completed item. Concatenated deltas no longer equal the authoritative final input, creating a mid-stream rewind for clients that render or accumulate tool input.

Expected behaviour

A supported wrapper must have one stable streaming representation. Hold recognized fallback-wrapper prefixes until repair is resolved, or emit deltas whose concatenation equals the final repaired input. Unsupported/ordinary freeform inputs must keep current byte behavior.

Required regressions

  • delta concatenation equals done/completed input for every accepted fallback key;
  • a wrapper split at every byte boundary does not leak raw JSON then rewind;
  • ambiguous or multi-field objects stay unrepaired and byte-exact; and
  • cancellation/EOF releases any held buffer without manufacturing a successful tool call.

Regression origin

Merged in #4983; current dev still defines only FREEFORM_WRAP_PREFIX = '{"input":"' for partial streaming.

Activity

  1. added
    bugSomething isn't working
    streamingSSE, WebSocket, terminal stream frames
    toolstool_calls, MCP, web-search / sidecar tools
    on Sep 18, 2026
  2. lidge-jun commented on Sep 18, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 72 / 80

    이 이슈는 #4983이 열어 준 “fallback freeform wrapper” 수리가 완료 시점에는 되는데, 스트리밍 중간에는 아직 옛 규칙만 남는다는 불일치를 가리킵니다. 지금 dev HEAD(facd2b6ca, tip #5040, package 2.59.0)의 src/bridge/sse.ts를 보면, 부분 입력 스트리밍용 FREEFORM_WRAP_PREFIX가 여전히 딱 한 가지입니다.

    const FREEFORM_WRAP_PREFIX = '{"input":"';
    

    즉 {"input":"..."} 형태만 중간에 벗겨서 response.custom_tool_call_input.delta로 흘리고, {"code":"const x = 1"} 같은 #4983이 새로 인정한 wrapper는 prefix가 안 맞아서 원본 JSON 바이트가 그대로 delta로 나갑니다. 그런데 스트림이 끝나면 repair가 적용되어 done/completed item에는 const x = 1만 남습니다. 클라이언트가 delta를 이어 붙이면 “중간에 보던 문자열”과 “최종 권위 입력”이 달라지고, UI나 누적기가 한 번 rewind하는 것처럼 보입니다. 이슈가 말한 mid-stream rewind입니다.

    기대 동작도 분명합니다. 지원하는 wrapper는 스트리밍 표현이 하나여야 합니다. (1) 인식된 fallback prefix를 repair가 끝날 때까지 홀드하거나, (2) delta를 이어 붙인 결과가 최종 repaired input과 같게 내보내야 합니다. 지원하지 않는 일반 freeform은 지금처럼 바이트 그대로여야 하고, 애매한 다필드 객체는 수리하지 않은 채 바이트 정확을 유지해야 합니다. 취소/EOF 때 홀드 버퍼를 성공 tool call로 만들어 내면 안 됩니다.

    회귀 목록도 실무에 바로 쓸 수 있습니다. 허용된 fallback key마다 delta 이어붙임 = done 입력, 바이트 경계마다 쪼개도 raw JSON 누수 후 rewind 없음, 다필드/애매 객체는 unrepaired, cancel/EOF는 held buffer 해제만. types.ts/config.ts 분할과 무관하고, 이미 merge된 #4983의 후속 구멍이라 중복 close 대상이 아닙니다. 별도 좁은 fix PR이 맞습니다.

    라인 155-158 (src/bridge/sse.ts) - FREEFORM_WRAP_PREFIX가 compact {"input":"만 본다. #4983이 완료 경로에서 인정한 code 등 fallback은 여기 없다.
    라인 140-149 (src/bridge/sse.ts, freeformInput) - 완료 쪽은 resolveCodeModeHelperName / repairFreeformToolInput으로 수리하지만, partial 경로(freeformPartialInput)는 prefix 한 줄에만 의존한다.
    경로 src/server/responses-custom-tool-repair.ts - 완료 수리에는 FREEFORM_WRAP_PREFIX_RE와 fallback 키가 더 넓다. SSE partial과 완료 repair의 허용 집합이 어긋난 상태다.
    경로 #4983 / 현재 tip #5040 - 회귀 원인이 #4983 merge라는 주장은 checkout과 맞다. Ultrafast/catalog 쪽 최근 tip과 무관한 streaming/tools 버그다.

    메인테이너의 판단이 필요한 지점

    • partial을 “홀드 후 한 번에 내보내기”로 갈지, “wrapper별로 progressive unwrap”으로 갈지. 홀드가 구현·UI 지연은 단순하고, progressive는 체감은 좋은데 key마다 파서가 는다.
    • code/patch 등 fallback key 집합을 SSE partial과 custom-tool-repair가 한 모듈에서 공유할지, 아니면 SSE는 의도적으로 input만 유지할지.
    • 클라이언트가 delta를 “미리보기 전용”으로만 쓰는지(codex-rs 주석 방향) vs 누적해 최종로 쓰는지. 누적 클라이언트가 있으면 rewind가 사용자 버그로 보인다.

    너의 추천
    인정하고 좁은 fix PR을 받는다. 완료 repair가 이미 아는 fallback key 목록을 partial hold/unwrap에도 같은 소스에서 읽게 맞추고, 이슈의 네 회귀(이어붙임 일치·바이트 경계·다필드 unrepaired·cancel/EOF)를 테스트로 고정한 뒤 merge. 범위는 sse.ts partial + 공유 prefix/key 헬퍼 정도면 충분하다.

    이 댓글은 grok-bot이 작성했습니다

  3. added a commit that references this issue on Sep 18, 2026
    2da3917
  4. lidge-jun commented on Sep 18, 2026

    @lidge-jun
    Owner

    Fixed on dev by #5053, merged as 2da3917341.

    src/bridge/sse.ts now holds a buffer that is still turning into a recognized wrapper and publishes the repaired body once, instead of streaming the JSON and then unwrapping at completion.

    The split follows what unwrapFreeformToolInput can actually decide. input stays progressive, because it is returned whenever the key is present whatever else the object carries, so its value is decidable from the prefix and can never be retracted. A fallback key is not: it unwraps only when it is the single string field, and a second key can still arrive, so a value emitted early would have to be taken back — the same rewind one step earlier. That is the shape responses-custom-tool-repair.ts already uses for routed passthrough, which is why that path never had the defect.

    One thing the fix had to match rather than invent: repairFreeformToolInput drops the tool name for a namespaced tool that does not own the apply-patch grammar, so the streaming side now drops it on the same condition. Streaming under a vocabulary the completed item does not use would have been the same disagreement in the other direction, introduced by this change rather than found by it.

    All four regressions you listed are covered in tests/adapters/bridge.test.ts: concatenation equals the completed input for every accepted fallback key; all sixteen cuts of a split wrapper leak no raw JSON and never rewind; an ambiguous multi-field object and a non-string value stay byte-exact, the latter proving ordinary bodies still stream immediately; and a stream that dies inside a held wrapper emits no deltas and produces no completed custom_tool_call.

    Recorded and deliberately left open: an input value that is itself a fenced block still streams its fence characters and is stripped at completion. That is a pre-existing divergence, not one of the regressions here, and closing it means either holding every input body that opens with a fence or retracting an emitted value. It deserves its own reasoning rather than being folded in.

  5. Ingwannu commented on Sep 18, 2026

    @Ingwannu
    OwnerAuthor

    Reopening because current dev still rewinds for JSON forms accepted by completion.

    #5053 holds only compact prefixes such as {"code":". Valid JSON with leading whitespace, spaces around separators, or another property before the fallback key is streamed as raw JSON, while completion uses JSON.parse and may unwrap it. The delta sequence therefore still disagrees with the completed input.

    For explicit apply_patch, a compact fallback wrapper can also emit the unwrapped but decorated *** Begin Patch *** body while completion runs repairFreeformToolInput and normalizes delimiters, creating another mismatch.

    Required closure: streaming detection must cover every JSON representation the completion parser accepts, or hold potential objects until completion; and any emitted final fallback value must use the same full repair routine as the completed item. Add every-byte-split tests for whitespace, property order, decorated patch delimiters, ambiguity, EOF and cancellation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions