<!-- task=openai_text_tool_call_boundary plan=1 tag=REVIEW_BUGFIX_TOOLCALL -->

# Plan - REVIEW_BUGFIX_TOOLCALL

## 이 파일을 읽는 구현 에이전트에게

이 계획은 이전 리뷰 WARN의 후속 구현 루프 입력이다. 구현 후 `CODE_REVIEW-local-G04.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우고 active 파일을 그대로 둔 뒤 review ready를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.

구현 중 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 근거로 채우고 멈춘다. 환경/secret/service 준비, 일반 범위 조정, 반복 실패, 증거 공백은 사용자 리뷰 요청이 아니라 `검증 결과` 또는 후속 plan으로 남긴다.

## Archive Evidence Snapshot

- 이전 루프 archive:
  - plan: `agent-task/openai_text_tool_call_boundary/plan_local_G06_0.log`
  - review: `agent-task/openai_text_tool_call_boundary/code_review_local_G06_0.log`
- 판정: WARN (Required 0, Suggested 1, Nit 2)
- Suggested 요약: `apps/edge/internal/openai/chat_handler.go:383-386` — native tool call과 content가 공존할 때 `streamToolTextFilter{pending: message.Content}.FlushNonCandidate()`가 content 안 어디든 후보 힌트(`<tool_call` 또는 `{{`)가 있으면 선행 prose까지 content 전체를 버린다. live stream 경로(`stream.go:200-205`)는 `Append`가 안전 prefix를 방출한 뒤 잔여만 버린다. `{{config}}` 같은 비후보 텍스트만으로도 non-stream 성공 응답 content가 빈 문자열이 된다.
- Nit 요약:
  - `apps/edge/internal/openai/types.go:477,490,712,1089` — `collectTextToolCallMatches`/`collectXMLTextToolCallMatches`/`collectMustacheTextToolCallMatches`/`parseTextToolCallBlock` 체인은 호출자 없는 dead code.
  - `apps/edge/internal/openai/types.go:608` — `close` builtin shadowing. `tool_validation.go:49`의 `toolValidationContractForChatRequest`는 인자 하나를 버리는 passthrough.
- 영향 파일: `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/types.go`, `apps/edge/internal/openai/tool_validation.go`, `apps/edge/internal/openai/server_test.go`
- 이전 루프 검증 evidence: `go test -count=1 -timeout 120s ./apps/edge/internal/openai` PASS(1.575s), `gofmt -l` clean, `go build ./apps/edge/...` OK. `go vet`의 `input_estimator.go:95` 경고는 이번 작업 범위 밖 기존 이슈. dev-runtime smoke는 로컬 변경 미배포로 미실행(이전 plan의 조건부 항목).
- Roadmap carryover: 없음 (non-roadmap dev 회귀 bugfix, Roadmap Targets 섹션 없음)
- 추가 컨텍스트가 필요하면 위 두 archive log 파일만 좁게 읽는다. `agent-task/archive/**` 광범위 탐색은 금지.

## 범위 결정 근거

- 포함: 이전 리뷰의 Suggested 1건 수정과 회귀 테스트, Nit 2건 정리. 모두 `apps/edge/internal/openai` 한 패키지.
- 제외: scanner 파싱 정책 변경, stream buffered 라우팅 정책 변경, dev-runtime smoke(배포 후 별도 작업).
- lane/grade: `local-G04`. 수정이 기계적으로 명확하고 deterministic unit test로 검증 가능하다.

## 구현 체크리스트

- [ ] [REVIEW_BUGFIX_TOOLCALL-1] native tool call과 content 혼합 시 후보 시작 전 prose를 보존하도록 `collectChatCompletionOutput`의 content 정리를 `Append` 기반으로 바꾸고 회귀 테스트를 추가한다.
- [ ] [REVIEW_BUGFIX_TOOLCALL-2] Nit 정리: `findXMLOpenTag`의 `close` shadowing 개명, `toolValidationContractForChatRequest` passthrough 정리, dead text tool-call match 체인을 컴파일/테스트 확인 하에 삭제한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## REVIEW_BUGFIX_TOOLCALL-1 native tool call 혼합 content의 선행 prose 보존

### 문제

`apps/edge/internal/openai/chat_handler.go:383-386`:

```go
if len(req.Tools) > 0 && strings.TrimSpace(message.Content) != "" {
    filter := &streamToolTextFilter{pending: message.Content}
    message.Content = filter.FlushNonCandidate()
}
```

`FlushNonCandidate()`는 pending에 후보 힌트가 있으면 전체를 버린다. 이 호출은 content 전체를 pending으로 넣으므로, 모델이 "설명 prose + raw `<tool_call>` echo"를 내면 prose까지 사라지고, `{{config}}` 같은 비후보 텍스트만 있어도 content가 빈 문자열이 된다. non-stream 경로(`completeChatCompletion`)와 buffered stream 경로 모두 `collectChatCompletionOutput`을 쓰므로 둘 다 영향을 받는다. 변경 전 non-stream은 content를 유지했으므로 이는 새 회귀다.

### 해결 방법

live stream 경로(`stream.go:200-205`)와 같은 의미가 되도록 `Append`로 안전 prefix를 먼저 얻고 잔여만 `FlushNonCandidate()`로 정리한다.

Before:

```go
filter := &streamToolTextFilter{pending: message.Content}
message.Content = filter.FlushNonCandidate()
```

After:

```go
filter := &streamToolTextFilter{}
prefix := filter.Append(message.Content)
message.Content = prefix + filter.FlushNonCandidate()
```

`Append`는 첫 후보 힌트 직전까지(오른쪽 공백 trim) 반환하고 잔여를 pending에 남긴다. 잔여가 후보로 시작하면 `FlushNonCandidate()`는 빈 문자열을 반환하므로 raw 후보 fragment는 여전히 새지 않는다.

### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/chat_handler.go`: `collectChatCompletionOutput`의 native tool call content 정리를 Append 기반으로 교체.
- [ ] `apps/edge/internal/openai/server_test.go`: native tool call + 선행 prose + raw `<tool_call>` echo 혼합 non-stream 회귀 테스트 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: 같은 fixture의 stream(buffered) variant 추가 또는 기존 stream 테스트 확장.

### 테스트 작성

작성한다. Test names:

- `TestChatCompletionsNativeToolCallPreservesLeadingProse`
- `TestChatCompletionsToolsStreamNativeToolCallPreservesLeadingProse`

요구:

- fake run이 `delta`로 "선행 prose\n\n<tool_call>...</tool_call>" content를 주고 `complete` metadata의 `openai_tool_calls`에 valid native tool call을 담는다.
- 응답 content에 선행 prose가 남고, `<tool_call`/`<tool_call`은 응답 어디에도 없다.
- `finish_reason == "tool_calls"`, tool call은 native 것이 유지된다.

### 중간 검증

```bash
go test ./apps/edge/internal/openai -run 'TestChatCompletions(NativeToolCallPreservesLeadingProse|ToolsStreamNativeToolCallPreservesLeadingProse)$'
```

Expected: PASS. prose 보존 + raw 후보 무누출.

## REVIEW_BUGFIX_TOOLCALL-2 Nit 정리

### 문제

- `apps/edge/internal/openai/types.go:608` — `close := strings.IndexByte(...)`가 builtin `close`를 shadowing.
- `apps/edge/internal/openai/tool_validation.go:49` — `toolValidationContractForChatRequest(req, _ strictOutputPolicy)`는 `toolValidationContractFromRequest`를 그대로 반환하는 passthrough.
- `apps/edge/internal/openai/types.go` — `collectTextToolCallMatches`(477), `collectXMLTextToolCallMatches`(490), `collectMustacheTextToolCallMatches`(712), `parseTextToolCallBlock`(1089), `encodeXMLTextToolCallArguments` 중 production/test 호출자가 없는 체인이 dead code로 남음.

### 해결 방법

- `close` 변수를 `closeIdx`로 개명.
- `chat_handler.go:79` 호출부를 `toolValidationContractFromRequest(req)`로 바꾸고 wrapper 함수와 주석을 정리(주석의 buffered 경로 설명은 유지 가치가 있으면 `toolValidationContractFromRequest` 쪽으로 이동).
- dead 체인 삭제 전 `rg -n "<symbol>" apps/ packages/`로 호출자 부재를 재확인하고, 삭제 후 `go build`/`go test`로 확인한다. `textToolCallMatch` 타입 등 공유 타입이 다른 곳에서 쓰이면 타입은 남긴다. 확인 결과 일부 심볼이 실제로 쓰이고 있으면 해당 심볼은 삭제하지 않고 `계획 대비 변경 사항`에 기록한다.

### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/types.go`: `close` 개명, dead 체인 삭제.
- [ ] `apps/edge/internal/openai/tool_validation.go`: passthrough wrapper 정리.
- [ ] `apps/edge/internal/openai/chat_handler.go`: 호출부 갱신.

### 테스트 작성

새 테스트는 만들지 않는다. 기존 패키지 테스트가 회귀 검증이다.

### 중간 검증

```bash
go build ./apps/edge/... && go test ./apps/edge/internal/openai
```

Expected: build OK, PASS. (`go vet`의 `input_estimator.go:95` 경고는 기존 이슈로 이번 범위가 아니다.)

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/edge/internal/openai/chat_handler.go` | REVIEW_BUGFIX_TOOLCALL-1, REVIEW_BUGFIX_TOOLCALL-2 |
| `apps/edge/internal/openai/server_test.go` | REVIEW_BUGFIX_TOOLCALL-1 |
| `apps/edge/internal/openai/types.go` | REVIEW_BUGFIX_TOOLCALL-2 |
| `apps/edge/internal/openai/tool_validation.go` | REVIEW_BUGFIX_TOOLCALL-2 |

## 최종 검증

```bash
go test -count=1 -timeout 60s ./apps/edge/internal/openai
```

Expected: PASS. 신규 테스트 2개가 목록에 나타나고 전체 패키지 테스트가 통과한다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-local-G04.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
