<!-- task=m-model-serving-load-routing/01_node_reasoning_event plan=0 tag=OE2E-NODE -->

# Node Ollama Reasoning Event Plan

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

구현 후 반드시 같은 디렉터리의 `CODE_REVIEW-*-G??.md`에서 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 검증 명령을 실행하고 실제 stdout/stderr를 남긴다. active 파일은 그대로 두고 리뷰 준비 상태를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다.

## 배경

`gemma4:26b`는 Ollama `/api/chat`에서 답변 content 전에 `message.thinking`을 stream할 수 있다. 현재 Node Ollama adapter는 `message.content`만 `delta`로 relay하므로 thinking-only 구간이 빈 응답처럼 보인다. OpenAI-compatible UX에서 thinking을 보존하려면 Node가 content delta와 reasoning delta를 먼저 구분해 Edge로 보내야 한다.

## 분석 결과

### 읽은 파일

- `apps/node/internal/adapters/ollama/ollama.go`
- `apps/node/internal/adapters/ollama/ollama_test.go`
- `apps/node/internal/runtime/types.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`
- `scripts/e2e-openai-ollama.sh`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md`

### 테스트 커버리지 공백

- Ollama content delta stream은 `TestOllamaExecuteStreamsChatDeltas`가 커버한다.
- Ollama `message.thinking` stream은 기존 테스트가 없다.
- `RuntimeEvent`의 새 reasoning event type 관례는 기존 테스트가 없다.

### 심볼 참조

- renamed/removed symbol: none.
- 새 symbol 후보: `runtime.EventTypeReasoningDelta`. 기존 call site 없음.

### 분할 판단

Split decision policy를 먼저 평가했다. 공유 task group은 `m-model-serving-load-routing`이다.

- `01_node_reasoning_event`: Node runtime/Ollama adapter가 reasoning event를 생성한다. 선행 의존성 없음.
- `02+01_edge_reasoning_surface`: Edge OpenAI surface가 Node reasoning event를 OpenAI-compatible 응답으로 변환한다. `01` 완료 후 진행한다.
- `03+01,02_e2e_field_validation`: fake Ollama 보조 smoke와 실제 field 검증을 갱신한다. `01`, `02` 완료 후 진행한다.

Node adapter event 생성과 Edge HTTP response 변환은 소유 도메인과 테스트 전략이 달라 분리한다. E2E 검증은 bin shell/외부 endpoint 검증이라 별도 terminal-risk 작업으로 분리한다.

### 범위 결정 근거

- Edge OpenAI response 변경은 이 작업에서 제외한다. `02+01_edge_reasoning_surface`가 소유한다.
- `scripts/e2e-openai-ollama.sh` 변경과 실제 field 검증은 제외한다. `03+01,02_e2e_field_validation`이 소유한다.
- proto schema 변경은 제외한다. `RunEvent.type`은 string이므로 `reasoning_delta` 관례 추가로 충분하다.
- `think:false` 기본 주입은 제외한다. 마일스톤 표준선은 thinking 보존이다.

### 빌드 등급

`local-G05` — Node adapter 내부 변경과 런타임 event constant 추가이며 대상 패키지 테스트로 검증 가능하다.

## 구현 체크리스트

- [ ] `runtime.EventTypeReasoningDelta`를 추가한다.
- [ ] Ollama chat chunk의 `message.thinking`을 decode한다.
- [ ] `message.thinking`은 `reasoning_delta` event로, `message.content`는 기존 `delta` event로 분리 emit한다.
- [ ] content token/usage 계산은 기존 content 기준을 유지한다.
- [ ] `TestOllamaExecuteStreamsThinkingDeltasSeparately`를 추가한다.
- [ ] 중간 및 최종 검증 명령을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [OE2E-NODE-1] Runtime Reasoning Event Type

#### 문제

[types.go](/config/workspace/iop/apps/node/internal/runtime/types.go:50)는 `start`, `delta`, `complete`, `error`, `cancelled`만 상수로 둔다. `RunEvent.type`은 string이라 새 type을 proto 변경 없이 보낼 수 있지만, adapter 코드에서 명확한 상수가 필요하다.

Before:

```go
50 const (
51     EventTypeStart     EventType = "start"
52     EventTypeDelta     EventType = "delta"
53     EventTypeComplete  EventType = "complete"
54     EventTypeError     EventType = "error"
55     EventTypeCancelled EventType = "cancelled"
56 )
```

#### 해결 방법

`EventTypeReasoningDelta EventType = "reasoning_delta"`를 `EventTypeDelta` 바로 뒤에 추가한다. 기존 event type 값은 변경하지 않는다.

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

- [ ] `apps/node/internal/runtime/types.go`에 `EventTypeReasoningDelta` 추가

#### 테스트 작성

별도 테스트는 `OE2E-NODE-2`의 Ollama adapter 테스트에서 새 상수를 직접 assert한다.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/adapters/ollama
```

예상: pass.

### [OE2E-NODE-2] Ollama Thinking Delta Relay

#### 문제

[ollama.go](/config/workspace/iop/apps/node/internal/adapters/ollama/ollama.go:129)는 `chunk.Message.Content`만 delta로 emit한다. [ollama.go](/config/workspace/iop/apps/node/internal/adapters/ollama/ollama.go:272)의 `ollamaMessage`도 `thinking` field를 decode하지 않는다. `gemma4:26b`처럼 thinking-only chunk를 먼저 보내는 모델은 Edge로 아무 output이 전달되지 않는다.

Before:

```go
129 if chunk.Message.Content != "" {
130     outputTokens += len(strings.Fields(chunk.Message.Content))
131     if err := sink.Emit(ctx, runtime.RuntimeEvent{
132         RunID:     spec.RunID,
133         Type:      runtime.EventTypeDelta,
134         Delta:     chunk.Message.Content,
135         Timestamp: time.Now(),
136     }); err != nil {
137         return err
138     }
139 }
```

Before:

```go
272 type ollamaMessage struct {
273     Role    string `json:"role"`
274     Content string `json:"content"`
275 }
```

#### 해결 방법

`ollamaMessage`에 `Thinking string 'json:"thinking,omitempty"'`를 추가한다. decode loop에서 `chunk.Message.Thinking != ""`이면 `runtime.EventTypeReasoningDelta` with `Delta: chunk.Message.Thinking`을 먼저 emit한다. 같은 chunk에 thinking과 content가 모두 있으면 reasoning event를 먼저 emit하고 content event를 emit한다. `outputTokens`는 content delta 기준으로 유지한다.

After shape:

```go
if chunk.Message.Thinking != "" {
    if err := sink.Emit(ctx, runtime.RuntimeEvent{
        RunID: spec.RunID, Type: runtime.EventTypeReasoningDelta,
        Delta: chunk.Message.Thinking, Timestamp: time.Now(),
    }); err != nil {
        return err
    }
}
if chunk.Message.Content != "" {
    ...
}
```

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

- [ ] `apps/node/internal/adapters/ollama/ollama.go` decode struct 확장
- [ ] `apps/node/internal/adapters/ollama/ollama.go` Execute loop에서 reasoning/content emit 순서 구현
- [ ] `apps/node/internal/adapters/ollama/ollama_test.go`에 thinking-only chunk regression test 추가

#### 테스트 작성

추가: `apps/node/internal/adapters/ollama/ollama_test.go`

- test name: `TestOllamaExecuteStreamsThinkingDeltasSeparately`
- fixture: fake `/api/chat` returns one chunk with `message.thinking:"thinking..."`, one chunk with `message.content:"answer"`, then done.
- assertions: event sequence is `start`, `reasoning_delta`, `delta`, `complete`; reasoning text does not appear in content delta; content delta remains answer only.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/adapters/ollama
```

예상: pass.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/node/internal/runtime/types.go` | OE2E-NODE-1 |
| `apps/node/internal/adapters/ollama/ollama.go` | OE2E-NODE-2 |
| `apps/node/internal/adapters/ollama/ollama_test.go` | OE2E-NODE-2 |

## 최종 검증

```bash
go test -count=1 ./apps/node/internal/adapters/ollama
```

예상: pass. Go test cache output은 허용하지 않는다.

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